@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 +18 -0
- package/package.json +7 -2
- package/src/backoffice/BlocksLibraryView.tsx +463 -0
- package/src/backoffice/GlobalSectionBuilderView.tsx +225 -0
- package/src/backoffice/GlobalSectionsListView.tsx +193 -0
- package/src/backoffice/PagesListView.tsx +369 -0
- package/src/backoffice/ReviewsView.tsx +231 -0
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.
|
|
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
|
+
}
|