@bygghammar/cms-platform 0.16.0 → 0.18.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/package.json +9 -3
- package/src/backoffice/FormEditorView.tsx +839 -0
- package/src/backoffice/FormsIndexView.tsx +435 -0
- package/src/backoffice/MediaLibraryView.tsx +1090 -0
- package/src/backoffice/RedirectsView.tsx +223 -0
- package/src/media/library-types.ts +22 -0
- package/src/media/pick-video-poster.tsx +128 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import type React from "react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { toast } from "sonner";
|
|
4
|
+
import {
|
|
5
|
+
Activity,
|
|
6
|
+
Plus,
|
|
7
|
+
Settings2,
|
|
8
|
+
Inbox,
|
|
9
|
+
Trash2,
|
|
10
|
+
Copy,
|
|
11
|
+
RotateCcw,
|
|
12
|
+
ShieldAlert,
|
|
13
|
+
} from "lucide-react";
|
|
14
|
+
import { FORM_TYPES, type FormFieldDef } from "../forms/field-sets";
|
|
15
|
+
|
|
16
|
+
/** One row in the forms list. */
|
|
17
|
+
export type FormsListRow = {
|
|
18
|
+
id: string;
|
|
19
|
+
key: string;
|
|
20
|
+
name: string;
|
|
21
|
+
form_type: string;
|
|
22
|
+
is_active: boolean;
|
|
23
|
+
fields: FormFieldDef[];
|
|
24
|
+
submissions: number;
|
|
25
|
+
newSubmissions: number;
|
|
26
|
+
lastSubmissionAt?: string | null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** One soft-deleted form row. */
|
|
30
|
+
export type DeletedFormRow = {
|
|
31
|
+
id: string;
|
|
32
|
+
key: string;
|
|
33
|
+
name: string;
|
|
34
|
+
form_type: string;
|
|
35
|
+
deleted_at: string;
|
|
36
|
+
deleted_by_email?: string | null;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Host-provided data access and links for the forms index. */
|
|
40
|
+
export type FormsIndexDeps = {
|
|
41
|
+
forms: FormsListRow[];
|
|
42
|
+
isLoading: boolean;
|
|
43
|
+
bin: { rows: DeletedFormRow[]; canPurge: boolean } | undefined;
|
|
44
|
+
createForm: (input: {
|
|
45
|
+
data: { key: string; name: string; form_type: string };
|
|
46
|
+
}) => Promise<unknown>;
|
|
47
|
+
duplicateForm: (input: { data: { id: string; key: string; name: string } }) => Promise<unknown>;
|
|
48
|
+
deleteForm: (input: { data: { id: string } }) => Promise<unknown>;
|
|
49
|
+
restoreForm: (input: { data: { id: string } }) => Promise<unknown>;
|
|
50
|
+
purgeForm: (input: { data: { id: string } }) => Promise<unknown>;
|
|
51
|
+
getFormUsage: (input: {
|
|
52
|
+
data: { key: string };
|
|
53
|
+
}) => Promise<{ pages: string[]; globalSections: string[] }>;
|
|
54
|
+
refresh: () => Promise<unknown> | void;
|
|
55
|
+
/** Host router link; `params` carries the form key for the editor route. */
|
|
56
|
+
LinkComponent: (props: {
|
|
57
|
+
to: string;
|
|
58
|
+
params?: Record<string, string>;
|
|
59
|
+
search?: Record<string, unknown>;
|
|
60
|
+
className?: string;
|
|
61
|
+
children: React.ReactNode;
|
|
62
|
+
}) => React.ReactElement;
|
|
63
|
+
paths?: {
|
|
64
|
+
editor?: string;
|
|
65
|
+
submissions?: string;
|
|
66
|
+
deliveries?: string;
|
|
67
|
+
settings?: string;
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function slugify(v: string) {
|
|
72
|
+
return v
|
|
73
|
+
.toLowerCase()
|
|
74
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
75
|
+
.replace(/^-+|-+$/g, "")
|
|
76
|
+
.slice(0, 64);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Host-agnostic forms index: create, list, duplicate, bin and restore forms. */
|
|
80
|
+
export function FormsIndexView({ deps }: { deps: FormsIndexDeps }) {
|
|
81
|
+
const {
|
|
82
|
+
forms,
|
|
83
|
+
isLoading,
|
|
84
|
+
bin,
|
|
85
|
+
createForm,
|
|
86
|
+
duplicateForm,
|
|
87
|
+
deleteForm,
|
|
88
|
+
restoreForm,
|
|
89
|
+
purgeForm,
|
|
90
|
+
getFormUsage,
|
|
91
|
+
refresh,
|
|
92
|
+
LinkComponent: Link,
|
|
93
|
+
} = deps;
|
|
94
|
+
const paths = {
|
|
95
|
+
editor: deps.paths?.editor ?? "/kontoret/forms/$key",
|
|
96
|
+
submissions: deps.paths?.submissions ?? "/kontoret/forms/submissions",
|
|
97
|
+
deliveries: deps.paths?.deliveries ?? "/kontoret/forms/deliveries",
|
|
98
|
+
settings: deps.paths?.settings ?? "/kontoret/forms/settings",
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const [view, setView] = useState<"list" | "bin">("list");
|
|
102
|
+
const [name, setName] = useState("");
|
|
103
|
+
const [type, setType] = useState("contact");
|
|
104
|
+
const [busy, setBusy] = useState(false);
|
|
105
|
+
|
|
106
|
+
const onCreate = async () => {
|
|
107
|
+
if (!name.trim()) return;
|
|
108
|
+
setBusy(true);
|
|
109
|
+
try {
|
|
110
|
+
await createForm({ data: { key: slugify(name), name: name.trim(), form_type: type } });
|
|
111
|
+
await refresh();
|
|
112
|
+
setName("");
|
|
113
|
+
toast.success("Form created");
|
|
114
|
+
} catch (e) {
|
|
115
|
+
toast.error(e instanceof Error ? e.message : "Could not create the form");
|
|
116
|
+
} finally {
|
|
117
|
+
setBusy(false);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const onDuplicate = async (f: { id: string; name: string; key: string }) => {
|
|
122
|
+
const newName = window.prompt("Name for the copy", `${f.name} (copy)`);
|
|
123
|
+
if (!newName?.trim()) return;
|
|
124
|
+
let key = slugify(newName);
|
|
125
|
+
const taken = new Set(forms.map((x) => x.key));
|
|
126
|
+
if (!key || taken.has(key)) {
|
|
127
|
+
let i = 2;
|
|
128
|
+
const base = key || `${f.key}-copy`;
|
|
129
|
+
key = base;
|
|
130
|
+
while (taken.has(key)) key = `${base}-${i++}`;
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
await duplicateForm({ data: { id: f.id, key, name: newName.trim() } });
|
|
134
|
+
await refresh();
|
|
135
|
+
toast.success(`Copied as "${newName.trim()}" (inactive)`);
|
|
136
|
+
} catch (e) {
|
|
137
|
+
toast.error(e instanceof Error ? e.message : "Could not copy the form");
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const onDelete = async (f: { id: string; name: string; key: string }) => {
|
|
142
|
+
let warning = "";
|
|
143
|
+
try {
|
|
144
|
+
const u = await getFormUsage({ data: { key: f.key } });
|
|
145
|
+
const places = [...u.pages, ...u.globalSections];
|
|
146
|
+
if (places.length) {
|
|
147
|
+
warning = `\n\nWARNING: this form is still used on: ${places.join(", ")}. Those places will show nothing until you pick another form.`;
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
/* usage check is advisory only */
|
|
151
|
+
}
|
|
152
|
+
if (
|
|
153
|
+
!window.confirm(
|
|
154
|
+
`Move the form "${f.name}" to the bin? Submissions are kept and stay visible in the inbox.${warning}`,
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
return;
|
|
158
|
+
try {
|
|
159
|
+
await deleteForm({ data: { id: f.id } });
|
|
160
|
+
await refresh();
|
|
161
|
+
toast.success("Form moved to the bin");
|
|
162
|
+
} catch (e) {
|
|
163
|
+
toast.error(e instanceof Error ? e.message : "Could not delete");
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<div className="p-8 md:p-12 w-full">
|
|
169
|
+
<div className="flex flex-wrap items-end justify-between gap-4">
|
|
170
|
+
<div>
|
|
171
|
+
<h1 className="font-serif text-2xl text-ink">Forms</h1>
|
|
172
|
+
<p className="mt-2 text-sm text-ink/60">
|
|
173
|
+
Every form on the site in one place — fields, notifications, submissions and Google
|
|
174
|
+
Sheets sync. All forms inherit the shared defaults.
|
|
175
|
+
</p>
|
|
176
|
+
</div>
|
|
177
|
+
<div className="flex gap-2">
|
|
178
|
+
<Link
|
|
179
|
+
to={paths.submissions}
|
|
180
|
+
className="inline-flex items-center gap-2 border border-ink/15 bg-white px-4 py-2.5 text-xs uppercase tracking-[0.2em] text-ink hover:border-sea"
|
|
181
|
+
>
|
|
182
|
+
<Inbox className="size-4" />
|
|
183
|
+
Submissions
|
|
184
|
+
</Link>
|
|
185
|
+
<Link
|
|
186
|
+
to={paths.deliveries}
|
|
187
|
+
className="inline-flex items-center gap-2 border border-ink/15 bg-white px-4 py-2.5 text-xs uppercase tracking-[0.2em] text-ink hover:border-sea"
|
|
188
|
+
>
|
|
189
|
+
<Activity className="size-4" />
|
|
190
|
+
Delivery monitor
|
|
191
|
+
</Link>
|
|
192
|
+
<Link
|
|
193
|
+
to={paths.settings}
|
|
194
|
+
className="inline-flex items-center gap-2 border border-ink/15 bg-white px-4 py-2.5 text-xs uppercase tracking-[0.2em] text-ink hover:border-sea"
|
|
195
|
+
>
|
|
196
|
+
<Settings2 className="size-4" />
|
|
197
|
+
Shared settings
|
|
198
|
+
</Link>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
|
|
202
|
+
<div className="mt-8 border border-ink/10 bg-white p-6">
|
|
203
|
+
<div className="text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">New form</div>
|
|
204
|
+
<div className="mt-4 flex flex-wrap items-end gap-3">
|
|
205
|
+
<label className="flex-1 min-w-[220px]">
|
|
206
|
+
<span className="text-xs text-ink/55">Name</span>
|
|
207
|
+
<input
|
|
208
|
+
value={name}
|
|
209
|
+
onChange={(e) => setName(e.target.value)}
|
|
210
|
+
placeholder="Contact form"
|
|
211
|
+
className="mt-1 w-full border border-ink/20 px-3 py-2 text-sm"
|
|
212
|
+
/>
|
|
213
|
+
</label>
|
|
214
|
+
<label className="min-w-[220px]">
|
|
215
|
+
<span className="text-xs text-ink/55">Type</span>
|
|
216
|
+
<select
|
|
217
|
+
value={type}
|
|
218
|
+
onChange={(e) => setType(e.target.value)}
|
|
219
|
+
className="mt-1 w-full border border-ink/20 bg-white px-3 py-2 text-sm"
|
|
220
|
+
>
|
|
221
|
+
{FORM_TYPES.map((t) => (
|
|
222
|
+
<option key={t.value} value={t.value}>
|
|
223
|
+
{t.label} — {t.description}
|
|
224
|
+
</option>
|
|
225
|
+
))}
|
|
226
|
+
</select>
|
|
227
|
+
</label>
|
|
228
|
+
<button
|
|
229
|
+
onClick={onCreate}
|
|
230
|
+
disabled={busy || !name.trim()}
|
|
231
|
+
className="inline-flex items-center gap-2 bg-ink px-5 py-2.5 text-xs uppercase tracking-[0.2em] text-paper hover:bg-sea hover:text-ink disabled:opacity-50"
|
|
232
|
+
>
|
|
233
|
+
<Plus className="size-4" />
|
|
234
|
+
Create
|
|
235
|
+
</button>
|
|
236
|
+
</div>
|
|
237
|
+
{name.trim() ? (
|
|
238
|
+
<p className="mt-3 text-xs text-ink/50">Key: {slugify(name) || "—"}</p>
|
|
239
|
+
) : null}
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
<div className="mt-8 flex gap-2">
|
|
243
|
+
{(["list", "bin"] as const).map((v) => (
|
|
244
|
+
<button
|
|
245
|
+
key={v}
|
|
246
|
+
onClick={() => setView(v)}
|
|
247
|
+
className={
|
|
248
|
+
view === v
|
|
249
|
+
? "border border-ink bg-ink px-4 py-2 text-[0.65rem] uppercase tracking-[0.2em] text-paper"
|
|
250
|
+
: "border border-ink/15 bg-white px-4 py-2 text-[0.65rem] uppercase tracking-[0.2em] text-ink/60 hover:border-sea"
|
|
251
|
+
}
|
|
252
|
+
>
|
|
253
|
+
{v === "list" ? "Forms" : `Bin${bin?.rows.length ? ` (${bin.rows.length})` : ""}`}
|
|
254
|
+
</button>
|
|
255
|
+
))}
|
|
256
|
+
</div>
|
|
257
|
+
|
|
258
|
+
{view === "list" ? (
|
|
259
|
+
<div className="mt-8 border border-ink/10 bg-white overflow-x-auto">
|
|
260
|
+
<table className="w-full text-sm">
|
|
261
|
+
<thead>
|
|
262
|
+
<tr className="border-b border-ink/10 text-left text-[0.65rem] uppercase tracking-[0.2em] text-ink/50">
|
|
263
|
+
<th className="px-6 py-3">Form</th>
|
|
264
|
+
<th className="px-6 py-3">Key</th>
|
|
265
|
+
<th className="px-6 py-3">Type</th>
|
|
266
|
+
<th className="px-6 py-3">Fields</th>
|
|
267
|
+
<th className="px-6 py-3">Status</th>
|
|
268
|
+
<th className="px-6 py-3">Submissions</th>
|
|
269
|
+
<th className="px-6 py-3">Last</th>
|
|
270
|
+
<th className="px-6 py-3"></th>
|
|
271
|
+
</tr>
|
|
272
|
+
</thead>
|
|
273
|
+
<tbody>
|
|
274
|
+
{isLoading ? (
|
|
275
|
+
<tr>
|
|
276
|
+
<td className="px-6 py-6 text-ink/50" colSpan={8}>
|
|
277
|
+
Loading…
|
|
278
|
+
</td>
|
|
279
|
+
</tr>
|
|
280
|
+
) : forms.length === 0 ? (
|
|
281
|
+
<tr>
|
|
282
|
+
<td className="px-6 py-6 text-ink/50" colSpan={8}>
|
|
283
|
+
No forms yet.
|
|
284
|
+
</td>
|
|
285
|
+
</tr>
|
|
286
|
+
) : (
|
|
287
|
+
forms.map((f) => (
|
|
288
|
+
<tr key={f.id} className="border-b border-ink/5 last:border-0">
|
|
289
|
+
<td className="px-6 py-3">
|
|
290
|
+
<Link
|
|
291
|
+
to={paths.editor}
|
|
292
|
+
params={{ key: f.key }}
|
|
293
|
+
className="text-ink hover:text-sea"
|
|
294
|
+
>
|
|
295
|
+
{f.name}
|
|
296
|
+
</Link>
|
|
297
|
+
</td>
|
|
298
|
+
<td className="px-6 py-3 text-ink/60">{f.key}</td>
|
|
299
|
+
<td className="px-6 py-3 text-ink/60">{f.form_type}</td>
|
|
300
|
+
<td className="px-6 py-3 text-ink/60">
|
|
301
|
+
{f.fields.filter((x) => x.enabled).length}/
|
|
302
|
+
{f.fields.filter((x) => !x.removed).length}
|
|
303
|
+
</td>
|
|
304
|
+
<td className="px-6 py-3">
|
|
305
|
+
<span
|
|
306
|
+
className={
|
|
307
|
+
f.is_active
|
|
308
|
+
? "text-[0.65rem] uppercase tracking-[0.2em] text-sea"
|
|
309
|
+
: "text-[0.65rem] uppercase tracking-[0.2em] text-ink/40"
|
|
310
|
+
}
|
|
311
|
+
>
|
|
312
|
+
{f.is_active ? "Active" : "Inactive"}
|
|
313
|
+
</span>
|
|
314
|
+
</td>
|
|
315
|
+
<td className="px-6 py-3 text-ink/60">
|
|
316
|
+
<Link to={paths.submissions} search={{ form: f.key }} className="hover:text-sea">
|
|
317
|
+
{f.submissions}
|
|
318
|
+
{f.newSubmissions > 0 ? (
|
|
319
|
+
<span className="ml-2 bg-sea px-1.5 py-0.5 text-[0.6rem] uppercase tracking-[0.18em] text-ink">
|
|
320
|
+
{f.newSubmissions} new
|
|
321
|
+
</span>
|
|
322
|
+
) : null}
|
|
323
|
+
</Link>
|
|
324
|
+
</td>
|
|
325
|
+
<td className="px-6 py-3 text-ink/50 whitespace-nowrap">
|
|
326
|
+
{f.lastSubmissionAt
|
|
327
|
+
? new Date(f.lastSubmissionAt).toLocaleDateString("sv-SE")
|
|
328
|
+
: "—"}
|
|
329
|
+
</td>
|
|
330
|
+
<td className="px-6 py-3 text-right whitespace-nowrap">
|
|
331
|
+
<button
|
|
332
|
+
onClick={() => onDuplicate(f)}
|
|
333
|
+
className="mr-3 text-ink/40 hover:text-sea"
|
|
334
|
+
title="Duplicate form"
|
|
335
|
+
>
|
|
336
|
+
<Copy className="size-4" />
|
|
337
|
+
</button>
|
|
338
|
+
<button
|
|
339
|
+
onClick={() => onDelete(f)}
|
|
340
|
+
className="text-ink/40 hover:text-red-500"
|
|
341
|
+
title="Move form to bin"
|
|
342
|
+
>
|
|
343
|
+
<Trash2 className="size-4" />
|
|
344
|
+
</button>
|
|
345
|
+
</td>
|
|
346
|
+
</tr>
|
|
347
|
+
))
|
|
348
|
+
)}
|
|
349
|
+
</tbody>
|
|
350
|
+
</table>
|
|
351
|
+
</div>
|
|
352
|
+
) : (
|
|
353
|
+
<div className="mt-8 border border-ink/10 bg-white overflow-x-auto">
|
|
354
|
+
<table className="w-full text-sm">
|
|
355
|
+
<thead>
|
|
356
|
+
<tr className="border-b border-ink/10 text-left text-[0.65rem] uppercase tracking-[0.2em] text-ink/50">
|
|
357
|
+
<th className="px-6 py-3">Form</th>
|
|
358
|
+
<th className="px-6 py-3">Key</th>
|
|
359
|
+
<th className="px-6 py-3">Type</th>
|
|
360
|
+
<th className="px-6 py-3">Deleted</th>
|
|
361
|
+
<th className="px-6 py-3">By</th>
|
|
362
|
+
<th className="px-6 py-3"></th>
|
|
363
|
+
</tr>
|
|
364
|
+
</thead>
|
|
365
|
+
<tbody>
|
|
366
|
+
{(bin?.rows ?? []).length === 0 ? (
|
|
367
|
+
<tr>
|
|
368
|
+
<td className="px-6 py-6 text-ink/50" colSpan={6}>
|
|
369
|
+
The bin is empty.
|
|
370
|
+
</td>
|
|
371
|
+
</tr>
|
|
372
|
+
) : (
|
|
373
|
+
(bin?.rows ?? []).map((f) => (
|
|
374
|
+
<tr key={f.id} className="border-b border-ink/5 last:border-0">
|
|
375
|
+
<td className="px-6 py-3 text-ink">{f.name}</td>
|
|
376
|
+
<td className="px-6 py-3 text-ink/60">{f.key}</td>
|
|
377
|
+
<td className="px-6 py-3 text-ink/60">{f.form_type}</td>
|
|
378
|
+
<td className="px-6 py-3 text-ink/50 whitespace-nowrap">
|
|
379
|
+
{new Date(f.deleted_at).toLocaleString("sv-SE")}
|
|
380
|
+
</td>
|
|
381
|
+
<td className="px-6 py-3 text-ink/60">{f.deleted_by_email ?? "—"}</td>
|
|
382
|
+
<td className="px-6 py-3 text-right whitespace-nowrap">
|
|
383
|
+
<button
|
|
384
|
+
onClick={async () => {
|
|
385
|
+
try {
|
|
386
|
+
await restoreForm({ data: { id: f.id } });
|
|
387
|
+
await refresh();
|
|
388
|
+
toast.success("Form restored (inactive)");
|
|
389
|
+
} catch (e) {
|
|
390
|
+
toast.error(e instanceof Error ? e.message : "Could not restore");
|
|
391
|
+
}
|
|
392
|
+
}}
|
|
393
|
+
className="mr-3 inline-flex items-center gap-1 text-xs uppercase tracking-[0.18em] text-ink/60 hover:text-sea"
|
|
394
|
+
>
|
|
395
|
+
<RotateCcw className="size-4" />
|
|
396
|
+
Restore
|
|
397
|
+
</button>
|
|
398
|
+
{bin?.canPurge ? (
|
|
399
|
+
<button
|
|
400
|
+
onClick={async () => {
|
|
401
|
+
if (
|
|
402
|
+
!window.confirm(
|
|
403
|
+
`Permanently delete "${f.name}"? This cannot be undone. Submissions are kept in the inbox.`,
|
|
404
|
+
)
|
|
405
|
+
)
|
|
406
|
+
return;
|
|
407
|
+
try {
|
|
408
|
+
await purgeForm({ data: { id: f.id } });
|
|
409
|
+
await refresh();
|
|
410
|
+
toast.success("Form permanently deleted");
|
|
411
|
+
} catch (e) {
|
|
412
|
+
toast.error(e instanceof Error ? e.message : "Could not delete");
|
|
413
|
+
}
|
|
414
|
+
}}
|
|
415
|
+
className="inline-flex items-center gap-1 text-xs uppercase tracking-[0.18em] text-ink/50 hover:text-red-500"
|
|
416
|
+
>
|
|
417
|
+
<ShieldAlert className="size-4" />
|
|
418
|
+
Delete permanently
|
|
419
|
+
</button>
|
|
420
|
+
) : (
|
|
421
|
+
<span className="text-[0.65rem] uppercase tracking-[0.18em] text-ink/35">
|
|
422
|
+
Superadmin only
|
|
423
|
+
</span>
|
|
424
|
+
)}
|
|
425
|
+
</td>
|
|
426
|
+
</tr>
|
|
427
|
+
))
|
|
428
|
+
)}
|
|
429
|
+
</tbody>
|
|
430
|
+
</table>
|
|
431
|
+
</div>
|
|
432
|
+
)}
|
|
433
|
+
</div>
|
|
434
|
+
);
|
|
435
|
+
}
|