@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.
@@ -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
+ }