@morphika/andami 0.2.8 → 0.2.10

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,1581 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect, useCallback, useRef } from "react";
4
+ import { csrfHeaders } from "../../../lib/csrf-client";
5
+ import { formatDate, formatBytes } from "../../../lib/format-utils";
6
+ import { getSiteConfig } from "../../../lib/config";
7
+ import { getMimeType } from "../../../lib/storage/types";
8
+ import type { BackupManifest, BackupStatus } from "../../../lib/backup/manifest";
9
+
10
+ // ============================================
11
+ // Types
12
+ // ============================================
13
+
14
+ /** Shape returned by GET /api/admin/backups/prepare-export (V2) */
15
+ interface ExportData {
16
+ manifest: BackupManifest;
17
+ documents: {
18
+ pages: Record<string, unknown>[];
19
+ siteSettings: Record<string, unknown> | null;
20
+ siteStyles: Record<string, unknown> | null;
21
+ assetRegistry: Record<string, unknown> | null;
22
+ customSections: Record<string, unknown>[];
23
+ };
24
+ assets: {
25
+ r2_connected: boolean;
26
+ public_url: string | null;
27
+ files: { key: string; size: number }[];
28
+ };
29
+ }
30
+
31
+ /** A presigned upload URL pre-generated by the server for a single asset key. */
32
+ interface PresignedUploadUrl {
33
+ key: string;
34
+ uploadUrl: string;
35
+ contentType: string;
36
+ }
37
+
38
+ /** Shape returned by POST /api/admin/backups/restore-data (V2) */
39
+ interface RestoreDataResult {
40
+ success: boolean;
41
+ sanity: {
42
+ wiped: Record<string, number>;
43
+ restored: Record<string, number>;
44
+ };
45
+ r2_wiped: number | null;
46
+ /**
47
+ * Presigned PUT URLs (one per asset key) — the browser uploads directly
48
+ * to R2 without invoking Vercel per file. Undefined when R2 isn't
49
+ * connected or the request didn't include any assets.
50
+ */
51
+ upload_urls?: PresignedUploadUrl[];
52
+ /** TTL of the presigned URLs, in seconds. */
53
+ upload_ttl_seconds?: number;
54
+ duration_ms: number;
55
+ }
56
+
57
+ // ============================================
58
+ // SVG Icons
59
+ // ============================================
60
+
61
+ function BackupIcon({ size = 20 }: { size?: number }) {
62
+ return (
63
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="#3b82f6" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
64
+ <path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" />
65
+ <path d="M12 12v9" />
66
+ <path d="m16 16-4-4-4 4" />
67
+ </svg>
68
+ );
69
+ }
70
+
71
+ function RestoreIcon({ size = 20 }: { size?: number }) {
72
+ return (
73
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
74
+ <path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" />
75
+ <path d="M12 12v9" />
76
+ <path d="m8 17 4 4 4-4" />
77
+ </svg>
78
+ );
79
+ }
80
+
81
+ function SpinnerIcon({ size = 16 }: { size?: number }) {
82
+ return (
83
+ <svg className="animate-spin" width={size} height={size} viewBox="0 0 24 24" fill="none">
84
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
85
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
86
+ </svg>
87
+ );
88
+ }
89
+
90
+ function CheckIcon({ size = 16 }: { size?: number }) {
91
+ return (
92
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
93
+ <polyline points="20 6 9 17 4 12" />
94
+ </svg>
95
+ );
96
+ }
97
+
98
+ function AlertIcon({ size = 16 }: { size?: number }) {
99
+ return (
100
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
101
+ <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
102
+ <line x1="12" y1="9" x2="12" y2="13" />
103
+ <line x1="12" y1="17" x2="12.01" y2="17" />
104
+ </svg>
105
+ );
106
+ }
107
+
108
+ function UploadIcon({ size = 32 }: { size?: number }) {
109
+ return (
110
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
111
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
112
+ <polyline points="17 8 12 3 7 8" />
113
+ <line x1="12" y1="3" x2="12" y2="15" />
114
+ </svg>
115
+ );
116
+ }
117
+
118
+ // ============================================
119
+ // Helpers
120
+ // ============================================
121
+
122
+ /** Total document count from a BackupStatus */
123
+ function totalDocs(status: BackupStatus): number {
124
+ const c = status.document_counts;
125
+ return (
126
+ c.pages +
127
+ c.custom_sections +
128
+ (c.site_settings ? 1 : 0) +
129
+ (c.site_styles ? 1 : 0) +
130
+ (c.asset_registry ? 1 : 0)
131
+ );
132
+ }
133
+
134
+ /** Total document count from a BackupManifest */
135
+ function totalManifestDocs(m: BackupManifest): number {
136
+ const d = m.documents;
137
+ return d.pages + d.custom_sections + d.site_settings + d.site_styles + d.asset_registry;
138
+ }
139
+
140
+ // ============================================
141
+ // Concurrency pool — download/upload assets in parallel
142
+ // ============================================
143
+
144
+ interface PoolProgress {
145
+ completed: number;
146
+ total: number;
147
+ bytesTransferred: number;
148
+ /**
149
+ * L-8: total expected size in bytes across all files (when known).
150
+ * The upload flow sets this from the ZIP entry `uncompressedSize` stats
151
+ * so the progress bar can be weighted by bytes instead of file count —
152
+ * which is much smoother when a handful of large files dominate the
153
+ * upload (e.g. a 200 MB video alongside 50 tiny thumbnails).
154
+ *
155
+ * `0` means byte totals aren't available and the UI should fall back to
156
+ * file-count progress. The download flow always populates this because
157
+ * prepare-export returns sizes alongside keys.
158
+ */
159
+ totalBytes: number;
160
+ failed: string[];
161
+ }
162
+
163
+ /**
164
+ * Download assets from R2 CDN into a JSZip instance with a concurrency pool.
165
+ * Each file is fetched directly from the R2 public URL — no Vercel traffic.
166
+ */
167
+ async function downloadAssetsToZip(
168
+ files: { key: string; size: number }[],
169
+ publicUrl: string,
170
+ zip: import("jszip"),
171
+ onProgress: (p: PoolProgress) => void,
172
+ abortSignal: AbortSignal,
173
+ concurrency = 6
174
+ ): Promise<PoolProgress> {
175
+ const progress: PoolProgress = {
176
+ completed: 0,
177
+ total: files.length,
178
+ bytesTransferred: 0,
179
+ // L-8: use the sizes from prepare-export so the bar reflects byte
180
+ // progress, not file count. Falls back gracefully to 0 when sizes
181
+ // aren't available (the caller then shows a count-based bar).
182
+ totalBytes: files.reduce((s, f) => s + (f.size || 0), 0),
183
+ failed: [],
184
+ };
185
+
186
+ const queue = [...files];
187
+
188
+ async function worker() {
189
+ while (queue.length > 0) {
190
+ if (abortSignal.aborted) return;
191
+
192
+ const file = queue.shift()!;
193
+ try {
194
+ const res = await fetch(`${publicUrl.replace(/\/$/, "")}/${file.key}`, {
195
+ signal: abortSignal,
196
+ });
197
+ if (res.ok) {
198
+ const blob = await res.blob();
199
+ zip.file(`assets/${file.key}`, blob);
200
+ progress.bytesTransferred += blob.size;
201
+ } else {
202
+ progress.failed.push(file.key);
203
+ }
204
+ } catch (err) {
205
+ if (abortSignal.aborted) return;
206
+ progress.failed.push(file.key);
207
+ }
208
+ progress.completed++;
209
+ onProgress({ ...progress });
210
+ }
211
+ }
212
+
213
+ await Promise.all(Array.from({ length: Math.min(concurrency, files.length) }, () => worker()));
214
+ return progress;
215
+ }
216
+
217
+ /**
218
+ * Upload assets to R2 using presigned PUT URLs that were pre-generated
219
+ * server-side (see `/api/admin/backups/restore-data`). This keeps Vercel
220
+ * out of the per-asset hot path — the restore endpoint signs every URL
221
+ * in one invocation, then the browser PUTs directly to R2.
222
+ *
223
+ * Each failed upload is retried up to `maxRetries` times with exponential
224
+ * backoff. Transient R2 or network errors no longer silently "eat" files.
225
+ */
226
+ async function uploadAssetsFromZip(
227
+ zip: import("jszip"),
228
+ presigned: PresignedUploadUrl[],
229
+ onProgress: (p: PoolProgress) => void,
230
+ abortSignal: AbortSignal,
231
+ concurrency = 6,
232
+ maxRetries = 3
233
+ ): Promise<PoolProgress> {
234
+ // Map by key for O(1) lookup
235
+ const urlByKey = new Map<string, PresignedUploadUrl>();
236
+ for (const p of presigned) urlByKey.set(p.key, p);
237
+
238
+ // Collect all asset entries from the ZIP. We also read each entry's
239
+ // uncompressed size from JSZip's internal `_data` stats so L-8 can
240
+ // weight the progress bar by bytes rather than file count. JSZip does
241
+ // not expose a typed accessor for this, so we cast carefully.
242
+ const assetEntries: { key: string; zipPath: string; size: number }[] = [];
243
+ zip.forEach((relativePath: string, zipEntry) => {
244
+ if (relativePath.startsWith("assets/") && !relativePath.endsWith("/")) {
245
+ const key = relativePath.replace(/^assets\//, "");
246
+ const size =
247
+ (zipEntry as unknown as { _data?: { uncompressedSize?: number } })._data
248
+ ?.uncompressedSize ?? 0;
249
+ assetEntries.push({ key, zipPath: relativePath, size });
250
+ }
251
+ });
252
+
253
+ const progress: PoolProgress = {
254
+ completed: 0,
255
+ total: assetEntries.length,
256
+ bytesTransferred: 0,
257
+ // L-8: sum of uncompressed sizes — may be 0 if JSZip couldn't read
258
+ // the stats (extremely old ZIPs). In that case `ProgressBar` falls
259
+ // back to file-count mode.
260
+ totalBytes: assetEntries.reduce((s, e) => s + (e.size || 0), 0),
261
+ failed: [],
262
+ };
263
+
264
+ if (assetEntries.length === 0) return progress;
265
+
266
+ const queue = [...assetEntries];
267
+
268
+ async function uploadOne(
269
+ entry: { key: string; zipPath: string }
270
+ ): Promise<{ ok: true; bytes: number } | { ok: false }> {
271
+ const zipFile = zip.file(entry.zipPath);
272
+ if (!zipFile) return { ok: false };
273
+
274
+ const blob = await zipFile.async("blob");
275
+ const presignedEntry = urlByKey.get(entry.key);
276
+ if (!presignedEntry) return { ok: false };
277
+
278
+ // Content-Type MUST match what the server signed with, otherwise R2
279
+ // rejects with 403. We trust the server's contentType and fall back
280
+ // to a local lookup if the field is missing.
281
+ const contentType =
282
+ presignedEntry.contentType || getMimeType(entry.key);
283
+
284
+ const putRes = await fetch(presignedEntry.uploadUrl, {
285
+ method: "PUT",
286
+ headers: { "Content-Type": contentType },
287
+ body: blob,
288
+ signal: abortSignal,
289
+ });
290
+
291
+ if (putRes.ok) {
292
+ return { ok: true, bytes: blob.size };
293
+ }
294
+ return { ok: false };
295
+ }
296
+
297
+ async function uploadWithRetry(
298
+ entry: { key: string; zipPath: string }
299
+ ): Promise<{ ok: boolean; bytes: number }> {
300
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
301
+ if (abortSignal.aborted) return { ok: false, bytes: 0 };
302
+ try {
303
+ const result = await uploadOne(entry);
304
+ if (result.ok) return { ok: true, bytes: result.bytes };
305
+ } catch (err) {
306
+ if (abortSignal.aborted) return { ok: false, bytes: 0 };
307
+ // Fallthrough to retry below.
308
+ }
309
+ if (attempt < maxRetries) {
310
+ // Exponential backoff with jitter: 400ms, 900ms, 2000ms (approx)
311
+ const delayMs = 400 * Math.pow(2, attempt) + Math.random() * 200;
312
+ await new Promise<void>((resolve) => {
313
+ const timer = setTimeout(() => resolve(), delayMs);
314
+ // If aborted during backoff, clear the timer and bail out.
315
+ const onAbort = () => {
316
+ clearTimeout(timer);
317
+ resolve();
318
+ };
319
+ if (abortSignal.aborted) {
320
+ onAbort();
321
+ } else {
322
+ abortSignal.addEventListener("abort", onAbort, { once: true });
323
+ }
324
+ });
325
+ }
326
+ }
327
+ return { ok: false, bytes: 0 };
328
+ }
329
+
330
+ async function worker() {
331
+ while (queue.length > 0) {
332
+ if (abortSignal.aborted) return;
333
+
334
+ const entry = queue.shift()!;
335
+ try {
336
+ const result = await uploadWithRetry(entry);
337
+ if (result.ok) {
338
+ progress.bytesTransferred += result.bytes;
339
+ } else {
340
+ progress.failed.push(entry.key);
341
+ }
342
+ } catch (err) {
343
+ if (abortSignal.aborted) return;
344
+ progress.failed.push(entry.key);
345
+ }
346
+ progress.completed++;
347
+ onProgress({ ...progress });
348
+ }
349
+ }
350
+
351
+ await Promise.all(Array.from({ length: Math.min(concurrency, assetEntries.length) }, () => worker()));
352
+ return progress;
353
+ }
354
+
355
+ // ============================================
356
+ // Progress Bar Component
357
+ // ============================================
358
+
359
+ function ProgressBar({
360
+ phase,
361
+ progress,
362
+ totalFiles,
363
+ bytesTransferred,
364
+ totalBytes,
365
+ failedCount,
366
+ }: {
367
+ phase: string;
368
+ /**
369
+ * Fallback count-based progress in the 0–100 range. Used when
370
+ * `totalBytes` is 0 (e.g. very old ZIPs where JSZip can't read the
371
+ * uncompressed size). When `totalBytes > 0` the bar uses bytes instead
372
+ * and `progress` is ignored — this keeps the ProgressBar contract
373
+ * backward-compatible with older callers.
374
+ */
375
+ progress: number;
376
+ totalFiles?: number;
377
+ bytesTransferred?: number;
378
+ /**
379
+ * L-8: total expected bytes across all files. When > 0, the progress
380
+ * bar is weighted by bytes transferred — much smoother when a handful
381
+ * of large files dominate the transfer.
382
+ */
383
+ totalBytes?: number;
384
+ failedCount?: number;
385
+ }) {
386
+ // L-8: prefer byte-weighted progress when we have total bytes; fall
387
+ // back to the count-based value otherwise.
388
+ const useBytes = totalBytes !== undefined && totalBytes > 0 && bytesTransferred !== undefined;
389
+ const displayProgress = useBytes
390
+ ? Math.min(100, (bytesTransferred! / totalBytes!) * 100)
391
+ : progress;
392
+ return (
393
+ <div className="space-y-1.5">
394
+ <div className="flex items-center justify-between text-xs">
395
+ <span className="text-neutral-600 font-medium">{phase}</span>
396
+ {totalFiles !== undefined && totalFiles > 0 && (
397
+ <span className="text-neutral-400">
398
+ {Math.round(displayProgress)}%
399
+ {bytesTransferred !== undefined && bytesTransferred > 0 && ` · ${formatBytes(bytesTransferred)}`}
400
+ {useBytes && ` / ${formatBytes(totalBytes!)}`}
401
+ </span>
402
+ )}
403
+ </div>
404
+ <div className="w-full bg-neutral-100 rounded-full h-2 overflow-hidden">
405
+ <div
406
+ className="bg-blue-500 h-full rounded-full transition-all duration-300 ease-out"
407
+ style={{ width: `${Math.min(100, Math.max(0, displayProgress))}%` }}
408
+ />
409
+ </div>
410
+ {failedCount !== undefined && failedCount > 0 && (
411
+ <p className="text-[11px] text-amber-600">
412
+ {failedCount} file{failedCount !== 1 ? "s" : ""} failed
413
+ </p>
414
+ )}
415
+ </div>
416
+ );
417
+ }
418
+
419
+ // ============================================
420
+ // Create Backup Section (V2 — client-side)
421
+ // ============================================
422
+
423
+ type ExportPhase = "idle" | "fetching" | "downloading" | "zipping" | "done" | "error";
424
+
425
+ function CreateBackupSection({
426
+ status,
427
+ statusLoading,
428
+ statusError,
429
+ }: {
430
+ status: BackupStatus | null;
431
+ statusLoading: boolean;
432
+ statusError: string | null;
433
+ }) {
434
+ const [phase, setPhase] = useState<ExportPhase>("idle");
435
+ const [exportError, setExportError] = useState<string | null>(null);
436
+ const [downloadProgress, setDownloadProgress] = useState<PoolProgress | null>(null);
437
+ const [zipProgress, setZipProgress] = useState(0);
438
+ const abortRef = useRef<AbortController | null>(null);
439
+
440
+ const handleCreateBackup = async () => {
441
+ setPhase("fetching");
442
+ setExportError(null);
443
+ setDownloadProgress(null);
444
+ setZipProgress(0);
445
+ abortRef.current = new AbortController();
446
+
447
+ try {
448
+ // ── Phase 1: Fetch export data (JSON) from Vercel ──
449
+ const res = await fetch("/api/admin/backups/prepare-export", {
450
+ signal: abortRef.current.signal,
451
+ });
452
+
453
+ if (!res.ok) {
454
+ const data = await res.json().catch(() => ({ error: "Export failed" }));
455
+ throw new Error(data.error || `Export failed (${res.status})`);
456
+ }
457
+
458
+ const exportData: ExportData = await res.json();
459
+
460
+ // ── Phase 2: Build ZIP in browser ──
461
+ const JSZip = (await import("jszip")).default;
462
+ const zip = new JSZip();
463
+
464
+ // Add manifest
465
+ zip.file("manifest.json", JSON.stringify(exportData.manifest, null, 2));
466
+
467
+ // Add Sanity documents
468
+ zip.file("sanity/pages.json", JSON.stringify(exportData.documents.pages, null, 2));
469
+
470
+ if (exportData.documents.siteSettings) {
471
+ zip.file("sanity/settings.json", JSON.stringify(exportData.documents.siteSettings, null, 2));
472
+ }
473
+ if (exportData.documents.siteStyles) {
474
+ zip.file("sanity/styles.json", JSON.stringify(exportData.documents.siteStyles, null, 2));
475
+ }
476
+ if (exportData.documents.assetRegistry) {
477
+ zip.file("sanity/asset-registry.json", JSON.stringify(exportData.documents.assetRegistry, null, 2));
478
+ }
479
+ if (exportData.documents.customSections.length > 0) {
480
+ zip.file("sanity/custom-sections.json", JSON.stringify(exportData.documents.customSections, null, 2));
481
+ }
482
+
483
+ // ── Phase 3: Download assets directly from R2 ──
484
+ let assetResult: PoolProgress | null = null;
485
+
486
+ if (
487
+ exportData.assets.r2_connected &&
488
+ exportData.assets.public_url &&
489
+ exportData.assets.files.length > 0
490
+ ) {
491
+ // L-3: ZIP bomb / runaway-export guard.
492
+ //
493
+ // The file list comes from our own Sanity-backed asset registry so it
494
+ // isn't adversarial by default, but an unusually large R2 bucket (or
495
+ // a registry row pointing at a surprisingly large object) can still
496
+ // make the browser OOM while it builds the ZIP in memory. We apply:
497
+ //
498
+ // • A soft warning (console.warn) above 500 MB — existing behaviour,
499
+ // kept so developers can spot runaway exports in dev tools.
500
+ // • A hard abort above HARD_MAX_EXPORT_BYTES (2 GB). At that scale
501
+ // JSZip's single-blob output is almost guaranteed to fail in
502
+ // practice across Chrome/Safari/Firefox, and continuing would
503
+ // just burn CPU before erroring with an opaque RangeError.
504
+ const HARD_MAX_EXPORT_BYTES = 2 * 1024 * 1024 * 1024; // 2 GB
505
+ const totalSize = exportData.assets.files.reduce((s, f) => s + f.size, 0);
506
+ if (totalSize > HARD_MAX_EXPORT_BYTES) {
507
+ throw new Error(
508
+ `Backup too large for client-side export: ${formatBytes(totalSize)} exceeds the ${formatBytes(
509
+ HARD_MAX_EXPORT_BYTES
510
+ )} safety cap. Please contact support for a server-side migration path.`
511
+ );
512
+ }
513
+ if (totalSize > 500 * 1024 * 1024) {
514
+ console.warn(
515
+ `[Backup] Large backup: ${formatBytes(totalSize)}. Browser memory may be affected.`
516
+ );
517
+ }
518
+
519
+ setPhase("downloading");
520
+ assetResult = await downloadAssetsToZip(
521
+ exportData.assets.files,
522
+ exportData.assets.public_url,
523
+ zip,
524
+ (p) => setDownloadProgress({ ...p }),
525
+ abortRef.current.signal
526
+ );
527
+
528
+ if (abortRef.current.signal.aborted) return;
529
+ }
530
+
531
+ // ── Phase 4: Generate final ZIP blob ──
532
+ setPhase("zipping");
533
+ const blob = await zip.generateAsync(
534
+ { type: "blob", compression: "DEFLATE", compressionOptions: { level: 5 } },
535
+ (meta) => setZipProgress(meta.percent)
536
+ );
537
+
538
+ // ── Phase 5: Trigger browser download ──
539
+ const date = new Date().toISOString().split("T")[0];
540
+ const filename = `andami-backup-${date}.zip`;
541
+
542
+ const url = URL.createObjectURL(blob);
543
+ const a = document.createElement("a");
544
+ a.href = url;
545
+ a.download = filename;
546
+ document.body.appendChild(a);
547
+ a.click();
548
+ document.body.removeChild(a);
549
+ URL.revokeObjectURL(url);
550
+
551
+ setPhase("done");
552
+ setDownloadProgress(assetResult);
553
+ setTimeout(() => setPhase("idle"), 8000);
554
+ } catch (err) {
555
+ if (abortRef.current?.signal.aborted) {
556
+ setPhase("idle");
557
+ return;
558
+ }
559
+ setExportError(err instanceof Error ? err.message : "Export failed");
560
+ setPhase("error");
561
+ }
562
+ };
563
+
564
+ const handleCancel = () => {
565
+ abortRef.current?.abort();
566
+ setPhase("idle");
567
+ };
568
+
569
+ const isWorking = phase === "fetching" || phase === "downloading" || phase === "zipping";
570
+
571
+ return (
572
+ <div className="border rounded-xl bg-white border-blue-200/60 ring-1 ring-blue-100/40">
573
+ {/* Header */}
574
+ <div className="flex items-center gap-3 px-4 py-3 border-b border-neutral-100">
575
+ <div className="w-8 h-8 rounded-lg bg-blue-50 flex items-center justify-center shrink-0">
576
+ <BackupIcon size={16} />
577
+ </div>
578
+ <h2 className="text-sm font-semibold text-neutral-900">Create Backup</h2>
579
+ </div>
580
+
581
+ {/* Content */}
582
+ <div className="px-4 py-3 space-y-3">
583
+ {/* Status loading */}
584
+ {statusLoading && (
585
+ <div className="flex items-center gap-2 text-xs text-neutral-400">
586
+ <SpinnerIcon size={14} />
587
+ Loading site status...
588
+ </div>
589
+ )}
590
+
591
+ {/* Status error */}
592
+ {statusError && (
593
+ <div className="p-2.5 rounded-lg bg-red-50 border border-red-200">
594
+ <p className="text-xs text-red-700">{statusError}</p>
595
+ </div>
596
+ )}
597
+
598
+ {/* Status card */}
599
+ {status && !statusLoading && (
600
+ <div className="space-y-3">
601
+ {/* R2 warning */}
602
+ {!status.r2_connected && (
603
+ <div className="flex items-start gap-2 p-2.5 rounded-lg bg-amber-50 border border-amber-200">
604
+ <span className="text-amber-600 mt-0.5 shrink-0"><AlertIcon size={14} /></span>
605
+ <p className="text-xs text-amber-700">
606
+ R2 storage is not connected. The backup will contain Sanity data only (no assets).
607
+ </p>
608
+ </div>
609
+ )}
610
+
611
+ {/* Stats grid */}
612
+ <div className="grid grid-cols-2 gap-2">
613
+ <div className="p-2.5 rounded-lg bg-neutral-50 border border-neutral-100">
614
+ <p className="text-[10px] uppercase tracking-wider text-neutral-400 font-medium mb-0.5">Documents</p>
615
+ <p className="text-sm font-semibold text-neutral-900">{totalDocs(status)}</p>
616
+ <p className="text-[11px] text-neutral-500 mt-0.5">
617
+ {status.document_counts.pages} pages · {status.document_counts.custom_sections} sections
618
+ </p>
619
+ </div>
620
+ <div className="p-2.5 rounded-lg bg-neutral-50 border border-neutral-100">
621
+ <p className="text-[10px] uppercase tracking-wider text-neutral-400 font-medium mb-0.5">Assets</p>
622
+ <p className="text-sm font-semibold text-neutral-900">
623
+ {status.r2_connected ? status.asset_count.toLocaleString() : "—"}
624
+ </p>
625
+ <p className="text-[11px] text-neutral-500 mt-0.5">
626
+ {status.r2_connected ? formatBytes(status.asset_size_bytes) : "R2 not connected"}
627
+ </p>
628
+ </div>
629
+ </div>
630
+
631
+ {/* Export error */}
632
+ {phase === "error" && exportError && (
633
+ <div className="p-2.5 rounded-lg bg-red-50 border border-red-200">
634
+ <p className="text-xs text-red-700">{exportError}</p>
635
+ </div>
636
+ )}
637
+
638
+ {/* Progress indicators */}
639
+ {phase === "fetching" && (
640
+ <ProgressBar phase="Fetching site data..." progress={15} />
641
+ )}
642
+
643
+ {phase === "downloading" && downloadProgress && (
644
+ <ProgressBar
645
+ phase={`Downloading assets... ${downloadProgress.completed} / ${downloadProgress.total} files`}
646
+ progress={downloadProgress.total > 0
647
+ ? (downloadProgress.completed / downloadProgress.total) * 100
648
+ : 0
649
+ }
650
+ totalFiles={downloadProgress.total}
651
+ bytesTransferred={downloadProgress.bytesTransferred}
652
+ totalBytes={downloadProgress.totalBytes}
653
+ failedCount={downloadProgress.failed.length}
654
+ />
655
+ )}
656
+
657
+ {phase === "zipping" && (
658
+ <ProgressBar phase="Building ZIP archive..." progress={zipProgress} />
659
+ )}
660
+
661
+ {/* Done message */}
662
+ {phase === "done" && (
663
+ <div className="flex items-start gap-2 p-2.5 rounded-lg bg-green-50 border border-green-200">
664
+ <span className="text-green-600 mt-0.5 shrink-0"><CheckIcon size={14} /></span>
665
+ <div>
666
+ <p className="text-xs text-green-700 font-medium">Backup downloaded successfully</p>
667
+ {downloadProgress && downloadProgress.failed.length > 0 && (
668
+ <p className="text-[11px] text-amber-600 mt-0.5">
669
+ {downloadProgress.completed - downloadProgress.failed.length} of {downloadProgress.total} assets included.{" "}
670
+ {downloadProgress.failed.length} failed to download.
671
+ </p>
672
+ )}
673
+ </div>
674
+ </div>
675
+ )}
676
+
677
+ {/* Action buttons */}
678
+ <div className="flex items-center gap-2">
679
+ {!isWorking && phase !== "done" && (
680
+ <button
681
+ onClick={handleCreateBackup}
682
+ className="inline-flex items-center gap-2 rounded-lg bg-blue-600 text-white text-sm font-medium px-4 py-2 hover:bg-blue-700 transition-colors"
683
+ >
684
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
685
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
686
+ <polyline points="7 10 12 15 17 10" />
687
+ <line x1="12" y1="15" x2="12" y2="3" />
688
+ </svg>
689
+ Create Backup
690
+ </button>
691
+ )}
692
+
693
+ {isWorking && (
694
+ <button
695
+ onClick={handleCancel}
696
+ className="inline-flex items-center gap-2 rounded-lg border border-neutral-200 bg-white text-neutral-600 text-sm font-medium px-4 py-2 hover:bg-neutral-50 transition-colors"
697
+ >
698
+ Cancel
699
+ </button>
700
+ )}
701
+ </div>
702
+ </div>
703
+ )}
704
+ </div>
705
+ </div>
706
+ );
707
+ }
708
+
709
+ // ============================================
710
+ // Restore Section (V2 — client-side assets)
711
+ // ============================================
712
+
713
+ type RestorePhase =
714
+ | "idle"
715
+ | "preview"
716
+ | "confirming"
717
+ | "restoring_docs"
718
+ | "uploading_assets"
719
+ | "done"
720
+ | "error";
721
+
722
+ function RestoreSection({
723
+ status,
724
+ onRestoreSuccess,
725
+ }: {
726
+ status: BackupStatus | null;
727
+ /**
728
+ * Invoked after a restore finishes successfully (documents restored,
729
+ * asset upload phase complete or unneeded). Used by the parent to
730
+ * refetch `/api/admin/backups/status` so the "Create Backup" card
731
+ * immediately reflects the new doc/asset counts — otherwise the
732
+ * admin sees stale pre-restore numbers and panics (M-7).
733
+ */
734
+ onRestoreSuccess?: () => void;
735
+ }) {
736
+ const [phase, setPhase] = useState<RestorePhase>("idle");
737
+ const [file, setFile] = useState<File | null>(null);
738
+ const [zipInstance, setZipInstance] = useState<import("jszip") | null>(null);
739
+ const [manifest, setManifest] = useState<BackupManifest | null>(null);
740
+ const [parseError, setParseError] = useState<string | null>(null);
741
+ const [confirmText, setConfirmText] = useState("");
742
+ const [restoreError, setRestoreError] = useState<string | null>(null);
743
+ const [dragOver, setDragOver] = useState(false);
744
+ const fileInputRef = useRef<HTMLInputElement>(null);
745
+ const abortRef = useRef<AbortController | null>(null);
746
+
747
+ // Result state
748
+ const [docResult, setDocResult] = useState<RestoreDataResult | null>(null);
749
+ const [uploadProgress, setUploadProgress] = useState<PoolProgress | null>(null);
750
+ const [totalDuration, setTotalDuration] = useState(0);
751
+
752
+ // Count assets in the ZIP
753
+ const [assetCount, setAssetCount] = useState(0);
754
+
755
+ // ── Upload-phase tracking for M-8 (TTL warning) ──
756
+ // We record when the upload phase started and the TTL the server granted
757
+ // to every presigned URL. If the upload hasn't finished by the time we're
758
+ // past ~80% of the TTL budget, we surface a warning so the admin can
759
+ // decide whether to abort and re-generate fresh URLs.
760
+ const [uploadStartedAt, setUploadStartedAt] = useState<number | null>(null);
761
+ const [uploadTtlSeconds, setUploadTtlSeconds] = useState<number | null>(null);
762
+ const [uploadNearExpiry, setUploadNearExpiry] = useState(false);
763
+
764
+ // ── L-12: user-tunable upload concurrency ──
765
+ //
766
+ // Default is 4 parallel workers — a reasonable sweet spot on home fiber
767
+ // that avoids saturating the admin's connection while keeping the
768
+ // restore under typical TTL budgets. Admins on faster pipes can push
769
+ // this up to 8, slower connections can drop to 2. The value persists
770
+ // in localStorage so the choice survives between restores. Any value
771
+ // outside the 2..8 range is normalized on read.
772
+ const CONCURRENCY_STORAGE_KEY = "andami.backup.restore.uploadConcurrency";
773
+ const DEFAULT_CONCURRENCY = 4;
774
+ const clampConcurrency = (n: number) =>
775
+ Math.max(2, Math.min(8, Math.round(n)));
776
+ const [uploadConcurrency, setUploadConcurrency] = useState<number>(
777
+ DEFAULT_CONCURRENCY
778
+ );
779
+
780
+ // Hydrate the persisted concurrency once after mount (avoids SSR / hydration
781
+ // mismatch — localStorage is window-only).
782
+ useEffect(() => {
783
+ try {
784
+ const raw =
785
+ typeof window !== "undefined" &&
786
+ window.localStorage.getItem(CONCURRENCY_STORAGE_KEY);
787
+ if (raw) {
788
+ const parsed = Number.parseInt(raw, 10);
789
+ if (Number.isFinite(parsed)) {
790
+ setUploadConcurrency(clampConcurrency(parsed));
791
+ }
792
+ }
793
+ } catch {
794
+ // localStorage can throw in private-mode Safari, etc. Safe to ignore —
795
+ // we just fall back to the default.
796
+ }
797
+ }, []);
798
+
799
+ const persistConcurrency = useCallback((n: number) => {
800
+ const clamped = clampConcurrency(n);
801
+ setUploadConcurrency(clamped);
802
+ try {
803
+ if (typeof window !== "undefined") {
804
+ window.localStorage.setItem(CONCURRENCY_STORAGE_KEY, String(clamped));
805
+ }
806
+ } catch {
807
+ // See comment above — non-fatal.
808
+ }
809
+ }, []);
810
+
811
+ const siteName = getSiteConfig().name;
812
+
813
+ // ── Warn the user if they try to close the tab mid-restore ──
814
+ // Closing during `restoring_docs` leaves Sanity mid-wipe. Closing during
815
+ // `uploading_assets` leaves R2 partially populated. Both produce a broken
816
+ // site with no "resume" path. The beforeunload prompt gives the admin a
817
+ // last chance to stay on the page.
818
+ //
819
+ // M-6 explicitly covers both phases because the upload phase is where
820
+ // most of the clock time lives — it's the phase where an admin is most
821
+ // likely to get impatient and close the tab.
822
+ useEffect(() => {
823
+ const isDestructive =
824
+ phase === "restoring_docs" || phase === "uploading_assets";
825
+ if (!isDestructive) return;
826
+ const message =
827
+ phase === "restoring_docs"
828
+ ? "Restore in progress — closing this page will leave the site in a broken state."
829
+ : "Asset upload in progress — closing this page will leave the site with broken images.";
830
+ const handler = (e: BeforeUnloadEvent) => {
831
+ e.preventDefault();
832
+ // Modern browsers show their own generic text, but some still fall
833
+ // back to `returnValue` — so we populate it with the phase-specific
834
+ // message for the handful of environments that still display it.
835
+ e.returnValue = message;
836
+ return message;
837
+ };
838
+ window.addEventListener("beforeunload", handler);
839
+ return () => window.removeEventListener("beforeunload", handler);
840
+ }, [phase]);
841
+
842
+ // ── M-8: Poll TTL budget during upload phase ──
843
+ // Every 5s while uploading, check whether we've consumed >80% of the TTL
844
+ // granted by the server. If so, set `uploadNearExpiry` so the UI shows
845
+ // a banner suggesting the admin either waits patiently or cancels to
846
+ // retry with a fresh batch of URLs.
847
+ useEffect(() => {
848
+ if (phase !== "uploading_assets" || !uploadStartedAt || !uploadTtlSeconds) {
849
+ return;
850
+ }
851
+ const check = () => {
852
+ const elapsedSec = (Date.now() - uploadStartedAt) / 1000;
853
+ if (elapsedSec > uploadTtlSeconds * 0.8) {
854
+ setUploadNearExpiry(true);
855
+ }
856
+ };
857
+ check();
858
+ const id = setInterval(check, 5000);
859
+ return () => clearInterval(id);
860
+ }, [phase, uploadStartedAt, uploadTtlSeconds]);
861
+
862
+ // ── Parse ZIP and extract manifest ──
863
+ const handleFile = useCallback(async (f: File) => {
864
+ setFile(f);
865
+ setParseError(null);
866
+ setManifest(null);
867
+ setZipInstance(null);
868
+ setDocResult(null);
869
+ setUploadProgress(null);
870
+ setRestoreError(null);
871
+ setConfirmText("");
872
+ setAssetCount(0);
873
+
874
+ if (!f.name.endsWith(".zip")) {
875
+ setParseError("Please select a .zip backup file.");
876
+ setPhase("idle");
877
+ return;
878
+ }
879
+
880
+ try {
881
+ const JSZip = (await import("jszip")).default;
882
+ const zip = await JSZip.loadAsync(f);
883
+ const manifestEntry = zip.file("manifest.json");
884
+
885
+ if (!manifestEntry) {
886
+ setParseError("Invalid backup: manifest.json not found in the ZIP archive.");
887
+ setPhase("idle");
888
+ return;
889
+ }
890
+
891
+ const manifestText = await manifestEntry.async("string");
892
+ const parsed = JSON.parse(manifestText);
893
+
894
+ if (parsed.version !== 1) {
895
+ setParseError(`Unsupported backup version: ${parsed.version}. Expected version 1.`);
896
+ setPhase("idle");
897
+ return;
898
+ }
899
+
900
+ if (!parsed.documents || !parsed.assets) {
901
+ setParseError("Invalid backup: manifest is missing required sections.");
902
+ setPhase("idle");
903
+ return;
904
+ }
905
+
906
+ // Count asset files in the ZIP
907
+ let assets = 0;
908
+ zip.forEach((path: string) => {
909
+ if (path.startsWith("assets/") && !path.endsWith("/")) assets++;
910
+ });
911
+
912
+ setZipInstance(zip);
913
+ setManifest(parsed as BackupManifest);
914
+ setAssetCount(assets);
915
+ setPhase("preview");
916
+ } catch (err) {
917
+ setParseError(
918
+ err instanceof Error
919
+ ? `Failed to read backup: ${err.message}`
920
+ : "Failed to read the backup file."
921
+ );
922
+ setPhase("idle");
923
+ }
924
+ }, []);
925
+
926
+ // ── Drag & Drop ──
927
+ const handleDrop = useCallback((e: React.DragEvent) => {
928
+ e.preventDefault();
929
+ setDragOver(false);
930
+ const f = e.dataTransfer.files?.[0];
931
+ if (f) handleFile(f);
932
+ }, [handleFile]);
933
+
934
+ const handleDragOver = useCallback((e: React.DragEvent) => {
935
+ e.preventDefault();
936
+ setDragOver(true);
937
+ }, []);
938
+
939
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
940
+ e.preventDefault();
941
+ setDragOver(false);
942
+ }, []);
943
+
944
+ const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
945
+ const f = e.target.files?.[0];
946
+ if (f) handleFile(f);
947
+ }, [handleFile]);
948
+
949
+ // ── V2 Restore ──
950
+ const handleRestore = async () => {
951
+ if (!zipInstance || !manifest) return;
952
+ const startTime = Date.now();
953
+ setPhase("restoring_docs");
954
+ setRestoreError(null);
955
+ setDocResult(null);
956
+ setUploadProgress(null);
957
+ setUploadStartedAt(null);
958
+ setUploadTtlSeconds(null);
959
+ setUploadNearExpiry(false);
960
+ abortRef.current = new AbortController();
961
+
962
+ try {
963
+ // ── Phase 1: Extract Sanity documents from ZIP ──
964
+ const extractJson = async (path: string): Promise<unknown | null> => {
965
+ const entry = zipInstance.file(path);
966
+ if (!entry) return null;
967
+ const text = await entry.async("string");
968
+ return JSON.parse(text);
969
+ };
970
+
971
+ const documents = {
972
+ pages: (await extractJson("sanity/pages.json")) ?? [],
973
+ siteSettings: await extractJson("sanity/settings.json"),
974
+ siteStyles: await extractJson("sanity/styles.json"),
975
+ assetRegistry: await extractJson("sanity/asset-registry.json"),
976
+ customSections: (await extractJson("sanity/custom-sections.json")) ?? [],
977
+ };
978
+
979
+ // Collect the asset keys present in the ZIP. The server uses this
980
+ // list to pre-sign upload URLs in a single invocation, which keeps
981
+ // Vercel invocations flat regardless of how many assets we restore
982
+ // (no more N calls to /api/admin/r2/upload-url, no 60 req/min cap).
983
+ const assetEntries: { key: string; size: number }[] = [];
984
+ zipInstance.forEach((relativePath, zipEntry) => {
985
+ if (relativePath.startsWith("assets/") && !relativePath.endsWith("/") && !zipEntry.dir) {
986
+ const key = relativePath.replace(/^assets\//, "");
987
+ // Size is available on the zip entry's uncompressed stats.
988
+ const rawSize =
989
+ (zipEntry as unknown as { _data?: { uncompressedSize?: number } })._data?.uncompressedSize ?? 0;
990
+ assetEntries.push({ key, size: rawSize });
991
+ }
992
+ });
993
+
994
+ // Determine if we need to wipe R2 (only if there are assets to upload)
995
+ const hasAssets = assetEntries.length > 0;
996
+ const r2Connected = status?.r2_connected ?? false;
997
+ const shouldWipeR2 = hasAssets && r2Connected;
998
+
999
+ // ── Phase 2: Send documents to Vercel (JSON only) ──
1000
+ // M-8: request a generous TTL when there are many assets. The server
1001
+ // clamps to a safe range (5 min..1 h) so this is a hint, not a demand.
1002
+ // Rough heuristic: 30 min baseline + 30s per 10 assets, capped to 1 h.
1003
+ const requestedTtlSec = Math.min(
1004
+ 3600,
1005
+ Math.max(1800, 1800 + Math.ceil(assetEntries.length / 10) * 30)
1006
+ );
1007
+
1008
+ const res = await fetch("/api/admin/backups/restore-data", {
1009
+ method: "POST",
1010
+ headers: {
1011
+ ...csrfHeaders(),
1012
+ "Content-Type": "application/json",
1013
+ },
1014
+ body: JSON.stringify({
1015
+ manifest,
1016
+ documents,
1017
+ wipe_r2: shouldWipeR2,
1018
+ assets: r2Connected ? assetEntries : [],
1019
+ upload_ttl_seconds: requestedTtlSec,
1020
+ }),
1021
+ signal: abortRef.current.signal,
1022
+ });
1023
+
1024
+ const data = await res.json();
1025
+
1026
+ if (!res.ok) {
1027
+ throw new Error(data.error || `Restore failed (${res.status})`);
1028
+ }
1029
+
1030
+ const result = data as RestoreDataResult;
1031
+ setDocResult(result);
1032
+
1033
+ // ── Phase 3: Upload assets directly to R2 via presigned URLs ──
1034
+ if (hasAssets && r2Connected) {
1035
+ setPhase("uploading_assets");
1036
+ // Record the upload deadline so the TTL-warning effect can fire.
1037
+ setUploadStartedAt(Date.now());
1038
+ setUploadTtlSeconds(result.upload_ttl_seconds ?? null);
1039
+
1040
+ const uploads = result.upload_urls ?? [];
1041
+ if (uploads.length === 0) {
1042
+ // Server couldn't pre-sign (R2 config missing or signing failed).
1043
+ // Mark all assets as failed rather than silently skipping them.
1044
+ setUploadProgress({
1045
+ completed: assetEntries.length,
1046
+ total: assetEntries.length,
1047
+ bytesTransferred: 0,
1048
+ totalBytes: assetEntries.reduce((s, a) => s + (a.size || 0), 0),
1049
+ failed: assetEntries.map((a) => a.key),
1050
+ });
1051
+ } else {
1052
+ const uploadResult = await uploadAssetsFromZip(
1053
+ zipInstance,
1054
+ uploads,
1055
+ (p) => setUploadProgress({ ...p }),
1056
+ abortRef.current.signal,
1057
+ // L-12: user-configurable concurrency (persisted in localStorage)
1058
+ uploadConcurrency
1059
+ );
1060
+
1061
+ if (abortRef.current.signal.aborted) return;
1062
+ setUploadProgress(uploadResult);
1063
+ }
1064
+ }
1065
+
1066
+ setTotalDuration(Date.now() - startTime);
1067
+ setPhase("done");
1068
+ // M-7: refresh the "Create Backup" status card so its doc/asset
1069
+ // counts reflect the freshly-restored state. Fire-and-forget — we
1070
+ // don't block the "done" UI on this.
1071
+ onRestoreSuccess?.();
1072
+ } catch (err) {
1073
+ if (abortRef.current?.signal.aborted) {
1074
+ setPhase("idle");
1075
+ return;
1076
+ }
1077
+ setRestoreError(err instanceof Error ? err.message : "Restore failed");
1078
+ setPhase("error");
1079
+ }
1080
+ };
1081
+
1082
+ const handleCancel = () => {
1083
+ abortRef.current?.abort();
1084
+ setPhase("idle");
1085
+ };
1086
+
1087
+ // ── Reset ──
1088
+ const handleReset = () => {
1089
+ setPhase("idle");
1090
+ setFile(null);
1091
+ setZipInstance(null);
1092
+ setManifest(null);
1093
+ setParseError(null);
1094
+ setConfirmText("");
1095
+ setDocResult(null);
1096
+ setUploadProgress(null);
1097
+ setRestoreError(null);
1098
+ setAssetCount(0);
1099
+ setTotalDuration(0);
1100
+ setUploadStartedAt(null);
1101
+ setUploadTtlSeconds(null);
1102
+ setUploadNearExpiry(false);
1103
+ if (fileInputRef.current) fileInputRef.current.value = "";
1104
+ };
1105
+
1106
+ // M-1: exact match only. GitHub-style "type the literal site name".
1107
+ // No trim, no lowercasing — if the admin can't get the casing right,
1108
+ // they shouldn't be firing a destructive, irreversible operation.
1109
+ // If `siteName` ever resolves to an empty string (misconfigured instance)
1110
+ // the match would trivially pass on an empty textbox, so we also require
1111
+ // a non-empty confirmText. A sensible fallback ("CONFIRM") is used when
1112
+ // the site name is missing so the admin still has to type something.
1113
+ const hasSiteName = typeof siteName === "string" && siteName.length > 0;
1114
+ const confirmTarget = hasSiteName ? siteName : "CONFIRM";
1115
+ const confirmationMatch =
1116
+ confirmText.length > 0 && confirmText === confirmTarget;
1117
+
1118
+ return (
1119
+ <div className="border rounded-xl bg-white border-red-200/60 ring-1 ring-red-100/40">
1120
+ {/* Header */}
1121
+ <div className="flex items-center gap-3 px-4 py-3 border-b border-neutral-100">
1122
+ <div className="w-8 h-8 rounded-lg bg-red-50 flex items-center justify-center shrink-0">
1123
+ <RestoreIcon size={16} />
1124
+ </div>
1125
+ <div className="flex items-center gap-2 min-w-0 flex-1">
1126
+ <h2 className="text-sm font-semibold text-neutral-900">Restore from Backup</h2>
1127
+ <span className="inline-flex items-center text-[9px] font-semibold uppercase tracking-wider text-red-600 bg-red-50 px-1.5 py-0.5 rounded-full">
1128
+ Danger
1129
+ </span>
1130
+ </div>
1131
+ </div>
1132
+
1133
+ <div className="px-4 py-3 space-y-3">
1134
+ {/* R2 not connected warning */}
1135
+ {status && !status.r2_connected && (
1136
+ <div className="flex items-start gap-2 p-2.5 rounded-lg bg-amber-50 border border-amber-200">
1137
+ <span className="text-amber-600 mt-0.5 shrink-0"><AlertIcon size={14} /></span>
1138
+ <p className="text-xs text-amber-700">
1139
+ R2 storage is not connected. Only Sanity documents will be restored — assets in the backup will be skipped.
1140
+ </p>
1141
+ </div>
1142
+ )}
1143
+
1144
+ {/* ── Phase: idle — Drop zone ── */}
1145
+ {(phase === "idle") && (
1146
+ <>
1147
+ <div
1148
+ onDrop={handleDrop}
1149
+ onDragOver={handleDragOver}
1150
+ onDragLeave={handleDragLeave}
1151
+ onClick={() => fileInputRef.current?.click()}
1152
+ className={`
1153
+ flex flex-col items-center justify-center gap-2 p-6 rounded-lg border-2 border-dashed cursor-pointer
1154
+ transition-colors duration-150
1155
+ ${dragOver
1156
+ ? "border-blue-400 bg-blue-50/50"
1157
+ : "border-neutral-200 bg-neutral-50/50 hover:border-neutral-300 hover:bg-neutral-50"
1158
+ }
1159
+ `}
1160
+ >
1161
+ <span className="text-neutral-400"><UploadIcon size={28} /></span>
1162
+ <p className="text-sm text-neutral-600">
1163
+ Drop a <span className="font-medium">.zip</span> backup file here, or click to browse
1164
+ </p>
1165
+ <p className="text-[11px] text-neutral-400">Maximum file size: 500 MB</p>
1166
+ </div>
1167
+ <input
1168
+ ref={fileInputRef}
1169
+ type="file"
1170
+ accept=".zip"
1171
+ onChange={handleInputChange}
1172
+ className="hidden"
1173
+ />
1174
+
1175
+ {parseError && (
1176
+ <div className="p-2.5 rounded-lg bg-red-50 border border-red-200">
1177
+ <p className="text-xs text-red-700">{parseError}</p>
1178
+ </div>
1179
+ )}
1180
+ </>
1181
+ )}
1182
+
1183
+ {/* ── Phase: preview — Show manifest contents ── */}
1184
+ {phase === "preview" && manifest && file && (
1185
+ <div className="space-y-3">
1186
+ {/* File info */}
1187
+ <div className="flex items-center justify-between">
1188
+ <div className="flex items-center gap-2 min-w-0">
1189
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1190
+ <path d="M21 8v13H3V8" />
1191
+ <path d="M1 3h22v5H1z" />
1192
+ <path d="M10 12h4" />
1193
+ </svg>
1194
+ <span className="text-sm font-medium text-neutral-900 truncate">{file.name}</span>
1195
+ <span className="text-xs text-neutral-400 shrink-0">{formatBytes(file.size)}</span>
1196
+ </div>
1197
+ <button
1198
+ onClick={handleReset}
1199
+ className="text-[11px] text-neutral-400 hover:text-neutral-600 transition-colors px-2 py-0.5 rounded hover:bg-neutral-50"
1200
+ >
1201
+ Change file
1202
+ </button>
1203
+ </div>
1204
+
1205
+ {/* Manifest details */}
1206
+ <div className="p-3 rounded-lg bg-neutral-50 border border-neutral-100 space-y-2">
1207
+ <div className="flex items-center justify-between">
1208
+ <p className="text-[10px] uppercase tracking-wider text-neutral-400 font-medium">Backup Contents</p>
1209
+ <p className="text-[11px] text-neutral-400">v{manifest.framework_version}</p>
1210
+ </div>
1211
+
1212
+ <div className="grid grid-cols-2 gap-2">
1213
+ <div>
1214
+ <p className="text-[11px] text-neutral-500">Created</p>
1215
+ <p className="text-xs font-medium text-neutral-800">{formatDate(manifest.created_at)}</p>
1216
+ </div>
1217
+ <div>
1218
+ <p className="text-[11px] text-neutral-500">Total documents</p>
1219
+ <p className="text-xs font-medium text-neutral-800">{totalManifestDocs(manifest)}</p>
1220
+ </div>
1221
+ </div>
1222
+
1223
+ <div className="grid grid-cols-2 gap-2">
1224
+ <div>
1225
+ <p className="text-[11px] text-neutral-500">Pages</p>
1226
+ <p className="text-xs font-medium text-neutral-800">{manifest.documents.pages}</p>
1227
+ </div>
1228
+ <div>
1229
+ <p className="text-[11px] text-neutral-500">Custom sections</p>
1230
+ <p className="text-xs font-medium text-neutral-800">{manifest.documents.custom_sections}</p>
1231
+ </div>
1232
+ </div>
1233
+
1234
+ <div className="grid grid-cols-2 gap-2">
1235
+ <div>
1236
+ <p className="text-[11px] text-neutral-500">Assets in ZIP</p>
1237
+ <p className="text-xs font-medium text-neutral-800">
1238
+ {assetCount > 0
1239
+ ? `${assetCount.toLocaleString()} files`
1240
+ : manifest.assets.r2_connected
1241
+ ? `${manifest.assets.total_files.toLocaleString()} files (${formatBytes(manifest.assets.total_size_bytes)})`
1242
+ : "None (R2 was not connected)"
1243
+ }
1244
+ </p>
1245
+ </div>
1246
+ <div>
1247
+ <p className="text-[11px] text-neutral-500">Settings</p>
1248
+ <p className="text-xs font-medium text-neutral-800">
1249
+ {[
1250
+ manifest.documents.site_settings && "Site settings",
1251
+ manifest.documents.site_styles && "Styles",
1252
+ manifest.documents.asset_registry && "Asset registry",
1253
+ ].filter(Boolean).join(", ") || "None"}
1254
+ </p>
1255
+ </div>
1256
+ </div>
1257
+ </div>
1258
+
1259
+ {/* Proceed to confirmation */}
1260
+ <button
1261
+ onClick={() => setPhase("confirming")}
1262
+ className="inline-flex items-center gap-2 rounded-lg bg-red-600 text-white text-sm font-medium px-4 py-2 hover:bg-red-700 transition-colors"
1263
+ >
1264
+ Continue to Restore
1265
+ </button>
1266
+ </div>
1267
+ )}
1268
+
1269
+ {/* ── Phase: confirming — Danger confirmation ── */}
1270
+ {phase === "confirming" && manifest && (
1271
+ <div className="space-y-3">
1272
+ <div className="p-3 rounded-lg bg-red-50 border-2 border-red-300 space-y-2">
1273
+ <div className="flex items-start gap-2">
1274
+ <span className="text-red-600 mt-0.5 shrink-0"><AlertIcon size={16} /></span>
1275
+ <div>
1276
+ <p className="text-sm font-semibold text-red-800">This will replace ALL existing content and assets</p>
1277
+ <p className="text-xs text-red-700 mt-1">
1278
+ All current pages, custom sections, site settings, styles, and assets will be permanently deleted
1279
+ and replaced with the contents of this backup. This action cannot be undone.
1280
+ </p>
1281
+ </div>
1282
+ </div>
1283
+ </div>
1284
+
1285
+ {/* L-12: upload concurrency selector. Only meaningful when R2
1286
+ is connected AND the backup carries assets — otherwise it
1287
+ has nothing to do and would just clutter the confirm screen. */}
1288
+ {assetCount > 0 && status?.r2_connected && (
1289
+ <div>
1290
+ <label className="block text-xs font-medium text-neutral-600 mb-1">
1291
+ Upload concurrency{" "}
1292
+ <span className="text-neutral-400 font-normal">
1293
+ (parallel workers)
1294
+ </span>
1295
+ </label>
1296
+ <div className="flex items-center gap-3">
1297
+ <input
1298
+ type="range"
1299
+ min={2}
1300
+ max={8}
1301
+ step={1}
1302
+ value={uploadConcurrency}
1303
+ onChange={(e) => persistConcurrency(Number(e.target.value))}
1304
+ className="flex-1 max-w-xs accent-red-600"
1305
+ />
1306
+ <span className="font-mono text-xs text-neutral-700 w-6 text-right">
1307
+ {uploadConcurrency}
1308
+ </span>
1309
+ </div>
1310
+ <p className="text-[11px] text-neutral-400 mt-1">
1311
+ Lower (2) for slower connections, higher (8) for fast
1312
+ pipes. Your choice is remembered for future restores.
1313
+ </p>
1314
+ </div>
1315
+ )}
1316
+
1317
+ <div>
1318
+ <label className="block text-xs font-medium text-neutral-600 mb-1">
1319
+ Type{" "}
1320
+ <span className="font-mono font-bold text-neutral-900">
1321
+ {confirmTarget}
1322
+ </span>{" "}
1323
+ to confirm (exact match, case-sensitive)
1324
+ </label>
1325
+ <input
1326
+ type="text"
1327
+ value={confirmText}
1328
+ onChange={(e) => setConfirmText(e.target.value)}
1329
+ placeholder={confirmTarget}
1330
+ spellCheck={false}
1331
+ autoCapitalize="off"
1332
+ autoCorrect="off"
1333
+ className="w-full max-w-xs rounded-lg border border-neutral-200 bg-white px-3 py-2 text-sm text-neutral-900 placeholder:text-neutral-300 focus:outline-none focus:ring-2 focus:ring-red-300 focus:border-red-400 font-mono"
1334
+ autoFocus
1335
+ />
1336
+ {!hasSiteName && (
1337
+ <div className="mt-2 p-2 rounded-md bg-amber-50 border border-amber-200">
1338
+ <p className="text-[11px] text-amber-800 font-medium">
1339
+ Site name missing from <span className="font-mono">site.config.ts</span>
1340
+ </p>
1341
+ <p className="text-[11px] text-amber-700 mt-0.5">
1342
+ We couldn&apos;t detect a site name to use as the confirmation phrase, so you must type the literal word <span className="font-mono font-semibold">CONFIRM</span> instead. Adding <span className="font-mono">name: &quot;…&quot;</span> to your instance config will restore the stricter per-site confirmation going forward.
1343
+ </p>
1344
+ </div>
1345
+ )}
1346
+ </div>
1347
+
1348
+ <div className="flex items-center gap-2">
1349
+ <button
1350
+ onClick={handleRestore}
1351
+ disabled={!confirmationMatch}
1352
+ className="inline-flex items-center gap-2 rounded-lg bg-red-600 text-white text-sm font-medium px-4 py-2 hover:bg-red-700 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
1353
+ >
1354
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1355
+ <path d="M12 11V21" />
1356
+ <polyline points="8 17 12 21 16 17" />
1357
+ <path d="M20 17.58A5 5 0 0 0 18 8h-1.26A8 8 0 1 0 4 16.25" />
1358
+ </svg>
1359
+ Restore Backup
1360
+ </button>
1361
+ <button
1362
+ onClick={() => setPhase("preview")}
1363
+ className="text-xs text-neutral-400 hover:text-neutral-600 transition-colors px-3 py-2"
1364
+ >
1365
+ Back
1366
+ </button>
1367
+ </div>
1368
+ </div>
1369
+ )}
1370
+
1371
+ {/* ── Phase: restoring_docs — Sanity restore progress ── */}
1372
+ {phase === "restoring_docs" && (
1373
+ <div className="space-y-3 py-4">
1374
+ <ProgressBar phase="Restoring documents..." progress={40} />
1375
+ <p className="text-[11px] text-neutral-400">
1376
+ Wiping existing content and restoring from backup. Do not close this page.
1377
+ </p>
1378
+ </div>
1379
+ )}
1380
+
1381
+ {/* ── Phase: uploading_assets — R2 upload progress ── */}
1382
+ {phase === "uploading_assets" && uploadProgress && (
1383
+ <div className="space-y-3 py-4">
1384
+ {/* Doc phase complete indicator */}
1385
+ <div className="flex items-center gap-2 text-xs text-green-700">
1386
+ <CheckIcon size={12} />
1387
+ <span>Documents restored</span>
1388
+ </div>
1389
+
1390
+ <ProgressBar
1391
+ phase={`Uploading assets... ${uploadProgress.completed} / ${uploadProgress.total} files`}
1392
+ progress={uploadProgress.total > 0
1393
+ ? (uploadProgress.completed / uploadProgress.total) * 100
1394
+ : 0
1395
+ }
1396
+ totalFiles={uploadProgress.total}
1397
+ bytesTransferred={uploadProgress.bytesTransferred}
1398
+ totalBytes={uploadProgress.totalBytes}
1399
+ failedCount={uploadProgress.failed.length}
1400
+ />
1401
+
1402
+ {/* M-8: TTL warning banner — presigned URLs expire after
1403
+ `uploadTtlSeconds`; once we're past ~80% we warn the admin. */}
1404
+ {uploadNearExpiry && uploadTtlSeconds && (
1405
+ <div className="flex items-start gap-2 p-2.5 rounded-lg bg-amber-50 border border-amber-200">
1406
+ <span className="text-amber-600 mt-0.5 shrink-0">
1407
+ <AlertIcon size={14} />
1408
+ </span>
1409
+ <div>
1410
+ <p className="text-xs text-amber-800 font-medium">
1411
+ Upload taking longer than expected
1412
+ </p>
1413
+ <p className="text-[11px] text-amber-700 mt-0.5">
1414
+ The presigned upload URLs expire {Math.round(uploadTtlSeconds / 60)} minutes after they were generated.
1415
+ Remaining files may fail to upload — cancel and retry to get a fresh batch.
1416
+ </p>
1417
+ </div>
1418
+ </div>
1419
+ )}
1420
+
1421
+ <button
1422
+ onClick={handleCancel}
1423
+ className="text-xs text-neutral-400 hover:text-neutral-600 transition-colors px-2 py-1 rounded hover:bg-neutral-50"
1424
+ >
1425
+ Cancel asset upload
1426
+ </button>
1427
+
1428
+ <p className="text-[11px] text-neutral-400">
1429
+ Uploading assets directly to R2. Documents have already been restored.
1430
+ </p>
1431
+ </div>
1432
+ )}
1433
+
1434
+ {/* ── Phase: done — Result ── */}
1435
+ {phase === "done" && docResult && (
1436
+ <div className="space-y-3">
1437
+ <div className="flex items-start gap-2 p-3 rounded-lg bg-green-50 border border-green-200">
1438
+ <span className="text-green-600 mt-0.5 shrink-0"><CheckIcon size={16} /></span>
1439
+ <div>
1440
+ <p className="text-sm font-semibold text-green-800">Restore completed successfully</p>
1441
+ <p className="text-xs text-green-700 mt-0.5">
1442
+ Completed in {(totalDuration / 1000).toFixed(1)}s
1443
+ </p>
1444
+ </div>
1445
+ </div>
1446
+
1447
+ {/* Result stats */}
1448
+ <div className="p-3 rounded-lg bg-neutral-50 border border-neutral-100 space-y-2">
1449
+ <p className="text-[10px] uppercase tracking-wider text-neutral-400 font-medium">Results</p>
1450
+
1451
+ <div className="grid grid-cols-2 gap-2">
1452
+ <div>
1453
+ <p className="text-[11px] text-neutral-500">Documents restored</p>
1454
+ <p className="text-xs font-medium text-neutral-800">
1455
+ {Object.values(docResult.sanity.restored).reduce((a, b) => a + b, 0)}
1456
+ </p>
1457
+ </div>
1458
+ <div>
1459
+ <p className="text-[11px] text-neutral-500">Documents wiped</p>
1460
+ <p className="text-xs font-medium text-neutral-800">
1461
+ {Object.values(docResult.sanity.wiped).reduce((a, b) => a + b, 0)}
1462
+ </p>
1463
+ </div>
1464
+ </div>
1465
+
1466
+ {uploadProgress && (
1467
+ <div className="grid grid-cols-2 gap-2">
1468
+ <div>
1469
+ <p className="text-[11px] text-neutral-500">Assets uploaded</p>
1470
+ <p className="text-xs font-medium text-neutral-800">
1471
+ {uploadProgress.completed - uploadProgress.failed.length} / {uploadProgress.total}
1472
+ </p>
1473
+ </div>
1474
+ {uploadProgress.failed.length > 0 && (
1475
+ <div>
1476
+ <p className="text-[11px] text-neutral-500">Assets failed</p>
1477
+ <p className="text-xs font-medium text-red-600">
1478
+ {uploadProgress.failed.length}
1479
+ </p>
1480
+ </div>
1481
+ )}
1482
+ </div>
1483
+ )}
1484
+
1485
+ {!uploadProgress && assetCount > 0 && status && !status.r2_connected && (
1486
+ <p className="text-[11px] text-amber-600">
1487
+ Assets skipped: R2 not connected
1488
+ </p>
1489
+ )}
1490
+
1491
+ {uploadProgress && uploadProgress.failed.length > 0 && (
1492
+ <details className="text-xs">
1493
+ <summary className="text-red-600 cursor-pointer hover:text-red-700">
1494
+ View failed assets ({uploadProgress.failed.length})
1495
+ </summary>
1496
+ <ul className="mt-1 space-y-0.5 text-neutral-500 max-h-32 overflow-y-auto">
1497
+ {uploadProgress.failed.map((path) => (
1498
+ <li key={path} className="font-mono text-[11px] truncate">{path}</li>
1499
+ ))}
1500
+ </ul>
1501
+ </details>
1502
+ )}
1503
+ </div>
1504
+
1505
+ <button
1506
+ onClick={handleReset}
1507
+ className="text-xs text-neutral-400 hover:text-neutral-600 transition-colors px-2 py-1 rounded hover:bg-neutral-50"
1508
+ >
1509
+ Done
1510
+ </button>
1511
+ </div>
1512
+ )}
1513
+
1514
+ {/* ── Phase: error ── */}
1515
+ {phase === "error" && (
1516
+ <div className="space-y-3">
1517
+ <div className="p-3 rounded-lg bg-red-50 border border-red-200">
1518
+ <p className="text-sm font-semibold text-red-800 mb-1">Restore failed</p>
1519
+ <p className="text-xs text-red-700">{restoreError}</p>
1520
+ </div>
1521
+ <button
1522
+ onClick={handleReset}
1523
+ className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors px-2 py-1 rounded hover:bg-neutral-50"
1524
+ >
1525
+ Try again
1526
+ </button>
1527
+ </div>
1528
+ )}
1529
+ </div>
1530
+ </div>
1531
+ );
1532
+ }
1533
+
1534
+ // ============================================
1535
+ // Main Page Component
1536
+ // ============================================
1537
+
1538
+ export default function BackupsPage() {
1539
+ const [status, setStatus] = useState<BackupStatus | null>(null);
1540
+ const [statusLoading, setStatusLoading] = useState(true);
1541
+ const [statusError, setStatusError] = useState<string | null>(null);
1542
+
1543
+ const fetchStatus = useCallback(async () => {
1544
+ try {
1545
+ const res = await fetch("/api/admin/backups/status");
1546
+ if (!res.ok) {
1547
+ const data = await res.json().catch(() => ({ error: "Failed to fetch status" }));
1548
+ throw new Error(data.error || `Status check failed (${res.status})`);
1549
+ }
1550
+ const data: BackupStatus = await res.json();
1551
+ setStatus(data);
1552
+ } catch (err) {
1553
+ setStatusError(err instanceof Error ? err.message : "Failed to load backup status");
1554
+ } finally {
1555
+ setStatusLoading(false);
1556
+ }
1557
+ }, []);
1558
+
1559
+ useEffect(() => {
1560
+ fetchStatus();
1561
+ }, [fetchStatus]);
1562
+
1563
+ return (
1564
+ <div className="space-y-4">
1565
+ {/* Header */}
1566
+ <div className="flex items-center justify-between mb-2">
1567
+ <h1 className="text-2xl font-semibold text-neutral-900">Backups</h1>
1568
+ </div>
1569
+
1570
+ <div className="max-w-xl space-y-4">
1571
+ <CreateBackupSection
1572
+ status={status}
1573
+ statusLoading={statusLoading}
1574
+ statusError={statusError}
1575
+ />
1576
+
1577
+ <RestoreSection status={status} onRestoreSuccess={fetchStatus} />
1578
+ </div>
1579
+ </div>
1580
+ );
1581
+ }