Phase 8: Data-driven slide architecture + template management overhaul

Replaces TSX/Babel compilation pipeline with a JSON element model:
- New _do_parse_v2(): 1 LLM call/layout (vs 2) classifies OXML geometry
  elements into placeholder types → JSON stored in layout_code
- SlideRenderer.tsx: renders JSON element model as %-positioned divs,
  no Babel compilation or runtime errors
- parseLayoutSchema.ts: isJsonLayoutCode() / parseLayoutSchema() /
  mergeElementsWithContent() — full JSON schema parsing layer
- useCustomTemplates.ts: transparent dual-format support (JSON + TSX)
  via parsedLayoutToCompiled() adapter

Template management improvements:
- PresentationLayoutCodeModel: +is_enabled (bool) +thumbnail_path (str)
- Migration 005: adds both columns to presentation_layout_codes
- DELETE /master-decks/{id}: hard delete (files + TemplateModel +
  PresentationLayoutCodeModel rows + MasterDeckModel)
- PATCH /template-management/layouts/{db_id}/toggle-enabled: new endpoint
- LayoutData response: +db_id, +is_enabled, +thumbnail_path
- _register_as_template(): stores thumbnail_path + is_enabled per layout

Admin UI:
- /admin/templates/ — list all custom templates with delete
- /admin/templates/[id]/ — layout grid with screenshots + enable/disable
- AdminSidebar: Templates nav item

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-03-01 20:05:25 +00:00
parent af95e73806
commit ae41562103
11 changed files with 1086 additions and 14 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<string, unknown>;
/** 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 (
<div
className={`relative overflow-hidden ${className}`}
style={{
aspectRatio: `${slideWidth} / ${slideHeight}`,
background,
...style,
}}
>
{elements.map((elem) => (
<ElementRenderer
key={elem.id}
elem={elem}
slideWidth={slideWidth}
slideHeight={slideHeight}
/>
))}
</div>
);
}
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 (
<div style={{ ...posStyle, background: "rgba(255,255,255,0.1)" }} />
);
}
return (
<div style={posStyle}>
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
overflow: "hidden",
...elem.style,
}}
>
<span style={{ width: "100%" }}>{displayText}</span>
</div>
</div>
);
}
if (elem.type === "image") {
const src = elem.imageUrl;
if (!src) {
return (
<div
style={{
...posStyle,
background: "rgba(128,128,128,0.2)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<span style={{ color: "rgba(128,128,128,0.6)", fontSize: "10px" }}>
Image
</span>
</div>
);
}
return (
<div style={posStyle}>
<img
src={src}
alt=""
style={{
width: "100%",
height: "100%",
objectFit: "cover",
...elem.style,
}}
/>
</div>
);
}
if (elem.type === "chart") {
return (
<div
style={{
...posStyle,
background: "rgba(128,128,128,0.1)",
display: "flex",
alignItems: "center",
justifyContent: "center",
border: "1px dashed rgba(128,128,128,0.3)",
}}
>
<span style={{ color: "rgba(128,128,128,0.6)", fontSize: "10px" }}>
Chart
</span>
</div>
);
}
return null;
}
export default SlideRenderer;

View file

@ -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: <Users className="w-4 h-4" />, roles: ['super_admin'] },
{ label: 'Clients', href: '/admin/clients', icon: <Building2 className="w-4 h-4" />, roles: ['super_admin', 'client_admin'] },
{ label: 'Templates', href: '/admin/templates', icon: <LayoutTemplate className="w-4 h-4" />, roles: ['super_admin', 'client_admin'] },
{ label: 'Storage', href: '/admin/storage', icon: <HardDrive className="w-4 h-4" />, roles: ['super_admin', 'client_admin'] },
{ label: 'Audit Log', href: '/admin/audit', icon: <FileText className="w-4 h-4" />, roles: ['super_admin', 'client_admin'] },
{ label: 'Analytics', href: '/admin/analytics', icon: <BarChart3 className="w-4 h-4" />, roles: ['super_admin', 'client_admin'] },

View file

@ -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<TemplateDetail | null>(null);
const [layouts, setLayouts] = useState<LayoutItem[]>([]);
const [loading, setLoading] = useState(true);
const [togglingId, setTogglingId] = useState<number | null>(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 (
<div className="space-y-6 max-w-5xl">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/admin/templates">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4 mr-1" />Back
</Button>
</Link>
<div>
<h1 className="text-2xl font-semibold">
{loading ? 'Loading...' : template?.name || 'Template Layouts'}
</h1>
{!loading && (
<p className="text-sm text-gray-500">
{enabledCount} of {layouts.length} layouts enabled
</p>
)}
</div>
</div>
<Button variant="outline" size="sm" onClick={load} disabled={loading}>
<RefreshCw className={`w-4 h-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
{loading ? (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{[...Array(8)].map((_, i) => (
<div key={i} className="aspect-video bg-gray-200 rounded-lg animate-pulse" />
))}
</div>
) : layouts.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 border-2 border-dashed border-gray-300 rounded-lg">
<LayoutTemplate className="w-10 h-10 text-gray-400 mb-2" />
<p className="text-gray-500">No layouts in this template.</p>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{layouts.map((layout) => (
<div
key={layout.layout_id}
className={`bg-white rounded-lg border overflow-hidden transition-opacity ${
layout.is_enabled ? '' : 'opacity-50'
}`}
>
{/* Thumbnail — served via admin screenshot API */}
{layout.thumbnail_path ? (
<div className="aspect-video bg-gray-100 overflow-hidden">
<img
src={`/api/v1/admin/master-decks/${templateId}/screenshot/${layout.thumbnail_path.split('/').pop()}`}
alt={layout.layout_name}
className="w-full h-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
</div>
) : (
<div className="aspect-video bg-gray-50 flex items-center justify-center">
<FileCode className="w-8 h-8 text-gray-300" />
</div>
)}
{/* Info + toggle */}
<div className="p-2">
<p className="text-xs font-medium truncate mb-1">{layout.layout_name}</p>
<div className="flex items-center justify-between">
<span className={`text-[10px] px-1.5 py-0.5 rounded ${
layout.has_code
? 'bg-green-50 text-green-600'
: 'bg-orange-50 text-orange-600'
}`}>
{layout.has_code ? 'Has layout' : 'No layout'}
</span>
<div className="flex items-center gap-1">
{togglingId === layout.db_id ? (
<Loader2 className="w-3.5 h-3.5 animate-spin text-gray-400" />
) : (
<Switch
checked={layout.is_enabled}
onCheckedChange={() => handleToggle(layout)}
className="scale-75"
/>
)}
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View file

@ -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<TemplateSummary[]>([]);
const [loading, setLoading] = useState(true);
const [deleteTarget, setDeleteTarget] = useState<TemplateSummary | null>(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 (
<div className="space-y-6 max-w-4xl">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/admin">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4 mr-1" />Back
</Button>
</Link>
<h1 className="text-2xl font-semibold">Custom Templates</h1>
</div>
<Button variant="outline" size="sm" onClick={load} disabled={loading}>
<RefreshCw className={`w-4 h-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
{loading ? (
<div className="space-y-3">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-16 bg-gray-200 rounded-lg animate-pulse" />
))}
</div>
) : templates.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 border-2 border-dashed border-gray-300 rounded-lg">
<LayoutTemplate className="w-10 h-10 text-gray-400 mb-2" />
<p className="text-gray-500">No custom templates yet.</p>
<p className="text-sm text-gray-400 mt-1">
Upload a PPTX master deck from a client page to get started.
</p>
</div>
) : (
<div className="space-y-2">
{templates.map((tpl) => (
<div
key={tpl.id}
className="flex items-center justify-between bg-white rounded-lg border px-4 py-3 hover:border-[#5146E5]/30 transition-colors"
>
<Link
href={`/admin/templates/${tpl.id}`}
className="flex items-center gap-3 flex-1 min-w-0 group"
>
<LayoutTemplate className="w-5 h-5 text-[#5146E5] flex-shrink-0" />
<div className="min-w-0">
<p className="font-medium truncate group-hover:text-[#5146E5] transition-colors">
{tpl.name}
</p>
<p className="text-xs text-gray-500">
{tpl.layoutCount} layout{tpl.layoutCount !== 1 ? 's' : ''}
{tpl.lastUpdated && ` · Updated ${new Date(tpl.lastUpdated).toLocaleDateString()}`}
</p>
</div>
<ChevronRight className="w-4 h-4 text-gray-400 ml-auto flex-shrink-0 group-hover:text-[#5146E5]" />
</Link>
<Button
variant="ghost"
size="sm"
className="ml-2 flex-shrink-0"
onClick={() => setDeleteTarget(tpl)}
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
))}
</div>
)}
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Delete Template</DialogTitle>
</DialogHeader>
<p className="text-sm text-gray-600">
Delete <strong>{deleteTarget?.name}</strong>? This will remove all{' '}
{deleteTarget?.layoutCount} layouts and cannot be undone.
</p>
<div className="flex justify-end gap-2 mt-4">
<Button variant="outline" size="sm" onClick={() => setDeleteTarget(null)}>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
onClick={handleDelete}
disabled={deleting}
>
{deleting ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : null}
Delete
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -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<string, string | number>;
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<string, string>;
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<string, string> = {};
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<string, unknown>
): 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,
}));
}

View file

@ -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}`);
}
}