ppt-tool/frontend/app/admin/templates/page.tsx
Vadym Samoilenko ae41562103 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>
2026-03-01 20:05:25 +00:00

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