@bygghammar/cms-platform 0.25.0 → 0.27.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,788 @@
1
+ import { useMemo, useRef, useState } from "react";
2
+ import { toast } from "sonner";
3
+ import { Download, Upload, ShieldAlert, Loader2, CheckCircle2, AlertTriangle } from "lucide-react";
4
+
5
+ import { ProgressBar } from "./ProgressBar";
6
+ import { ALL_GROUP_IDS, DATASET_GROUPS, DEFAULT_GROUP_IDS, type ImportMode } from "../backup/datasets";
7
+
8
+ type Estimate = {
9
+ counts: Record<string, number>;
10
+ mediaFiles: number;
11
+ mediaBytes: number;
12
+ mediaError?: string;
13
+ };
14
+
15
+ type Analysis = {
16
+ manifest: {
17
+ createdAt: string;
18
+ createdBy: string | null;
19
+ site: string | null;
20
+ groups: string[];
21
+ mediaFiles: number;
22
+ mediaBytes: number;
23
+ };
24
+ compatible: boolean;
25
+ problems: string[];
26
+ mediaFiles: number;
27
+ mediaBytes: number;
28
+ datasets: {
29
+ group: string;
30
+ label: string;
31
+ insertOnly: boolean;
32
+ tables: { table: string; incoming: number; existing: number }[];
33
+ mediaFiles?: number;
34
+ mediaBytes?: number;
35
+ }[];
36
+ };
37
+
38
+ type ImportResult = {
39
+ ok: boolean;
40
+ applied: {
41
+ table: string;
42
+ inserted: number;
43
+ skipped: number;
44
+ mode: string;
45
+ failedKeys?: string[];
46
+ }[];
47
+ aborted?: string[];
48
+ mediaRestored: number;
49
+ mediaBytesRestored: number;
50
+ mediaFilesInArchive: number;
51
+ mediaBytesInArchive: number;
52
+ quarantinedMedia?: string[];
53
+ warnings: string[];
54
+ };
55
+
56
+ export type MediaArchiveProgress = {
57
+ phase: string;
58
+ completedFiles: number;
59
+ completedBytes: number;
60
+ totalFiles: number;
61
+ totalBytes: number;
62
+ };
63
+
64
+ /** Host-provided permissions, credentials and media assembly for the backup tool. */
65
+ export type BackupDeps = {
66
+ /** Permission state, resolved by the host. */
67
+ isSuper: boolean;
68
+ loading?: boolean;
69
+ error?: boolean;
70
+ retry?: () => void;
71
+ /** Authorization header for the backup HTTP endpoints. */
72
+ authHeaders: () => Promise<Record<string, string>>;
73
+ /**
74
+ * Assembles the large media payload in the browser. The host loads the
75
+ * implementation lazily and supplies its own storage client.
76
+ */
77
+ buildBrowserMediaArchive: (args: {
78
+ baseArchive: Uint8Array;
79
+ groups: string[];
80
+ onProgress: (p: MediaArchiveProgress) => void;
81
+ }) => Promise<{ blob: Blob }>;
82
+ /** Endpoint overrides; defaults match the canonical route layout. */
83
+ endpoints?: { estimate?: string; export?: string; import?: string; analyze?: string };
84
+ };
85
+
86
+ function bytes(n: number) {
87
+ if (!n) return "0 B";
88
+ const units = ["B", "KB", "MB", "GB"];
89
+ const i = Math.min(units.length - 1, Math.floor(Math.log(n) / Math.log(1024)));
90
+ return `${(n / 1024 ** i).toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
91
+ }
92
+
93
+ type Progress = { label: string; value: number | null; detail?: string };
94
+
95
+ /**
96
+ * Ramps a value towards `ceiling` while the server works without reporting
97
+ * progress, so the bar keeps moving during opaque phases.
98
+ */
99
+ function createRamp(from: number, ceiling: number, ms: number, onTick: (v: number) => void) {
100
+ const start = Date.now();
101
+ const id = setInterval(() => {
102
+ const t = Math.min(1, (Date.now() - start) / ms);
103
+ onTick(from + (ceiling - from) * (1 - Math.pow(1 - t, 2)));
104
+ }, 200);
105
+ return () => clearInterval(id);
106
+ }
107
+
108
+ /** POST a body with real upload progress (fetch cannot report upload bytes). */
109
+ function postWithUploadProgress(
110
+ url: string,
111
+ body: XMLHttpRequestBodyInit,
112
+ headers: Record<string, string>,
113
+ onUpload: (loaded: number, total: number) => void,
114
+ onUploaded: () => void,
115
+ ): Promise<{ ok: boolean; status: number; text: string }> {
116
+ return new Promise((resolve, reject) => {
117
+ const xhr = new XMLHttpRequest();
118
+ xhr.open("POST", url, true);
119
+ for (const [k, v] of Object.entries(headers)) xhr.setRequestHeader(k, v);
120
+ xhr.upload.onprogress = (e) => {
121
+ if (e.lengthComputable) onUpload(e.loaded, e.total);
122
+ };
123
+ xhr.upload.onload = () => onUploaded();
124
+ xhr.onerror = () => reject(new Error("Network error"));
125
+ xhr.onload = () =>
126
+ resolve({
127
+ ok: xhr.status >= 200 && xhr.status < 300,
128
+ status: xhr.status,
129
+ text: xhr.responseText,
130
+ });
131
+ xhr.send(body);
132
+ });
133
+ }
134
+
135
+ /** Shared Backup & restore screen. Permissions and media come from the host. */
136
+ export function BackupView({ deps }: { deps: BackupDeps }) {
137
+ if (deps.loading) {
138
+ return <div className="p-8 md:p-12 text-sm text-ink/60">Loading…</div>;
139
+ }
140
+ if (deps.error) {
141
+ return (
142
+ <div className="p-8 md:p-12">
143
+ <div className="flex items-start gap-3 border border-ink/10 bg-white p-6">
144
+ <ShieldAlert className="size-5 text-ink/60" />
145
+ <div>
146
+ <div className="font-serif text-lg text-ink">Could not verify your permissions</div>
147
+ <p className="mt-1 text-sm text-ink/60">
148
+ The connection to the server failed. Please try again.
149
+ </p>
150
+ <button
151
+ type="button"
152
+ onClick={() => deps.retry?.()}
153
+ className="mt-3 border border-ink/20 px-3 py-1.5 text-xs uppercase tracking-wide text-ink hover:bg-ink/5"
154
+ >
155
+ Try again
156
+ </button>
157
+ </div>
158
+ </div>
159
+ </div>
160
+ );
161
+ }
162
+ if (!deps.isSuper) {
163
+ return (
164
+ <div className="p-8 md:p-12">
165
+ <div className="flex items-start gap-3 border border-ink/10 bg-white p-6">
166
+ <ShieldAlert className="size-5 text-ink/60" />
167
+ <div>
168
+ <div className="font-serif text-lg text-ink">Superadmin only</div>
169
+ <p className="mt-1 text-sm text-ink/60">
170
+ Backup and restore is restricted to superadmins.
171
+ </p>
172
+ </div>
173
+ </div>
174
+ </div>
175
+ );
176
+ }
177
+ return <BackupTool deps={deps} />;
178
+ }
179
+
180
+ function BackupTool({ deps }: { deps: BackupDeps }) {
181
+ const endpoints = {
182
+ estimate: deps.endpoints?.estimate ?? "/api/backup/estimate",
183
+ export: deps.endpoints?.export ?? "/api/backup/export",
184
+ import: deps.endpoints?.import ?? "/api/backup/import",
185
+ analyze: deps.endpoints?.analyze ?? "/api/backup/analyze",
186
+ };
187
+
188
+ /* ---------------- export ---------------- */
189
+ const [selected, setSelected] = useState<string[]>(DEFAULT_GROUP_IDS);
190
+ const [estimate, setEstimate] = useState<Estimate | null>(null);
191
+ const [estimating, setEstimating] = useState(false);
192
+ const [exporting, setExporting] = useState(false);
193
+ const [exportProgress, setExportProgress] = useState<Progress | null>(null);
194
+
195
+ const toggle = (id: string) =>
196
+ setSelected((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]));
197
+
198
+ const estimateRows = useMemo(
199
+ () => (estimate ? Object.values(estimate.counts).reduce((a, b) => a + b, 0) : null),
200
+ [estimate],
201
+ );
202
+
203
+ const runEstimate = async (groups = selected) => {
204
+ setEstimating(true);
205
+ try {
206
+ const res = await fetch(endpoints.estimate, {
207
+ method: "POST",
208
+ headers: { ...(await deps.authHeaders()), "content-type": "application/json" },
209
+ body: JSON.stringify({ groups }),
210
+ });
211
+ if (!res.ok) throw new Error(await res.text());
212
+ setEstimate((await res.json()) as Estimate);
213
+ } catch (e) {
214
+ toast.error(e instanceof Error ? e.message : "Could not calculate size");
215
+ } finally {
216
+ setEstimating(false);
217
+ }
218
+ };
219
+
220
+ const download = async (groups: string[], label: string, onProgress?: (p: Progress) => void) => {
221
+ onProgress?.({ label: "Collecting data", value: 2 });
222
+ const headers = { ...(await deps.authHeaders()), "content-type": "application/json" };
223
+ const browserMedia = groups.includes("mediaFiles");
224
+
225
+ // Non-media exports are built entirely by the server. Media exports defer
226
+ // their large payload to the authenticated browser below.
227
+ let ramped = 2;
228
+ const stopRamp = createRamp(2, browserMedia ? 10 : 60, 25_000, (v) => {
229
+ ramped = v;
230
+ onProgress?.({ label: browserMedia ? "Collecting metadata" : "Building archive", value: v });
231
+ });
232
+
233
+ let res: Response;
234
+ try {
235
+ res = await fetch(endpoints.export, {
236
+ method: "POST",
237
+ headers,
238
+ body: JSON.stringify({ groups, label, deferMediaToBrowser: browserMedia }),
239
+ });
240
+ } finally {
241
+ stopRamp();
242
+ }
243
+ if (!res.ok) throw new Error(await res.text());
244
+
245
+ const total = Number(res.headers.get("content-length") ?? 0);
246
+ let blob: Blob;
247
+ if (browserMedia) {
248
+ onProgress?.({ label: "Downloading metadata", value: 10 });
249
+ const baseArchive = new Uint8Array(await res.arrayBuffer());
250
+ const built = await deps.buildBrowserMediaArchive({
251
+ baseArchive,
252
+ groups,
253
+ onProgress: (progress) => {
254
+ if (progress.phase === "listing") {
255
+ onProgress?.({ label: "Listing media files", value: 12 });
256
+ return;
257
+ }
258
+ if (progress.phase === "finalizing") {
259
+ onProgress?.({
260
+ label: "Finalizing archive",
261
+ value: 98,
262
+ detail: `${progress.completedFiles.toLocaleString()} files · ${bytes(
263
+ progress.completedBytes,
264
+ )}`,
265
+ });
266
+ return;
267
+ }
268
+
269
+ const fileRatio = progress.totalFiles ? progress.completedFiles / progress.totalFiles : 0;
270
+ const byteRatio = progress.totalBytes
271
+ ? progress.completedBytes / progress.totalBytes
272
+ : fileRatio;
273
+ const ratio = Math.max(0, Math.min(1, byteRatio));
274
+ onProgress?.({
275
+ label: `Downloading media ${progress.completedFiles.toLocaleString()} / ${progress.totalFiles.toLocaleString()}`,
276
+ value: 12 + ratio * 84,
277
+ detail: `${bytes(progress.completedBytes)} of ${bytes(progress.totalBytes)}`,
278
+ });
279
+ },
280
+ });
281
+ blob = built.blob;
282
+ } else if (res.body) {
283
+ const reader = res.body.getReader();
284
+ const chunks: BlobPart[] = [];
285
+ let loaded = 0;
286
+ for (;;) {
287
+ const { done, value } = await reader.read();
288
+ if (done) break;
289
+ chunks.push(value as unknown as BlobPart);
290
+ loaded += value.byteLength;
291
+ onProgress?.({
292
+ label: "Downloading archive",
293
+ value: total ? 60 + (loaded / total) * 39 : Math.min(98, ramped + loaded / 2_000_000),
294
+ detail: total ? `${bytes(loaded)} of ${bytes(total)}` : bytes(loaded),
295
+ });
296
+ }
297
+ blob = new Blob(chunks, { type: "application/zip" });
298
+ } else {
299
+ blob = await res.blob();
300
+ }
301
+
302
+ const name =
303
+ res.headers.get("content-disposition")?.match(/filename="(.+?)"/)?.[1] ?? `${label}.zip`;
304
+ const url = URL.createObjectURL(blob);
305
+ const a = document.createElement("a");
306
+ a.href = url;
307
+ a.download = name;
308
+ document.body.appendChild(a);
309
+ a.click();
310
+ a.remove();
311
+ // Revoking immediately can cancel a large browser download.
312
+ setTimeout(() => URL.revokeObjectURL(url), 30_000);
313
+ onProgress?.({ label: "Done", value: 100, detail: bytes(blob.size) });
314
+ };
315
+
316
+ const runExport = async (groups: string[], label: string) => {
317
+ setExporting(true);
318
+ setExportProgress({ label: "Starting", value: 0 });
319
+ try {
320
+ await download(groups, label, setExportProgress);
321
+ toast.success("Backup downloaded.");
322
+ setTimeout(() => setExportProgress(null), 2500);
323
+ } catch (e) {
324
+ toast.error(e instanceof Error ? e.message : "Export failed");
325
+ setExportProgress(null);
326
+ } finally {
327
+ setExporting(false);
328
+ }
329
+ };
330
+
331
+ /* ---------------- import ---------------- */
332
+ const fileRef = useRef<HTMLInputElement>(null);
333
+ const [file, setFile] = useState<File | null>(null);
334
+ const [analysis, setAnalysis] = useState<Analysis | null>(null);
335
+ const [analyzing, setAnalyzing] = useState(false);
336
+ const [importGroups, setImportGroups] = useState<string[]>([]);
337
+ const [mode, setMode] = useState<ImportMode>("merge");
338
+ const [includeMedia, setIncludeMedia] = useState(true);
339
+ const [rewriteMedia, setRewriteMedia] = useState(false);
340
+ const [mediaBase, setMediaBase] = useState("");
341
+
342
+ const [importing, setImporting] = useState(false);
343
+ const [result, setResult] = useState<ImportResult | null>(null);
344
+ const [confirmText, setConfirmText] = useState("");
345
+ const [analyzeProgress, setAnalyzeProgress] = useState<Progress | null>(null);
346
+ const [importProgress, setImportProgress] = useState<Progress | null>(null);
347
+
348
+ const onPick = async (f: File | null) => {
349
+ setFile(f);
350
+ setAnalysis(null);
351
+ setResult(null);
352
+ setConfirmText("");
353
+ setImportProgress(null);
354
+ setAnalyzeProgress(null);
355
+ if (!f) return;
356
+ setAnalyzing(true);
357
+ const ramp: { stop: (() => void) | null } = { stop: null };
358
+ try {
359
+ setAnalyzeProgress({ label: "Uploading archive", value: 0, detail: bytes(0) });
360
+ const res = await postWithUploadProgress(
361
+ endpoints.analyze,
362
+ f,
363
+ { ...(await deps.authHeaders()), "content-type": "application/zip" },
364
+ (loaded, total) =>
365
+ setAnalyzeProgress({
366
+ label: "Uploading archive",
367
+ value: (loaded / total) * 70,
368
+ detail: `${bytes(loaded)} of ${bytes(total)}`,
369
+ }),
370
+ () => {
371
+ ramp.stop = createRamp(70, 97, 15_000, (v) =>
372
+ setAnalyzeProgress({ label: "Reading archive", value: v }),
373
+ );
374
+ },
375
+ );
376
+ ramp.stop?.();
377
+ if (!res.ok) throw new Error(res.text || `Request failed (${res.status})`);
378
+ const a = JSON.parse(res.text) as Analysis;
379
+ setAnalysis(a);
380
+ setImportGroups(a.manifest.groups ?? []);
381
+ setAnalyzeProgress({ label: "Archive read", value: 100 });
382
+ setTimeout(() => setAnalyzeProgress(null), 1500);
383
+ } catch (e) {
384
+ ramp.stop?.();
385
+ setAnalyzeProgress(null);
386
+ toast.error(e instanceof Error ? e.message : "Could not read the archive");
387
+ } finally {
388
+ setAnalyzing(false);
389
+ }
390
+ };
391
+
392
+ const runImport = async () => {
393
+ if (!file || !analysis) return;
394
+ setImporting(true);
395
+ setResult(null);
396
+ const ramp: { stop: (() => void) | null } = { stop: null };
397
+ try {
398
+ // Safety snapshot of everything the import will touch, taken first.
399
+ toast.info("Taking a safety snapshot before importing…");
400
+ await download(importGroups, "pre-import-snapshot", (p) =>
401
+ // Snapshot occupies the first 40% of the import progress.
402
+ setImportProgress({
403
+ label: `Safety snapshot — ${p.label.toLowerCase()}`,
404
+ value: p.value === null ? null : (p.value / 100) * 40,
405
+ detail: p.detail,
406
+ }),
407
+ );
408
+
409
+ const qs = new URLSearchParams({
410
+ groups: importGroups.join(","),
411
+ mode,
412
+ media: includeMedia ? "1" : "0",
413
+ rewrite: rewriteMedia ? "1" : "0",
414
+ ...(rewriteMedia && mediaBase.trim() ? { mediaBase: mediaBase.trim() } : {}),
415
+ });
416
+
417
+ const res = await postWithUploadProgress(
418
+ `${endpoints.import}?${qs}`,
419
+ file,
420
+ { ...(await deps.authHeaders()), "content-type": "application/zip" },
421
+ (loaded, total) =>
422
+ setImportProgress({
423
+ label: "Uploading archive",
424
+ value: 40 + (loaded / total) * 30,
425
+ detail: `${bytes(loaded)} of ${bytes(total)}`,
426
+ }),
427
+ () => {
428
+ ramp.stop = createRamp(70, 97, 30_000, (v) =>
429
+ setImportProgress({ label: "Restoring data & media", value: v }),
430
+ );
431
+ },
432
+ );
433
+ ramp.stop?.();
434
+ if (!res.ok) throw new Error(res.text || `Request failed (${res.status})`);
435
+ const r = JSON.parse(res.text) as ImportResult;
436
+ setResult(r);
437
+ setImportProgress({ label: "Import complete", value: 100 });
438
+ toast.success("Import finished.");
439
+ setTimeout(() => setImportProgress(null), 2500);
440
+ } catch (e) {
441
+ ramp.stop?.();
442
+ setImportProgress(null);
443
+ toast.error(e instanceof Error ? e.message : "Import failed");
444
+ } finally {
445
+ setImporting(false);
446
+ }
447
+ };
448
+
449
+ const canImport =
450
+ Boolean(analysis?.compatible) &&
451
+ importGroups.length > 0 &&
452
+ (mode === "merge" || confirmText === "REPLACE") &&
453
+ !importing;
454
+
455
+ return (
456
+ <div className="p-8 md:p-12 w-full space-y-10">
457
+ <header>
458
+ <div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/55">Superadmin</div>
459
+ <h2 className="mt-2 font-serif text-2xl text-ink">Backup &amp; restore</h2>
460
+ <p className="mt-2 max-w-2xl text-sm text-ink/60">
461
+ Export the whole site — content, settings, forms, submissions, media and roles — as a
462
+ single archive, and restore it again on this or another installation. Customer submissions
463
+ are always insert-only and can never be overwritten or deleted by an import.
464
+ </p>
465
+ </header>
466
+
467
+ {/* EXPORT */}
468
+ <section className="border border-ink/10 bg-white p-6">
469
+ <div className="flex items-center gap-3">
470
+ <Download className="size-5 text-ink/70" />
471
+ <h3 className="font-serif text-lg text-ink">Export</h3>
472
+ </div>
473
+
474
+ <div className="mt-6 grid gap-3 md:grid-cols-2">
475
+ {DATASET_GROUPS.map((g) => (
476
+ <label
477
+ key={g.id}
478
+ className="flex cursor-pointer items-start gap-3 border border-ink/10 p-4 hover:border-sea"
479
+ >
480
+ <input
481
+ type="checkbox"
482
+ checked={selected.includes(g.id)}
483
+ onChange={() => toggle(g.id)}
484
+ className="mt-1"
485
+ />
486
+ <span>
487
+ <span className="block text-sm text-ink">{g.label}</span>
488
+ <span className="mt-1 block text-xs text-ink/55">{g.description}</span>
489
+ {estimate && selected.includes(g.id) ? (
490
+ <span className="mt-2 block text-[0.65rem] uppercase tracking-[0.18em] text-ink/45">
491
+ {g.special === "mediaFiles"
492
+ ? estimate.mediaError
493
+ ? `Media unavailable — ${estimate.mediaError}`
494
+ : `${estimate.mediaFiles.toLocaleString()} files · ${bytes(estimate.mediaBytes)}`
495
+ : `${g.tables
496
+ .reduce((s, t) => s + (estimate.counts[t.table] ?? 0), 0)
497
+ .toLocaleString()} rows`}
498
+ </span>
499
+ ) : null}
500
+ </span>
501
+ </label>
502
+ ))}
503
+ </div>
504
+
505
+ <div className="mt-6 flex flex-wrap items-center gap-3">
506
+ <button
507
+ type="button"
508
+ onClick={() => setSelected(ALL_GROUP_IDS)}
509
+ className="border border-ink/20 px-3 py-2 text-xs uppercase tracking-[0.22em] text-ink hover:border-sea"
510
+ >
511
+ Select everything
512
+ </button>
513
+ <button
514
+ type="button"
515
+ onClick={() => setSelected(DEFAULT_GROUP_IDS)}
516
+ className="border border-ink/20 px-3 py-2 text-xs uppercase tracking-[0.22em] text-ink hover:border-sea"
517
+ >
518
+ Recommended
519
+ </button>
520
+ <button
521
+ type="button"
522
+ onClick={() => runEstimate()}
523
+ disabled={estimating || !selected.length}
524
+ className="border border-ink/20 px-3 py-2 text-xs uppercase tracking-[0.22em] text-ink hover:border-sea disabled:opacity-50"
525
+ >
526
+ {estimating ? "Calculating…" : "Calculate size"}
527
+ </button>
528
+ <button
529
+ type="button"
530
+ onClick={() => runExport(selected, "backup")}
531
+ disabled={exporting || !selected.length}
532
+ className="inline-flex items-center gap-2 border border-ink bg-ink px-4 py-2 text-xs uppercase tracking-[0.22em] text-white hover:bg-sea hover:border-sea disabled:opacity-50"
533
+ >
534
+ {exporting ? (
535
+ <Loader2 className="size-3.5 animate-spin" />
536
+ ) : (
537
+ <Download className="size-3.5" />
538
+ )}
539
+ {exporting ? "Building archive…" : "Download backup"}
540
+ </button>
541
+ <button
542
+ type="button"
543
+ onClick={() => runExport(ALL_GROUP_IDS, "full-backup")}
544
+ disabled={exporting}
545
+ className="border border-ink/20 px-3 py-2 text-xs uppercase tracking-[0.22em] text-ink hover:border-sea disabled:opacity-50"
546
+ >
547
+ Full backup (everything)
548
+ </button>
549
+ </div>
550
+
551
+ {selected.includes("mediaFiles") ? (
552
+ <p className="mt-4 max-w-2xl text-xs text-ink/55">
553
+ Media files are downloaded and verified one at a time in this browser. Keep this tab
554
+ open until the ZIP download starts; the progress bar shows the real file and byte count.
555
+ </p>
556
+ ) : null}
557
+
558
+ {exportProgress ? (
559
+ <div className="mt-6">
560
+ <ProgressBar
561
+ value={exportProgress.value}
562
+ label={exportProgress.label}
563
+ detail={exportProgress.detail}
564
+ />
565
+ </div>
566
+ ) : null}
567
+
568
+ {estimateRows !== null ? (
569
+ <p className="mt-4 text-xs text-ink/55">
570
+ {estimateRows.toLocaleString()} rows
571
+ {estimate?.mediaFiles
572
+ ? ` · ${estimate.mediaFiles.toLocaleString()} media files (${bytes(estimate.mediaBytes)})`
573
+ : ""}
574
+ {estimate?.mediaError ? ` · media: ${estimate.mediaError}` : ""}
575
+ </p>
576
+ ) : null}
577
+ </section>
578
+
579
+ {/* IMPORT */}
580
+ <section className="border border-ink/10 bg-white p-6">
581
+ <div className="flex items-center gap-3">
582
+ <Upload className="size-5 text-ink/70" />
583
+ <h3 className="font-serif text-lg text-ink">Import / restore</h3>
584
+ </div>
585
+ <p className="mt-2 max-w-2xl text-sm text-ink/60">
586
+ Upload an archive created by this tool. Nothing is written until you confirm — and a
587
+ safety snapshot of the affected data is downloaded automatically first.
588
+ </p>
589
+
590
+ <input
591
+ ref={fileRef}
592
+ type="file"
593
+ accept=".zip,application/zip"
594
+ disabled={analyzing}
595
+ onChange={(e) => onPick(e.target.files?.[0] ?? null)}
596
+ className="mt-4 block text-sm text-ink"
597
+ />
598
+
599
+ {analyzeProgress ? (
600
+ <div className="mt-4">
601
+ <ProgressBar
602
+ value={analyzeProgress.value}
603
+ label={analyzeProgress.label}
604
+ detail={analyzeProgress.detail}
605
+ />
606
+ </div>
607
+ ) : null}
608
+
609
+ {analysis ? (
610
+ <div className="mt-6 space-y-5">
611
+ <div className="text-xs text-ink/60">
612
+ Created {new Date(analysis.manifest.createdAt).toLocaleString()}
613
+ {analysis.manifest.createdBy ? ` by ${analysis.manifest.createdBy}` : ""}
614
+ {analysis.manifest.site ? ` on ${analysis.manifest.site}` : ""}
615
+ {analysis.mediaFiles
616
+ ? ` · verified ${analysis.mediaFiles.toLocaleString()} media files (${bytes(analysis.mediaBytes)})`
617
+ : ""}
618
+ </div>
619
+
620
+ {analysis.problems.length ? (
621
+ <ul className="border border-red-300 bg-red-50 p-4 text-xs text-red-700 space-y-1">
622
+ {analysis.problems.map((p) => (
623
+ <li key={p}>{p}</li>
624
+ ))}
625
+ </ul>
626
+ ) : null}
627
+
628
+ <div className="space-y-3">
629
+ {analysis.datasets.map((d) => (
630
+ <label key={d.group} className="flex items-start gap-3 border border-ink/10 p-4">
631
+ <input
632
+ type="checkbox"
633
+ checked={importGroups.includes(d.group)}
634
+ onChange={() =>
635
+ setImportGroups((s) =>
636
+ s.includes(d.group) ? s.filter((x) => x !== d.group) : [...s, d.group],
637
+ )
638
+ }
639
+ className="mt-1"
640
+ />
641
+ <span className="flex-1">
642
+ <span className="block text-sm text-ink">
643
+ {d.label}
644
+ {d.insertOnly ? (
645
+ <span className="ml-2 text-[0.6rem] uppercase tracking-[0.18em] text-ink/45">
646
+ insert-only
647
+ </span>
648
+ ) : null}
649
+ </span>
650
+ <span className="mt-1 block text-xs text-ink/55">
651
+ {d.tables
652
+ .map((t) => `${t.table}: ${t.incoming} in archive / ${t.existing} here`)
653
+ .join(" · ")}
654
+ {d.mediaFiles !== undefined
655
+ ? ` · ${d.mediaFiles.toLocaleString()} media files · ${bytes(d.mediaBytes ?? 0)}`
656
+ : ""}
657
+ </span>
658
+ </span>
659
+ </label>
660
+ ))}
661
+ </div>
662
+
663
+ <div className="flex flex-wrap items-center gap-4">
664
+ <label className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/55">Mode</label>
665
+ <select
666
+ value={mode}
667
+ onChange={(e) => setMode(e.target.value as ImportMode)}
668
+ className="border border-ink/20 bg-white px-3 py-2 text-xs uppercase tracking-[0.22em] text-ink"
669
+ >
670
+ <option value="merge">Merge — update and add, delete nothing</option>
671
+ <option value="replace">Replace — clear tables first</option>
672
+ </select>
673
+ <label className="inline-flex items-center gap-2 text-xs text-ink/70">
674
+ <input
675
+ type="checkbox"
676
+ checked={includeMedia}
677
+ onChange={(e) => setIncludeMedia(e.target.checked)}
678
+ />
679
+ Restore media files
680
+ </label>
681
+ <label className="inline-flex items-center gap-2 text-xs text-ink/70">
682
+ <input
683
+ type="checkbox"
684
+ checked={rewriteMedia}
685
+ onChange={(e) => setRewriteMedia(e.target.checked)}
686
+ />
687
+ Rewrite media URLs to this installation
688
+ </label>
689
+ {rewriteMedia ? (
690
+ <input
691
+ value={mediaBase}
692
+ onChange={(e) => setMediaBase(e.target.value)}
693
+ placeholder="/media"
694
+ className="w-56 border border-ink/20 bg-white px-2 py-1 text-xs text-ink"
695
+ />
696
+ ) : null}
697
+ </div>
698
+
699
+ {mode === "replace" ? (
700
+ <div className="border border-amber-300 bg-amber-50 p-4 text-xs text-amber-800">
701
+ Replace clears the selected tables before loading. Customer data is never cleared.
702
+ Type <strong>REPLACE</strong> to confirm.
703
+ <input
704
+ value={confirmText}
705
+ onChange={(e) => setConfirmText(e.target.value)}
706
+ className="mt-2 block w-40 border border-amber-400 bg-white px-2 py-1 text-xs text-ink"
707
+ />
708
+ </div>
709
+ ) : null}
710
+
711
+ <button
712
+ type="button"
713
+ onClick={runImport}
714
+ disabled={!canImport}
715
+ className="inline-flex items-center gap-2 border border-ink bg-ink px-4 py-2 text-xs uppercase tracking-[0.22em] text-white hover:bg-sea hover:border-sea disabled:opacity-50"
716
+ >
717
+ {importing ? (
718
+ <Loader2 className="size-3.5 animate-spin" />
719
+ ) : (
720
+ <Upload className="size-3.5" />
721
+ )}
722
+ {importing ? "Importing…" : "Snapshot & import"}
723
+ </button>
724
+
725
+ {importProgress ? (
726
+ <ProgressBar
727
+ value={importProgress.value}
728
+ label={importProgress.label}
729
+ detail={importProgress.detail}
730
+ />
731
+ ) : null}
732
+ </div>
733
+ ) : null}
734
+
735
+ {result ? (
736
+ <div className="mt-6 border border-ink/10 p-4">
737
+ <div className="flex items-center gap-2 text-sm text-ink">
738
+ {result.ok ? (
739
+ <>
740
+ <CheckCircle2 className="size-4 text-sea" /> Import complete
741
+ </>
742
+ ) : (
743
+ <>
744
+ <AlertTriangle className="size-4 text-red-600" /> Import finished with errors —
745
+ the restore is incomplete
746
+ </>
747
+ )}
748
+ </div>
749
+ <ul className="mt-3 space-y-1 text-xs text-ink/60">
750
+ {result.applied.map((a) => (
751
+ <li key={a.table} className={a.skipped ? "text-red-700" : undefined}>
752
+ {a.table}: {a.inserted} written{a.skipped ? `, ${a.skipped} failed` : ""} (
753
+ {a.mode})
754
+ {a.failedKeys?.length
755
+ ? ` — ${a.failedKeys.slice(0, 5).join(", ")}${a.failedKeys.length > 5 ? " …" : ""}`
756
+ : ""}
757
+ </li>
758
+ ))}
759
+ {result.aborted?.length ? (
760
+ <li className="text-red-700">not attempted: {result.aborted.join(", ")}</li>
761
+ ) : null}
762
+ {result.mediaFilesInArchive ? (
763
+ <li>
764
+ media restored: {result.mediaRestored.toLocaleString()}/
765
+ {result.mediaFilesInArchive.toLocaleString()} files ·{" "}
766
+ {bytes(result.mediaBytesRestored)} of {bytes(result.mediaBytesInArchive)}
767
+ </li>
768
+ ) : null}
769
+ {result.quarantinedMedia?.length ? (
770
+ <li className="text-amber-700">
771
+ {result.quarantinedMedia.length} non-media entries were not restored
772
+ </li>
773
+ ) : null}
774
+ </ul>
775
+
776
+ {result.warnings.length ? (
777
+ <ul className="mt-3 space-y-1 text-xs text-amber-700">
778
+ {result.warnings.map((w, i) => (
779
+ <li key={i}>{w}</li>
780
+ ))}
781
+ </ul>
782
+ ) : null}
783
+ </div>
784
+ ) : null}
785
+ </section>
786
+ </div>
787
+ );
788
+ }