diff --git a/servers/fastapi/api/v1/ppt/endpoints/slide_to_html.py b/servers/fastapi/api/v1/ppt/endpoints/slide_to_html.py index bb9f3157..0ffc6a6a 100644 --- a/servers/fastapi/api/v1/ppt/endpoints/slide_to_html.py +++ b/servers/fastapi/api/v1/ppt/endpoints/slide_to_html.py @@ -12,13 +12,14 @@ from utils.asset_directory_utils import get_images_directory from services.database import get_async_session from models.sql.presentation_layout_code import PresentationLayoutCodeModel from .prompts import GENERATE_HTML_SYSTEM_PROMPT, HTML_TO_REACT_SYSTEM_PROMPT, HTML_EDIT_SYSTEM_PROMPT +from models.sql.template import TemplateModel # Create separate routers for each functionality SLIDE_TO_HTML_ROUTER = APIRouter(prefix="/slide-to-html", tags=["slide-to-html"]) HTML_TO_REACT_ROUTER = APIRouter(prefix="/html-to-react", tags=["html-to-react"]) HTML_EDIT_ROUTER = APIRouter(prefix="/html-edit", tags=["html-edit"]) -LAYOUT_MANAGEMENT_ROUTER = APIRouter(prefix="/layout-management", tags=["layout-management"]) +LAYOUT_MANAGEMENT_ROUTER = APIRouter(prefix="/template-management", tags=["template-management"]) # Request/Response models for slide-to-html endpoint @@ -74,12 +75,15 @@ class GetLayoutsResponse(BaseModel): success: bool layouts: list[LayoutData] message: Optional[str] = None + template: Optional[dict] = None + fonts: Optional[List[str]] = None class PresentationSummary(BaseModel): presentation_id: str layout_count: int last_updated_at: Optional[datetime] = None + template: Optional[dict] = None class GetPresentationSummaryResponse(BaseModel): @@ -96,6 +100,25 @@ class ErrorResponse(BaseModel): error_code: Optional[str] = None +class TemplateCreateRequest(BaseModel): + id: str + name: str + description: Optional[str] = None + + +class TemplateCreateResponse(BaseModel): + success: bool + template: dict + message: Optional[str] = None + + +class TemplateInfo(BaseModel): + id: str + name: Optional[str] = None + description: Optional[str] = None + created_at: Optional[datetime] = None + + async def generate_html_from_slide(base64_image: str, media_type: str, xml_content: str, api_key: str, fonts: Optional[List[str]] = None) -> str: """ Generate HTML content from slide image and XML using OpenAI GPT-5 Responses API. @@ -623,7 +646,7 @@ async def edit_html_with_images_endpoint( # ENDPOINT 4: Save layouts for a presentation @LAYOUT_MANAGEMENT_ROUTER.post( - "/save-layouts", + "/save-templates", response_model=SaveLayoutsResponse, responses={ 400: {"model": ErrorResponse, "description": "Validation error"}, @@ -739,7 +762,7 @@ async def save_layouts( # ENDPOINT 5: Get layouts for a presentation @LAYOUT_MANAGEMENT_ROUTER.get( - "/get-layouts/{presentation_id}", + "/get-templates/{presentation_id}", response_model=GetLayoutsResponse, responses={ 400: {"model": ErrorResponse, "description": "Invalid presentation ID"}, @@ -798,10 +821,30 @@ async def get_layouts( for layout in layouts_db ] + # Aggregate unique fonts across all layouts + aggregated_fonts: set[str] = set() + for layout in layouts_db: + if layout.fonts: + aggregated_fonts.update([f for f in layout.fonts if isinstance(f, str)]) + fonts_list = sorted(list(aggregated_fonts)) if aggregated_fonts else None + + # Fetch template meta + template_meta = await session.get(TemplateModel, presentation_id) + template = None + if template_meta: + template = { + "id": template_meta.id, + "name": template_meta.name, + "description": template_meta.description, + "created_at": template_meta.created_at, + } + return GetLayoutsResponse( success=True, layouts=layouts, - message=f"Retrieved {len(layouts)} layout(s) for presentation {presentation_id}" + message=f"Retrieved {len(layouts)} layout(s) for presentation {presentation_id}", + template=template, + fonts=fonts_list, ) except HTTPException: @@ -823,11 +866,11 @@ async def get_layouts( description="Retrieve a summary of all presentations and the number of layouts in each", responses={ 200: {"model": GetPresentationSummaryResponse, "description": "Presentations summary retrieved successfully"}, - 500: {"model": ErrorResponse, "description": "Internal server error"} - } + 500: {"model": ErrorResponse, "description": "Internal server error"}, + }, ) async def get_presentations_summary( - session: AsyncSession = Depends(get_async_session) + session: AsyncSession = Depends(get_async_session), ): """ Get summary of all presentations with their layout counts. @@ -843,16 +886,27 @@ async def get_presentations_summary( result = await session.execute(stmt) presentation_data = result.all() - # Convert to response format - presentations = [ - PresentationSummary( - presentation_id=row.presentation_id, - layout_count=row.layout_count, - last_updated_at=row.last_updated_at + # Convert to response format with template info if available + presentations = [] + for row in presentation_data: + template_meta = await session.get(TemplateModel, row.presentation_id) + template = None + if template_meta: + template = { + "id": template_meta.id, + "name": template_meta.name, + "description": template_meta.description, + "created_at": template_meta.created_at, + } + presentations.append( + PresentationSummary( + presentation_id=row.presentation_id, + layout_count=row.layout_count, + last_updated_at=row.last_updated_at, + template=template, + ) ) - for row in presentation_data - ] - + # Calculate totals total_presentations = len(presentations) total_layouts = sum(p.layout_count for p in presentations) @@ -862,7 +916,7 @@ async def get_presentations_summary( presentations=presentations, total_presentations=total_presentations, total_layouts=total_layouts, - message=f"Retrieved {total_presentations} presentation(s) with {total_layouts} total layout(s)" + message=f"Retrieved {total_presentations} presentation(s) with {total_layouts} total layout(s)", ) except Exception as e: @@ -870,4 +924,53 @@ async def get_presentations_summary( raise HTTPException( status_code=500, detail=f"Internal server error while retrieving presentations summary: {str(e)}" - ) \ No newline at end of file + ) + + +@LAYOUT_MANAGEMENT_ROUTER.post( + "/templates", + response_model=TemplateCreateResponse, + responses={ + 400: {"model": ErrorResponse, "description": "Validation error"}, + 500: {"model": ErrorResponse, "description": "Internal server error"}, + }, +) +async def create_template( + request: TemplateCreateRequest, + session: AsyncSession = Depends(get_async_session), +): + try: + if not request.id or not request.name: + raise HTTPException(status_code=400, detail="id and name are required") + + # Upsert template by id + existing = await session.get(TemplateModel, request.id) + if existing: + existing.name = request.name + existing.description = request.description + else: + session.add( + TemplateModel( + id=request.id, name=request.name, description=request.description + ) + ) + await session.commit() + + # Read back + template = await session.get(TemplateModel, request.id) + return TemplateCreateResponse( + success=True, + template={ + "id": template.id, + "name": template.name, + "description": template.description, + "created_at": template.created_at, + }, + message="Template saved", + ) + except HTTPException: + await session.rollback() + raise + except Exception as e: + await session.rollback() + raise HTTPException(status_code=500, detail=f"Failed to save template: {str(e)}") \ No newline at end of file diff --git a/servers/fastapi/models/sql/template.py b/servers/fastapi/models/sql/template.py new file mode 100644 index 00000000..8a1e7457 --- /dev/null +++ b/servers/fastapi/models/sql/template.py @@ -0,0 +1,13 @@ +from datetime import datetime +from typing import Optional +from sqlalchemy import Column, DateTime +from sqlmodel import SQLModel, Field + + +class TemplateModel(SQLModel, table=True): + __tablename__ = "templates" + + id: str = Field(primary_key=True, description="UUID for the template (matches presentation_id)") + name: str = Field(description="Human friendly template name") + description: Optional[str] = Field(default=None, description="Optional template description") + created_at: datetime = Field(sa_column=Column(DateTime, default=datetime.now)) \ No newline at end of file diff --git a/servers/fastapi/services/database.py b/servers/fastapi/services/database.py index 533e4c45..64c9d48d 100644 --- a/servers/fastapi/services/database.py +++ b/servers/fastapi/services/database.py @@ -14,6 +14,7 @@ from models.sql.ollama_pull_status import OllamaPullStatus from models.sql.presentation import PresentationModel from models.sql.slide import SlideModel from models.sql.presentation_layout_code import PresentationLayoutCodeModel +from models.sql.template import TemplateModel from utils.get_env import get_app_data_directory_env, get_database_url_env @@ -70,10 +71,11 @@ async def create_db_and_tables(): SlideModel.__table__, KeyValueSqlModel.__table__, ImageAsset.__table__, - PresentationLayoutCodeModel.__table__, - ], - ) - ) + PresentationLayoutCodeModel.__table__, + TemplateModel.__table__, + ], + ) + ) async with container_db_engine.begin() as conn: await conn.run_sync( diff --git a/servers/nextjs/app/(presentation-generator)/context/LayoutContext.tsx b/servers/nextjs/app/(presentation-generator)/context/LayoutContext.tsx index 71fa5f87..6ec0e7fd 100644 --- a/servers/nextjs/app/(presentation-generator)/context/LayoutContext.tsx +++ b/servers/nextjs/app/(presentation-generator)/context/LayoutContext.tsx @@ -346,7 +346,7 @@ export const LayoutProvider: React.FC<{ const fullDataByGroup = new Map(); try { const customGroupResponse = await fetch( - "/api/v1/ppt/layout-management/summary" + "/api/v1/ppt/template-management/summary" ); const customGroupData = await customGroupResponse.json(); @@ -360,7 +360,7 @@ export const LayoutProvider: React.FC<{ } const presentationId = group.presentation_id; const customLayoutResponse = await fetch( - `/api/v1/ppt/layout-management/get-layouts/${presentationId}` + `/api/v1/ppt/template-management/get-templates/${presentationId}` ); const customLayoutsData = await customLayoutResponse.json(); const allLayout = customLayoutsData.layouts; diff --git a/servers/nextjs/app/(presentation-generator)/custom-template/components/FontManager.tsx b/servers/nextjs/app/(presentation-generator)/custom-template/components/FontManager.tsx index 5ce0f53b..a7dc0ea4 100644 --- a/servers/nextjs/app/(presentation-generator)/custom-template/components/FontManager.tsx +++ b/servers/nextjs/app/(presentation-generator)/custom-template/components/FontManager.tsx @@ -97,8 +97,7 @@ const FontManager: React.FC = ({ Font Management

- Manage fonts across all slides. Upload fonts once and they'll be - available for all slides. + We couldn't load these fonts automatically. Please upload them manually. Make sure naem of the font should be exactly as shown.

diff --git a/servers/nextjs/app/(presentation-generator)/custom-template/hooks/useLayoutSaving.ts b/servers/nextjs/app/(presentation-generator)/custom-template/hooks/useLayoutSaving.ts index 570e73f2..14d8bd06 100644 --- a/servers/nextjs/app/(presentation-generator)/custom-template/hooks/useLayoutSaving.ts +++ b/servers/nextjs/app/(presentation-generator)/custom-template/hooks/useLayoutSaving.ts @@ -2,11 +2,12 @@ import { useState, useCallback } from "react"; import { toast } from "sonner"; import { v4 as uuidv4 } from "uuid"; import { ApiResponseHandler } from "@/app/(presentation-generator)/services/api/api-error-handler"; -import { ProcessedSlide, UploadedFont } from "../types"; +import { ProcessedSlide, UploadedFont, FontData } from "../types"; export const useLayoutSaving = ( slides: ProcessedSlide[], UploadedFonts: UploadedFont[], + fontsData: FontData | null, refetch: () => void, setSlides: React.Dispatch> ) => { @@ -90,8 +91,10 @@ export const useLayoutSaving = ( const reactComponents: any[] = []; const presentationId = uuidv4(); - // Get all uploaded font URLs - const FontUrls = UploadedFonts.map((font) => font.fontUrl); + // Collect uploaded font URLs and Google Fonts CSS URLs + const uploadedFontUrls = UploadedFonts.map((font) => font.fontUrl); + const googleFontCssUrls = fontsData?.internally_supported_fonts?.map(f => f.google_fonts_url).filter(Boolean) || []; + const FontUrls = Array.from(new Set([...(uploadedFontUrls || []), ...googleFontCssUrls])); console.log("FontUrls", FontUrls); for (let i = 0; i < slides.length; i++) { @@ -134,9 +137,16 @@ export const useLayoutSaving = ( } console.log(reactComponents); + // First create/update the template metadata + await fetch("/api/v1/ppt/template-management/templates", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: presentationId, name: layoutName, description }), + }); + // Save the layout components to the app_data/layouts folder const saveResponse = await fetch( - "/api/v1/ppt/layout-management/save-layouts", + "/api/v1/ppt/template-management/save-templates", { method: "POST", headers: { @@ -144,8 +154,6 @@ export const useLayoutSaving = ( }, body: JSON.stringify({ layouts: reactComponents, - layout_name: layoutName, - description: description, }), } ); @@ -181,7 +189,7 @@ export const useLayoutSaving = ( } finally { setIsSavingLayout(false); } - }, [slides, UploadedFonts, refetch, closeSaveModal, setSlides]); + }, [slides, UploadedFonts, fontsData, refetch, closeSaveModal, setSlides]); return { isSavingLayout, diff --git a/servers/nextjs/app/(presentation-generator)/custom-template/page.tsx b/servers/nextjs/app/(presentation-generator)/custom-template/page.tsx index 640762b3..de9a32f1 100644 --- a/servers/nextjs/app/(presentation-generator)/custom-template/page.tsx +++ b/servers/nextjs/app/(presentation-generator)/custom-template/page.tsx @@ -35,6 +35,7 @@ const CustomTemplatePage = () => { const { isSavingLayout, isModalOpen, openSaveModal, closeSaveModal, saveLayout } = useLayoutSaving( slides, UploadedFonts, + fontsData, refetch, setSlides ); diff --git a/servers/nextjs/app/(presentation-generator)/outline/components/LayoutSelection.tsx b/servers/nextjs/app/(presentation-generator)/outline/components/LayoutSelection.tsx index c10b5422..57c2a77b 100644 --- a/servers/nextjs/app/(presentation-generator)/outline/components/LayoutSelection.tsx +++ b/servers/nextjs/app/(presentation-generator)/outline/components/LayoutSelection.tsx @@ -20,18 +20,22 @@ const LayoutSelection: React.FC = ({ loading } = useLayout(); - const [summaryMap, setSummaryMap] = React.useState>({}); + const [summaryMap, setSummaryMap] = React.useState>({}); useEffect(() => { - // Fetch custom templates summary to get last_updated_at for sorting - fetch("/api/v1/ppt/layout-management/summary") + // Fetch custom templates summary to get last_updated_at and template meta for sorting and display + fetch("/api/v1/ppt/template-management/summary") .then(res => res.json()) .then(data => { - const map: Record = {}; + const map: Record = {}; if (data && Array.isArray(data.presentations)) { for (const p of data.presentations) { - // groups are named custom- - map[`custom-${p.presentation_id}`] = p.last_updated_at ? new Date(p.last_updated_at).getTime() : 0; + const slug = `custom-${p.presentation_id}`; + map[slug] = { + lastUpdatedAt: p.last_updated_at ? new Date(p.last_updated_at).getTime() : 0, + name: p.template?.name, + description: p.template?.description, + }; } } setSummaryMap(map); @@ -45,10 +49,12 @@ const LayoutSelection: React.FC = ({ const Groups: LayoutGroup[] = groups.map(groupName => { const settings = getGroupSetting(groupName); + const customMeta = summaryMap[groupName]; + const isCustom = groupName.toLowerCase().startsWith("custom-"); return { id: groupName, - name: groupName, - description: settings?.description || `${groupName} presentation templates`, + name: isCustom && customMeta?.name ? customMeta.name : groupName, + description: (isCustom && customMeta?.description) ? customMeta.description : (settings?.description || `${groupName} presentation templates`), ordered: settings?.ordered || false, default: settings?.default || false, }; @@ -60,16 +66,16 @@ const LayoutSelection: React.FC = ({ if (!a.default && b.default) return 1; return a.name.localeCompare(b.name); }); - }, [getAllGroups, getLayoutsByGroup, getGroupSetting]); + }, [getAllGroups, getLayoutsByGroup, getGroupSetting, summaryMap]); const inBuiltGroups = React.useMemo( - () => layoutGroups.filter(g => !g.name.toLowerCase().startsWith("custom-")), + () => layoutGroups.filter(g => !g.id.toLowerCase().startsWith("custom-")), [layoutGroups] ); const customGroups = React.useMemo(() => { - const unsorted = layoutGroups.filter(g => g.name.toLowerCase().startsWith("custom-")); - // Sort by last_updated_at desc using summaryMap - return unsorted.sort((a, b) => (summaryMap[b.name] || 0) - (summaryMap[a.name] || 0)); + const unsorted = layoutGroups.filter(g => g.id.toLowerCase().startsWith("custom-")); + // Sort by last_updated_at desc using summaryMap keyed by slug id + return unsorted.sort((a, b) => (summaryMap[b.id]?.lastUpdatedAt || 0) - (summaryMap[a.id]?.lastUpdatedAt || 0)); }, [layoutGroups, summaryMap]); // Auto-select first group when groups are loaded diff --git a/servers/nextjs/app/(presentation-generator)/template-preview/[slug]/backup.tsx b/servers/nextjs/app/(presentation-generator)/template-preview/[slug]/backup.tsx new file mode 100644 index 00000000..db76f920 --- /dev/null +++ b/servers/nextjs/app/(presentation-generator)/template-preview/[slug]/backup.tsx @@ -0,0 +1,400 @@ + "use client"; + + import React, { useEffect, useState, useRef } from "react"; + import { useParams, useRouter } from "next/navigation"; + // import { useGroupLayoutLoader } from '../hooks/useGroupLayoutLoader' + import LoadingStates from "../components/LoadingStates"; + import { Card } from "@/components/ui/card"; + import { Button } from "@/components/ui/button"; + import { ArrowLeft, Edit, Home, Trash2 } from "lucide-react"; + import { useLayout } from "@/app/(presentation-generator)/context/LayoutContext"; + + import html2canvas from "html2canvas"; +import { EditControls } from "../../custom-template/components/EachSlide/EditControls"; +import { useDrawingCanvas } from "../../custom-template/hooks/useDrawingCanvas"; + const GroupLayoutPreview = () => { + const params = useParams(); + const router = useRouter(); + const slug = params.slug as string; + // const isCustom = slug.includes("custom-"); + const isCustom = true; + // Custom hooks + const { + canvasRef, + slideDisplayRef, + strokeWidth, + strokeColor, + eraserMode, + isDrawing, + canvasDimensions, + setCanvasDimensions, + didYourDraw, + handleMouseDown, + handleMouseMove, + handleMouseUp, + handleTouchStart, + handleTouchMove, + handleTouchEnd, + handleClearCanvas, + handleEraserModeChange, + handleStrokeColorChange, + handleStrokeWidthChange, + } = useDrawingCanvas(); + + const slideContentRef = useRef(null); + + const { getFullDataByGroup, loading,refetch } = useLayout(); + const layoutGroup = getFullDataByGroup(slug); + const [isEditMode, setIsEditMode] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(null); + const [prompt, setPrompt] = useState(""); + const [isUpdating, setIsUpdating] = useState(false); + + useEffect(() => { + const existingScript = document.querySelector( + 'script[src*="tailwindcss.com"]' + ); + if (!existingScript) { + const script = document.createElement("script"); + script.src = "https://cdn.tailwindcss.com"; + script.async = true; + document.head.appendChild(script); + } + }, [slug]); + + // Size canvas to content when entering edit mode + useEffect(() => { + if (isEditMode && slideContentRef.current) { + const rect = slideContentRef.current.getBoundingClientRect(); + setCanvasDimensions({ + width: Math.max(rect.width, 800), + height: Math.max(rect.height, 600), + }); + } + }, [isEditMode, setCanvasDimensions]); + + // Handle loading state + if (loading) { + return ; + } + + // Handle empty state + if (!layoutGroup || layoutGroup.length === 0) { + return ; + } + const deleteLayouts = async () => { + const presentationId = slug.replace('custom-',''); + refetch(); + router.back(); + const response = await fetch(`/api/v1/ppt/template-management/delete-templates/${presentationId}`, { + method: "DELETE", + }); + if (response.ok) { + router.push("/template-preview"); + } + } + + const handleSave = async ( + slideDisplayRef: React.RefObject, + didYourDraw: boolean + ) => { + if ( + !slideContentRef.current || + !slideDisplayRef.current + ) + return; + + if (!prompt.trim()) { + alert("Please enter a prompt before saving."); + return; + } + + setIsUpdating(true); + + try { + // Take screenshot of the slide display area (slide only) + const slideOnly = await html2canvas(slideDisplayRef.current, { + backgroundColor: "#ffffff", + scale: 1, + logging: false, + useCORS: true, + ignoreElements: (element) => { + return element.tagName === "CANVAS"; + }, + }); + let slideWithCanvas; + if (didYourDraw) { + // Take screenshot of the entire slide display area including canvas + slideWithCanvas = await html2canvas(slideDisplayRef.current, { + backgroundColor: "#ffffff", + scale: 1, + logging: false, + useCORS: true, + }); + } + + + + const currentUiImageBlob = dataURLToBlob( + slideOnly.toDataURL("image/png") + ); + let sketchImageBlob; + if (didYourDraw && slideWithCanvas) { + sketchImageBlob = dataURLToBlob(slideWithCanvas.toDataURL("image/png")); + } + + // download the images + + const currentUiImageUrl = URL.createObjectURL(currentUiImageBlob); + if (currentUiImageUrl) { + const a = document.createElement("a"); + a.href = currentUiImageUrl; + a.download = `slide-current.png`; + a.click(); + } + if (sketchImageBlob) { + const sketchImageUrl = URL.createObjectURL(sketchImageBlob); + if (sketchImageUrl) { + const b = document.createElement("a"); + b.href = sketchImageUrl; + b.download = `slide-sketch.png`; + b.click(); + } + } + + + + + // const formData = new FormData(); + // formData.append( + // "current_ui_image", + // currentUiImageBlob, + // `slide--current.png` + // ); + // if (didYourDraw && slideWithCanvas && sketchImageBlob) { + // formData.append( + // "sketch_image", + // sketchImageBlob, + // `slide-sketch.png` + // ); + // } + // formData.append("html", ''); + // formData.append("prompt", prompt); + + // const response = await fetch("/api/v1/ppt/html-edit/", { + // method: "POST", + // body: formData, + // }); + + // if (!response.ok) { + // throw new Error(`API call failed: ${response.statusText}`); + // } + + // const data = await response.json(); + + + // Exit edit mode + setIsEditMode(false); + setPrompt(""); + } catch (error) { + console.error("Error updating slide:", error); + alert( + `Error updating slide: ${ + error instanceof Error ? error.message : "Unknown error" + }` + ); + } finally { + setIsUpdating(false); + } + }; + const dataURLToBlob = (dataURL: string): Blob => { + const parts = dataURL.split(","); + const contentType = parts[0].match(/:(.*?);/)?.[1] || "image/png"; + const raw = window.atob(parts[1]); + const rawLength = raw.length; + const uInt8Array = new Uint8Array(rawLength); + + for (let i = 0; i < rawLength; ++i) { + uInt8Array[i] = raw.charCodeAt(i); + } + + return new Blob([uInt8Array], { type: contentType }); + }; + + return ( +
+ {/* Header */} +
+
+ {/* Navigation */} +
+ + + {isCustom && } +
+ +
+

+ {layoutGroup[0].groupName} Layouts +

+

+ {layoutGroup.length} layout{layoutGroup.length !== 1 ? "s" : ""} •{" "} + {layoutGroup[0].groupName} +

+
+ +
+
+ + +
+ {/* Edit Controls (no HTML editor) */} + {isCustom && ( + { + setIsUpdating(true); + setTimeout(() => { + setIsUpdating(false); + setIsEditMode(false); + setSelectedIndex(null); + }, 300); + }} + onCancel={() => { + setIsEditMode(false); + setSelectedIndex(null); + handleClearCanvas(); + }} + onStrokeWidthChange={handleStrokeWidthChange} + onStrokeColorChange={handleStrokeColorChange} + onEraserModeChange={handleEraserModeChange} + onClearCanvas={handleClearCanvas} + /> + )} +
+ {layoutGroup.map((layout: any, index: number) => { + const { + component: LayoutComponent, + sampleData, + name, + fileName, + } = layout; + + const isSelected = isCustom && isEditMode && selectedIndex === index; + + return ( + + {/* Layout Header */} +
+
+
+

+ {name} +

+
+ + {fileName} + + + {layoutGroup[0].groupName} + +
+
+
+ {isCustom && ( + + )} +
+
+
+ + {/* Layout Content */} +
+
+ + {isSelected && ( + e.preventDefault()} + /> + )} +
+
+
+ ); + })} +
+
+ + {/* Footer */} +
+
+
+

+ {layoutGroup[0].groupName} • {layoutGroup.length} components +

+
+
+
+
+ ); + }; + + export default GroupLayoutPreview; + diff --git a/servers/nextjs/app/(presentation-generator)/template-preview/[slug]/page.tsx b/servers/nextjs/app/(presentation-generator)/template-preview/[slug]/page.tsx index 1b9f5d20..72cdb37b 100644 --- a/servers/nextjs/app/(presentation-generator)/template-preview/[slug]/page.tsx +++ b/servers/nextjs/app/(presentation-generator)/template-preview/[slug]/page.tsx @@ -33,6 +33,7 @@ const GroupLayoutPreview = () => { const [currentFonts, setCurrentFonts] = useState(undefined); const [isSaving, setIsSaving] = useState(false); const [layoutsMap, setLayoutsMap] = useState>({}); + const [templateMeta, setTemplateMeta] = useState<{ name?: string; description?: string } | null>(null); const injectFonts = (fontUrls: string[]) => { fontUrls.forEach((fontUrl) => { @@ -55,7 +56,7 @@ const GroupLayoutPreview = () => { const loadCustomLayouts = async () => { if (!isCustom) return; try { - const res = await fetch(`/api/v1/ppt/layout-management/get-layouts/${presentationId}`); + const res = await fetch(`/api/v1/ppt/template-management/get-templates/${presentationId}`); if (!res.ok) return; const data = await res.json(); const map: Record = {}; @@ -68,12 +69,13 @@ const GroupLayoutPreview = () => { }; } setLayoutsMap(map); - // Inject all fonts used by this custom group's layouts - // const allFonts: string[] = []; - // Object.values(map).forEach((entry) => { - // (entry.fonts || []).forEach((f) => allFonts.push(f)); - // }); - injectFonts(map[0].fonts || []); + // Set template meta and inject aggregated fonts if provided + if (data?.template) { + setTemplateMeta({ name: data.template.name, description: data.template.description }); + } + if (Array.isArray(data?.fonts) && data.fonts.length) { + injectFonts(data.fonts); + } } catch (e) { // noop } @@ -116,7 +118,7 @@ const GroupLayoutPreview = () => { const presentationId = slug.replace('custom-',''); refetch(); router.back(); - const response = await fetch(`/api/v1/ppt/layout-management/delete-layouts/${presentationId}`, { + const response = await fetch(`/api/v1/ppt/template-management/delete-templates/${presentationId}`, { method: "DELETE", }); if (response.ok) { @@ -157,7 +159,7 @@ const GroupLayoutPreview = () => { }, ], }; - const res = await fetch(`/api/v1/ppt/layout-management/save-layouts`, { + const res = await fetch(`/api/v1/ppt/template-management/save-templates`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), @@ -212,11 +214,10 @@ const GroupLayoutPreview = () => {

- {layoutGroup[0].groupName} Layouts + {templateMeta?.name || layoutGroup[0].groupName} Layouts

- {layoutGroup.length} layout{layoutGroup.length !== 1 ? "s" : ""} •{" "} - {layoutGroup[0].groupName} + {layoutGroup.length} layout{layoutGroup.length !== 1 ? "s" : ""} • {templateMeta?.description || layoutGroup[0].groupName}

diff --git a/servers/nextjs/app/(presentation-generator)/template-preview/hooks/useGroupLayoutLoader.ts b/servers/nextjs/app/(presentation-generator)/template-preview/hooks/useGroupLayoutLoader.ts new file mode 100644 index 00000000..325d3756 --- /dev/null +++ b/servers/nextjs/app/(presentation-generator)/template-preview/hooks/useGroupLayoutLoader.ts @@ -0,0 +1,362 @@ +"use client"; +import React, { useState, useEffect, useRef } from "react"; +import * as Babel from "@babel/standalone"; +import * as z from "zod"; + +import { + LayoutInfo, + LayoutGroup, + GroupedLayoutsResponse, + GroupSetting, +} from "../types"; +import { toast } from "sonner"; + +interface UseGroupLayoutLoaderReturn { + layoutGroup: LayoutGroup | null; + loading: boolean; + error: string | null; + retry: () => void; +} + +// Global cache to store layout groups and avoid re-fetching +const layoutGroupCache = new Map(); +const loadingGroupsCache = new Set(); + +// Extract Babel compilation logic into a utility function +const compileCustomLayout = (layoutCode: string, React: any, z: any) => { + const cleanCode = layoutCode + .replace(/import\s+React\s+from\s+'react';?/g, "") + .replace(/import\s*{\s*z\s*}\s*from\s+'zod';?/g, ""); + + const compiled = Babel.transform(cleanCode, { + presets: [ + ["react", { runtime: "classic" }], + ["typescript", { isTSX: true, allExtensions: true }], + ], + sourceType: "script", + }).code; + + const factory = new Function( + "React", + "z", + ` + ${compiled} + + /* everything declared in the string is in scope here */ + return { + __esModule: true, + default: dynamicSlideLayout, + layoutName, + layoutId, + layoutDescription, + Schema + }; + ` + ); + + return factory(React, z); +}; + +export const useGroupLayoutLoader = ( + groupSlug: string +): UseGroupLayoutLoaderReturn => { + const [layoutGroup, setLayoutGroup] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const hasMountedRef = useRef(false); + + const loadCustomLayouts = async () => { + try { + // Check if this is a custom group (starts with 'custom-') + if (!groupSlug.startsWith("custom-")) { + return null; + } + + const presentationId = groupSlug.replace("custom-", ""); + + const customLayoutResponse = await fetch( + `/api/v1/ppt/template-management/get-templates/${presentationId}` + ); + + if (!customLayoutResponse.ok) { + throw new Error( + `Failed to fetch custom layouts: ${customLayoutResponse.statusText}` + ); + } + + const customLayoutsData = await customLayoutResponse.json(); + const allLayouts = customLayoutsData.layouts; + const templateMeta = customLayoutsData.template; + + const groupLayouts: LayoutInfo[] = []; + const settings: GroupSetting = { + description: templateMeta?.description || `Custom presentation layouts`, + ordered: false, + default: false, + }; + + for (const layoutData of allLayouts) { + try { + // Compile custom layout code + const module = compileCustomLayout(layoutData.layout_code, React, z); + + if (!module.default) { + toast.error(`Custom Layout has no default export`, { + description: + "Please ensure the layout file exports a default component", + }); + console.warn(`❌ Custom Layout has no default export`); + continue; + } + + if (!module.Schema) { + toast.error(`Custom Layout has no Schema export`, { + description: "Please ensure the layout file exports a Schema", + }); + console.warn(`❌ Custom Layout has no Schema export`); + continue; + } + + // Use empty object to let schema apply its default values + const sampleData = module.Schema.parse({}); + + const originalLayoutId = + module.layoutId || + layoutData.layout_name.toLowerCase().replace(/layout$/, ""); + const layoutName = + module.layoutName || + layoutData.layout_name.replace(/([A-Z])/g, " $1").trim(); + + const layoutInfo: LayoutInfo = { + name: layoutName, + component: module.default, + schema: module.Schema, + sampleData, + fileName: layoutData.layout_name, + groupName: groupSlug, + layoutId: originalLayoutId, + }; + + groupLayouts.push(layoutInfo); + } catch (compilationError) { + console.error( + `Failed to compile custom layout ${layoutData.layout_name}:`, + compilationError + ); + toast.error(`Failed to compile ${layoutData.layout_name}`, { + description: "There was an error compiling the custom layout code", + }); + } + } + + if (groupLayouts.length === 0) { + throw new Error( + `No valid custom layouts found in "${groupSlug}" group.` + ); + } + + return { + groupName: groupSlug, + layouts: groupLayouts, + settings, + }; + } catch (error) { + console.error("Error loading custom layouts:", error); + throw error; + } + }; + + const loadGroupLayouts = async () => { + // Check cache first + if (layoutGroupCache.has(groupSlug)) { + setLayoutGroup(layoutGroupCache.get(groupSlug)!); + setLoading(false); + setError(null); + return; + } + + // Prevent multiple simultaneous requests for the same group + if (loadingGroupsCache.has(groupSlug)) { + return; + } + + try { + setLoading(true); + setError(null); + loadingGroupsCache.add(groupSlug); + + // Check if this is a custom group + if (groupSlug.startsWith("custom-")) { + const customGroup = await loadCustomLayouts(); + if (customGroup) { + // Cache the result + layoutGroupCache.set(groupSlug, customGroup); + setLayoutGroup(customGroup); + setError(null); + return; + } + } + + // Load standard layouts + const response = await fetch("/api/layouts"); + if (!response.ok) { + toast.error("Error loading layouts", { + description: response.statusText, + }); + return; + } + const groupedLayoutsData: GroupedLayoutsResponse[] = + await response.json(); + + // Find the specific group by slug + const targetGroupData = groupedLayoutsData.find( + (group) => group.groupName.toLowerCase() === groupSlug.toLowerCase() + ); + + if (!targetGroupData) { + setError(`Group "${groupSlug}" not found`); + return; + } + + const groupLayouts: LayoutInfo[] = []; + + // Use settings from settings.json or provide defaults + const groupSettings: GroupSetting = targetGroupData.settings + ? targetGroupData.settings + : { + description: `${targetGroupData.groupName} presentation layouts`, + ordered: false, + default: false, + }; + + for (const fileName of targetGroupData.files) { + try { + const layoutName = fileName.replace(".tsx", "").replace(".ts", ""); + const module = await import( + `@/presentation-layouts/${targetGroupData.groupName}/${layoutName}` + ); + + if (!module.default) { + toast.error(`${layoutName} has no default export`, { + description: + "Please ensure the layout file exports a default component", + }); + + console.warn(`${layoutName} has no default export`); + return; + } + + if (!module.Schema) { + toast.error(`${layoutName} is missing required Schema export`, { + description: "Please ensure the layout file exports a Schema", + }); + console.error(`${layoutName} is missing required Schema export`); + return; + } + + // Use empty object to let schema apply its default values + const sampleData = module.Schema.parse({}); + const layoutId = + module.layoutId || layoutName.toLowerCase().replace(/layout$/, ""); + + const layoutInfo: LayoutInfo = { + name: layoutName, + component: module.default, + schema: module.Schema, + sampleData, + fileName, + groupName: targetGroupData.groupName, + layoutId, + }; + + groupLayouts.push(layoutInfo); + } catch (importError) { + console.error( + `Failed to import ${fileName} from ${targetGroupData.groupName}:`, + importError + ); + + // Try alternative import path + try { + const layoutName = fileName.replace(".tsx", "").replace(".ts", ""); + const module = await import( + `@/presentation-layouts/${targetGroupData.groupName}/${layoutName}` + ); + + if (module.default && module.Schema) { + const sampleData = module.Schema.parse({}); + // if layoutId is not provided, use the layoutName + const layoutId = + module.layoutId || + layoutName.toLowerCase().replace(/layout$/, ""); + const layoutInfo: LayoutInfo = { + name: layoutName, + component: module.default, + schema: module.Schema, + sampleData, + fileName, + groupName: targetGroupData.groupName, + layoutId, + }; + groupLayouts.push(layoutInfo); + } else { + console.error( + `${layoutName} is missing required exports (default component or Schema)` + ); + } + } catch (altError) { + console.error( + `Alternative import also failed for ${fileName} from ${targetGroupData.groupName}:`, + altError + ); + } + } + } + + if (groupLayouts.length === 0) { + toast.error("No valid layouts found", { + description: `No valid layouts found in "${groupSlug}" group.`, + }); + setError(`No valid layouts found in "${groupSlug}" group.`); + } else { + const group: LayoutGroup = { + groupName: targetGroupData.groupName, + layouts: groupLayouts, + settings: groupSettings, + }; + + // Cache the result + layoutGroupCache.set(groupSlug, group); + setLayoutGroup(group); + setError(null); + } + } catch (error) { + console.error("Error loading group layouts:", error); + setError( + error instanceof Error ? error.message : "Failed to load group layouts" + ); + } finally { + setLoading(false); + loadingGroupsCache.delete(groupSlug); + } + }; + + const retry = () => { + hasMountedRef.current = false; + loadGroupLayouts(); + }; + + useEffect(() => { + if (groupSlug && !hasMountedRef.current) { + hasMountedRef.current = true; + loadGroupLayouts(); + } + }, [groupSlug]); + + return { + layoutGroup, + loading, + error, + retry, + }; +}; diff --git a/servers/nextjs/app/(presentation-generator)/template-preview/page.tsx b/servers/nextjs/app/(presentation-generator)/template-preview/page.tsx index 85135158..34d495e6 100644 --- a/servers/nextjs/app/(presentation-generator)/template-preview/page.tsx +++ b/servers/nextjs/app/(presentation-generator)/template-preview/page.tsx @@ -1,5 +1,5 @@ "use client"; -import React, { useEffect } from "react"; +import React, { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import LoadingStates from "./components/LoadingStates"; import { Card } from "@/components/ui/card"; @@ -18,6 +18,8 @@ const LayoutPreview = () => { } = useLayout(); const router = useRouter(); + const [summaryMap, setSummaryMap] = useState>({}); + useEffect(() => { const existingScript = document.querySelector( 'script[src*="tailwindcss.com"]' @@ -30,6 +32,27 @@ const LayoutPreview = () => { } }, []); + useEffect(() => { + // Fetch summary to map custom group slug to template meta and last updated time + fetch("/api/v1/ppt/template-management/summary") + .then((res) => res.json()) + .then((data) => { + const map: Record = {}; + if (data && Array.isArray(data.presentations)) { + for (const p of data.presentations) { + const slug = `custom-${p.presentation_id}`; + map[slug] = { + lastUpdatedAt: p.last_updated_at ? new Date(p.last_updated_at).getTime() : 0, + name: p.template?.name, + description: p.template?.description, + }; + } + } + setSummaryMap(map); + }) + .catch(() => setSummaryMap({})); + }, []); + // Transform context data to match expected format const layoutGroups = getAllGroups().map((groupName) => ({ groupName, @@ -44,6 +67,11 @@ const LayoutPreview = () => { g.groupName.toLowerCase().startsWith("custom-") ); + // Sort custom groups by last_updated_at desc using summaryMap + const customGroupsSorted = [...customGroups].sort( + (a, b) => (summaryMap[b.groupName]?.lastUpdatedAt || 0) - (summaryMap[a.groupName]?.lastUpdatedAt || 0) + ); + // Handle loading state if (loading) { return ; @@ -77,41 +105,47 @@ const LayoutPreview = () => {

In Built Templates

- {inBuiltGroups.map((group) => ( - router.push(`/template-preview/${group.groupName}`)} - > -
-
-

- {group.groupName} -

-
- - {group.layouts.length} + {inBuiltGroups.map((group) => { + const isCustom = group.groupName.toLowerCase().startsWith("custom-"); + const meta = summaryMap[group.groupName]; + const displayName = isCustom && meta?.name ? meta.name : group.groupName; + const displayDescription = isCustom && meta?.description ? meta.description : group.settings.description; + return ( + router.push(`/template-preview/${group.groupName}`)} + > +
+
+

+ {displayName} +

+
+ + {group.layouts.length} + + +
+
+

+ {displayDescription} +

+
+ + {group.layouts.length} layout + {group.layouts.length !== 1 ? "s" : ""} - + {group.settings.default && ( + + Default + + )}
-

- {group.settings.description} -

-
- - {group.layouts.length} layout - {group.layouts.length !== 1 ? "s" : ""} - - {group.settings.default && ( - - Default - - )} -
-
- - ))} + + ); + })}
@@ -123,37 +157,42 @@ const LayoutPreview = () => {

Custom AI Templates

- {customGroups.length > 0 ? ( - customGroups.map((group) => ( - router.push(`/template-preview/${group.groupName}`)} - > -
-
-

- {group.groupName} -

-
- - {group.layouts.length} + {customGroupsSorted.length > 0 ? ( + customGroupsSorted.map((group) => { + const meta = summaryMap[group.groupName]; + const displayName = meta?.name ? meta.name : group.groupName; + const displayDescription = meta?.description ? meta.description : group.settings.description; + return ( + router.push(`/template-preview/${group.groupName}`)} + > +
+
+

+ {displayName} +

+
+ + {group.layouts.length} + + +
+
+

+ {displayDescription} +

+
+ + {group.layouts.length} layout + {group.layouts.length !== 1 ? "s" : ""} -
-

- {group.settings.description} -

-
- - {group.layouts.length} layout - {group.layouts.length !== 1 ? "s" : ""} - -
-
- - )) + + ); + }) ) : (