@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,369 @@
|
|
|
1
|
+
import { useState, type ReactNode } from "react";
|
|
2
|
+
import { toast } from "sonner";
|
|
3
|
+
import { Plus, Trash2, Pencil, Globe as GlobeIcon, ExternalLink, Undo2 } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
/** A page row as listed by the host. */
|
|
6
|
+
export type PageListRow = {
|
|
7
|
+
id: string;
|
|
8
|
+
key: string;
|
|
9
|
+
name: string;
|
|
10
|
+
status: string;
|
|
11
|
+
updated_at: string;
|
|
12
|
+
is_system?: boolean | null;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** A soft-deleted page kept in the trash bin. */
|
|
16
|
+
export type DeletedPageRow = {
|
|
17
|
+
id: string;
|
|
18
|
+
key: string;
|
|
19
|
+
name: string;
|
|
20
|
+
deleted_at?: string | null;
|
|
21
|
+
deleted_by?: string | null;
|
|
22
|
+
deleted_by_email?: string | null;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type PagesListDeps = {
|
|
26
|
+
pages: PageListRow[] | undefined;
|
|
27
|
+
trash: { rows: DeletedPageRow[]; canPurge: boolean } | undefined;
|
|
28
|
+
/** Locale switcher — pass a single locale to hide the switcher. */
|
|
29
|
+
locales: { value: string; label: string }[];
|
|
30
|
+
locale: string;
|
|
31
|
+
setLocale: (locale: string) => void;
|
|
32
|
+
/** Public path for a page key in the active locale. */
|
|
33
|
+
pathFor: (key: string) => string;
|
|
34
|
+
/** Absolute URL for a page key, including the locale's domain when known. */
|
|
35
|
+
hrefFor: (key: string) => string;
|
|
36
|
+
createPage: (input: { key: string; name: string }) => Promise<unknown>;
|
|
37
|
+
renamePage: (input: { pageId: string; name: string }) => Promise<unknown>;
|
|
38
|
+
deletePage: (pageId: string) => Promise<unknown>;
|
|
39
|
+
restorePage: (pageId: string) => Promise<unknown>;
|
|
40
|
+
purgePage: (pageId: string) => Promise<unknown>;
|
|
41
|
+
refetchPages: () => void;
|
|
42
|
+
refetchTrash: () => void;
|
|
43
|
+
openEditor: (key: string) => void;
|
|
44
|
+
GlobalsLink: (props: { className?: string; children: ReactNode }) => ReactNode;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Page builder index: page list with public URLs, trash bin and page creation. */
|
|
48
|
+
export function PagesListView({ deps }: { deps: PagesListDeps }): ReactNode {
|
|
49
|
+
const {
|
|
50
|
+
pages,
|
|
51
|
+
trash,
|
|
52
|
+
locales,
|
|
53
|
+
locale,
|
|
54
|
+
setLocale,
|
|
55
|
+
pathFor,
|
|
56
|
+
hrefFor,
|
|
57
|
+
createPage,
|
|
58
|
+
renamePage,
|
|
59
|
+
deletePage,
|
|
60
|
+
restorePage,
|
|
61
|
+
purgePage,
|
|
62
|
+
refetchPages,
|
|
63
|
+
refetchTrash,
|
|
64
|
+
openEditor,
|
|
65
|
+
GlobalsLink,
|
|
66
|
+
} = deps;
|
|
67
|
+
|
|
68
|
+
const [newKey, setNewKey] = useState("");
|
|
69
|
+
const [newName, setNewName] = useState("");
|
|
70
|
+
const [busy, setBusy] = useState(false);
|
|
71
|
+
const [renaming, setRenaming] = useState<{ id: string; name: string } | null>(null);
|
|
72
|
+
|
|
73
|
+
const fail = (e: unknown) => toast.error(e instanceof Error ? e.message : "Failed");
|
|
74
|
+
|
|
75
|
+
const onRename = async () => {
|
|
76
|
+
if (!renaming || !renaming.name.trim()) return;
|
|
77
|
+
try {
|
|
78
|
+
await renamePage({ pageId: renaming.id, name: renaming.name.trim() });
|
|
79
|
+
toast.success("Page renamed");
|
|
80
|
+
setRenaming(null);
|
|
81
|
+
refetchPages();
|
|
82
|
+
} catch (e) {
|
|
83
|
+
fail(e);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const onCreate = async () => {
|
|
88
|
+
if (!newKey || !newName) return;
|
|
89
|
+
setBusy(true);
|
|
90
|
+
try {
|
|
91
|
+
await createPage({ key: newKey, name: newName });
|
|
92
|
+
toast.success("Page created");
|
|
93
|
+
setNewKey("");
|
|
94
|
+
setNewName("");
|
|
95
|
+
refetchPages();
|
|
96
|
+
} catch (e) {
|
|
97
|
+
fail(e);
|
|
98
|
+
} finally {
|
|
99
|
+
setBusy(false);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const onDelete = async (id: string, name: string) => {
|
|
104
|
+
if (!confirm(`Move page "${name}" to the trash bin? It can be restored later.`)) return;
|
|
105
|
+
try {
|
|
106
|
+
await deletePage(id);
|
|
107
|
+
toast.success("Moved to trash");
|
|
108
|
+
refetchPages();
|
|
109
|
+
refetchTrash();
|
|
110
|
+
} catch (e) {
|
|
111
|
+
fail(e);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const onRestore = async (id: string) => {
|
|
116
|
+
try {
|
|
117
|
+
await restorePage(id);
|
|
118
|
+
toast.success("Page restored");
|
|
119
|
+
refetchPages();
|
|
120
|
+
refetchTrash();
|
|
121
|
+
} catch (e) {
|
|
122
|
+
fail(e);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const onPurge = async (id: string, name: string) => {
|
|
127
|
+
if (!confirm(`Permanently delete "${name}"? This cannot be undone.`)) return;
|
|
128
|
+
try {
|
|
129
|
+
await purgePage(id);
|
|
130
|
+
toast.success("Permanently deleted");
|
|
131
|
+
refetchTrash();
|
|
132
|
+
} catch (e) {
|
|
133
|
+
fail(e);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<div className="p-8 md:p-12 w-full">
|
|
139
|
+
<div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/55">Page builder</div>
|
|
140
|
+
<div className="mt-2 flex flex-wrap items-end justify-between gap-4">
|
|
141
|
+
<h1 className="font-serif text-3xl md:text-4xl text-ink">Pages</h1>
|
|
142
|
+
<div className="flex items-center gap-3">
|
|
143
|
+
{locales.length > 1 && (
|
|
144
|
+
<div className="flex items-center gap-1">
|
|
145
|
+
{locales.map((loc) => (
|
|
146
|
+
<button
|
|
147
|
+
key={loc.value}
|
|
148
|
+
onClick={() => setLocale(loc.value)}
|
|
149
|
+
className={`px-2 py-1 text-[0.7rem] uppercase tracking-widest border ${
|
|
150
|
+
locale === loc.value
|
|
151
|
+
? "border-sea bg-ice/50 text-ink"
|
|
152
|
+
: "border-ink/15 text-ink/50 hover:text-ink"
|
|
153
|
+
}`}
|
|
154
|
+
>
|
|
155
|
+
{loc.label}
|
|
156
|
+
</button>
|
|
157
|
+
))}
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
160
|
+
<GlobalsLink className="inline-flex items-center gap-2 border border-ink/20 px-4 py-2 text-sm text-ink hover:border-sea">
|
|
161
|
+
<>
|
|
162
|
+
<GlobeIcon className="size-4" /> Global blocks
|
|
163
|
+
</>
|
|
164
|
+
</GlobalsLink>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
<p className="mt-4 max-w-2xl text-sm text-ink/60">
|
|
169
|
+
Compose each page from blocks and global blocks. Every save creates a new version — publish
|
|
170
|
+
when ready.
|
|
171
|
+
</p>
|
|
172
|
+
|
|
173
|
+
<div className="mt-8 border border-ink/10 bg-white">
|
|
174
|
+
<table className="w-full text-sm">
|
|
175
|
+
<thead className="bg-ink/[0.03] text-left text-[0.7rem] uppercase tracking-[0.18em] text-ink/60">
|
|
176
|
+
<tr>
|
|
177
|
+
<th className="px-4 py-3">Name</th>
|
|
178
|
+
<th className="px-4 py-3">Key</th>
|
|
179
|
+
<th className="px-4 py-3">URL</th>
|
|
180
|
+
<th className="px-4 py-3">Status</th>
|
|
181
|
+
<th className="px-4 py-3">Updated</th>
|
|
182
|
+
<th className="px-4 py-3 w-40"></th>
|
|
183
|
+
</tr>
|
|
184
|
+
</thead>
|
|
185
|
+
<tbody>
|
|
186
|
+
{pages?.map((p) => (
|
|
187
|
+
<tr key={p.id} className="border-t border-ink/10">
|
|
188
|
+
<td className="px-4 py-3 text-ink">
|
|
189
|
+
{renaming?.id === p.id ? (
|
|
190
|
+
<span className="flex items-center gap-2">
|
|
191
|
+
<input
|
|
192
|
+
autoFocus
|
|
193
|
+
value={renaming.name}
|
|
194
|
+
onChange={(e) => setRenaming({ id: p.id, name: e.target.value })}
|
|
195
|
+
onKeyDown={(e) => {
|
|
196
|
+
if (e.key === "Enter") onRename();
|
|
197
|
+
if (e.key === "Escape") setRenaming(null);
|
|
198
|
+
}}
|
|
199
|
+
className="border border-ink/20 px-2 py-1 text-sm"
|
|
200
|
+
/>
|
|
201
|
+
<button onClick={onRename} className="text-sea hover:underline text-xs">
|
|
202
|
+
Save
|
|
203
|
+
</button>
|
|
204
|
+
<button
|
|
205
|
+
onClick={() => setRenaming(null)}
|
|
206
|
+
className="text-ink/50 hover:underline text-xs"
|
|
207
|
+
>
|
|
208
|
+
Cancel
|
|
209
|
+
</button>
|
|
210
|
+
</span>
|
|
211
|
+
) : (
|
|
212
|
+
<>
|
|
213
|
+
{p.name}{" "}
|
|
214
|
+
{p.is_system && (
|
|
215
|
+
<span className="ml-2 text-[0.65rem] text-ink/50">SYSTEM</span>
|
|
216
|
+
)}
|
|
217
|
+
<button
|
|
218
|
+
onClick={() => setRenaming({ id: p.id, name: p.name })}
|
|
219
|
+
className="ml-3 text-[0.7rem] text-ink/50 hover:text-sea underline"
|
|
220
|
+
>
|
|
221
|
+
rename
|
|
222
|
+
</button>
|
|
223
|
+
</>
|
|
224
|
+
)}
|
|
225
|
+
</td>
|
|
226
|
+
<td className="px-4 py-3 text-ink/70 font-mono">{p.key}</td>
|
|
227
|
+
<td className="px-4 py-3">
|
|
228
|
+
<a
|
|
229
|
+
href={hrefFor(p.key)}
|
|
230
|
+
target="_blank"
|
|
231
|
+
rel="noreferrer"
|
|
232
|
+
title={`Open ${hrefFor(p.key)}`}
|
|
233
|
+
className="inline-flex items-center gap-1.5 font-mono text-xs text-sea hover:underline"
|
|
234
|
+
>
|
|
235
|
+
{pathFor(p.key)}
|
|
236
|
+
<ExternalLink className="size-3 text-ink/40" />
|
|
237
|
+
</a>
|
|
238
|
+
</td>
|
|
239
|
+
<td className="px-4 py-3 text-ink/70">{p.status}</td>
|
|
240
|
+
<td className="px-4 py-3 text-ink/50">{new Date(p.updated_at).toLocaleString()}</td>
|
|
241
|
+
<td className="px-4 py-3 text-right">
|
|
242
|
+
<button
|
|
243
|
+
onClick={() => openEditor(p.key)}
|
|
244
|
+
className="inline-flex items-center gap-1 text-sea hover:underline mr-3"
|
|
245
|
+
>
|
|
246
|
+
<Pencil className="size-3.5" /> Edit
|
|
247
|
+
</button>
|
|
248
|
+
{!p.is_system && (
|
|
249
|
+
<button
|
|
250
|
+
onClick={() => onDelete(p.id, p.name)}
|
|
251
|
+
className="inline-flex items-center gap-1 text-red-600 hover:underline"
|
|
252
|
+
>
|
|
253
|
+
<Trash2 className="size-3.5" /> Delete
|
|
254
|
+
</button>
|
|
255
|
+
)}
|
|
256
|
+
</td>
|
|
257
|
+
</tr>
|
|
258
|
+
))}
|
|
259
|
+
{pages?.length === 0 && (
|
|
260
|
+
<tr>
|
|
261
|
+
<td colSpan={6} className="px-4 py-8 text-center text-ink/50">
|
|
262
|
+
No pages yet.
|
|
263
|
+
</td>
|
|
264
|
+
</tr>
|
|
265
|
+
)}
|
|
266
|
+
</tbody>
|
|
267
|
+
</table>
|
|
268
|
+
</div>
|
|
269
|
+
|
|
270
|
+
<div className="mt-10 border border-ink/10 bg-white">
|
|
271
|
+
<div className="flex items-center justify-between px-6 py-4 border-b border-ink/10">
|
|
272
|
+
<div>
|
|
273
|
+
<div className="font-serif text-lg text-ink">Trash bin</div>
|
|
274
|
+
<p className="text-sm text-ink/60 mt-1">
|
|
275
|
+
Deleted pages are kept here with a record of who deleted them. Only a superadmin can
|
|
276
|
+
delete them permanently.
|
|
277
|
+
</p>
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
280
|
+
<table className="w-full text-sm">
|
|
281
|
+
<thead className="bg-ink/[0.03] text-left text-[0.7rem] uppercase tracking-[0.18em] text-ink/60">
|
|
282
|
+
<tr>
|
|
283
|
+
<th className="px-4 py-3">Name</th>
|
|
284
|
+
<th className="px-4 py-3">Key</th>
|
|
285
|
+
<th className="px-4 py-3">Deleted by</th>
|
|
286
|
+
<th className="px-4 py-3">Deleted at</th>
|
|
287
|
+
<th className="px-4 py-3 w-56"></th>
|
|
288
|
+
</tr>
|
|
289
|
+
</thead>
|
|
290
|
+
<tbody>
|
|
291
|
+
{trash?.rows.map((p) => (
|
|
292
|
+
<tr key={p.id} className="border-t border-ink/10">
|
|
293
|
+
<td className="px-4 py-3 text-ink">{p.name}</td>
|
|
294
|
+
<td className="px-4 py-3 font-mono text-ink/70">{p.key}</td>
|
|
295
|
+
<td className="px-4 py-3 text-ink/70">
|
|
296
|
+
{p.deleted_by_email ?? p.deleted_by ?? "Unknown user"}
|
|
297
|
+
</td>
|
|
298
|
+
<td className="px-4 py-3 text-ink/50">
|
|
299
|
+
{p.deleted_at ? new Date(p.deleted_at).toLocaleString() : "—"}
|
|
300
|
+
</td>
|
|
301
|
+
<td className="px-4 py-3 text-right">
|
|
302
|
+
<button
|
|
303
|
+
onClick={() => onRestore(p.id)}
|
|
304
|
+
className="inline-flex items-center gap-1 text-sea hover:underline mr-3"
|
|
305
|
+
>
|
|
306
|
+
<Undo2 className="size-3.5" /> Restore
|
|
307
|
+
</button>
|
|
308
|
+
{trash?.canPurge ? (
|
|
309
|
+
<button
|
|
310
|
+
onClick={() => onPurge(p.id, p.name)}
|
|
311
|
+
className="inline-flex items-center gap-1 text-red-600 hover:underline"
|
|
312
|
+
>
|
|
313
|
+
<Trash2 className="size-3.5" /> Delete forever
|
|
314
|
+
</button>
|
|
315
|
+
) : (
|
|
316
|
+
<span className="text-[0.7rem] uppercase tracking-widest text-ink/40">
|
|
317
|
+
Superadmin only
|
|
318
|
+
</span>
|
|
319
|
+
)}
|
|
320
|
+
</td>
|
|
321
|
+
</tr>
|
|
322
|
+
))}
|
|
323
|
+
{(trash?.rows.length ?? 0) === 0 && (
|
|
324
|
+
<tr>
|
|
325
|
+
<td colSpan={5} className="px-4 py-8 text-center text-ink/50">
|
|
326
|
+
Trash bin is empty.
|
|
327
|
+
</td>
|
|
328
|
+
</tr>
|
|
329
|
+
)}
|
|
330
|
+
</tbody>
|
|
331
|
+
</table>
|
|
332
|
+
</div>
|
|
333
|
+
|
|
334
|
+
<div className="mt-10 border border-ink/10 bg-white p-6">
|
|
335
|
+
<div className="font-serif text-lg text-ink">New page</div>
|
|
336
|
+
<p className="text-sm text-ink/60 mt-1">Create a blank page — you can add blocks after.</p>
|
|
337
|
+
<div className="mt-4 grid gap-3 md:grid-cols-[1fr_1fr_auto]">
|
|
338
|
+
<label className="block">
|
|
339
|
+
<span className="block text-xs uppercase tracking-wider text-ink/60 mb-1">Name</span>
|
|
340
|
+
<input
|
|
341
|
+
value={newName}
|
|
342
|
+
onChange={(e) => setNewName(e.target.value)}
|
|
343
|
+
className="w-full border border-ink/20 px-3 py-2 text-sm"
|
|
344
|
+
placeholder="About us"
|
|
345
|
+
/>
|
|
346
|
+
</label>
|
|
347
|
+
<label className="block">
|
|
348
|
+
<span className="block text-xs uppercase tracking-wider text-ink/60 mb-1">
|
|
349
|
+
Key (URL-safe)
|
|
350
|
+
</span>
|
|
351
|
+
<input
|
|
352
|
+
value={newKey}
|
|
353
|
+
onChange={(e) => setNewKey(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, ""))}
|
|
354
|
+
className="w-full border border-ink/20 px-3 py-2 text-sm font-mono"
|
|
355
|
+
placeholder="about-us"
|
|
356
|
+
/>
|
|
357
|
+
</label>
|
|
358
|
+
<button
|
|
359
|
+
disabled={busy || !newKey || !newName}
|
|
360
|
+
onClick={onCreate}
|
|
361
|
+
className="inline-flex items-center gap-2 bg-ink text-white px-5 py-2 text-sm disabled:opacity-40 self-end"
|
|
362
|
+
>
|
|
363
|
+
<Plus className="size-4" /> Create
|
|
364
|
+
</button>
|
|
365
|
+
</div>
|
|
366
|
+
</div>
|
|
367
|
+
</div>
|
|
368
|
+
);
|
|
369
|
+
}
|
|
@@ -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
|
+
}
|