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