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:
parent
af95e73806
commit
ae41562103
11 changed files with 1086 additions and 14 deletions
31
backend/alembic/versions/005_add_layout_fields.py
Normal file
31
backend/alembic/versions/005_add_layout_fields.py
Normal 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')
|
||||
|
|
@ -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}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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'] },
|
||||
|
|
|
|||
197
frontend/app/admin/templates/[id]/page.tsx
Normal file
197
frontend/app/admin/templates/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
169
frontend/app/admin/templates/page.tsx
Normal file
169
frontend/app/admin/templates/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
frontend/app/hooks/parseLayoutSchema.ts
Normal file
106
frontend/app/hooks/parseLayoutSchema.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue