@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
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { useEffect, useState, type ComponentType, type ReactNode } from "react";
|
|
2
|
+
import { toast } from "sonner";
|
|
3
|
+
import { ArrowLeft, Save, Pencil, Layers, Globe } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
export type GlobalSectionKind = "block" | "section";
|
|
6
|
+
|
|
7
|
+
/** Props the host editor component must accept — matches PageEditorView's contract. */
|
|
8
|
+
export type GlobalSectionEditorProps<TBlock> = {
|
|
9
|
+
pageKey: string;
|
|
10
|
+
initialBlocks: TBlock[];
|
|
11
|
+
blocks: TBlock[];
|
|
12
|
+
globals: Record<string, TBlock[]>;
|
|
13
|
+
globalSections: { key: string; name: string; kind: GlobalSectionKind; blocks: TBlock[] }[];
|
|
14
|
+
onBlocksChange: (blocks: TBlock[]) => void;
|
|
15
|
+
onDirtyChange: (dirty: boolean) => void;
|
|
16
|
+
singleBlock: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type GlobalSectionBuilderDeps<TBlock> = {
|
|
20
|
+
sectionKey: string;
|
|
21
|
+
section: { id: string; key: string; name: string; kind?: string } | undefined;
|
|
22
|
+
blocksFromServer: TBlock[] | undefined;
|
|
23
|
+
isLoading: boolean;
|
|
24
|
+
error?: Error | null;
|
|
25
|
+
/** Every global for the picker, including this one — it is filtered out here. */
|
|
26
|
+
allGlobals: { key: string; name: string; kind?: string; blocks?: unknown }[];
|
|
27
|
+
/** Places this global is used, shown as a save-impact note. */
|
|
28
|
+
usage: { name: string }[];
|
|
29
|
+
save: (input: { sectionId: string; blocks: TBlock[] }) => Promise<unknown>;
|
|
30
|
+
rename: (input: { sectionId: string; name: string }) => Promise<unknown>;
|
|
31
|
+
setKind: (input: { sectionId: string; kind: GlobalSectionKind }) => Promise<unknown>;
|
|
32
|
+
refetch: () => void;
|
|
33
|
+
/** Navigates back to the blocks or global-sections library. */
|
|
34
|
+
goToLibrary: (kind: GlobalSectionKind) => void;
|
|
35
|
+
Editor: ComponentType<GlobalSectionEditorProps<TBlock>>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Controlled failure screen — an auth rejection must never blank the shell. */
|
|
39
|
+
export function GlobalSectionEditorError({ error }: { error: Error }): ReactNode {
|
|
40
|
+
const message = error?.message ?? "";
|
|
41
|
+
const unauthorized = /unauthor|forbidden|sign in|401/i.test(message);
|
|
42
|
+
return (
|
|
43
|
+
<div className="p-12">
|
|
44
|
+
<h1 className="font-serif text-2xl text-ink">
|
|
45
|
+
{unauthorized ? "Your session expired" : "This editor could not load"}
|
|
46
|
+
</h1>
|
|
47
|
+
<p className="mt-3 text-sm text-ink/60">
|
|
48
|
+
{unauthorized
|
|
49
|
+
? "Sign in again to continue editing this global section."
|
|
50
|
+
: "Something went wrong while loading this global section."}
|
|
51
|
+
</p>
|
|
52
|
+
<button
|
|
53
|
+
onClick={() => window.location.reload()}
|
|
54
|
+
className="mt-6 inline-flex items-center bg-ink text-white px-4 py-2 text-sm"
|
|
55
|
+
>
|
|
56
|
+
Reload
|
|
57
|
+
</button>
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Editor for one global section or global block: rename, convert kind, edit its
|
|
64
|
+
* blocks and save to every place that uses it.
|
|
65
|
+
*/
|
|
66
|
+
export function GlobalSectionBuilderView<TBlock>({
|
|
67
|
+
deps,
|
|
68
|
+
}: {
|
|
69
|
+
deps: GlobalSectionBuilderDeps<TBlock>;
|
|
70
|
+
}): ReactNode {
|
|
71
|
+
const {
|
|
72
|
+
sectionKey,
|
|
73
|
+
section,
|
|
74
|
+
blocksFromServer,
|
|
75
|
+
isLoading,
|
|
76
|
+
error,
|
|
77
|
+
allGlobals,
|
|
78
|
+
usage,
|
|
79
|
+
save,
|
|
80
|
+
rename,
|
|
81
|
+
setKind,
|
|
82
|
+
refetch,
|
|
83
|
+
goToLibrary,
|
|
84
|
+
Editor,
|
|
85
|
+
} = deps;
|
|
86
|
+
|
|
87
|
+
const [blocks, setBlocks] = useState<TBlock[]>([]);
|
|
88
|
+
const [initialBlocks, setInitialBlocks] = useState<TBlock[]>([]);
|
|
89
|
+
const [dirty, setDirty] = useState(false);
|
|
90
|
+
const [busy, setBusy] = useState(false);
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (blocksFromServer) {
|
|
94
|
+
setBlocks(blocksFromServer);
|
|
95
|
+
setInitialBlocks(blocksFromServer);
|
|
96
|
+
setDirty(false);
|
|
97
|
+
}
|
|
98
|
+
}, [blocksFromServer]);
|
|
99
|
+
|
|
100
|
+
const onSave = async () => {
|
|
101
|
+
if (!section) return;
|
|
102
|
+
setBusy(true);
|
|
103
|
+
try {
|
|
104
|
+
await save({ sectionId: section.id, blocks });
|
|
105
|
+
toast.success("Saved");
|
|
106
|
+
setDirty(false);
|
|
107
|
+
refetch();
|
|
108
|
+
} catch (e) {
|
|
109
|
+
toast.error(e instanceof Error ? e.message : "Failed");
|
|
110
|
+
} finally {
|
|
111
|
+
setBusy(false);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const onRename = async () => {
|
|
116
|
+
if (!section) return;
|
|
117
|
+
const next = window.prompt("Name", section.name);
|
|
118
|
+
if (!next || next.trim() === "" || next === section.name) return;
|
|
119
|
+
try {
|
|
120
|
+
await rename({ sectionId: section.id, name: next.trim() });
|
|
121
|
+
toast.success("Renamed");
|
|
122
|
+
refetch();
|
|
123
|
+
} catch (e) {
|
|
124
|
+
toast.error(e instanceof Error ? e.message : "Failed");
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const onConvert = async (kind: GlobalSectionKind) => {
|
|
129
|
+
if (!section) return;
|
|
130
|
+
if (dirty) {
|
|
131
|
+
toast.error("Save your changes first");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
await setKind({ sectionId: section.id, kind });
|
|
136
|
+
toast.success(kind === "block" ? "Now a global block" : "Now a global section");
|
|
137
|
+
refetch();
|
|
138
|
+
} catch (e) {
|
|
139
|
+
toast.error(e instanceof Error ? e.message : "Failed");
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
if (isLoading) return <div className="p-12 text-ink/60">Loading…</div>;
|
|
144
|
+
if (error) return <GlobalSectionEditorError error={error} />;
|
|
145
|
+
if (!section) return null;
|
|
146
|
+
|
|
147
|
+
const kind: GlobalSectionKind = section.kind === "block" ? "block" : "section";
|
|
148
|
+
const isBlock = kind === "block";
|
|
149
|
+
|
|
150
|
+
// Prevent self-reference in the picker, and never offer globals inside a global block.
|
|
151
|
+
const globalOptions = allGlobals
|
|
152
|
+
.filter((g) => g.key !== sectionKey)
|
|
153
|
+
.map((g) => ({
|
|
154
|
+
key: g.key,
|
|
155
|
+
name: g.name,
|
|
156
|
+
kind: (g.kind === "block" ? "block" : "section") as GlobalSectionKind,
|
|
157
|
+
blocks: (g.blocks ?? []) as TBlock[],
|
|
158
|
+
}));
|
|
159
|
+
// Resolve nested global blocks so the preview renders them.
|
|
160
|
+
const globalsMap: Record<string, TBlock[]> = {};
|
|
161
|
+
for (const g of allGlobals) globalsMap[g.key] = (g.blocks ?? []) as TBlock[];
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<div className="p-6 md:p-10 w-full">
|
|
165
|
+
<button
|
|
166
|
+
onClick={() => goToLibrary(kind)}
|
|
167
|
+
className="inline-flex items-center gap-2 text-sm text-ink/60 hover:text-ink"
|
|
168
|
+
>
|
|
169
|
+
<ArrowLeft className="size-4" /> {isBlock ? "All global blocks" : "All global sections"}
|
|
170
|
+
</button>
|
|
171
|
+
|
|
172
|
+
<div className="mt-3 flex flex-wrap items-end justify-between gap-4">
|
|
173
|
+
<div>
|
|
174
|
+
<div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/55 flex items-center gap-1.5">
|
|
175
|
+
{isBlock ? <Globe className="size-3" /> : <Layers className="size-3" />}
|
|
176
|
+
{isBlock ? "Global block — one block" : "Global section — several blocks"}
|
|
177
|
+
</div>
|
|
178
|
+
<div className="flex items-center gap-2 mt-1">
|
|
179
|
+
<h1 className="font-serif text-3xl md:text-4xl text-ink">{section.name}</h1>
|
|
180
|
+
<button onClick={onRename} title="Rename" className="text-ink/40 hover:text-ink">
|
|
181
|
+
<Pencil className="size-4" />
|
|
182
|
+
</button>
|
|
183
|
+
</div>
|
|
184
|
+
<div className="text-xs text-ink/50 font-mono mt-1">
|
|
185
|
+
{section.key}
|
|
186
|
+
{dirty && " · unsaved changes"}
|
|
187
|
+
</div>
|
|
188
|
+
<div className="mt-2 text-xs text-ink/60">
|
|
189
|
+
{usage.length === 0
|
|
190
|
+
? "Not used anywhere yet."
|
|
191
|
+
: `Used in ${usage.length} place${usage.length === 1 ? "" : "s"}: ${usage
|
|
192
|
+
.map((u) => u.name)
|
|
193
|
+
.join(", ")} — saving updates them all.`}
|
|
194
|
+
</div>
|
|
195
|
+
<button
|
|
196
|
+
onClick={() => onConvert(isBlock ? "section" : "block")}
|
|
197
|
+
className="mt-2 text-xs text-sea hover:underline"
|
|
198
|
+
>
|
|
199
|
+
{isBlock ? "Convert to global section" : "Convert to global block"}
|
|
200
|
+
</button>
|
|
201
|
+
</div>
|
|
202
|
+
<button
|
|
203
|
+
disabled={busy || !dirty}
|
|
204
|
+
onClick={onSave}
|
|
205
|
+
className="inline-flex items-center gap-2 bg-ink text-white px-4 py-2 text-sm disabled:opacity-40"
|
|
206
|
+
>
|
|
207
|
+
<Save className="size-4" /> Save
|
|
208
|
+
</button>
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
<div className="mt-6">
|
|
212
|
+
<Editor
|
|
213
|
+
pageKey={`gs:${sectionKey}`}
|
|
214
|
+
initialBlocks={initialBlocks}
|
|
215
|
+
blocks={blocks}
|
|
216
|
+
globals={globalsMap}
|
|
217
|
+
globalSections={globalOptions}
|
|
218
|
+
onBlocksChange={setBlocks}
|
|
219
|
+
onDirtyChange={setDirty}
|
|
220
|
+
singleBlock={isBlock}
|
|
221
|
+
/>
|
|
222
|
+
</div>
|
|
223
|
+
</div>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { useState, type ReactNode } from "react";
|
|
2
|
+
import { toast } from "sonner";
|
|
3
|
+
import { Plus, Trash2, Pencil, Eye } from "lucide-react";
|
|
4
|
+
import { DataThumb } from "./SectionThumb";
|
|
5
|
+
import { PreviewLightbox } from "./PreviewLightbox";
|
|
6
|
+
|
|
7
|
+
/** One reusable group of blocks as returned by the host's list function. */
|
|
8
|
+
export type GlobalSectionRow = {
|
|
9
|
+
id: string;
|
|
10
|
+
key: string;
|
|
11
|
+
name: string;
|
|
12
|
+
updated_at: string;
|
|
13
|
+
blocks?: unknown;
|
|
14
|
+
kind?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type GlobalSectionsListDeps = {
|
|
18
|
+
rows: GlobalSectionRow[];
|
|
19
|
+
/** Maps a block instance to the label shown as a chip. */
|
|
20
|
+
blockLabels: (blocks: unknown) => string[];
|
|
21
|
+
create: (input: { key: string; name: string }) => Promise<unknown>;
|
|
22
|
+
remove: (sectionId: string) => Promise<unknown>;
|
|
23
|
+
refetch: () => void;
|
|
24
|
+
/** Navigates to the editor for one section key. */
|
|
25
|
+
openEditor: (key: string) => void;
|
|
26
|
+
/** Preview URL for a section key, e.g. `/preview/gs:${key}`. */
|
|
27
|
+
previewSrc: (key: string) => string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Library screen listing global sections — reusable groups of blocks that update
|
|
32
|
+
* everywhere at once. All data access and navigation is host-supplied.
|
|
33
|
+
*/
|
|
34
|
+
export function GlobalSectionsListView({ deps }: { deps: GlobalSectionsListDeps }): ReactNode {
|
|
35
|
+
const { rows, blockLabels, create, remove, refetch, openEditor, previewSrc } = deps;
|
|
36
|
+
const [newKey, setNewKey] = useState("");
|
|
37
|
+
const [newName, setNewName] = useState("");
|
|
38
|
+
const [busy, setBusy] = useState(false);
|
|
39
|
+
const [preview, setPreview] = useState<{ key: string; name: string } | null>(null);
|
|
40
|
+
|
|
41
|
+
const onCreate = async () => {
|
|
42
|
+
if (!newKey || !newName) return;
|
|
43
|
+
setBusy(true);
|
|
44
|
+
try {
|
|
45
|
+
await create({ key: newKey, name: newName });
|
|
46
|
+
toast.success("Global section created");
|
|
47
|
+
setNewKey("");
|
|
48
|
+
setNewName("");
|
|
49
|
+
refetch();
|
|
50
|
+
} catch (e) {
|
|
51
|
+
toast.error(e instanceof Error ? e.message : "Failed");
|
|
52
|
+
} finally {
|
|
53
|
+
setBusy(false);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const onDelete = async (id: string, name: string) => {
|
|
58
|
+
if (!confirm(`Delete "${name}"? Pages referencing it will render nothing there.`)) return;
|
|
59
|
+
try {
|
|
60
|
+
await remove(id);
|
|
61
|
+
toast.success("Deleted");
|
|
62
|
+
refetch();
|
|
63
|
+
} catch (e) {
|
|
64
|
+
toast.error(e instanceof Error ? e.message : "Failed");
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div className="p-8 md:p-12 w-full">
|
|
70
|
+
<div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/55">Library</div>
|
|
71
|
+
<h1 className="mt-2 font-serif text-3xl md:text-4xl text-ink">Global Sections</h1>
|
|
72
|
+
<p className="mt-4 max-w-2xl text-sm text-ink/60">
|
|
73
|
+
A global section holds several blocks that belong together. Reuse it on any page. Edit it
|
|
74
|
+
here and every place that uses it updates at the same time. For a single reusable block, use
|
|
75
|
+
Global blocks in the Blocks library instead.
|
|
76
|
+
</p>
|
|
77
|
+
|
|
78
|
+
<div className="mt-8 border border-ink/10 bg-white">
|
|
79
|
+
<table className="w-full text-sm">
|
|
80
|
+
<thead className="bg-ink/[0.03] text-left text-[0.7rem] uppercase tracking-[0.18em] text-ink/60">
|
|
81
|
+
<tr>
|
|
82
|
+
<th className="px-4 py-3 w-36">Preview</th>
|
|
83
|
+
<th className="px-4 py-3">Name</th>
|
|
84
|
+
<th className="px-4 py-3">Key</th>
|
|
85
|
+
<th className="px-4 py-3">Updated</th>
|
|
86
|
+
<th className="px-4 py-3 w-40"></th>
|
|
87
|
+
</tr>
|
|
88
|
+
</thead>
|
|
89
|
+
<tbody>
|
|
90
|
+
{rows.map((g) => {
|
|
91
|
+
const labels = blockLabels(g.blocks);
|
|
92
|
+
return (
|
|
93
|
+
<tr key={g.id} className="border-t border-ink/10">
|
|
94
|
+
<td className="px-4 py-3 align-top">
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
title="Preview section"
|
|
98
|
+
onClick={() => setPreview({ key: g.key, name: g.name })}
|
|
99
|
+
className="group relative block"
|
|
100
|
+
>
|
|
101
|
+
<DataThumb data={g.blocks} />
|
|
102
|
+
<span className="absolute inset-0 hidden items-center justify-center bg-ink/50 text-white group-hover:flex">
|
|
103
|
+
<Eye className="size-4" />
|
|
104
|
+
</span>
|
|
105
|
+
</button>
|
|
106
|
+
</td>
|
|
107
|
+
<td className="px-4 py-3 align-top">
|
|
108
|
+
<div className="text-ink">{g.name}</div>
|
|
109
|
+
{labels.length > 0 && (
|
|
110
|
+
<div className="mt-1 flex flex-wrap gap-1.5">
|
|
111
|
+
{labels.map((label, i) => (
|
|
112
|
+
<span
|
|
113
|
+
key={i}
|
|
114
|
+
className="inline-block text-[0.65rem] leading-none px-1.5 py-1 bg-ink/[0.06] text-ink/60 rounded"
|
|
115
|
+
>
|
|
116
|
+
{label}
|
|
117
|
+
</span>
|
|
118
|
+
))}
|
|
119
|
+
</div>
|
|
120
|
+
)}
|
|
121
|
+
</td>
|
|
122
|
+
<td className="px-4 py-3 text-ink/70 font-mono align-top">{g.key}</td>
|
|
123
|
+
<td className="px-4 py-3 text-ink/50 align-top">
|
|
124
|
+
{new Date(g.updated_at).toLocaleString()}
|
|
125
|
+
</td>
|
|
126
|
+
<td className="px-4 py-3 text-right align-top">
|
|
127
|
+
<button
|
|
128
|
+
onClick={() => openEditor(g.key)}
|
|
129
|
+
className="inline-flex items-center gap-1 text-sea hover:underline mr-3"
|
|
130
|
+
>
|
|
131
|
+
<Pencil className="size-3.5" /> Edit
|
|
132
|
+
</button>
|
|
133
|
+
<button
|
|
134
|
+
onClick={() => onDelete(g.id, g.name)}
|
|
135
|
+
className="inline-flex items-center gap-1 text-red-600 hover:underline"
|
|
136
|
+
>
|
|
137
|
+
<Trash2 className="size-3.5" /> Delete
|
|
138
|
+
</button>
|
|
139
|
+
</td>
|
|
140
|
+
</tr>
|
|
141
|
+
);
|
|
142
|
+
})}
|
|
143
|
+
{rows.length === 0 && (
|
|
144
|
+
<tr>
|
|
145
|
+
<td colSpan={5} className="px-4 py-8 text-center text-ink/50">
|
|
146
|
+
No global sections yet.
|
|
147
|
+
</td>
|
|
148
|
+
</tr>
|
|
149
|
+
)}
|
|
150
|
+
</tbody>
|
|
151
|
+
</table>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
<div className="mt-10 border border-ink/10 bg-white p-6">
|
|
155
|
+
<div className="font-serif text-lg text-ink">New global section</div>
|
|
156
|
+
<div className="mt-4 grid gap-3 md:grid-cols-[1fr_1fr_auto]">
|
|
157
|
+
<label className="block">
|
|
158
|
+
<span className="block text-xs uppercase tracking-wider text-ink/60 mb-1">Name</span>
|
|
159
|
+
<input
|
|
160
|
+
value={newName}
|
|
161
|
+
onChange={(e) => setNewName(e.target.value)}
|
|
162
|
+
className="w-full border border-ink/20 px-3 py-2 text-sm"
|
|
163
|
+
placeholder="Site-wide FAQ"
|
|
164
|
+
/>
|
|
165
|
+
</label>
|
|
166
|
+
<label className="block">
|
|
167
|
+
<span className="block text-xs uppercase tracking-wider text-ink/60 mb-1">Key</span>
|
|
168
|
+
<input
|
|
169
|
+
value={newKey}
|
|
170
|
+
onChange={(e) => setNewKey(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, ""))}
|
|
171
|
+
className="w-full border border-ink/20 px-3 py-2 text-sm font-mono"
|
|
172
|
+
placeholder="site-faq"
|
|
173
|
+
/>
|
|
174
|
+
</label>
|
|
175
|
+
<button
|
|
176
|
+
disabled={busy || !newKey || !newName}
|
|
177
|
+
onClick={onCreate}
|
|
178
|
+
className="inline-flex items-center gap-2 bg-ink text-white px-5 py-2 text-sm disabled:opacity-40 self-end"
|
|
179
|
+
>
|
|
180
|
+
<Plus className="size-4" /> Create
|
|
181
|
+
</button>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
<PreviewLightbox
|
|
186
|
+
open={preview != null}
|
|
187
|
+
onClose={() => setPreview(null)}
|
|
188
|
+
src={preview ? previewSrc(preview.key) : ""}
|
|
189
|
+
title={preview?.name ?? ""}
|
|
190
|
+
/>
|
|
191
|
+
</div>
|
|
192
|
+
);
|
|
193
|
+
}
|