diff --git a/backend/alembic/versions/005_add_layout_fields.py b/backend/alembic/versions/005_add_layout_fields.py new file mode 100644 index 0000000..328523e --- /dev/null +++ b/backend/alembic/versions/005_add_layout_fields.py @@ -0,0 +1,31 @@ +"""Add is_enabled and thumbnail_path to presentation_layout_codes + +Revision ID: 005_add_layout_fields +Revises: 004_add_rls_policies +Create Date: 2026-03-01 10:00:00.000000 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = '005_add_layout_fields' +down_revision = '004_add_rls_policies' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + 'presentation_layout_codes', + sa.Column('is_enabled', sa.Boolean(), nullable=True, server_default=sa.text('true')) + ) + op.add_column( + 'presentation_layout_codes', + sa.Column('thumbnail_path', sa.String(), nullable=True) + ) + + +def downgrade(): + op.drop_column('presentation_layout_codes', 'thumbnail_path') + op.drop_column('presentation_layout_codes', 'is_enabled') diff --git a/backend/api/v1/admin/master_decks_router.py b/backend/api/v1/admin/master_decks_router.py index 33fcb44..7c0fcd6 100644 --- a/backend/api/v1/admin/master_decks_router.py +++ b/backend/api/v1/admin/master_decks_router.py @@ -404,10 +404,27 @@ async def delete_master_deck( await check_team_admin(admin, deck.client_id, session) - # Soft delete - deck.is_active = False - await session.commit() + # Hard delete: remove physical files + all DB records + deck_dir = _deck_dir(deck.client_id, deck_id) + if os.path.isdir(deck_dir): + shutil.rmtree(deck_dir, ignore_errors=True) + # Delete PresentationLayoutCodeModel rows + from models.sql.presentation_layout_code import PresentationLayoutCodeModel + from models.sql.template import TemplateModel + from sqlalchemy import delete as sql_delete + await session.execute( + sql_delete(PresentationLayoutCodeModel).where( + PresentationLayoutCodeModel.presentation == deck_id + ) + ) + # Delete TemplateModel (deck_id == template id) + await session.execute( + sql_delete(TemplateModel).where(TemplateModel.id == deck_id) + ) + # Delete MasterDeckModel + await session.delete(deck) + await session.commit() return {"ok": True} diff --git a/backend/api/v1/ppt/endpoints/slide_to_html.py b/backend/api/v1/ppt/endpoints/slide_to_html.py index 847926d..c513984 100644 --- a/backend/api/v1/ppt/endpoints/slide_to_html.py +++ b/backend/api/v1/ppt/endpoints/slide_to_html.py @@ -70,6 +70,9 @@ class LayoutData(BaseModel): layout_name: str # Display name of the layout layout_code: str # TSX/React component code for the layout fonts: Optional[List[str]] = None # Optional list of font links + db_id: Optional[int] = None # new: DB primary key for toggle + is_enabled: Optional[bool] = True # new + thumbnail_path: Optional[str] = None # new class SaveLayoutsRequest(BaseModel): @@ -667,6 +670,9 @@ async def get_layouts( layout_name=layout.layout_name, layout_code=layout.layout_code, fonts=layout.fonts, + db_id=layout.id, + is_enabled=layout.is_enabled if layout.is_enabled is not None else True, + thumbnail_path=layout.thumbnail_path, ) for layout in layouts_db ] @@ -848,3 +854,25 @@ async def delete_template( await session.commit() except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to delete template") + + +class ToggleLayoutEnabledResponse(BaseModel): + ok: bool + is_enabled: bool + + +@LAYOUT_MANAGEMENT_ROUTER.patch( + "/layouts/{layout_db_id}/toggle-enabled", + response_model=ToggleLayoutEnabledResponse, +) +async def toggle_layout_enabled( + layout_db_id: int, + session: AsyncSession = Depends(get_async_session), +): + """Toggle the is_enabled flag for a specific layout.""" + layout = await session.get(PresentationLayoutCodeModel, layout_db_id) + if not layout: + raise HTTPException(status_code=404, detail="Layout not found") + layout.is_enabled = not (layout.is_enabled if layout.is_enabled is not None else True) + await session.commit() + return ToggleLayoutEnabledResponse(ok=True, is_enabled=layout.is_enabled) diff --git a/backend/models/sql/presentation_layout_code.py b/backend/models/sql/presentation_layout_code.py index fe57c01..02f7c05 100644 --- a/backend/models/sql/presentation_layout_code.py +++ b/backend/models/sql/presentation_layout_code.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import Optional, List import uuid -from sqlalchemy import Column, DateTime, Text, JSON +from sqlalchemy import Column, DateTime, Text, JSON, Boolean, String from sqlmodel import SQLModel, Field from utils.datetime_utils import get_current_utc_datetime @@ -22,6 +22,16 @@ class PresentationLayoutCodeModel(SQLModel, table=True): fonts: Optional[List[str]] = Field( sa_column=Column(JSON), default=None, description="Optional list of font links" ) + is_enabled: bool = Field( + sa_column=Column(Boolean, default=True, nullable=True), + default=True, + description="Whether this layout is enabled for selection" + ) + thumbnail_path: Optional[str] = Field( + sa_column=Column(String, nullable=True), + default=None, + description="Path to screenshot/thumbnail for this layout" + ) created_at: datetime = Field( sa_column=Column( DateTime(timezone=True), nullable=False, default=get_current_utc_datetime diff --git a/backend/services/master_deck_parser_service.py b/backend/services/master_deck_parser_service.py index 351ed3d..e1c44c1 100644 --- a/backend/services/master_deck_parser_service.py +++ b/backend/services/master_deck_parser_service.py @@ -10,6 +10,7 @@ Pipeline per layout: """ import asyncio import base64 +import json import os import shutil import tempfile @@ -69,6 +70,46 @@ LAYOUT_TYPE_HINTS = { "caption": "caption", } +GEOMETRY_TO_ELEMENTS_PROMPT = """You are analyzing a PowerPoint slide layout to classify its elements. + +You will receive: +1. A screenshot of the slide +2. A JSON array of elements with their pixel positions (x, y, width, height), assuming a 1280x720 slide + +Your task: Assign a placeholder type to each element. + +Available placeholder types: +- "title": Main slide title (large, prominent text) +- "subtitle": Secondary title or subtitle text +- "body": Main content (bullet points, paragraphs) +- "image": Image/picture placeholder +- "chart": Chart or graph area +- "shape": Decorative shape or background element (use for purely visual elements) +- "logo": Company logo area +- "footer": Footer text +- "date": Date/time placeholder + +Also determine: +- "layoutName": A descriptive name (e.g., "Title Slide", "Two Column Content", "Section Header") +- "background": The predominant CSS hex background color (e.g., "#1a1a2e" or "#ffffff") + +Respond with ONLY valid JSON, no markdown fences: +{ + "layoutName": "Title Slide", + "background": "#1a1a2e", + "elements": [ + {"id": "elem-0", "placeholder": "title", "defaultContent": "Slide Title"}, + {"id": "elem-1", "placeholder": "body", "defaultContent": "Content here"} + ] +} + +RULES: +- Element IDs must match input order exactly (elem-0, elem-1, ...) +- Every input element must have a corresponding output element +- Use "shape" for decorative elements when purpose is unclear +- "defaultContent" should be a realistic sample (e.g., "Your Title Here" for title) +""" + def _build_layout_to_slide_map(pptx_path: str, temp_dir: str) -> dict: """Build mapping from slideLayout filename → first slide index that uses it. @@ -309,6 +350,112 @@ def _guess_layout_type(layout_name: str) -> str: return "custom" +def _placeholder_to_type(placeholder: str) -> str: + """Map placeholder name to element type.""" + if placeholder in ("title", "subtitle", "body", "footer", "date", "logo"): + return "text" + elif placeholder == "image": + return "image" + elif placeholder == "chart": + return "chart" + else: + return "shape" + + +def _get_theme_text_color(theme_info: dict, placeholder: str) -> Optional[str]: + """Get text color from theme based on placeholder type.""" + colors = theme_info.get("colors", []) + if not colors: + return None + # Try to find a suitable text color + # Dark/accent colors first for title, light for body on dark backgrounds + color_map = {c.get("name", ""): c.get("hex", "") for c in colors} + if placeholder in ("title", "subtitle"): + return color_map.get("dk1") or color_map.get("lt1") or colors[0].get("hex") if colors else None + return color_map.get("lt1") or color_map.get("dk1") or colors[0].get("hex") if colors else None + + +async def _llm_classify_elements( + provider: dict, img_b64: str, geometry_elements: List[dict] +) -> dict: + """1 LLM call to classify element placeholder types. Returns dict with layoutName, background, elements.""" + import json + # Attach IDs to elements + labeled = [{"id": f"elem-{i}", **e} for i, e in enumerate(geometry_elements)] + user_text = f"SLIDE ELEMENTS (JSON):\n{json.dumps(labeled, indent=2)}" + + result = await UnifiedLLMService.generate_vision_completion( + system_prompt=GEOMETRY_TO_ELEMENTS_PROMPT, + user_text=user_text, + image_base64=img_b64, + provider_override=provider + ) + cleaned = UnifiedLLMService.clean_llm_code_output(result, ["json"]) + # Strip markdown fences if any + cleaned = cleaned.strip() + if cleaned.startswith("```"): + lines = cleaned.split("\n") + cleaned = "\n".join(lines[1:-1] if lines[-1] == "```" else lines[1:]) + return json.loads(cleaned) + + +def _build_element_model( + idx: int, + layout_name: str, + geometry_elements: List[dict], + llm_result: dict, + fonts: List[str], + theme_info: dict, +) -> dict: + """Build the JSON element model from geometry + LLM classification.""" + classified = {e["id"]: e for e in llm_result.get("elements", [])} + + elements = [] + for i, geom in enumerate(geometry_elements): + elem_id = f"elem-{i}" + classification = classified.get(elem_id, {}) + placeholder = classification.get("placeholder", "shape") + default_content = classification.get("defaultContent", "") + elem_type = _placeholder_to_type(placeholder) + + # Build basic style + style: dict = {} + if fonts: + style["fontFamily"] = fonts[0] + if placeholder == "title": + style.update({"fontSize": 48, "fontWeight": "bold"}) + elif placeholder == "subtitle": + style.update({"fontSize": 28}) + elif placeholder == "body": + style.update({"fontSize": 20}) + elif placeholder == "footer": + style.update({"fontSize": 14}) + + # Theme colors + theme_color = _get_theme_text_color(theme_info, placeholder) + if theme_color: + style["color"] = theme_color + + elements.append({ + "id": elem_id, + "type": elem_type, + "placeholder": placeholder, + "x": geom.get("x", 0), + "y": geom.get("y", 0), + "w": geom.get("width", geom.get("w", 0)), + "h": geom.get("height", geom.get("h", 0)), + "style": style, + "defaultContent": default_content, + }) + + return { + "layoutId": f"layout-{idx}", + "layoutName": llm_result.get("layoutName", layout_name), + "slideWidth": 1280, + "slideHeight": 720, + "background": llm_result.get("background", "#ffffff"), + "elements": elements, + } @@ -374,7 +521,7 @@ async def parse_master_deck(deck_id: uuid.UUID) -> None: await session.commit() try: - result = await _do_parse(deck_id) + result = await _do_parse_v2(deck_id) async with async_session_maker() as session: deck = await session.get(MasterDeckModel, deck_id) if not deck: @@ -625,6 +772,168 @@ async def _do_parse(deck_id: uuid.UUID) -> dict: } +async def _do_parse_v2(deck_id: uuid.UUID) -> dict: + """New JSON-based parsing pipeline. 1 LLM call per layout instead of 2. + + Output layout_code is a JSON element model, not TSX code. + """ + import json + async with async_session_maker() as session: + from models.sql.master_deck import MasterDeckModel + deck = await session.get(MasterDeckModel, deck_id) + if not deck: + raise ValueError("Deck not found") + pptx_path = deck.original_file_path + client_id = deck.client_id + parse_mode = getattr(deck, "parse_mode", None) or "layouts" + + if not os.path.exists(pptx_path): + raise FileNotFoundError(f"PPTX file not found: {pptx_path}") + + with tempfile.TemporaryDirectory() as temp_dir: + slide_xmls = _extract_slide_xmls(pptx_path, temp_dir) + layout_to_slide_map = _build_layout_to_slide_map(pptx_path, temp_dir) + + if parse_mode == "layouts": + primary_metas = _extract_slide_layout_xmls(pptx_path, temp_dir) + print(f"[ParserV2] Mode=layouts: {len(primary_metas)} slideLayouts") + else: + primary_metas = _extract_slides_with_layout_info(pptx_path, temp_dir) + print(f"[ParserV2] Mode=slides: {len(primary_metas)} actual slides") + + layout_metas_for_fonts = _extract_slide_layout_xmls(pptx_path, temp_dir) + theme_info = _extract_theme_info(pptx_path, temp_dir) + + # Generate screenshots + screenshots = [] + thumbnail_path = None + try: + pdf_path = await _convert_pptx_to_pdf(pptx_path, temp_dir) + screenshot_paths = await DocumentsLoader.get_page_images_from_pdf_async(pdf_path, temp_dir) + app_data = os.environ.get("APP_DATA_DIRECTORY", os.path.join(os.path.dirname(__file__), "..", "data")) + deck_dir = os.path.join(app_data, "clients", str(client_id), "master_decks", str(deck_id), "screenshots") + os.makedirs(deck_dir, exist_ok=True) + for i, sp in enumerate(screenshot_paths): + if os.path.exists(sp) and os.path.getsize(sp) > 0: + dest = os.path.join(deck_dir, f"slide_{i + 1}.png") + shutil.copy2(sp, dest) + screenshots.append(dest) + if i == 0: + thumbnail_path = dest + except Exception as e: + print(f"[ParserV2] Screenshot generation failed (non-fatal): {e}") + + # Collect fonts + all_fonts = set() + for lm in layout_metas_for_fonts: + raw = extract_fonts_from_oxml(lm["xml_content"]) + all_fonts.update(normalize_font_family_name(f) for f in raw if f) + for sx in slide_xmls: + raw = extract_fonts_from_oxml(sx) + all_fonts.update(normalize_font_family_name(f) for f in raw if f) + + # Screenshot mapping + layout_screenshot_map: dict = {} + if parse_mode == "layouts": + for idx, lm in enumerate(primary_metas): + layout_filename = lm.get("filename", "") + slide_idx = layout_to_slide_map.get(layout_filename) + if slide_idx is not None and slide_idx < len(screenshots): + layout_screenshot_map[idx] = screenshots[slide_idx] + else: + for idx in range(min(len(primary_metas), len(screenshots))): + layout_screenshot_map[idx] = screenshots[idx] + + llm_provider = _get_parsing_provider() + print(f"[ParserV2] Using {llm_provider['model']} for element classification") + + # Build layout entries + layout_entries = [] + for idx, lm in enumerate(primary_metas): + screenshot_path = layout_screenshot_map.get(idx) + per_layout_fonts = list( + {normalize_font_family_name(f) for f in extract_fonts_from_oxml(lm["xml_content"]) if f} + ) + layout_entries.append({ + "index": idx, + "layout_name": lm["layout_name"], + "xml_content": lm["xml_content"], + "fonts": per_layout_fonts, + "screenshot_path": screenshot_path, + "element_model": None, + }) + + # Parallel element classification + print(f"[ParserV2] Classifying elements for {len(layout_entries)} layouts...") + + async def classify_layout(entry): + if not entry["screenshot_path"] or not os.path.exists(entry["screenshot_path"]): + return None + try: + with open(entry["screenshot_path"], "rb") as img_f: + img_b64 = base64.b64encode(img_f.read()).decode("utf-8") + geometry = extract_geometry_from_oxml(entry["xml_content"]) + if not geometry: + return None + llm_result = await _llm_classify_elements(llm_provider, img_b64, geometry) + fonts_for_model = entry["fonts"] or list(all_fonts)[:3] + model = _build_element_model( + entry["index"], entry["layout_name"], + geometry, llm_result, fonts_for_model, theme_info + ) + return model + except Exception as e: + print(f"[ParserV2] Classification failed for {entry['layout_name']}: {e}") + return None + + tasks = [classify_layout(entry) for entry in layout_entries] + results = await asyncio.gather(*tasks, return_exceptions=True) + + for entry, result in zip(layout_entries, results): + if result and not isinstance(result, Exception): + entry["element_model"] = result + print(f"[ParserV2] Layout '{entry['layout_name']}' classified: {len(result.get('elements', []))} elements") + + # Build final layouts list (same structure as old, but layout_code is JSON) + layouts_result = [] + for entry in layout_entries: + model = entry.get("element_model") + if model: + layouts_result.append({ + "index": entry["index"], + "layout_name": model["layoutName"], + "layout_type": _guess_layout_type(model["layoutName"]), + "react_code": json.dumps(model), # Store JSON as react_code for compat + "fonts": entry["fonts"], + "screenshot_path": entry["screenshot_path"], + }) + else: + # No screenshot/failed → include without code + layouts_result.append({ + "index": entry["index"], + "layout_name": entry["layout_name"], + "layout_type": _guess_layout_type(entry["layout_name"]), + "react_code": None, + "fonts": entry["fonts"], + "screenshot_path": entry["screenshot_path"], + }) + + parsed_config = { + "theme": theme_info, + "total_slides": len(slide_xmls), + "total_layouts": len(layout_metas_for_fonts), + "parse_mode": parse_mode, + "fonts": sorted(all_fonts), + "parser_version": "v2", # Mark as new parser + } + + return { + "parsed_config": parsed_config, + "layouts": layouts_result, + "thumbnail_path": thumbnail_path, + } + + async def _register_as_template( deck_id: uuid.UUID, deck_name: str, @@ -672,6 +981,8 @@ async def _register_as_template( layout_name=layout.get("layout_name", f"Layout {idx + 1}"), layout_code=react_code, fonts=layout.get("fonts"), + thumbnail_path=layout.get("screenshot_path"), + is_enabled=True, ) session.add(layout_code) diff --git a/frontend/app/(presentation-generator)/components/SlideRenderer.tsx b/frontend/app/(presentation-generator)/components/SlideRenderer.tsx new file mode 100644 index 0000000..22b081c --- /dev/null +++ b/frontend/app/(presentation-generator)/components/SlideRenderer.tsx @@ -0,0 +1,151 @@ +"use client"; + +import React from "react"; +import { LayoutSchema, SlideElement, mergeElementsWithContent } from "@/app/hooks/parseLayoutSchema"; + +interface SlideRendererProps { + schema: LayoutSchema; + /** Optional slide content data (keys = placeholder names) */ + content?: Record; + /** Scale factor for the slide container (default: fill container) */ + className?: string; + style?: React.CSSProperties; +} + +/** + * Renders a slide from a JSON element model. + * Uses absolutely-positioned divs at percentage coordinates. + * No Babel/TSX compilation — pure data-driven rendering. + */ +export function SlideRenderer({ + schema, + content, + className = "", + style, +}: SlideRendererProps) { + const elements = mergeElementsWithContent(schema, content); + const { slideWidth, slideHeight, background } = schema; + + return ( +
+ {elements.map((elem) => ( + + ))} +
+ ); +} + +function ElementRenderer({ + elem, + slideWidth, + slideHeight, +}: { + elem: SlideElement; + slideWidth: number; + slideHeight: number; +}) { + const posStyle: React.CSSProperties = { + position: "absolute", + left: `${(elem.x / slideWidth) * 100}%`, + top: `${(elem.y / slideHeight) * 100}%`, + width: `${(elem.w / slideWidth) * 100}%`, + height: `${(elem.h / slideHeight) * 100}%`, + overflow: "hidden", + }; + + if (elem.type === "text" || elem.type === "shape") { + const displayText = elem.content || elem.defaultContent || ""; + if (!displayText && elem.type === "shape") { + // Decorative shape — render as colored block if we have background + return ( +
+ ); + } + return ( +
+
+ {displayText} +
+
+ ); + } + + if (elem.type === "image") { + const src = elem.imageUrl; + if (!src) { + return ( +
+ + Image + +
+ ); + } + return ( +
+ +
+ ); + } + + if (elem.type === "chart") { + return ( +
+ + Chart + +
+ ); + } + + return null; +} + +export default SlideRenderer; diff --git a/frontend/app/admin/components/AdminSidebar.tsx b/frontend/app/admin/components/AdminSidebar.tsx index a15bf2b..d17e7ba 100644 --- a/frontend/app/admin/components/AdminSidebar.tsx +++ b/frontend/app/admin/components/AdminSidebar.tsx @@ -15,6 +15,7 @@ import { Settings, ArrowLeft, LogOut, + LayoutTemplate, } from 'lucide-react'; interface NavItem { @@ -27,6 +28,7 @@ interface NavItem { const NAV_ITEMS: NavItem[] = [ { label: 'Users', href: '/admin/users', icon: , roles: ['super_admin'] }, { label: 'Clients', href: '/admin/clients', icon: , roles: ['super_admin', 'client_admin'] }, + { label: 'Templates', href: '/admin/templates', icon: , roles: ['super_admin', 'client_admin'] }, { label: 'Storage', href: '/admin/storage', icon: , roles: ['super_admin', 'client_admin'] }, { label: 'Audit Log', href: '/admin/audit', icon: , roles: ['super_admin', 'client_admin'] }, { label: 'Analytics', href: '/admin/analytics', icon: , roles: ['super_admin', 'client_admin'] }, diff --git a/frontend/app/admin/templates/[id]/page.tsx b/frontend/app/admin/templates/[id]/page.tsx new file mode 100644 index 0000000..7ef8e16 --- /dev/null +++ b/frontend/app/admin/templates/[id]/page.tsx @@ -0,0 +1,197 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { useParams } from 'next/navigation'; +import Link from 'next/link'; +import { Button } from '@/components/ui/button'; +import { + ArrowLeft, + Eye, + EyeOff, + FileCode, + Loader2, + RefreshCw, + LayoutTemplate, +} from 'lucide-react'; +import { toast } from 'sonner'; +import { Switch } from '@/components/ui/switch'; + +interface LayoutItem { + db_id: number; + layout_id: string; + layout_name: string; + is_enabled: boolean; + thumbnail_path: string | null; + has_code: boolean; +} + +interface TemplateDetail { + id: string; + name: string; + description?: string; +} + +export default function TemplateDetailPage() { + const params = useParams(); + const templateId = params.id as string; + + const [template, setTemplate] = useState(null); + const [layouts, setLayouts] = useState([]); + const [loading, setLoading] = useState(true); + const [togglingId, setTogglingId] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + try { + const res = await fetch(`/api/v1/ppt/template-management/get-templates/${templateId}`); + if (!res.ok) throw new Error('Failed to load template'); + const data = await res.json(); + + if (data.template) { + setTemplate({ + id: String(data.template.id), + name: data.template.name || 'Custom Template', + description: data.template.description, + }); + } + + const items: LayoutItem[] = (data.layouts || []).map((l: any) => ({ + db_id: l.db_id, + layout_id: l.layout_id, + layout_name: l.layout_name, + is_enabled: l.is_enabled !== false, + thumbnail_path: l.thumbnail_path || null, + has_code: !!l.layout_code, + })); + setLayouts(items); + } catch { + toast.error('Failed to load template layouts'); + } finally { + setLoading(false); + } + }, [templateId]); + + useEffect(() => { load(); }, [load]); + + const handleToggle = async (layout: LayoutItem) => { + if (!layout.db_id) { + toast.error('Cannot toggle: layout ID missing'); + return; + } + setTogglingId(layout.db_id); + try { + const res = await fetch( + `/api/v1/ppt/template-management/layouts/${layout.db_id}/toggle-enabled`, + { method: 'PATCH' } + ); + if (!res.ok) throw new Error('Toggle failed'); + const data = await res.json(); + setLayouts((prev) => + prev.map((l) => + l.db_id === layout.db_id ? { ...l, is_enabled: data.is_enabled } : l + ) + ); + toast.success(data.is_enabled ? 'Layout enabled' : 'Layout disabled'); + } catch { + toast.error('Failed to toggle layout'); + } finally { + setTogglingId(null); + } + }; + + const enabledCount = layouts.filter((l) => l.is_enabled).length; + + return ( +
+
+
+ + + +
+

+ {loading ? 'Loading...' : template?.name || 'Template Layouts'} +

+ {!loading && ( +

+ {enabledCount} of {layouts.length} layouts enabled +

+ )} +
+
+ +
+ + {loading ? ( +
+ {[...Array(8)].map((_, i) => ( +
+ ))} +
+ ) : layouts.length === 0 ? ( +
+ +

No layouts in this template.

+
+ ) : ( +
+ {layouts.map((layout) => ( +
+ {/* Thumbnail — served via admin screenshot API */} + {layout.thumbnail_path ? ( +
+ {layout.layout_name} { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> +
+ ) : ( +
+ +
+ )} + {/* Info + toggle */} +
+

{layout.layout_name}

+
+ + {layout.has_code ? 'Has layout' : 'No layout'} + +
+ {togglingId === layout.db_id ? ( + + ) : ( + handleToggle(layout)} + className="scale-75" + /> + )} +
+
+
+
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/app/admin/templates/page.tsx b/frontend/app/admin/templates/page.tsx new file mode 100644 index 0000000..17cff6b --- /dev/null +++ b/frontend/app/admin/templates/page.tsx @@ -0,0 +1,169 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import Link from 'next/link'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + ArrowLeft, + LayoutTemplate, + Trash2, + ChevronRight, + Loader2, + RefreshCw, +} from 'lucide-react'; +import { toast } from 'sonner'; + +interface TemplateSummary { + id: string; + name: string; + layoutCount: number; + lastUpdated?: string; +} + +export default function TemplatesPage() { + const [templates, setTemplates] = useState([]); + const [loading, setLoading] = useState(true); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleting, setDeleting] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + try { + const res = await fetch('/api/v1/ppt/template-management/summary'); + if (!res.ok) throw new Error('Failed to load templates'); + const data = await res.json(); + const mapped: TemplateSummary[] = (data.presentations || []).map((item: any) => ({ + id: item.template?.id || item.presentation_id, + name: item.template?.name || 'Unnamed Template', + layoutCount: item.layout_count || 0, + lastUpdated: item.last_updated_at, + })); + setTemplates(mapped); + } catch (err) { + toast.error('Failed to load templates'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { load(); }, [load]); + + const handleDelete = async () => { + if (!deleteTarget) return; + setDeleting(true); + try { + const res = await fetch(`/api/v1/ppt/template-management/delete-templates/${deleteTarget.id}`, { + method: 'DELETE', + }); + if (!res.ok && res.status !== 204) throw new Error('Failed to delete'); + toast.success(`"${deleteTarget.name}" deleted`); + setDeleteTarget(null); + load(); + } catch { + toast.error('Failed to delete template'); + } finally { + setDeleting(false); + } + }; + + return ( +
+
+
+ + + +

Custom Templates

+
+ +
+ + {loading ? ( +
+ {[...Array(3)].map((_, i) => ( +
+ ))} +
+ ) : templates.length === 0 ? ( +
+ +

No custom templates yet.

+

+ Upload a PPTX master deck from a client page to get started. +

+
+ ) : ( +
+ {templates.map((tpl) => ( +
+ + +
+

+ {tpl.name} +

+

+ {tpl.layoutCount} layout{tpl.layoutCount !== 1 ? 's' : ''} + {tpl.lastUpdated && ` · Updated ${new Date(tpl.lastUpdated).toLocaleDateString()}`} +

+
+ + + +
+ ))} +
+ )} + + !open && setDeleteTarget(null)}> + + + Delete Template + +

+ Delete {deleteTarget?.name}? This will remove all{' '} + {deleteTarget?.layoutCount} layouts and cannot be undone. +

+
+ + +
+
+
+
+ ); +} diff --git a/frontend/app/hooks/parseLayoutSchema.ts b/frontend/app/hooks/parseLayoutSchema.ts new file mode 100644 index 0000000..a181867 --- /dev/null +++ b/frontend/app/hooks/parseLayoutSchema.ts @@ -0,0 +1,106 @@ +"use client"; + +/** + * parseLayoutSchema.ts + * Parses the new JSON-based element model for slide layouts. + * Replaces the Babel/TSX compile approach for custom templates. + */ + +export interface SlideElement { + id: string; + type: "text" | "image" | "chart" | "shape"; + placeholder: string; + x: number; + y: number; + w: number; + h: number; + style?: Record; + defaultContent?: string; + content?: string; + imageUrl?: string; +} + +export interface LayoutSchema { + layoutId: string; + layoutName: string; + slideWidth: number; + slideHeight: number; + background: string; + elements: SlideElement[]; +} + +export interface ParsedLayout { + layoutId: string; + layoutName: string; + layoutDescription: string; + schema: LayoutSchema; + sampleData: Record; + isJsonLayout: true; +} + +/** + * Detect if a layout_code string is the new JSON element model format. + */ +export function isJsonLayoutCode(layoutCode: string): boolean { + if (!layoutCode || !layoutCode.trim().startsWith("{")) return false; + try { + const parsed = JSON.parse(layoutCode); + return Array.isArray(parsed.elements) && typeof parsed.layoutId === "string"; + } catch { + return false; + } +} + +/** + * Parse a JSON layout schema string into a ParsedLayout. + * Returns null if the string is not valid JSON or not the new format. + */ +export function parseLayoutSchema(layoutCode: string): ParsedLayout | null { + try { + const schema: LayoutSchema = JSON.parse(layoutCode); + if (!schema.elements || !Array.isArray(schema.elements)) { + return null; + } + + // Build sample data from element defaults + const sampleData: Record = {}; + schema.elements.forEach((elem) => { + if (elem.placeholder && elem.defaultContent) { + sampleData[elem.placeholder] = elem.defaultContent; + } + }); + + return { + layoutId: schema.layoutId || "layout-0", + layoutName: schema.layoutName || "Layout", + layoutDescription: `${schema.elements.length} elements`, + schema, + sampleData, + isJsonLayout: true, + }; + } catch { + return null; + } +} + +/** + * Merge layout schema elements with slide content data. + * Slide content keys map to element placeholder names. + */ +export function mergeElementsWithContent( + schema: LayoutSchema, + content?: Record +): SlideElement[] { + return schema.elements.map((elem) => ({ + ...elem, + content: + (content?.[elem.placeholder] as string) ?? + (content?.[elem.id] as string) ?? + elem.defaultContent ?? + "", + imageUrl: + (content?.[`${elem.placeholder}_url`] as string) ?? + (content?.[`image_url`] as string) ?? + undefined, + })); +} diff --git a/frontend/app/hooks/useCustomTemplates.ts b/frontend/app/hooks/useCustomTemplates.ts index 641cf1f..210cf6f 100644 --- a/frontend/app/hooks/useCustomTemplates.ts +++ b/frontend/app/hooks/useCustomTemplates.ts @@ -1,9 +1,34 @@ "use client"; +import React from "react"; import { useState, useEffect, useCallback } from "react"; import { compileCustomLayout, CompiledLayout } from "./compileLayout"; import TemplateService from "../(presentation-generator)/services/api/template"; +import { isJsonLayoutCode, parseLayoutSchema, ParsedLayout, LayoutSchema } from "./parseLayoutSchema"; +import { SlideRenderer } from "../(presentation-generator)/components/SlideRenderer"; + +// Adapter: convert ParsedLayout (JSON schema) into a CompiledLayout-compatible object +// so existing code that uses CompiledLayout can work with both formats. +function parsedLayoutToCompiled(parsed: ParsedLayout): CompiledLayout { + const schema: LayoutSchema = parsed.schema; + + // Create a React component that renders the JSON schema + function JsonSlideComponent({ data }: { data: any }) { + return React.createElement(SlideRenderer, { schema, content: data }); + } + JsonSlideComponent.displayName = parsed.layoutName; + + return { + component: JsonSlideComponent, + layoutId: parsed.layoutId, + layoutName: parsed.layoutName, + layoutDescription: parsed.layoutDescription, + schema: null, + sampleData: parsed.sampleData, + schemaJSON: null, + }; +} @@ -103,7 +128,13 @@ export async function getCustomTemplateFirstSlidePreview( return null; } - const compiled = compileCustomLayout(firstLayout.layout_code); + let compiled: CompiledLayout | null = null; + if (isJsonLayoutCode(firstLayout.layout_code)) { + const parsed = parseLayoutSchema(firstLayout.layout_code); + if (parsed) compiled = parsedLayoutToCompiled(parsed); + } else { + compiled = compileCustomLayout(firstLayout.layout_code); + } customTemplateFirstSlideCache.set(presentationId, compiled); return compiled; } catch (err) { @@ -156,7 +187,13 @@ export async function getCustomTemplateDetails( for (const layout of data.layouts) { try { - const compiled = compileCustomLayout(layout.layout_code); + let compiled: CompiledLayout | null = null; + if (isJsonLayoutCode(layout.layout_code)) { + const parsed = parseLayoutSchema(layout.layout_code); + if (parsed) compiled = parsedLayoutToCompiled(parsed); + } else { + compiled = compileCustomLayout(layout.layout_code); + } if (compiled) { compiledLayouts.push({ @@ -168,7 +205,7 @@ export async function getCustomTemplateDetails( fonts: layout.fonts, }); } else { - console.warn(`Failed to compile layout: ${layout.layout_name}`); + console.warn(`Failed to compile/parse layout: ${layout.layout_name}`); } } catch (compileError) { console.error(`Error compiling ${layout.layout_name}:`, compileError); @@ -315,7 +352,13 @@ export function useCustomTemplateDetails(templateDetail: { id: string, name: str for (const layout of data.layouts) { try { - const compiled = compileCustomLayout(layout.layout_code); + let compiled: CompiledLayout | null = null; + if (isJsonLayoutCode(layout.layout_code)) { + const parsed = parseLayoutSchema(layout.layout_code); + if (parsed) compiled = parsedLayoutToCompiled(parsed); + } else { + compiled = compileCustomLayout(layout.layout_code); + } if (compiled) { compiledLayouts.push({ @@ -328,7 +371,7 @@ export function useCustomTemplateDetails(templateDetail: { id: string, name: str layoutId: compiled?.layoutId ?? "", }); } else { - console.warn(`Failed to compile layout: ${layout.layout_name}`); + console.warn(`Failed to compile/parse layout: ${layout.layout_name}`); } } catch (compileError) { console.error(`Error compiling ${layout.layout_name}:`, compileError); @@ -407,12 +450,19 @@ export function useCustomTemplatePreview(presentationId: string) { for (const layout of layoutsToPreview) { try { - const result = compileCustomLayout(layout.layout_code); - if (result) { - compiled.push(result); + if (isJsonLayoutCode(layout.layout_code)) { + const parsed = parseLayoutSchema(layout.layout_code); + if (parsed) { + compiled.push(parsedLayoutToCompiled(parsed)); + } + } else { + const result = compileCustomLayout(layout.layout_code); + if (result) { + compiled.push(result); + } } } catch (e) { - console.warn(`Failed to compile preview: ${layout.layout_name}`); + console.warn(`Failed to compile/parse preview: ${layout.layout_name}`); } }