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>
169 lines
5.7 KiB
TypeScript
169 lines
5.7 KiB
TypeScript
'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>
|
|
);
|
|
}
|