@bygghammar/cms-platform 0.6.0 → 0.8.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 +10 -2
- package/src/backoffice/SaveGlobalDialog.tsx +221 -0
- package/src/backoffice/forms/FieldEditor.tsx +611 -0
- package/src/backoffice/forms/FormPreview.tsx +108 -0
- package/src/backoffice/forms/TestDeliveryPanel.tsx +201 -0
- package/src/backoffice/forms/VersionHistory.tsx +142 -0
- package/src/backoffice/media/MediaCleanup.tsx +186 -0
- package/src/backoffice/media/MediaReplaceDialog.tsx +158 -0
- package/src/backoffice/media/ReuploadOverwrite.tsx +226 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type React from "react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { LocaleProvider, SUPPORTED_LOCALES, type Locale } from "../../i18n";
|
|
4
|
+
import type {
|
|
5
|
+
FormDefaults,
|
|
6
|
+
FormFieldDef,
|
|
7
|
+
FormPrivateSettings,
|
|
8
|
+
FormPublicSettings,
|
|
9
|
+
PublicForm,
|
|
10
|
+
} from "../../forms/field-sets";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Renders the unsaved editor state with the exact public form component,
|
|
14
|
+
* including conditional show/hide and live validation errors.
|
|
15
|
+
* Submitting only runs the rules — nothing is stored or sent.
|
|
16
|
+
*/
|
|
17
|
+
export function FormPreview({
|
|
18
|
+
formKey,
|
|
19
|
+
name,
|
|
20
|
+
formType,
|
|
21
|
+
fields,
|
|
22
|
+
pub,
|
|
23
|
+
priv,
|
|
24
|
+
defaults,
|
|
25
|
+
dark = false,
|
|
26
|
+
renderForm,
|
|
27
|
+
}: {
|
|
28
|
+
formKey: string;
|
|
29
|
+
name: string;
|
|
30
|
+
formType: string;
|
|
31
|
+
fields: FormFieldDef[];
|
|
32
|
+
pub: FormPublicSettings;
|
|
33
|
+
priv: FormPrivateSettings;
|
|
34
|
+
defaults?: FormDefaults | null;
|
|
35
|
+
dark?: boolean;
|
|
36
|
+
/** Host site's public form renderer (kept out of the platform on purpose). */
|
|
37
|
+
renderForm: (args: { form: PublicForm; dark: boolean; preview: boolean }) => React.ReactNode;
|
|
38
|
+
}) {
|
|
39
|
+
const [locale, setLocale] = useState<Locale>("sv");
|
|
40
|
+
const [tone, setTone] = useState<"light" | "dark">(dark ? "dark" : "light");
|
|
41
|
+
|
|
42
|
+
const form: PublicForm = {
|
|
43
|
+
id: "preview",
|
|
44
|
+
key: formKey,
|
|
45
|
+
name,
|
|
46
|
+
form_type: formType,
|
|
47
|
+
fields,
|
|
48
|
+
title: pub.title,
|
|
49
|
+
intro: pub.intro,
|
|
50
|
+
submitLabel: pub.submitLabel ?? { sv: "Skicka", en: "Send" },
|
|
51
|
+
consentText: pub.consentText ?? defaults?.consent_text ?? {},
|
|
52
|
+
privacyPolicyUrl: defaults?.privacy_policy_url ?? "",
|
|
53
|
+
requireConsent: pub.requireConsent !== false,
|
|
54
|
+
thankyouMode:
|
|
55
|
+
(pub.thankyouMode ?? (defaults?.thankyou_mode as "inline" | "redirect") ?? "inline") ===
|
|
56
|
+
"redirect"
|
|
57
|
+
? "redirect"
|
|
58
|
+
: "inline",
|
|
59
|
+
thankyouMessage: pub.thankyouMessage ??
|
|
60
|
+
defaults?.thankyou_message ?? { sv: "Tack! Vi hör av oss snart.", en: "Thank you!" },
|
|
61
|
+
thankyouRedirect: pub.thankyouRedirect ?? defaults?.thankyou_redirect ?? "",
|
|
62
|
+
honeypot: false,
|
|
63
|
+
minSeconds: 0,
|
|
64
|
+
savePartials: priv.savePartials ?? defaults?.save_partials ?? true,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className="space-y-4">
|
|
69
|
+
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
70
|
+
<div>
|
|
71
|
+
<h3 className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/55">Live preview</h3>
|
|
72
|
+
<p className="mt-1 text-xs text-ink/45">
|
|
73
|
+
Exactly what visitors see. Submitting here only checks the rules.
|
|
74
|
+
</p>
|
|
75
|
+
</div>
|
|
76
|
+
<div className="flex flex-wrap gap-2">
|
|
77
|
+
<select
|
|
78
|
+
value={locale}
|
|
79
|
+
onChange={(e) => setLocale(e.target.value as Locale)}
|
|
80
|
+
className="border border-ink/15 px-2 py-1.5 text-xs"
|
|
81
|
+
>
|
|
82
|
+
{SUPPORTED_LOCALES.map((l: Locale) => (
|
|
83
|
+
<option key={l} value={l}>
|
|
84
|
+
{l.toUpperCase()}
|
|
85
|
+
</option>
|
|
86
|
+
))}
|
|
87
|
+
</select>
|
|
88
|
+
<button
|
|
89
|
+
type="button"
|
|
90
|
+
onClick={() => setTone(tone === "dark" ? "light" : "dark")}
|
|
91
|
+
className="border border-ink/15 px-3 py-1.5 text-[0.65rem] uppercase tracking-[0.2em] hover:border-sea"
|
|
92
|
+
>
|
|
93
|
+
{tone === "dark" ? "Light background" : "Dark background"}
|
|
94
|
+
</button>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<div className={tone === "dark" ? "bg-ink p-6" : "bg-paper p-6"}>
|
|
99
|
+
<LocaleProvider locale={locale}>
|
|
100
|
+
{/* Remount on locale/tone change so state starts clean. */}
|
|
101
|
+
<div key={`${locale}-${tone}`}>
|
|
102
|
+
{renderForm({ form, dark: tone === "dark", preview: true })}
|
|
103
|
+
</div>
|
|
104
|
+
</LocaleProvider>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { toast } from "sonner";
|
|
3
|
+
import { Send, TestTube } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
import type { FormFieldDef, FormPrivateSettings } from "../../forms/field-sets";
|
|
6
|
+
import { fieldLabel } from "../../forms/conditions";
|
|
7
|
+
|
|
8
|
+
export type TestNotificationResult = {
|
|
9
|
+
ok: boolean;
|
|
10
|
+
recipients?: string[];
|
|
11
|
+
error?: string | null;
|
|
12
|
+
[key: string]: unknown;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Verifies notification templates, sender/recipient settings and conditional
|
|
17
|
+
* routing against example answers. Nothing is stored as a submission.
|
|
18
|
+
*/
|
|
19
|
+
export function TestDeliveryPanel({
|
|
20
|
+
formKey,
|
|
21
|
+
fields,
|
|
22
|
+
priv,
|
|
23
|
+
sendTest,
|
|
24
|
+
}: {
|
|
25
|
+
formKey: string;
|
|
26
|
+
fields: FormFieldDef[];
|
|
27
|
+
priv: FormPrivateSettings;
|
|
28
|
+
/** Host site's test-delivery server function. */
|
|
29
|
+
sendTest: (input: { data: Record<string, unknown> }) => Promise<TestNotificationResult>;
|
|
30
|
+
}) {
|
|
31
|
+
const test = sendTest;
|
|
32
|
+
const [what, setWhat] = useState<"notification" | "autoreply">("notification");
|
|
33
|
+
const [locale, setLocale] = useState("sv");
|
|
34
|
+
const [to, setTo] = useState("");
|
|
35
|
+
const [values, setValues] = useState<Record<string, string>>({});
|
|
36
|
+
const [busy, setBusy] = useState(false);
|
|
37
|
+
const [result, setResult] = useState<TestNotificationResult | null>(null);
|
|
38
|
+
|
|
39
|
+
const run = async (send: boolean) => {
|
|
40
|
+
setBusy(true);
|
|
41
|
+
try {
|
|
42
|
+
const res = await test({
|
|
43
|
+
data: {
|
|
44
|
+
formKey,
|
|
45
|
+
locale,
|
|
46
|
+
what,
|
|
47
|
+
send,
|
|
48
|
+
to: to.trim() || undefined,
|
|
49
|
+
fields: fields as unknown as Record<string, unknown>[],
|
|
50
|
+
private_settings: priv as Record<string, unknown>,
|
|
51
|
+
values,
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
setResult(res);
|
|
55
|
+
if (res.error) toast.error(res.error);
|
|
56
|
+
else if (send && res.sent) toast.success("Test email sent");
|
|
57
|
+
else if (send) toast.error("Nothing was sent — see the report below");
|
|
58
|
+
else toast.success("Dry run complete");
|
|
59
|
+
} catch (e) {
|
|
60
|
+
toast.error(e instanceof Error ? e.message : "Test failed");
|
|
61
|
+
} finally {
|
|
62
|
+
setBusy(false);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const active = fields.filter((f) => f.enabled);
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div className="space-y-4">
|
|
70
|
+
<div>
|
|
71
|
+
<h3 className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/55">
|
|
72
|
+
Send test notification
|
|
73
|
+
</h3>
|
|
74
|
+
<p className="mt-1 text-xs text-ink/45">
|
|
75
|
+
Uses the settings currently on screen. No submission is created.
|
|
76
|
+
</p>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<div className="grid gap-3 sm:grid-cols-3">
|
|
80
|
+
<label className="block">
|
|
81
|
+
<span className="text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">Email</span>
|
|
82
|
+
<select
|
|
83
|
+
value={what}
|
|
84
|
+
onChange={(e) => setWhat(e.target.value as "notification" | "autoreply")}
|
|
85
|
+
className="mt-2 w-full border border-ink/20 bg-white px-3 py-2 text-sm"
|
|
86
|
+
>
|
|
87
|
+
<option value="notification">Admin notification</option>
|
|
88
|
+
<option value="autoreply">Autoreply to sender</option>
|
|
89
|
+
</select>
|
|
90
|
+
</label>
|
|
91
|
+
<label className="block">
|
|
92
|
+
<span className="text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">Language</span>
|
|
93
|
+
<select
|
|
94
|
+
value={locale}
|
|
95
|
+
onChange={(e) => setLocale(e.target.value)}
|
|
96
|
+
className="mt-2 w-full border border-ink/20 bg-white px-3 py-2 text-sm"
|
|
97
|
+
>
|
|
98
|
+
<option value="sv">SV</option>
|
|
99
|
+
<option value="en">EN</option>
|
|
100
|
+
</select>
|
|
101
|
+
</label>
|
|
102
|
+
<label className="block">
|
|
103
|
+
<span className="text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">
|
|
104
|
+
Send to (optional override)
|
|
105
|
+
</span>
|
|
106
|
+
<input
|
|
107
|
+
value={to}
|
|
108
|
+
onChange={(e) => setTo(e.target.value)}
|
|
109
|
+
placeholder="you@example.com"
|
|
110
|
+
className="mt-2 w-full border border-ink/20 bg-white px-3 py-2 text-sm"
|
|
111
|
+
/>
|
|
112
|
+
</label>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<div className="space-y-2">
|
|
116
|
+
<div className="text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">
|
|
117
|
+
Example answers (blank = generated sample)
|
|
118
|
+
</div>
|
|
119
|
+
<div className="grid gap-2 sm:grid-cols-2">
|
|
120
|
+
{active.map((f) => (
|
|
121
|
+
<label key={f.key} className="block">
|
|
122
|
+
<span className="text-[0.6rem] uppercase tracking-[0.16em] text-ink/45">
|
|
123
|
+
{fieldLabel(f, "sv")} · {f.key}
|
|
124
|
+
</span>
|
|
125
|
+
<input
|
|
126
|
+
value={values[f.key] ?? ""}
|
|
127
|
+
onChange={(e) => setValues({ ...values, [f.key]: e.target.value })}
|
|
128
|
+
className="mt-1 w-full border border-ink/15 px-2 py-1 text-xs"
|
|
129
|
+
/>
|
|
130
|
+
</label>
|
|
131
|
+
))}
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
<div className="flex flex-wrap gap-2">
|
|
136
|
+
<button
|
|
137
|
+
type="button"
|
|
138
|
+
disabled={busy}
|
|
139
|
+
onClick={() => run(false)}
|
|
140
|
+
className="inline-flex items-center gap-2 border border-ink/15 px-4 py-2 text-xs uppercase tracking-[0.2em] hover:border-sea disabled:opacity-50"
|
|
141
|
+
>
|
|
142
|
+
<TestTube className="size-4" />
|
|
143
|
+
Dry run (no email)
|
|
144
|
+
</button>
|
|
145
|
+
<button
|
|
146
|
+
type="button"
|
|
147
|
+
disabled={busy}
|
|
148
|
+
onClick={() => run(true)}
|
|
149
|
+
className="inline-flex items-center gap-2 bg-ink px-4 py-2 text-xs uppercase tracking-[0.2em] text-paper hover:bg-sea hover:text-ink disabled:opacity-50"
|
|
150
|
+
>
|
|
151
|
+
<Send className="size-4" />
|
|
152
|
+
Send test
|
|
153
|
+
</button>
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
{result ? (
|
|
157
|
+
<div className="space-y-2 border border-ink/10 bg-paper/60 p-4 text-xs text-ink/70">
|
|
158
|
+
<div>
|
|
159
|
+
<strong>Status:</strong>{" "}
|
|
160
|
+
{result.error
|
|
161
|
+
? `Failed — ${result.error}`
|
|
162
|
+
: result.sent
|
|
163
|
+
? "Sent"
|
|
164
|
+
: "Not sent (report only)"}
|
|
165
|
+
</div>
|
|
166
|
+
<div>
|
|
167
|
+
<strong>Recipients:</strong> {result.recipients.join(", ") || "—"}
|
|
168
|
+
</div>
|
|
169
|
+
<div>
|
|
170
|
+
<strong>Subject:</strong> {result.subject || "(standard subject)"}
|
|
171
|
+
</div>
|
|
172
|
+
<div>
|
|
173
|
+
<strong>From:</strong> {result.from || "(provider default)"} ·{" "}
|
|
174
|
+
<strong>Reply-to:</strong> {result.replyTo || "(submitter email)"}
|
|
175
|
+
</div>
|
|
176
|
+
<div>
|
|
177
|
+
<strong>Matched routing rules:</strong>{" "}
|
|
178
|
+
{result.matchedRules.length
|
|
179
|
+
? result.matchedRules
|
|
180
|
+
.map((r) => `${r.to}${r.exclusive ? " (only these)" : ""}`)
|
|
181
|
+
.join(", ")
|
|
182
|
+
: "none"}
|
|
183
|
+
</div>
|
|
184
|
+
{result.warnings.length ? (
|
|
185
|
+
<ul className="list-disc pl-5 text-amber-700">
|
|
186
|
+
{result.warnings.map((w, i) => (
|
|
187
|
+
<li key={i}>{w}</li>
|
|
188
|
+
))}
|
|
189
|
+
</ul>
|
|
190
|
+
) : null}
|
|
191
|
+
<details>
|
|
192
|
+
<summary className="cursor-pointer text-ink/50">Example answers used</summary>
|
|
193
|
+
<pre className="mt-2 overflow-auto whitespace-pre-wrap text-[0.7rem]">
|
|
194
|
+
{JSON.stringify(result.sampleValues, null, 2)}
|
|
195
|
+
</pre>
|
|
196
|
+
</details>
|
|
197
|
+
</div>
|
|
198
|
+
) : null}
|
|
199
|
+
</div>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type React from "react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
4
|
+
import { toast } from "sonner";
|
|
5
|
+
import { History, RotateCcw } from "lucide-react";
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
FormFieldDef,
|
|
9
|
+
FormPrivateSettings,
|
|
10
|
+
FormPublicSettings,
|
|
11
|
+
} from "../../forms/field-sets";
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Published history for one form. Every save stores an immutable snapshot of
|
|
16
|
+
* the definition (never of submissions), so any earlier state can be previewed
|
|
17
|
+
* and rolled back without touching received data.
|
|
18
|
+
*/
|
|
19
|
+
export type FormVersionRow = {
|
|
20
|
+
id: string;
|
|
21
|
+
version_no: number;
|
|
22
|
+
name: string;
|
|
23
|
+
form_type: string;
|
|
24
|
+
created_at: string;
|
|
25
|
+
created_by_email?: string | null;
|
|
26
|
+
note?: string | null;
|
|
27
|
+
fields: FormFieldDef[];
|
|
28
|
+
public_settings: FormPublicSettings;
|
|
29
|
+
private_settings: FormPrivateSettings;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function VersionHistory({
|
|
33
|
+
formKey,
|
|
34
|
+
listVersions,
|
|
35
|
+
restoreVersion,
|
|
36
|
+
renderPreview,
|
|
37
|
+
}: {
|
|
38
|
+
formKey: string;
|
|
39
|
+
listVersions: (input: { data: { formKey: string } }) => Promise<FormVersionRow[]>;
|
|
40
|
+
restoreVersion: (input: {
|
|
41
|
+
data: { versionId: string };
|
|
42
|
+
}) => Promise<{ carriedFields: string[] }>;
|
|
43
|
+
/** Host renders the preview so the platform stays free of site form UI. */
|
|
44
|
+
renderPreview: (version: FormVersionRow) => React.ReactNode;
|
|
45
|
+
}) {
|
|
46
|
+
const qc = useQueryClient();
|
|
47
|
+
const list = listVersions;
|
|
48
|
+
const restore = restoreVersion;
|
|
49
|
+
const [openId, setOpenId] = useState<string | null>(null);
|
|
50
|
+
const [busy, setBusy] = useState(false);
|
|
51
|
+
|
|
52
|
+
const { data: versions, isLoading } = useQuery({
|
|
53
|
+
queryKey: ["form-versions", formKey],
|
|
54
|
+
queryFn: () => list({ data: { formKey } }),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const selected = (versions ?? []).find((v) => v.id === openId) ?? null;
|
|
58
|
+
|
|
59
|
+
const onRestore = async (id: string, no: number) => {
|
|
60
|
+
if (!window.confirm(`Roll this form back to version ${no}? Submissions are untouched.`)) return;
|
|
61
|
+
setBusy(true);
|
|
62
|
+
try {
|
|
63
|
+
const res = await restore({ data: { versionId: id } });
|
|
64
|
+
await qc.invalidateQueries({ queryKey: ["backoffice-forms"] });
|
|
65
|
+
await qc.invalidateQueries({ queryKey: ["form-versions", formKey] });
|
|
66
|
+
toast.success(
|
|
67
|
+
res.carriedFields.length
|
|
68
|
+
? `Rolled back. Kept ${res.carriedFields.length} field(s) with existing answers (disabled).`
|
|
69
|
+
: "Rolled back",
|
|
70
|
+
);
|
|
71
|
+
} catch (e) {
|
|
72
|
+
toast.error(e instanceof Error ? e.message : "Rollback failed");
|
|
73
|
+
} finally {
|
|
74
|
+
setBusy(false);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div className="grid gap-6 lg:grid-cols-[minmax(0,420px)_1fr]">
|
|
80
|
+
<div className="border border-ink/10 bg-white p-6">
|
|
81
|
+
<h2 className="flex items-center gap-2 text-xs uppercase tracking-[0.22em] text-ink/50">
|
|
82
|
+
<History className="size-4" />
|
|
83
|
+
Version history
|
|
84
|
+
</h2>
|
|
85
|
+
{isLoading ? (
|
|
86
|
+
<p className="mt-4 text-sm text-ink/45">Loading…</p>
|
|
87
|
+
) : !versions?.length ? (
|
|
88
|
+
<p className="mt-4 text-sm text-ink/45">
|
|
89
|
+
No snapshots yet. The next save creates version 1.
|
|
90
|
+
</p>
|
|
91
|
+
) : (
|
|
92
|
+
<ul className="mt-4 divide-y divide-ink/10">
|
|
93
|
+
{versions.map((v) => (
|
|
94
|
+
<li key={v.id} className="flex items-start gap-3 py-3">
|
|
95
|
+
<button
|
|
96
|
+
onClick={() => setOpenId(v.id)}
|
|
97
|
+
className={`flex-1 text-left ${openId === v.id ? "text-ink" : "text-ink/70"}`}
|
|
98
|
+
>
|
|
99
|
+
<span className="text-sm font-medium">
|
|
100
|
+
v{v.version_no} · {v.name}
|
|
101
|
+
</span>
|
|
102
|
+
<span className="mt-0.5 block text-xs text-ink/45">
|
|
103
|
+
{new Date(v.created_at).toLocaleString("sv-SE")}
|
|
104
|
+
{v.created_by_email ? ` · ${v.created_by_email}` : ""}
|
|
105
|
+
</span>
|
|
106
|
+
{v.note ? (
|
|
107
|
+
<span className="mt-0.5 block text-xs text-ink/45">{v.note}</span>
|
|
108
|
+
) : null}
|
|
109
|
+
<span className="mt-0.5 block text-[0.65rem] uppercase tracking-[0.18em] text-ink/35">
|
|
110
|
+
{v.fields.filter((f) => f.enabled !== false).length} active field(s)
|
|
111
|
+
</span>
|
|
112
|
+
</button>
|
|
113
|
+
<button
|
|
114
|
+
onClick={() => onRestore(v.id, v.version_no)}
|
|
115
|
+
disabled={busy}
|
|
116
|
+
title="Roll back to this version"
|
|
117
|
+
className="inline-flex items-center gap-2 border border-ink/15 px-3 py-1.5 text-[0.65rem] uppercase tracking-[0.18em] hover:border-sea disabled:opacity-50"
|
|
118
|
+
>
|
|
119
|
+
<RotateCcw className="size-3.5" />
|
|
120
|
+
Restore
|
|
121
|
+
</button>
|
|
122
|
+
</li>
|
|
123
|
+
))}
|
|
124
|
+
</ul>
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
<div className="border border-ink/10 bg-white p-6">
|
|
129
|
+
<h2 className="text-xs uppercase tracking-[0.22em] text-ink/50">
|
|
130
|
+
{selected ? `Preview of v${selected.version_no}` : "Preview"}
|
|
131
|
+
</h2>
|
|
132
|
+
{selected ? (
|
|
133
|
+
<div className="mt-4">
|
|
134
|
+
{renderPreview(selected)}
|
|
135
|
+
</div>
|
|
136
|
+
) : (
|
|
137
|
+
<p className="mt-4 text-sm text-ink/45">Pick a version on the left to preview it.</p>
|
|
138
|
+
)}
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Loader2, Wand2 } from "lucide-react";
|
|
3
|
+
import { toast } from "sonner";
|
|
4
|
+
|
|
5
|
+
/** Plan returned by the host site's duplicate scan. */
|
|
6
|
+
export type MediaCleanupPlan = {
|
|
7
|
+
scanned: number;
|
|
8
|
+
duplicateBytes: number;
|
|
9
|
+
duplicates: { hash: string; keep: string; remove: string[] }[];
|
|
10
|
+
renames: { from: string; to: string }[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** Result of applying a cleanup plan. */
|
|
14
|
+
export type MediaCleanupResult = {
|
|
15
|
+
merged: number;
|
|
16
|
+
renamed: number;
|
|
17
|
+
rewritten: number;
|
|
18
|
+
freedBytes: number;
|
|
19
|
+
errors?: string[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function bytes(n: number) {
|
|
23
|
+
if (n < 1024) return `${n} B`;
|
|
24
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
25
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Superadmin-only library maintenance: merge byte-identical duplicates and
|
|
30
|
+
* rename legacy timestamp-prefixed files. References on the site are repointed
|
|
31
|
+
* first; the extra copies go to the bin, never straight to deletion.
|
|
32
|
+
*/
|
|
33
|
+
export function MediaCleanup({
|
|
34
|
+
onDone,
|
|
35
|
+
scanDuplicates,
|
|
36
|
+
runCleanup,
|
|
37
|
+
}: {
|
|
38
|
+
onDone: () => Promise<void> | void;
|
|
39
|
+
/** Host-provided duplicate scan. */
|
|
40
|
+
scanDuplicates: () => Promise<MediaCleanupPlan>;
|
|
41
|
+
/** Host-provided cleanup runner. */
|
|
42
|
+
runCleanup: (input: {
|
|
43
|
+
data: { duplicates: boolean; renames: boolean };
|
|
44
|
+
}) => Promise<MediaCleanupResult>;
|
|
45
|
+
}) {
|
|
46
|
+
const scan = scanDuplicates;
|
|
47
|
+
const apply = runCleanup;
|
|
48
|
+
const [open, setOpen] = useState(false);
|
|
49
|
+
const [busy, setBusy] = useState(false);
|
|
50
|
+
const [plan, setPlan] = useState<MediaCleanupPlan | null>(null);
|
|
51
|
+
|
|
52
|
+
const doScan = async () => {
|
|
53
|
+
setBusy(true);
|
|
54
|
+
try {
|
|
55
|
+
const result = (await scan()) as MediaCleanupPlan;
|
|
56
|
+
setPlan(result);
|
|
57
|
+
setOpen(true);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
toast.error(error instanceof Error ? error.message : "Scan failed");
|
|
60
|
+
} finally {
|
|
61
|
+
setBusy(false);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const doApply = async (opts: { duplicates: boolean; renames: boolean }) => {
|
|
66
|
+
setBusy(true);
|
|
67
|
+
try {
|
|
68
|
+
const res = await apply({ data: opts });
|
|
69
|
+
toast.success(
|
|
70
|
+
`Merged ${res.merged} duplicate${res.merged === 1 ? "" : "s"}, renamed ${res.renamed}, updated ${res.rewritten} reference${res.rewritten === 1 ? "" : "s"} (${bytes(res.freedBytes)} freed).`,
|
|
71
|
+
);
|
|
72
|
+
if (res.errors?.length) toast.error(res.errors.slice(0, 3).join(" · "));
|
|
73
|
+
setPlan(null);
|
|
74
|
+
setOpen(false);
|
|
75
|
+
await onDone();
|
|
76
|
+
} catch (error) {
|
|
77
|
+
toast.error(error instanceof Error ? error.message : "Cleanup failed");
|
|
78
|
+
} finally {
|
|
79
|
+
setBusy(false);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<>
|
|
85
|
+
<button
|
|
86
|
+
type="button"
|
|
87
|
+
onClick={doScan}
|
|
88
|
+
disabled={busy}
|
|
89
|
+
title="Find duplicate files and legacy timestamp filenames"
|
|
90
|
+
className="inline-flex items-center gap-2 border border-ink/15 px-4 py-2 text-sm hover:border-sea disabled:opacity-50"
|
|
91
|
+
>
|
|
92
|
+
{busy && !open ? <Loader2 className="size-4 animate-spin" /> : <Wand2 className="size-4" />}
|
|
93
|
+
Clean up
|
|
94
|
+
</button>
|
|
95
|
+
|
|
96
|
+
{open && plan ? (
|
|
97
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/50 p-4">
|
|
98
|
+
<div className="w-full max-w-2xl max-h-[85vh] overflow-auto bg-white border border-ink/10">
|
|
99
|
+
<header className="px-5 py-4 border-b border-ink/10">
|
|
100
|
+
<div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/55">
|
|
101
|
+
Library cleanup
|
|
102
|
+
</div>
|
|
103
|
+
<div className="text-sm text-ink/70 mt-1">
|
|
104
|
+
{plan.scanned} files scanned ·{" "}
|
|
105
|
+
{plan.duplicates.reduce((n, g) => n + g.remove.length, 0)} duplicate cop
|
|
106
|
+
{plan.duplicates.reduce((n, g) => n + g.remove.length, 0) === 1 ? "y" : "ies"} (
|
|
107
|
+
{bytes(plan.duplicateBytes)}) · {plan.renames.length} timestamp name
|
|
108
|
+
{plan.renames.length === 1 ? "" : "s"}
|
|
109
|
+
</div>
|
|
110
|
+
</header>
|
|
111
|
+
|
|
112
|
+
<div className="p-5 space-y-5 text-sm">
|
|
113
|
+
{plan.duplicates.length > 0 ? (
|
|
114
|
+
<section className="space-y-2">
|
|
115
|
+
<div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/55">
|
|
116
|
+
Duplicates to merge
|
|
117
|
+
</div>
|
|
118
|
+
{plan.duplicates.map((g) => (
|
|
119
|
+
<div key={g.hash} className="border border-ink/10 p-3">
|
|
120
|
+
<div className="text-ink">Keep: {g.keep}</div>
|
|
121
|
+
<ul className="mt-1 text-ink/60 text-xs list-disc pl-5">
|
|
122
|
+
{g.remove.map((p) => (
|
|
123
|
+
<li key={p}>{p}</li>
|
|
124
|
+
))}
|
|
125
|
+
</ul>
|
|
126
|
+
</div>
|
|
127
|
+
))}
|
|
128
|
+
</section>
|
|
129
|
+
) : null}
|
|
130
|
+
|
|
131
|
+
{plan.renames.length > 0 ? (
|
|
132
|
+
<section className="space-y-2">
|
|
133
|
+
<div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/55">
|
|
134
|
+
Renames
|
|
135
|
+
</div>
|
|
136
|
+
<ul className="text-xs text-ink/70 space-y-1">
|
|
137
|
+
{plan.renames.map((r) => (
|
|
138
|
+
<li key={r.from}>
|
|
139
|
+
{r.from} <span className="text-ink/40">→</span> {r.to}
|
|
140
|
+
</li>
|
|
141
|
+
))}
|
|
142
|
+
</ul>
|
|
143
|
+
</section>
|
|
144
|
+
) : null}
|
|
145
|
+
|
|
146
|
+
{plan.duplicates.length === 0 && plan.renames.length === 0 ? (
|
|
147
|
+
<div className="text-ink/60">Nothing to clean up — the library is tidy.</div>
|
|
148
|
+
) : null}
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<footer className="px-5 py-4 border-t border-ink/10 flex flex-wrap items-center justify-end gap-2">
|
|
152
|
+
<button
|
|
153
|
+
type="button"
|
|
154
|
+
onClick={() => setOpen(false)}
|
|
155
|
+
className="border border-ink/15 px-4 py-2 text-sm hover:border-sea"
|
|
156
|
+
>
|
|
157
|
+
Close
|
|
158
|
+
</button>
|
|
159
|
+
{plan.renames.length > 0 ? (
|
|
160
|
+
<button
|
|
161
|
+
type="button"
|
|
162
|
+
disabled={busy}
|
|
163
|
+
onClick={() => doApply({ duplicates: false, renames: true })}
|
|
164
|
+
className="border border-ink/15 px-4 py-2 text-sm hover:border-sea disabled:opacity-50"
|
|
165
|
+
>
|
|
166
|
+
Rename only
|
|
167
|
+
</button>
|
|
168
|
+
) : null}
|
|
169
|
+
{plan.duplicates.length > 0 || plan.renames.length > 0 ? (
|
|
170
|
+
<button
|
|
171
|
+
type="button"
|
|
172
|
+
disabled={busy}
|
|
173
|
+
onClick={() => doApply({ duplicates: true, renames: true })}
|
|
174
|
+
className="inline-flex items-center gap-2 bg-ink text-paper px-4 py-2 text-sm hover:bg-sea disabled:opacity-50"
|
|
175
|
+
>
|
|
176
|
+
{busy ? <Loader2 className="size-4 animate-spin" /> : null}
|
|
177
|
+
Merge duplicates & rename
|
|
178
|
+
</button>
|
|
179
|
+
) : null}
|
|
180
|
+
</footer>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
) : null}
|
|
184
|
+
</>
|
|
185
|
+
);
|
|
186
|
+
}
|