@bygghammar/cms-platform 0.28.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,23 @@
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
+
11
+ ## 0.29.0
12
+
13
+ - Added `backoffice/GlobalSectionsListView` — the global-sections library with
14
+ previews, block chips, create and delete.
15
+ - Added `backoffice/GlobalSectionBuilderView` — editor for one global section or
16
+ global block: rename, convert kind, usage note, save; the block editor and
17
+ data access are host-injected.
18
+ - Added `backoffice/ReviewsView` — the reviews/testimonials slider editor with
19
+ host-supplied media picker and uploader.
20
+
3
21
  ## 0.28.0
4
22
 
5
23
  - Added `backoffice/FormSubmissionsView` — the submissions inbox (filters, bulk
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.28.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,
@@ -130,7 +130,12 @@
130
130
  "./backoffice/BackupView": "./src/backoffice/BackupView.tsx",
131
131
  "./backoffice/FormSubmissionsView": "./src/backoffice/FormSubmissionsView.tsx",
132
132
  "./backoffice/FormDeliveriesView": "./src/backoffice/FormDeliveriesView.tsx",
133
- "./backoffice/FormSettingsView": "./src/backoffice/FormSettingsView.tsx"
133
+ "./backoffice/FormSettingsView": "./src/backoffice/FormSettingsView.tsx",
134
+ "./backoffice/GlobalSectionsListView": "./src/backoffice/GlobalSectionsListView.tsx",
135
+ "./backoffice/GlobalSectionBuilderView": "./src/backoffice/GlobalSectionBuilderView.tsx",
136
+ "./backoffice/ReviewsView": "./src/backoffice/ReviewsView.tsx",
137
+ "./backoffice/PagesListView": "./src/backoffice/PagesListView.tsx",
138
+ "./backoffice/BlocksLibraryView": "./src/backoffice/BlocksLibraryView.tsx"
134
139
  },
135
140
  "dependencies": {
136
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
+ }