1"use client";
2
3
14import { useQuery } from "@tanstack/react-query";
15import { Dialog } from "./dialog";
16import { templateApi } from "@/lib/api";
17import { blockText } from "@/components/editor/version-text";
18import { codeBlockText } from "@/components/editor/block-renderer";
19import { sortByPosition } from "@/lib/utils";
20import type { TemplateInfo } from "@/lib/types";
21
22
23interface SnapshotBlock {
24 id: string;
25 type: string;
26 content: unknown;
27 properties: Record<string, unknown>;
28 parentId: string | null;
29 position: string;
30}
31interface TemplateSnapshot {
32 page?: { title: string; icon: string | null; description: string | null } | null;
33 blocks: SnapshotBlock[];
34 children: { title: string; blocks: SnapshotBlock[] }[];
35}
36
37function Placeholder({ label }: { label: string }) {
38 return (
39 <span className="inline-block rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">
40 {label}
41 </span>
42 );
43}
44
45function BlockView({ b }: { b: SnapshotBlock }) {
46 const text = blockText(b.content);
47 const props = b.properties ?? {};
48 switch (b.type) {
49 case "heading_1":
50 return <p className="text-2xl font-bold">{text}</p>;
51 case "heading_2":
52 return <p className="text-xl font-semibold">{text}</p>;
53 case "heading_3":
54 return <p className="text-lg font-semibold">{text}</p>;
55 case "quote":
56 return <p className="border-l-[3px] border-foreground pl-3 text-[15px]">{text}</p>;
57 case "callout":
58 return (
59 <p className="rounded-md border border-border bg-muted/40 p-2">
60 <span className="mr-1" aria-hidden>{(props.icon as string) ?? "ℹ️"}</span>
61 {text}
62 </p>
63 );
64 case "bulleted_list":
65 return <p className="flex gap-2"><span className="mt-[0.6em] size-1.5 shrink-0 rounded-full bg-foreground" aria-hidden />{text}</p>;
66 case "numbered_list":
67 return <p className="flex gap-2"><span className="w-5 shrink-0 text-right text-sm text-muted-foreground" aria-hidden>•</span>{text}</p>;
68 case "todo":
69 return (
70 <p className="flex gap-2">
71 <span className="mt-[0.2em] flex size-4 shrink-0 items-center justify-center rounded-sm border border-muted-foreground/50 text-[10px]">
72 {props.checked ? "✓" : ""}
73 </span>
74 <span className={props.checked ? "text-muted-foreground line-through" : undefined}>{text}</span>
75 </p>
76 );
77 case "code":
78 return (
79 <pre className="overflow-x-auto rounded-md border border-border bg-muted/30 p-3 font-mono text-sm whitespace-pre">
80 {codeBlockText(b.content)}
81 </pre>
82 );
83 case "table": {
84 const rows = (props.rows as string[][] | undefined) ?? [];
85 return rows.length ? (
86 <div className="overflow-x-auto rounded-md border border-border">
87 <table className="w-full border-collapse text-sm">
88 <tbody>
89 {rows.map((row, ri) => (
90 <tr key={ri}>
91 {row.map((cell, ci) => (
92 <td key={ci} className="border-b border-border px-3 py-1.5">{cell}</td>
93 ))}
94 </tr>
95 ))}
96 </tbody>
97 </table>
98 </div>
99 ) : (
100 <Placeholder label="table" />
101 );
102 }
103 case "divider":
104 return <hr className="border-border" />;
105 case "spacer":
106 return null;
107 case "image":
108 case "file":
109 case "video":
110 case "audio":
111 return <Placeholder label={(props.name as string) || b.type} />;
112 case "bookmark":
113 case "embed":
114 return <Placeholder label={(props.title as string) ?? (props.src as string) ?? b.type} />;
115 case "database":
116 case "linked_database":
117 case "child_database":
118 return <Placeholder label="database" />;
119 case "chart":
120 return <Placeholder label="chart" />;
121 case "form":
122 return <Placeholder label="form" />;
123 case "child_page":
124 return <Placeholder label={(props.title as string) ?? "page link"} />;
125 case "synced_block":
126 return <Placeholder label="synced block" />;
127 case "toc":
128 return <Placeholder label="table of contents" />;
129 case "equation":
130 return <pre className="rounded-md border border-border bg-muted/30 p-2 font-mono text-sm">{text}</pre>;
131 default:
132
133 return text ? <p>{text}</p> : null;
134 }
135}
136
137export function TemplatePreviewDialog({ template, onClose }: { template: TemplateInfo; onClose: () => void }) {
138 const query = useQuery({
139 queryKey: ["templates", template.id, "preview"],
140 queryFn: () => templateApi.preview(template.id),
141 staleTime: 60_000
142 });
143 const snap: TemplateSnapshot | null = (query.data as { snapshot?: TemplateSnapshot } | null)?.snapshot ?? null;
144
145 return (
146 <Dialog
147 open
148 title={`Preview — ${template.name || "Untitled"}`}
149 description="Read-only — the saved snapshot. Later edits to the source page do not change it."
150 onClose={onClose}
151 width="max-w-3xl"
152 >
153 {query.isLoading ? (
154 <p className="py-6 text-center text-sm text-muted-foreground" data-testid="template-preview-loading">
155 Loading preview…
156 </p>
157 ) : query.isError ? (
158 <p className="py-6 text-center text-sm text-destructive" data-testid="template-preview-error">
159 Could not load the template snapshot.
160 </p>
161 ) : (
162 <div className="space-y-2" data-testid="template-preview-body">
163 {snap?.page?.icon && (
164 <p className="text-2xl" aria-hidden>{snap.page.icon}</p>
165 )}
166 <p className="text-xl font-semibold" data-testid="template-preview-title">
167 {snap?.page?.title || template.name || "Untitled"}
168 </p>
169 {sortByPosition(snap?.blocks ?? []).map((b) => (
170 <div key={b.id}>{<BlockView b={b} />}</div>
171 ))}
172 {snap && snap.blocks.length === 0 && (
173 <p className="text-sm text-muted-foreground" data-testid="template-preview-empty">
174 This template has no blocks.
175 </p>
176 )}
177 {snap && snap.children.length > 0 && (
178 <div className="mt-3 border-t border-border pt-2">
179 <p className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
180 Sub-pages
181 </p>
182 <ul className="space-y-0.5">
183 {snap.children.map((c, i) => (
184 <li key={i} className="flex items-center gap-1.5 text-sm">
185 <span aria-hidden>▸</span>
186 {c.title}
187 </li>
188 ))}
189 </ul>
190 </div>
191 )}
192 </div>
193 )}
194 </Dialog>
195 );
196}
197