@bygghammar/cms-platform 0.28.0 → 0.29.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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.29.0
4
+
5
+ - Added `backoffice/GlobalSectionsListView` — the global-sections library with
6
+ previews, block chips, create and delete.
7
+ - Added `backoffice/GlobalSectionBuilderView` — editor for one global section or
8
+ global block: rename, convert kind, usage note, save; the block editor and
9
+ data access are host-injected.
10
+ - Added `backoffice/ReviewsView` — the reviews/testimonials slider editor with
11
+ host-supplied media picker and uploader.
12
+
3
13
  ## 0.28.0
4
14
 
5
15
  - 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.29.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,10 @@
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"
134
137
  },
135
138
  "dependencies": {
136
139
  "fflate": "^0.8.3"
@@ -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
+ }
@@ -0,0 +1,231 @@
1
+ import { useEffect, useState, type ReactNode } from "react";
2
+ import { toast } from "sonner";
3
+ import { Plus, Trash2, ArrowUp, ArrowDown, Save, ChevronLeft } from "lucide-react";
4
+ import type { ComponentType } from "react";
5
+
6
+ type LocalizedText = Record<string, string>;
7
+
8
+ /** One quote in the reviews slider. */
9
+ export type ReviewItem = {
10
+ quote: LocalizedText;
11
+ name: LocalizedText;
12
+ role: LocalizedText;
13
+ image?: string;
14
+ imageAlt?: LocalizedText;
15
+ };
16
+
17
+ export type ReviewsSection = {
18
+ eyebrow: LocalizedText;
19
+ title: LocalizedText;
20
+ autoplayMs: number;
21
+ items: ReviewItem[];
22
+ };
23
+
24
+ export type ReviewsDeps = {
25
+ /** Current section content, already merged from overrides and code defaults. */
26
+ source: ReviewsSection | undefined;
27
+ /** Locale the editor writes to; the public site of a single-locale host uses this. */
28
+ locale: string;
29
+ save: (section: ReviewsSection) => Promise<unknown>;
30
+ /** Host media picker — usually the project's wrapper around `inspectors/fields`. */
31
+ MediaField: ComponentType<{
32
+ label: string;
33
+ value: string | undefined;
34
+ onChange: (url: string) => void;
35
+ accept?: "image" | "video" | "any";
36
+ }>;
37
+ /** Host review-image uploader, already wired to its storage. */
38
+ ReviewImageUpload: ComponentType<{ onUploaded: (url: string) => void }>;
39
+ BackLink: (props: { className?: string; children: ReactNode }) => ReactNode;
40
+ };
41
+
42
+ /** Editor for the reviews/testimonials slider content section. */
43
+ export function ReviewsView({ deps }: { deps: ReviewsDeps }): ReactNode {
44
+ const { source, locale, save, BackLink, MediaField, ReviewImageUpload } = deps;
45
+ const [section, setSection] = useState<ReviewsSection | null>(null);
46
+ const [busy, setBusy] = useState(false);
47
+
48
+ useEffect(() => {
49
+ if (source) setSection(structuredClone(source));
50
+ }, [source]);
51
+
52
+ if (!section) return <div className="p-8">Loading…</div>;
53
+
54
+ const one = (value: string): LocalizedText => ({ [locale]: value });
55
+ const update = (patch: Partial<ReviewsSection>) => setSection({ ...section, ...patch });
56
+ const updateItem = (i: number, patch: Partial<ReviewItem>) => {
57
+ const items = section.items.slice();
58
+ items[i] = { ...items[i], ...patch } as ReviewItem;
59
+ update({ items });
60
+ };
61
+ const removeItem = (i: number) => update({ items: section.items.filter((_, idx) => idx !== i) });
62
+ const move = (i: number, dir: -1 | 1) => {
63
+ const items = section.items.slice();
64
+ const j = i + dir;
65
+ if (j < 0 || j >= items.length) return;
66
+ [items[i], items[j]] = [items[j]!, items[i]!];
67
+ update({ items });
68
+ };
69
+ const addItem = () =>
70
+ update({ items: [...section.items, { quote: one(""), name: one(""), role: one("") }] });
71
+
72
+ const onSave = async () => {
73
+ setBusy(true);
74
+ try {
75
+ await save(section);
76
+ toast.success("Reviews saved");
77
+ } catch (err) {
78
+ toast.error(err instanceof Error ? err.message : "Could not save");
79
+ } finally {
80
+ setBusy(false);
81
+ }
82
+ };
83
+
84
+ return (
85
+ <div className="p-8 md:p-12 w-full">
86
+ <BackLink className="inline-flex items-center gap-2 text-xs uppercase tracking-[0.22em] text-ink/60 hover:text-ink">
87
+ <>
88
+ <ChevronLeft className="size-4" />
89
+ Dashboard
90
+ </>
91
+ </BackLink>
92
+ <h1 className="mt-4 font-serif text-3xl md:text-4xl text-ink">Reviews</h1>
93
+ <p className="mt-3 text-ink/65 max-w-xl">
94
+ Edit the quotes shown in the slider. Changes appear after saving.
95
+ </p>
96
+
97
+ <div className="mt-8 grid gap-3 md:grid-cols-2">
98
+ <label className="block">
99
+ <span className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/55">Eyebrow</span>
100
+ <input
101
+ className="mt-2 w-full border border-ink/15 bg-white px-4 py-3"
102
+ value={section.eyebrow[locale] ?? ""}
103
+ onChange={(e) => update({ eyebrow: one(e.target.value) })}
104
+ />
105
+ </label>
106
+ <label className="block">
107
+ <span className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/55">Title</span>
108
+ <input
109
+ className="mt-2 w-full border border-ink/15 bg-white px-4 py-3"
110
+ value={section.title[locale] ?? ""}
111
+ onChange={(e) => update({ title: one(e.target.value) })}
112
+ />
113
+ </label>
114
+ <label className="block">
115
+ <span className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/55">
116
+ Autoplay (ms)
117
+ </span>
118
+ <input
119
+ type="number"
120
+ min={0}
121
+ className="mt-2 w-full border border-ink/15 bg-white px-4 py-3"
122
+ value={section.autoplayMs}
123
+ onChange={(e) => update({ autoplayMs: Number(e.target.value) || 0 })}
124
+ />
125
+ </label>
126
+ </div>
127
+
128
+ <div className="mt-8 space-y-4">
129
+ {section.items.map((it, i) => (
130
+ <div key={i} className="border border-ink/10 bg-white p-5">
131
+ <div className="flex items-center justify-between gap-3">
132
+ <div className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/55">#{i + 1}</div>
133
+ <div className="flex items-center gap-1">
134
+ <button
135
+ onClick={() => move(i, -1)}
136
+ disabled={i === 0}
137
+ className="p-2 hover:bg-ink/5 disabled:opacity-30"
138
+ >
139
+ <ArrowUp className="size-4" />
140
+ </button>
141
+ <button
142
+ onClick={() => move(i, 1)}
143
+ disabled={i === section.items.length - 1}
144
+ className="p-2 hover:bg-ink/5 disabled:opacity-30"
145
+ >
146
+ <ArrowDown className="size-4" />
147
+ </button>
148
+ <button onClick={() => removeItem(i)} className="p-2 hover:bg-red-50 text-red-600">
149
+ <Trash2 className="size-4" />
150
+ </button>
151
+ </div>
152
+ </div>
153
+ <div className="mt-4 grid gap-3">
154
+ <label className="block">
155
+ <span className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/55">
156
+ Quote
157
+ </span>
158
+ <textarea
159
+ className="mt-2 w-full border border-ink/15 bg-white px-3 py-2 text-sm min-h-[90px]"
160
+ value={it.quote[locale] ?? ""}
161
+ onChange={(e) => updateItem(i, { quote: one(e.target.value) })}
162
+ />
163
+ </label>
164
+ <div className="grid gap-3 md:grid-cols-2">
165
+ <label className="block">
166
+ <span className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/55">
167
+ Name
168
+ </span>
169
+ <input
170
+ className="mt-2 w-full border border-ink/15 bg-white px-3 py-2 text-sm"
171
+ value={it.name[locale] ?? ""}
172
+ onChange={(e) => updateItem(i, { name: one(e.target.value) })}
173
+ />
174
+ </label>
175
+ <label className="block">
176
+ <span className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/55">
177
+ Role
178
+ </span>
179
+ <input
180
+ className="mt-2 w-full border border-ink/15 bg-white px-3 py-2 text-sm"
181
+ value={it.role[locale] ?? ""}
182
+ onChange={(e) => updateItem(i, { role: one(e.target.value) })}
183
+ />
184
+ </label>
185
+ </div>
186
+ <div className="grid gap-3 md:grid-cols-2">
187
+ <div className="space-y-2">
188
+ <MediaField
189
+ label="Image"
190
+ accept="image"
191
+ value={it.image}
192
+ onChange={(url) => updateItem(i, { image: url || undefined })}
193
+ />
194
+ <ReviewImageUpload onUploaded={(url) => updateItem(i, { image: url })} />
195
+ </div>
196
+ <label className="block">
197
+ <span className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/55">
198
+ Image alt text
199
+ </span>
200
+ <input
201
+ className="mt-2 w-full border border-ink/15 bg-white px-3 py-2 text-sm"
202
+ value={it.imageAlt?.[locale] ?? ""}
203
+ onChange={(e) => updateItem(i, { imageAlt: one(e.target.value) })}
204
+ />
205
+ </label>
206
+ </div>
207
+ </div>
208
+ </div>
209
+ ))}
210
+ </div>
211
+
212
+ <div className="mt-6 flex flex-wrap items-center gap-3">
213
+ <button
214
+ onClick={addItem}
215
+ className="inline-flex items-center gap-2 border border-ink/20 px-5 py-2.5 text-xs uppercase tracking-[0.22em] hover:border-ink"
216
+ >
217
+ <Plus className="size-4" />
218
+ Add review
219
+ </button>
220
+ <button
221
+ onClick={onSave}
222
+ disabled={busy}
223
+ className="inline-flex items-center gap-2 bg-ink text-paper px-6 py-3 text-xs uppercase tracking-[0.22em] hover:bg-ink/85 disabled:opacity-50"
224
+ >
225
+ <Save className="size-4" />
226
+ {busy ? "Saving…" : "Save all changes"}
227
+ </button>
228
+ </div>
229
+ </div>
230
+ );
231
+ }