@bygghammar/cms-platform 0.29.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.30.0
4
+
5
+ - Added `backoffice/PagesListView` — the page-builder index with public URLs per
6
+ locale, rename inline, trash bin with restore/purge and page creation.
7
+ - Added `backoffice/BlocksLibraryView` — the blocks library (block types, global
8
+ blocks and legacy one-off copies) with previews; the block registry, preview
9
+ draft store and navigation are host-supplied.
10
+
3
11
  ## 0.29.0
4
12
 
5
13
  - Added `backoffice/GlobalSectionsListView` — the global-sections library with
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -133,7 +133,9 @@
133
133
  "./backoffice/FormSettingsView": "./src/backoffice/FormSettingsView.tsx",
134
134
  "./backoffice/GlobalSectionsListView": "./src/backoffice/GlobalSectionsListView.tsx",
135
135
  "./backoffice/GlobalSectionBuilderView": "./src/backoffice/GlobalSectionBuilderView.tsx",
136
- "./backoffice/ReviewsView": "./src/backoffice/ReviewsView.tsx"
136
+ "./backoffice/ReviewsView": "./src/backoffice/ReviewsView.tsx",
137
+ "./backoffice/PagesListView": "./src/backoffice/PagesListView.tsx",
138
+ "./backoffice/BlocksLibraryView": "./src/backoffice/BlocksLibraryView.tsx"
137
139
  },
138
140
  "dependencies": {
139
141
  "fflate": "^0.8.3"
@@ -0,0 +1,463 @@
1
+ import { useCallback, useMemo, useState, type ReactNode } from "react";
2
+ import { toast } from "sonner";
3
+ import { Eye, Layers, Pencil, Trash2, Search, Globe, Plus } from "lucide-react";
4
+ import { DataThumb } from "./SectionThumb";
5
+ import { PreviewLightbox } from "./PreviewLightbox";
6
+
7
+ /** Minimal shape of a block instance used by this screen. */
8
+ export type LibraryBlock = {
9
+ id: string;
10
+ type: string;
11
+ props?: Record<string, unknown>;
12
+ visible?: boolean;
13
+ };
14
+
15
+ /** A block type as described by the host registry. */
16
+ export type LibraryBlockDef = {
17
+ type: string;
18
+ label: string;
19
+ description: string;
20
+ category: string;
21
+ defaultProps: Record<string, unknown>;
22
+ };
23
+
24
+ /** A legacy one-off block copy. */
25
+ export type LibraryTemplate = {
26
+ id: string;
27
+ name: string;
28
+ description?: string | null;
29
+ blockType: string;
30
+ block: LibraryBlock;
31
+ };
32
+
33
+ /** A saved global block/section row. */
34
+ export type LibraryGlobalRow = {
35
+ id: string;
36
+ key: string;
37
+ name: string;
38
+ blocks?: unknown;
39
+ kind?: string;
40
+ };
41
+
42
+ export type BlocksLibraryDeps = {
43
+ defs: LibraryBlockDef[];
44
+ globals: LibraryGlobalRow[];
45
+ templates: LibraryTemplate[];
46
+ /** Fallback thumbnail per block type. */
47
+ typeImages: Record<string, string | null | undefined>;
48
+ /** Human label for a block type. */
49
+ blockTypeLabel: (type: string) => string;
50
+ /** Persists the preview payload so the preview route can render it. */
51
+ writePreviewDraft: (payload: { blocks: LibraryBlock[]; globals: Record<string, LibraryBlock[]> }) => void;
52
+ /** Preview iframe URL. */
53
+ previewSrc: string;
54
+ createGlobal: (input: {
55
+ key: string;
56
+ name: string;
57
+ kind: "block" | "section";
58
+ blocks: LibraryBlock[];
59
+ }) => Promise<unknown>;
60
+ renameGlobal: (input: { sectionId: string; name: string }) => Promise<unknown>;
61
+ deleteGlobal: (sectionId: string) => Promise<unknown>;
62
+ saveTemplate: (input: {
63
+ id: string;
64
+ name: string;
65
+ description?: string | null;
66
+ block: LibraryBlock;
67
+ }) => Promise<unknown>;
68
+ deleteTemplate: (id: string) => Promise<unknown>;
69
+ refetchGlobals: () => Promise<unknown> | void;
70
+ refetchTemplates: () => Promise<unknown> | void;
71
+ /** Navigates to the global block editor. */
72
+ openGlobal: (key: string) => void;
73
+ };
74
+
75
+ function uid(prefix = "b") {
76
+ return `${prefix}_${Math.random().toString(36).slice(2, 9)}`;
77
+ }
78
+
79
+ /** Turn a name into a url-safe global block key. */
80
+ export function slugifyKey(name: string) {
81
+ const base = name
82
+ .toLowerCase()
83
+ .normalize("NFD")
84
+ .replace(/[\u0300-\u036f]/g, "")
85
+ .replace(/[^a-z0-9]+/g, "-")
86
+ .replace(/^-+|-+$/g, "")
87
+ .slice(0, 48);
88
+ return base || "section";
89
+ }
90
+
91
+ /** Library screen for block types, global blocks and legacy one-off copies. */
92
+ export function BlocksLibraryView({ deps }: { deps: BlocksLibraryDeps }): ReactNode {
93
+ const {
94
+ defs: allDefs,
95
+ globals,
96
+ templates,
97
+ typeImages,
98
+ blockTypeLabel,
99
+ writePreviewDraft,
100
+ previewSrc,
101
+ createGlobal,
102
+ renameGlobal,
103
+ deleteGlobal,
104
+ saveTemplate,
105
+ deleteTemplate,
106
+ refetchGlobals,
107
+ refetchTemplates,
108
+ openGlobal,
109
+ } = deps;
110
+
111
+ const [tab, setTab] = useState<"types" | "saved" | "copies">("saved");
112
+ const [q, setQ] = useState("");
113
+ const [preview, setPreview] = useState<{ title: string } | null>(null);
114
+
115
+ const fail = (e: unknown) => toast.error(e instanceof Error ? e.message : "Failed");
116
+
117
+ const globalsMap = useMemo(() => {
118
+ const m: Record<string, LibraryBlock[]> = {};
119
+ for (const g of globals) m[g.key] = ((g.blocks ?? []) as LibraryBlock[]) ?? [];
120
+ return m;
121
+ }, [globals]);
122
+
123
+ const openPreview = useCallback(
124
+ (blocks: LibraryBlock[], title: string) => {
125
+ writePreviewDraft({ blocks, globals: globalsMap });
126
+ setPreview({ title });
127
+ },
128
+ [globalsMap, writePreviewDraft],
129
+ );
130
+
131
+ const toSavedBlock = useCallback(
132
+ async (block: LibraryBlock, defaultName: string) => {
133
+ const name = window.prompt("Global block name", defaultName);
134
+ if (!name) return;
135
+ const key = window.prompt("Key (url-safe, unique)", slugifyKey(name));
136
+ if (!key) return;
137
+ try {
138
+ await createGlobal({
139
+ key,
140
+ name,
141
+ kind: "block",
142
+ blocks: [{ ...structuredClone(block), id: uid() }],
143
+ });
144
+ toast.success("Global block created");
145
+ await refetchGlobals();
146
+ openGlobal(key);
147
+ } catch (e) {
148
+ fail(e);
149
+ }
150
+ },
151
+ [createGlobal, openGlobal, refetchGlobals],
152
+ );
153
+
154
+ const convertTemplate = useCallback(
155
+ async (t: LibraryTemplate) => {
156
+ const key = window.prompt("Key (url-safe, unique)", slugifyKey(t.name));
157
+ if (!key) return;
158
+ try {
159
+ await createGlobal({
160
+ key,
161
+ name: t.name,
162
+ kind: "block",
163
+ blocks: [{ ...structuredClone(t.block), id: uid() }],
164
+ });
165
+ await deleteTemplate(t.id);
166
+ toast.success("Converted to a global block");
167
+ await refetchTemplates();
168
+ await refetchGlobals();
169
+ openGlobal(key);
170
+ } catch (e) {
171
+ fail(e);
172
+ }
173
+ },
174
+ [createGlobal, deleteTemplate, openGlobal, refetchGlobals, refetchTemplates],
175
+ );
176
+
177
+ const onRenameTpl = async (t: LibraryTemplate) => {
178
+ const name = window.prompt("Name", t.name);
179
+ if (!name || name === t.name) return;
180
+ try {
181
+ await saveTemplate({ id: t.id, name, description: t.description, block: t.block });
182
+ toast.success("Renamed");
183
+ await refetchTemplates();
184
+ } catch (e) {
185
+ fail(e);
186
+ }
187
+ };
188
+
189
+ const onDeleteTpl = async (t: LibraryTemplate) => {
190
+ if (!confirm(`Delete copy "${t.name}"?`)) return;
191
+ try {
192
+ await deleteTemplate(t.id);
193
+ toast.success("Deleted");
194
+ await refetchTemplates();
195
+ } catch (e) {
196
+ fail(e);
197
+ }
198
+ };
199
+
200
+ const onRenameSaved = async (id: string, current: string) => {
201
+ const name = window.prompt("Global block name", current);
202
+ if (!name || name === current) return;
203
+ try {
204
+ await renameGlobal({ sectionId: id, name });
205
+ toast.success("Renamed");
206
+ await refetchGlobals();
207
+ } catch (e) {
208
+ fail(e);
209
+ }
210
+ };
211
+
212
+ const onDeleteSaved = async (id: string, name: string) => {
213
+ if (!confirm(`Delete global block "${name}"? Pages using it will render nothing there.`)) return;
214
+ try {
215
+ await deleteGlobal(id);
216
+ toast.success("Deleted");
217
+ await refetchGlobals();
218
+ } catch (e) {
219
+ fail(e);
220
+ }
221
+ };
222
+
223
+ const needle = q.trim().toLowerCase();
224
+ const defs = allDefs.filter(
225
+ (d) =>
226
+ !needle ||
227
+ d.label.toLowerCase().includes(needle) ||
228
+ d.description.toLowerCase().includes(needle) ||
229
+ d.type.toLowerCase().includes(needle),
230
+ );
231
+ const savedBlocks = globals
232
+ .filter((g) => (g.kind ?? "section") === "block")
233
+ .filter(
234
+ (g) =>
235
+ !needle || g.name.toLowerCase().includes(needle) || g.key.toLowerCase().includes(needle),
236
+ );
237
+ const copies = templates.filter(
238
+ (t) =>
239
+ !needle ||
240
+ t.name.toLowerCase().includes(needle) ||
241
+ (t.description ?? "").toLowerCase().includes(needle) ||
242
+ t.blockType.toLowerCase().includes(needle),
243
+ );
244
+
245
+ const tabBtn = (id: typeof tab, label: string) => (
246
+ <button
247
+ type="button"
248
+ onClick={() => setTab(id)}
249
+ className={`px-4 py-2 text-[0.7rem] uppercase tracking-[0.18em] ${tab === id ? "bg-ink text-paper" : "text-ink/60 hover:text-ink"}`}
250
+ >
251
+ {label}
252
+ </button>
253
+ );
254
+
255
+ return (
256
+ <div className="p-8 md:p-12 w-full">
257
+ <div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/55">Library</div>
258
+ <h1 className="mt-2 font-serif text-3xl md:text-4xl text-ink">Blocks</h1>
259
+ <p className="mt-4 max-w-2xl text-sm text-ink/60">
260
+ Block types are the building bricks. A <strong>global block</strong> is a named, editable
261
+ block you reuse on pages and inside global sections — edit it once here and every place that
262
+ uses it updates at the same time. A global block always holds exactly one block; for several
263
+ blocks together, use Global Sections.
264
+ </p>
265
+
266
+ <div className="mt-8 flex flex-wrap items-center gap-3">
267
+ <div className="inline-flex border border-ink/15 bg-white">
268
+ {tabBtn("saved", `Global blocks (${savedBlocks.length})`)}
269
+ {tabBtn("types", `Block types (${defs.length})`)}
270
+ {copies.length > 0 && tabBtn("copies", `One-off copies (${copies.length})`)}
271
+ </div>
272
+ <div className="relative">
273
+ <Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-ink/40" />
274
+ <input
275
+ value={q}
276
+ onChange={(e) => setQ(e.target.value)}
277
+ placeholder="Search blocks…"
278
+ className="border border-ink/15 bg-white py-2 pl-9 pr-3 text-sm outline-none focus:border-sea"
279
+ />
280
+ </div>
281
+ </div>
282
+
283
+ {tab === "types" && (
284
+ <div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
285
+ {defs.map((def) => {
286
+ const block: LibraryBlock = {
287
+ id: "preview",
288
+ type: def.type,
289
+ props: structuredClone(def.defaultProps),
290
+ };
291
+ return (
292
+ <div key={def.type} className="border border-ink/10 bg-white p-4">
293
+ <button
294
+ type="button"
295
+ title="Preview block"
296
+ onClick={() => openPreview([block], def.label)}
297
+ className="group relative block"
298
+ >
299
+ <DataThumb
300
+ data={def.defaultProps}
301
+ fallbackImage={typeImages[def.type]}
302
+ blockType={def.type}
303
+ />
304
+ <span className="absolute inset-0 hidden items-center justify-center bg-ink/50 text-white group-hover:flex">
305
+ <Eye className="size-4" />
306
+ </span>
307
+ </button>
308
+ <div className="mt-3 flex items-center gap-2">
309
+ <div className="font-serif text-lg text-ink">{def.label}</div>
310
+ <span className="rounded bg-ink/[0.06] px-1.5 py-1 text-[0.6rem] uppercase tracking-[0.16em] text-ink/55">
311
+ {def.category}
312
+ </span>
313
+ </div>
314
+ <p className="mt-1 text-sm text-ink/60">{def.description}</p>
315
+ <div className="mt-3 flex items-center justify-between gap-2">
316
+ <span className="font-mono text-[0.7rem] text-ink/40">{def.type}</span>
317
+ <button
318
+ onClick={() => toSavedBlock(block, def.label)}
319
+ className="inline-flex items-center gap-1 text-[0.75rem] text-sea hover:underline"
320
+ >
321
+ <Plus className="size-3.5" /> Save as global block
322
+ </button>
323
+ </div>
324
+ </div>
325
+ );
326
+ })}
327
+ </div>
328
+ )}
329
+
330
+ {tab === "saved" && (
331
+ <div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
332
+ {savedBlocks.length === 0 && (
333
+ <p className="text-sm text-ink/55">
334
+ No global blocks yet. Open a block type above and choose "Save as global block", or
335
+ select a block in the page builder and choose "Save as global block".
336
+ </p>
337
+ )}
338
+ {savedBlocks.map((g) => {
339
+ const blocks = (g.blocks ?? []) as LibraryBlock[];
340
+ const labels = blocks
341
+ .filter((b) => b.visible !== false)
342
+ .map((b) => blockTypeLabel(b.type));
343
+ return (
344
+ <div key={g.id} className="border border-ink/10 bg-white p-4">
345
+ <button
346
+ type="button"
347
+ title="Preview global block"
348
+ onClick={() => openPreview(blocks, g.name)}
349
+ className="group relative block"
350
+ >
351
+ <DataThumb
352
+ data={blocks}
353
+ fallbackImage={blocks.map((b) => typeImages[b.type]).find(Boolean) ?? null}
354
+ />
355
+ <span className="absolute inset-0 hidden items-center justify-center bg-ink/50 text-white group-hover:flex">
356
+ <Eye className="size-4" />
357
+ </span>
358
+ </button>
359
+ <div className="mt-3 flex items-center gap-2">
360
+ <div className="font-serif text-lg text-ink">{g.name}</div>
361
+ <Globe className="size-3.5 text-ink/40" aria-label="Global block" />
362
+ </div>
363
+ {labels.length > 0 && (
364
+ <div className="mt-1 flex flex-wrap gap-1.5">
365
+ {labels.map((label, i) => (
366
+ <span
367
+ key={i}
368
+ className="inline-block rounded bg-ink/[0.06] px-1.5 py-1 text-[0.65rem] leading-none text-ink/60"
369
+ >
370
+ {label}
371
+ </span>
372
+ ))}
373
+ </div>
374
+ )}
375
+ <div className="mt-3 font-mono text-[0.7rem] text-ink/40">{g.key}</div>
376
+ <div className="mt-4 flex flex-wrap gap-3 text-[0.75rem]">
377
+ <button
378
+ onClick={() => openGlobal(g.key)}
379
+ className="inline-flex items-center gap-1 text-sea hover:underline"
380
+ >
381
+ <Pencil className="size-3.5" /> Edit
382
+ </button>
383
+ <button
384
+ onClick={() => onRenameSaved(g.id, g.name)}
385
+ className="inline-flex items-center gap-1 text-ink/60 hover:text-ink"
386
+ >
387
+ <Layers className="size-3.5" /> Rename
388
+ </button>
389
+ <button
390
+ onClick={() => onDeleteSaved(g.id, g.name)}
391
+ className="inline-flex items-center gap-1 text-red-600/80 hover:text-red-700"
392
+ >
393
+ <Trash2 className="size-3.5" /> Delete
394
+ </button>
395
+ </div>
396
+ </div>
397
+ );
398
+ })}
399
+ </div>
400
+ )}
401
+
402
+ {tab === "copies" && (
403
+ <div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
404
+ {copies.map((t) => (
405
+ <div key={t.id} className="border border-ink/10 bg-white p-4">
406
+ <button
407
+ type="button"
408
+ title="Preview block"
409
+ onClick={() => openPreview([t.block], t.name)}
410
+ className="group relative block"
411
+ >
412
+ <DataThumb
413
+ data={t.block.props ?? {}}
414
+ fallbackImage={typeImages[t.blockType]}
415
+ blockType={t.blockType}
416
+ />
417
+ <span className="absolute inset-0 hidden items-center justify-center bg-ink/50 text-white group-hover:flex">
418
+ <Eye className="size-4" />
419
+ </span>
420
+ </button>
421
+ <div className="mt-3 flex items-center gap-2">
422
+ <div className="font-serif text-lg text-ink">{t.name}</div>
423
+ <span className="rounded bg-ink/[0.06] px-1.5 py-1 text-[0.6rem] uppercase tracking-[0.16em] text-ink/55">
424
+ {blockTypeLabel(t.blockType)}
425
+ </span>
426
+ </div>
427
+ <p className="mt-1 text-sm text-ink/60">
428
+ {t.description || "A one-off copy: pages using it keep their own version."}
429
+ </p>
430
+ <div className="mt-4 flex flex-wrap gap-3 text-[0.75rem]">
431
+ <button
432
+ onClick={() => convertTemplate(t)}
433
+ className="inline-flex items-center gap-1 text-sea hover:underline"
434
+ >
435
+ <Globe className="size-3.5" /> Convert to global block
436
+ </button>
437
+ <button
438
+ onClick={() => onRenameTpl(t)}
439
+ className="inline-flex items-center gap-1 text-ink/60 hover:text-ink"
440
+ >
441
+ <Pencil className="size-3.5" /> Rename
442
+ </button>
443
+ <button
444
+ onClick={() => onDeleteTpl(t)}
445
+ className="inline-flex items-center gap-1 text-red-600/80 hover:text-red-700"
446
+ >
447
+ <Trash2 className="size-3.5" /> Delete
448
+ </button>
449
+ </div>
450
+ </div>
451
+ ))}
452
+ </div>
453
+ )}
454
+
455
+ <PreviewLightbox
456
+ open={preview != null}
457
+ onClose={() => setPreview(null)}
458
+ src={previewSrc}
459
+ title={preview?.title ?? ""}
460
+ />
461
+ </div>
462
+ );
463
+ }
@@ -0,0 +1,369 @@
1
+ import { useState, type ReactNode } from "react";
2
+ import { toast } from "sonner";
3
+ import { Plus, Trash2, Pencil, Globe as GlobeIcon, ExternalLink, Undo2 } from "lucide-react";
4
+
5
+ /** A page row as listed by the host. */
6
+ export type PageListRow = {
7
+ id: string;
8
+ key: string;
9
+ name: string;
10
+ status: string;
11
+ updated_at: string;
12
+ is_system?: boolean | null;
13
+ };
14
+
15
+ /** A soft-deleted page kept in the trash bin. */
16
+ export type DeletedPageRow = {
17
+ id: string;
18
+ key: string;
19
+ name: string;
20
+ deleted_at?: string | null;
21
+ deleted_by?: string | null;
22
+ deleted_by_email?: string | null;
23
+ };
24
+
25
+ export type PagesListDeps = {
26
+ pages: PageListRow[] | undefined;
27
+ trash: { rows: DeletedPageRow[]; canPurge: boolean } | undefined;
28
+ /** Locale switcher — pass a single locale to hide the switcher. */
29
+ locales: { value: string; label: string }[];
30
+ locale: string;
31
+ setLocale: (locale: string) => void;
32
+ /** Public path for a page key in the active locale. */
33
+ pathFor: (key: string) => string;
34
+ /** Absolute URL for a page key, including the locale's domain when known. */
35
+ hrefFor: (key: string) => string;
36
+ createPage: (input: { key: string; name: string }) => Promise<unknown>;
37
+ renamePage: (input: { pageId: string; name: string }) => Promise<unknown>;
38
+ deletePage: (pageId: string) => Promise<unknown>;
39
+ restorePage: (pageId: string) => Promise<unknown>;
40
+ purgePage: (pageId: string) => Promise<unknown>;
41
+ refetchPages: () => void;
42
+ refetchTrash: () => void;
43
+ openEditor: (key: string) => void;
44
+ GlobalsLink: (props: { className?: string; children: ReactNode }) => ReactNode;
45
+ };
46
+
47
+ /** Page builder index: page list with public URLs, trash bin and page creation. */
48
+ export function PagesListView({ deps }: { deps: PagesListDeps }): ReactNode {
49
+ const {
50
+ pages,
51
+ trash,
52
+ locales,
53
+ locale,
54
+ setLocale,
55
+ pathFor,
56
+ hrefFor,
57
+ createPage,
58
+ renamePage,
59
+ deletePage,
60
+ restorePage,
61
+ purgePage,
62
+ refetchPages,
63
+ refetchTrash,
64
+ openEditor,
65
+ GlobalsLink,
66
+ } = deps;
67
+
68
+ const [newKey, setNewKey] = useState("");
69
+ const [newName, setNewName] = useState("");
70
+ const [busy, setBusy] = useState(false);
71
+ const [renaming, setRenaming] = useState<{ id: string; name: string } | null>(null);
72
+
73
+ const fail = (e: unknown) => toast.error(e instanceof Error ? e.message : "Failed");
74
+
75
+ const onRename = async () => {
76
+ if (!renaming || !renaming.name.trim()) return;
77
+ try {
78
+ await renamePage({ pageId: renaming.id, name: renaming.name.trim() });
79
+ toast.success("Page renamed");
80
+ setRenaming(null);
81
+ refetchPages();
82
+ } catch (e) {
83
+ fail(e);
84
+ }
85
+ };
86
+
87
+ const onCreate = async () => {
88
+ if (!newKey || !newName) return;
89
+ setBusy(true);
90
+ try {
91
+ await createPage({ key: newKey, name: newName });
92
+ toast.success("Page created");
93
+ setNewKey("");
94
+ setNewName("");
95
+ refetchPages();
96
+ } catch (e) {
97
+ fail(e);
98
+ } finally {
99
+ setBusy(false);
100
+ }
101
+ };
102
+
103
+ const onDelete = async (id: string, name: string) => {
104
+ if (!confirm(`Move page "${name}" to the trash bin? It can be restored later.`)) return;
105
+ try {
106
+ await deletePage(id);
107
+ toast.success("Moved to trash");
108
+ refetchPages();
109
+ refetchTrash();
110
+ } catch (e) {
111
+ fail(e);
112
+ }
113
+ };
114
+
115
+ const onRestore = async (id: string) => {
116
+ try {
117
+ await restorePage(id);
118
+ toast.success("Page restored");
119
+ refetchPages();
120
+ refetchTrash();
121
+ } catch (e) {
122
+ fail(e);
123
+ }
124
+ };
125
+
126
+ const onPurge = async (id: string, name: string) => {
127
+ if (!confirm(`Permanently delete "${name}"? This cannot be undone.`)) return;
128
+ try {
129
+ await purgePage(id);
130
+ toast.success("Permanently deleted");
131
+ refetchTrash();
132
+ } catch (e) {
133
+ fail(e);
134
+ }
135
+ };
136
+
137
+ return (
138
+ <div className="p-8 md:p-12 w-full">
139
+ <div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/55">Page builder</div>
140
+ <div className="mt-2 flex flex-wrap items-end justify-between gap-4">
141
+ <h1 className="font-serif text-3xl md:text-4xl text-ink">Pages</h1>
142
+ <div className="flex items-center gap-3">
143
+ {locales.length > 1 && (
144
+ <div className="flex items-center gap-1">
145
+ {locales.map((loc) => (
146
+ <button
147
+ key={loc.value}
148
+ onClick={() => setLocale(loc.value)}
149
+ className={`px-2 py-1 text-[0.7rem] uppercase tracking-widest border ${
150
+ locale === loc.value
151
+ ? "border-sea bg-ice/50 text-ink"
152
+ : "border-ink/15 text-ink/50 hover:text-ink"
153
+ }`}
154
+ >
155
+ {loc.label}
156
+ </button>
157
+ ))}
158
+ </div>
159
+ )}
160
+ <GlobalsLink className="inline-flex items-center gap-2 border border-ink/20 px-4 py-2 text-sm text-ink hover:border-sea">
161
+ <>
162
+ <GlobeIcon className="size-4" /> Global blocks
163
+ </>
164
+ </GlobalsLink>
165
+ </div>
166
+ </div>
167
+
168
+ <p className="mt-4 max-w-2xl text-sm text-ink/60">
169
+ Compose each page from blocks and global blocks. Every save creates a new version — publish
170
+ when ready.
171
+ </p>
172
+
173
+ <div className="mt-8 border border-ink/10 bg-white">
174
+ <table className="w-full text-sm">
175
+ <thead className="bg-ink/[0.03] text-left text-[0.7rem] uppercase tracking-[0.18em] text-ink/60">
176
+ <tr>
177
+ <th className="px-4 py-3">Name</th>
178
+ <th className="px-4 py-3">Key</th>
179
+ <th className="px-4 py-3">URL</th>
180
+ <th className="px-4 py-3">Status</th>
181
+ <th className="px-4 py-3">Updated</th>
182
+ <th className="px-4 py-3 w-40"></th>
183
+ </tr>
184
+ </thead>
185
+ <tbody>
186
+ {pages?.map((p) => (
187
+ <tr key={p.id} className="border-t border-ink/10">
188
+ <td className="px-4 py-3 text-ink">
189
+ {renaming?.id === p.id ? (
190
+ <span className="flex items-center gap-2">
191
+ <input
192
+ autoFocus
193
+ value={renaming.name}
194
+ onChange={(e) => setRenaming({ id: p.id, name: e.target.value })}
195
+ onKeyDown={(e) => {
196
+ if (e.key === "Enter") onRename();
197
+ if (e.key === "Escape") setRenaming(null);
198
+ }}
199
+ className="border border-ink/20 px-2 py-1 text-sm"
200
+ />
201
+ <button onClick={onRename} className="text-sea hover:underline text-xs">
202
+ Save
203
+ </button>
204
+ <button
205
+ onClick={() => setRenaming(null)}
206
+ className="text-ink/50 hover:underline text-xs"
207
+ >
208
+ Cancel
209
+ </button>
210
+ </span>
211
+ ) : (
212
+ <>
213
+ {p.name}{" "}
214
+ {p.is_system && (
215
+ <span className="ml-2 text-[0.65rem] text-ink/50">SYSTEM</span>
216
+ )}
217
+ <button
218
+ onClick={() => setRenaming({ id: p.id, name: p.name })}
219
+ className="ml-3 text-[0.7rem] text-ink/50 hover:text-sea underline"
220
+ >
221
+ rename
222
+ </button>
223
+ </>
224
+ )}
225
+ </td>
226
+ <td className="px-4 py-3 text-ink/70 font-mono">{p.key}</td>
227
+ <td className="px-4 py-3">
228
+ <a
229
+ href={hrefFor(p.key)}
230
+ target="_blank"
231
+ rel="noreferrer"
232
+ title={`Open ${hrefFor(p.key)}`}
233
+ className="inline-flex items-center gap-1.5 font-mono text-xs text-sea hover:underline"
234
+ >
235
+ {pathFor(p.key)}
236
+ <ExternalLink className="size-3 text-ink/40" />
237
+ </a>
238
+ </td>
239
+ <td className="px-4 py-3 text-ink/70">{p.status}</td>
240
+ <td className="px-4 py-3 text-ink/50">{new Date(p.updated_at).toLocaleString()}</td>
241
+ <td className="px-4 py-3 text-right">
242
+ <button
243
+ onClick={() => openEditor(p.key)}
244
+ className="inline-flex items-center gap-1 text-sea hover:underline mr-3"
245
+ >
246
+ <Pencil className="size-3.5" /> Edit
247
+ </button>
248
+ {!p.is_system && (
249
+ <button
250
+ onClick={() => onDelete(p.id, p.name)}
251
+ className="inline-flex items-center gap-1 text-red-600 hover:underline"
252
+ >
253
+ <Trash2 className="size-3.5" /> Delete
254
+ </button>
255
+ )}
256
+ </td>
257
+ </tr>
258
+ ))}
259
+ {pages?.length === 0 && (
260
+ <tr>
261
+ <td colSpan={6} className="px-4 py-8 text-center text-ink/50">
262
+ No pages yet.
263
+ </td>
264
+ </tr>
265
+ )}
266
+ </tbody>
267
+ </table>
268
+ </div>
269
+
270
+ <div className="mt-10 border border-ink/10 bg-white">
271
+ <div className="flex items-center justify-between px-6 py-4 border-b border-ink/10">
272
+ <div>
273
+ <div className="font-serif text-lg text-ink">Trash bin</div>
274
+ <p className="text-sm text-ink/60 mt-1">
275
+ Deleted pages are kept here with a record of who deleted them. Only a superadmin can
276
+ delete them permanently.
277
+ </p>
278
+ </div>
279
+ </div>
280
+ <table className="w-full text-sm">
281
+ <thead className="bg-ink/[0.03] text-left text-[0.7rem] uppercase tracking-[0.18em] text-ink/60">
282
+ <tr>
283
+ <th className="px-4 py-3">Name</th>
284
+ <th className="px-4 py-3">Key</th>
285
+ <th className="px-4 py-3">Deleted by</th>
286
+ <th className="px-4 py-3">Deleted at</th>
287
+ <th className="px-4 py-3 w-56"></th>
288
+ </tr>
289
+ </thead>
290
+ <tbody>
291
+ {trash?.rows.map((p) => (
292
+ <tr key={p.id} className="border-t border-ink/10">
293
+ <td className="px-4 py-3 text-ink">{p.name}</td>
294
+ <td className="px-4 py-3 font-mono text-ink/70">{p.key}</td>
295
+ <td className="px-4 py-3 text-ink/70">
296
+ {p.deleted_by_email ?? p.deleted_by ?? "Unknown user"}
297
+ </td>
298
+ <td className="px-4 py-3 text-ink/50">
299
+ {p.deleted_at ? new Date(p.deleted_at).toLocaleString() : "—"}
300
+ </td>
301
+ <td className="px-4 py-3 text-right">
302
+ <button
303
+ onClick={() => onRestore(p.id)}
304
+ className="inline-flex items-center gap-1 text-sea hover:underline mr-3"
305
+ >
306
+ <Undo2 className="size-3.5" /> Restore
307
+ </button>
308
+ {trash?.canPurge ? (
309
+ <button
310
+ onClick={() => onPurge(p.id, p.name)}
311
+ className="inline-flex items-center gap-1 text-red-600 hover:underline"
312
+ >
313
+ <Trash2 className="size-3.5" /> Delete forever
314
+ </button>
315
+ ) : (
316
+ <span className="text-[0.7rem] uppercase tracking-widest text-ink/40">
317
+ Superadmin only
318
+ </span>
319
+ )}
320
+ </td>
321
+ </tr>
322
+ ))}
323
+ {(trash?.rows.length ?? 0) === 0 && (
324
+ <tr>
325
+ <td colSpan={5} className="px-4 py-8 text-center text-ink/50">
326
+ Trash bin is empty.
327
+ </td>
328
+ </tr>
329
+ )}
330
+ </tbody>
331
+ </table>
332
+ </div>
333
+
334
+ <div className="mt-10 border border-ink/10 bg-white p-6">
335
+ <div className="font-serif text-lg text-ink">New page</div>
336
+ <p className="text-sm text-ink/60 mt-1">Create a blank page — you can add blocks after.</p>
337
+ <div className="mt-4 grid gap-3 md:grid-cols-[1fr_1fr_auto]">
338
+ <label className="block">
339
+ <span className="block text-xs uppercase tracking-wider text-ink/60 mb-1">Name</span>
340
+ <input
341
+ value={newName}
342
+ onChange={(e) => setNewName(e.target.value)}
343
+ className="w-full border border-ink/20 px-3 py-2 text-sm"
344
+ placeholder="About us"
345
+ />
346
+ </label>
347
+ <label className="block">
348
+ <span className="block text-xs uppercase tracking-wider text-ink/60 mb-1">
349
+ Key (URL-safe)
350
+ </span>
351
+ <input
352
+ value={newKey}
353
+ onChange={(e) => setNewKey(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, ""))}
354
+ className="w-full border border-ink/20 px-3 py-2 text-sm font-mono"
355
+ placeholder="about-us"
356
+ />
357
+ </label>
358
+ <button
359
+ disabled={busy || !newKey || !newName}
360
+ onClick={onCreate}
361
+ className="inline-flex items-center gap-2 bg-ink text-white px-5 py-2 text-sm disabled:opacity-40 self-end"
362
+ >
363
+ <Plus className="size-4" /> Create
364
+ </button>
365
+ </div>
366
+ </div>
367
+ </div>
368
+ );
369
+ }