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..b9e8517d 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,14 @@ class GetLayoutsResponse(BaseModel): success: bool layouts: list[LayoutData] message: Optional[str] = None + template: Optional[dict] = 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 +99,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 +645,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 +761,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 +820,22 @@ async def get_layouts( for layout in layouts_db ] + # 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, ) except HTTPException: @@ -823,11 +857,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 +877,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 +907,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 +915,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/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 d1612d74..09a563a0 100644 --- a/servers/nextjs/app/(presentation-generator)/context/LayoutContext.tsx +++ b/servers/nextjs/app/(presentation-generator)/context/LayoutContext.tsx @@ -345,7 +345,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(); @@ -359,7 +359,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..7c4bf06d 100644 --- a/servers/nextjs/app/(presentation-generator)/custom-template/hooks/useLayoutSaving.ts +++ b/servers/nextjs/app/(presentation-generator)/custom-template/hooks/useLayoutSaving.ts @@ -134,9 +134,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 +151,6 @@ export const useLayoutSaving = ( }, body: JSON.stringify({ layouts: reactComponents, - layout_name: layoutName, - description: description, }), } ); diff --git a/servers/nextjs/app/(presentation-generator)/outline/components/LayoutSelection.tsx b/servers/nextjs/app/(presentation-generator)/outline/components/LayoutSelection.tsx index d0d01a81..ca21df93 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 index 4cc4055a..0d3b6ce7 100644 --- a/servers/nextjs/app/(presentation-generator)/template-preview/[slug]/backup.tsx +++ b/servers/nextjs/app/(presentation-generator)/template-preview/[slug]/backup.tsx @@ -86,7 +86,7 @@ import { useDrawingCanvas } from "../../custom-template/hooks/useDrawingCanvas"; 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) { 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 6b634900..4a65b2c3 100644 --- a/servers/nextjs/app/(presentation-generator)/template-preview/[slug]/page.tsx +++ b/servers/nextjs/app/(presentation-generator)/template-preview/[slug]/page.tsx @@ -7,6 +7,7 @@ import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { ArrowLeft, Home, Trash2 } from "lucide-react"; import { useLayout } from "@/app/(presentation-generator)/context/LayoutContext"; + const GroupLayoutPreview = () => { const params = useParams(); const router = useRouter(); @@ -15,6 +16,8 @@ const GroupLayoutPreview = () => { const { getFullDataByGroup, loading,refetch } = useLayout(); const layoutGroup = getFullDataByGroup(slug); + const [templateMeta, setTemplateMeta] = React.useState<{ name?: string; description?: string } | null>(null); + useEffect(() => { const existingScript = document.querySelector( 'script[src*="tailwindcss.com"]' @@ -27,6 +30,21 @@ const GroupLayoutPreview = () => { } }, [slug]); + useEffect(() => { + // Load template meta for custom groups + if (slug.startsWith("custom-")) { + const presentationId = slug.replace("custom-", ""); + fetch(`/api/v1/ppt/template-management/get-templates/${presentationId}`) + .then(res => res.json()) + .then(data => { + if (data?.template) { + setTemplateMeta({ name: data.template.name, description: data.template.description }); + } + }) + .catch(() => setTemplateMeta(null)); + } + }, [slug]); + // Handle loading state if (loading) { return ; @@ -40,7 +58,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) { @@ -79,11 +97,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 index 7e556239..325d3756 100644 --- a/servers/nextjs/app/(presentation-generator)/template-preview/hooks/useGroupLayoutLoader.ts +++ b/servers/nextjs/app/(presentation-generator)/template-preview/hooks/useGroupLayoutLoader.ts @@ -75,7 +75,7 @@ export const useGroupLayoutLoader = ( const presentationId = groupSlug.replace("custom-", ""); const customLayoutResponse = await fetch( - `/api/v1/ppt/layout-management/get-layouts/${presentationId}` + `/api/v1/ppt/template-management/get-templates/${presentationId}` ); if (!customLayoutResponse.ok) { @@ -86,10 +86,11 @@ export const useGroupLayoutLoader = ( const customLayoutsData = await customLayoutResponse.json(); const allLayouts = customLayoutsData.layouts; + const templateMeta = customLayoutsData.template; const groupLayouts: LayoutInfo[] = []; const settings: GroupSetting = { - description: `Custom presentation layouts`, + description: templateMeta?.description || `Custom presentation layouts`, ordered: false, default: false, }; 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" : ""} - -
-
- - )) + + ); + }) ) : (