Merge branch 'pdf-pptx-layout' of github.com:presenton/presenton into pdf-pptx-layout

This commit is contained in:
shiva raj badu 2025-08-10 08:31:57 +05:45
commit b6c2cbd30b
No known key found for this signature in database
12 changed files with 1053 additions and 119 deletions

View file

@ -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)}"
)
)
@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)}")

View file

@ -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))

View file

@ -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(

View file

@ -346,7 +346,7 @@ export const LayoutProvider: React.FC<{
const fullDataByGroup = new Map<string, FullDataInfo[]>();
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;

View file

@ -97,8 +97,7 @@ const FontManager: React.FC<FontManagerProps> = ({
Font Management
</CardTitle>
<p className="text-sm text-gray-600">
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.
</p>
</CardHeader>
<CardContent className="space-y-6">

View file

@ -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<React.SetStateAction<ProcessedSlide[]>>
) => {
@ -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,

View file

@ -35,6 +35,7 @@ const CustomTemplatePage = () => {
const { isSavingLayout, isModalOpen, openSaveModal, closeSaveModal, saveLayout } = useLayoutSaving(
slides,
UploadedFonts,
fontsData,
refetch,
setSlides
);

View file

@ -20,18 +20,22 @@ const LayoutSelection: React.FC<LayoutSelectionProps> = ({
loading
} = useLayout();
const [summaryMap, setSummaryMap] = React.useState<Record<string, number>>({});
const [summaryMap, setSummaryMap] = React.useState<Record<string, { lastUpdatedAt?: number; name?: string; description?: string }>>({});
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<string, number> = {};
const map: Record<string, { lastUpdatedAt?: number; name?: string; description?: string }> = {};
if (data && Array.isArray(data.presentations)) {
for (const p of data.presentations) {
// groups are named custom-<presentation_id>
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<LayoutSelectionProps> = ({
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<LayoutSelectionProps> = ({
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

View file

@ -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<HTMLDivElement | null>(null);
const { getFullDataByGroup, loading,refetch } = useLayout();
const layoutGroup = getFullDataByGroup(slug);
const [isEditMode, setIsEditMode] = useState(false);
const [selectedIndex, setSelectedIndex] = useState<number | null>(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 <LoadingStates type="loading" />;
}
// Handle empty state
if (!layoutGroup || layoutGroup.length === 0) {
return <LoadingStates type="empty" />;
}
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<HTMLDivElement |null>,
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 (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white shadow-sm border-b sticky top-0 z-30">
<div className="max-w-7xl mx-auto px-6 py-6">
{/* Navigation */}
<div className="flex items-center gap-4 mb-4">
<Button
variant="outline"
size="sm"
onClick={() => router.back()}
className="flex items-center gap-2"
>
<ArrowLeft className="w-4 h-4" />
Back
</Button>
<Button
variant="outline"
size="sm"
onClick={() => router.push("/template-preview")}
className="flex items-center gap-2"
>
<Home className="w-4 h-4" />
All Groups
</Button>
{isCustom && <button className=" border border-red-200 flex justify-center items-center gap-2 text-red-700 px-4 py-1 rounded-md" onClick={() => {
deleteLayouts();
}}><Trash2 className="w-4 h-4" />Delete</button>}
</div>
<div className="text-center">
<h1 className="text-3xl font-bold text-gray-900 capitalize">
{layoutGroup[0].groupName} Layouts
</h1>
<p className="text-gray-600 mt-2">
{layoutGroup.length} layout{layoutGroup.length !== 1 ? "s" : ""} {" "}
{layoutGroup[0].groupName}
</p>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-6 py-8">
{/* Edit Controls (no HTML editor) */}
{isCustom && (
<EditControls
isEditMode={isEditMode}
prompt={prompt}
isUpdating={isUpdating}
strokeWidth={strokeWidth}
strokeColor={strokeColor}
eraserMode={eraserMode}
onPromptChange={setPrompt}
onSave={() => {
setIsUpdating(true);
setTimeout(() => {
setIsUpdating(false);
setIsEditMode(false);
setSelectedIndex(null);
}, 300);
}}
onCancel={() => {
setIsEditMode(false);
setSelectedIndex(null);
handleClearCanvas();
}}
onStrokeWidthChange={handleStrokeWidthChange}
onStrokeColorChange={handleStrokeColorChange}
onEraserModeChange={handleEraserModeChange}
onClearCanvas={handleClearCanvas}
/>
)}
<div className="space-y-8">
{layoutGroup.map((layout: any, index: number) => {
const {
component: LayoutComponent,
sampleData,
name,
fileName,
} = layout;
const isSelected = isCustom && isEditMode && selectedIndex === index;
return (
<Card
key={`${layoutGroup[0].groupName}-${index}`}
className="overflow-hidden shadow-md hover:shadow-lg transition-shadow"
>
{/* Layout Header */}
<div className="bg-white px-6 py-4 border-b">
<div className="flex items-center justify-between">
<div>
<h3 className="text-xl font-semibold text-gray-900">
{name}
</h3>
<div className="flex items-center gap-4 mt-1">
<span className="text-sm text-gray-500 font-mono">
{fileName}
</span>
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{layoutGroup[0].groupName}
</span>
</div>
</div>
<div className="text-right">
{isCustom && (
<button
className="border flex items-center gap-2 border-blue-400 bg-blue-50 px-4 py-1 rounded-md text-blue-700"
onClick={() => {
setIsEditMode(true);
setSelectedIndex(index);
}}
>
<Edit className="w-4 h-4" />Edit
</button>
)}
</div>
</div>
</div>
{/* Layout Content */}
<div ref={isSelected ? slideDisplayRef : undefined} className="relative mx-auto w-full">
<div
ref={isSelected ? slideContentRef : undefined}
className="bg-gray-50 aspect-video max-w-[1280px] w-full"
>
<LayoutComponent data={sampleData} />
{isSelected && (
<canvas
ref={canvasRef!}
width={canvasDimensions.width}
height={canvasDimensions.height}
style={{
position: "absolute",
top: 0,
left: 0,
zIndex: 30,
cursor: eraserMode ? "grab" : "crosshair",
pointerEvents: "auto",
touchAction: "none",
}}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onContextMenu={(e) => e.preventDefault()}
/>
)}
</div>
</div>
</Card>
);
})}
</div>
</main>
{/* Footer */}
<footer className="bg-white border-t mt-16">
<div className="max-w-7xl mx-auto px-6 py-8">
<div className="text-center text-gray-600">
<p>
{layoutGroup[0].groupName} {layoutGroup.length} components
</p>
</div>
</div>
</footer>
</div>
);
};
export default GroupLayoutPreview;

View file

@ -33,6 +33,7 @@ const GroupLayoutPreview = () => {
const [currentFonts, setCurrentFonts] = useState<string[] | undefined>(undefined);
const [isSaving, setIsSaving] = useState(false);
const [layoutsMap, setLayoutsMap] = useState<Record<string, { layout_id: string; layout_name: string; layout_code: string; fonts?: string[] }>>({});
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<string, { layout_id: string; layout_name: string; layout_code: string; fonts?: string[] }> = {};
@ -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 = () => {
<div className="text-center">
<h1 className="text-3xl font-bold text-gray-900 capitalize">
{layoutGroup[0].groupName} Layouts
{templateMeta?.name || layoutGroup[0].groupName} Layouts
</h1>
<p className="text-gray-600 mt-2">
{layoutGroup.length} layout{layoutGroup.length !== 1 ? "s" : ""} {" "}
{layoutGroup[0].groupName}
{layoutGroup.length} layout{layoutGroup.length !== 1 ? "s" : ""} {templateMeta?.description || layoutGroup[0].groupName}
</p>
</div>

View file

@ -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<string, LayoutGroup>();
const loadingGroupsCache = new Set<string>();
// 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<LayoutGroup | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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,
};
};

View file

@ -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<Record<string, { lastUpdatedAt?: number; name?: string; description?: string }>>({});
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<string, { lastUpdatedAt?: number; name?: string; description?: string }> = {};
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 <LoadingStates type="loading" />;
@ -77,41 +105,47 @@ const LayoutPreview = () => {
<div className="max-w-7xl mx-auto px-6 py-6 w-full">
<h2 className="text-xl font-semibold text-gray-900 mb-4">In Built Templates</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{inBuiltGroups.map((group) => (
<Card
key={group.groupName}
className="cursor-pointer hover:shadow-md transition-all duration-200 group"
onClick={() => router.push(`/template-preview/${group.groupName}`)}
>
<div className="p-6">
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold text-gray-900 capitalize group-hover:text-blue-600 transition-colors">
{group.groupName}
</h3>
<div className="flex items-center gap-2">
<span className="px-2 py-1 bg-blue-100 text-blue-800 rounded-full text-xs font-medium">
{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 (
<Card
key={group.groupName}
className="cursor-pointer hover:shadow-md transition-all duration-200 group"
onClick={() => router.push(`/template-preview/${group.groupName}`)}
>
<div className="p-6">
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold text-gray-900 capitalize group-hover:text-blue-600 transition-colors">
{displayName}
</h3>
<div className="flex items-center gap-2">
<span className="px-2 py-1 bg-blue-100 text-blue-800 rounded-full text-xs font-medium">
{group.layouts.length}
</span>
<ExternalLink className="w-4 h-4 text-gray-400 group-hover:text-blue-600 transition-colors" />
</div>
</div>
<p className="text-sm text-gray-600 mb-4">
{displayDescription}
</p>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500">
{group.layouts.length} layout
{group.layouts.length !== 1 ? "s" : ""}
</span>
<ExternalLink className="w-4 h-4 text-gray-400 group-hover:text-blue-600 transition-colors" />
{group.settings.default && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
Default
</span>
)}
</div>
</div>
<p className="text-sm text-gray-600 mb-4">
{group.settings.description}
</p>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500">
{group.layouts.length} layout
{group.layouts.length !== 1 ? "s" : ""}
</span>
{group.settings.default && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
Default
</span>
)}
</div>
</div>
</Card>
))}
</Card>
);
})}
</div>
</div>
</section>
@ -123,37 +157,42 @@ const LayoutPreview = () => {
<h2 className="text-xl font-semibold text-gray-900">Custom AI Templates</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{customGroups.length > 0 ? (
customGroups.map((group) => (
<Card
key={group.groupName}
className="cursor-pointer hover:shadow-md transition-all duration-200 group"
onClick={() => router.push(`/template-preview/${group.groupName}`)}
>
<div className="p-6">
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold text-gray-900 capitalize group-hover:text-blue-600 transition-colors">
{group.groupName}
</h3>
<div className="flex items-center gap-2">
<span className="px-2 py-1 bg-blue-100 text-blue-800 rounded-full text-xs font-medium">
{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 (
<Card
key={group.groupName}
className="cursor-pointer hover:shadow-md transition-all duration-200 group"
onClick={() => router.push(`/template-preview/${group.groupName}`)}
>
<div className="p-6">
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold text-gray-900 capitalize group-hover:text-blue-600 transition-colors">
{displayName}
</h3>
<div className="flex items-center gap-2">
<span className="px-2 py-1 bg-blue-100 text-blue-800 rounded-full text-xs font-medium">
{group.layouts.length}
</span>
<ExternalLink className="w-4 h-4 text-gray-400 group-hover:text-blue-600 transition-colors" />
</div>
</div>
<p className="text-sm text-gray-600 mb-4">
{displayDescription}
</p>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500">
{group.layouts.length} layout
{group.layouts.length !== 1 ? "s" : ""}
</span>
<ExternalLink className="w-4 h-4 text-gray-400 group-hover:text-blue-600 transition-colors" />
</div>
</div>
<p className="text-sm text-gray-600 mb-4">
{group.settings.description}
</p>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500">
{group.layouts.length} layout
{group.layouts.length !== 1 ? "s" : ""}
</span>
</div>
</div>
</Card>
))
</Card>
);
})
) : (
<Card
className="cursor-pointer hover:shadow-md transition-all border-blue-500 duration-200 group"