@bygghammar/cms-platform 0.29.0 → 0.30.1
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 +8 -0
- package/package.json +4 -2
- package/src/backoffice/BlocksLibraryView.tsx +463 -0
- package/src/backoffice/PagesListView.tsx +369 -0
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.
|
|
3
|
+
"version": "0.30.1",
|
|
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
|
+
}
|