"use client"; import { useState, useEffect, useCallback, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; import { CheckCircle2, AlertCircle, Loader2, RefreshCw, Upload, Search, ChevronDown, } from "lucide-react"; // ── Types ────────────────────────────────────────────────────────────────────── interface FileEntry { key: string; filename: string; folder: string; sizeBytes: number; hetznerExists: boolean; } type MigrateStatus = "idle" | "uploading" | "done" | "error"; function formatBytes(bytes: number) { if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GB`; if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`; return `${(bytes / 1024).toFixed(1)} KB`; } // ── Page ─────────────────────────────────────────────────────────────────────── export default function MigrationPage() { const [files, setFiles] = useState([]); const [loading, setLoading] = useState(true); const [hetznerConfigured, setHetznerConfigured] = useState(true); const [fetchError, setFetchError] = useState(null); // Selection: Set of keys const [selected, setSelected] = useState>(new Set()); // Per-file migration status const [statuses, setStatuses] = useState>(new Map()); const [errors, setErrors] = useState>(new Map()); // Filters const [search, setSearch] = useState(""); const [folderFilter, setFolderFilter] = useState("all"); const [folderMenuOpen, setFolderMenuOpen] = useState(false); const [migrating, setMigrating] = useState(false); // ── Load file list ────────────────────────────────────────────────────────── const loadFiles = useCallback(async () => { setLoading(true); setFetchError(null); try { const res = await fetch("/api/admin/migration"); const data = await res.json(); if (!res.ok) throw new Error(data.error ?? "Failed to load"); setFiles(data.files); setHetznerConfigured(data.hetznerConfigured); // Pre-select all files NOT yet on Hetzner const preSelected = new Set( (data.files as FileEntry[]) .filter((f) => !f.hetznerExists) .map((f) => f.key) ); setSelected(preSelected); setStatuses(new Map()); setErrors(new Map()); } catch (e: unknown) { setFetchError(e instanceof Error ? e.message : "Failed to load file list"); } finally { setLoading(false); } }, []); useEffect(() => { loadFiles(); }, [loadFiles]); // ── Derived data ──────────────────────────────────────────────────────────── const folders = useMemo( () => ["all", ...Array.from(new Set(files.map((f) => f.folder))).sort()], [files] ); const filtered = useMemo(() => { return files.filter((f) => { const matchFolder = folderFilter === "all" || f.folder === folderFilter; const matchSearch = !search || f.filename.toLowerCase().includes(search.toLowerCase()); return matchFolder && matchSearch; }); }, [files, folderFilter, search]); const stats = useMemo(() => { const synced = files.filter((f) => f.hetznerExists).length; const total = files.length; const totalBytes = files.reduce((a, f) => a + f.sizeBytes, 0); const selectedBytes = files .filter((f) => selected.has(f.key)) .reduce((a, f) => a + f.sizeBytes, 0); return { synced, total, totalBytes, selectedBytes }; }, [files, selected]); // ── Selection helpers ─────────────────────────────────────────────────────── const toggleOne = (key: string) => { setSelected((prev) => { const next = new Set(prev); next.has(key) ? next.delete(key) : next.add(key); return next; }); }; const selectAll = () => setSelected(new Set(filtered.map((f) => f.key))); const deselectAll = () => setSelected((prev) => { const next = new Set(prev); filtered.forEach((f) => next.delete(f.key)); return next; }); const selectUnsynced = () => setSelected(new Set(filtered.filter((f) => !f.hetznerExists).map((f) => f.key))); // ── Migration ─────────────────────────────────────────────────────────────── const handleMigrate = async () => { const toMigrate = files.filter((f) => selected.has(f.key)); if (!toMigrate.length) return; setMigrating(true); // Process in batches of CONCURRENCY to upload multiple files in parallel const CONCURRENCY = 5; const migrateOne = async (file: FileEntry) => { setStatuses((prev) => new Map(prev).set(file.key, "uploading")); try { const res = await fetch("/api/admin/migration", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key: file.key }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error ?? "Upload failed"); setStatuses((prev) => new Map(prev).set(file.key, "done")); setFiles((prev) => prev.map((f) => (f.key === file.key ? { ...f, hetznerExists: true } : f)) ); } catch (e: unknown) { setStatuses((prev) => new Map(prev).set(file.key, "error")); setErrors((prev) => new Map(prev).set(file.key, e instanceof Error ? e.message : "Failed") ); } }; // Run in batches of CONCURRENCY for (let i = 0; i < toMigrate.length; i += CONCURRENCY) { await Promise.all(toMigrate.slice(i, i + CONCURRENCY).map(migrateOne)); } setMigrating(false); }; // ── Render ────────────────────────────────────────────────────────────────── if (loading) { return (
Scanning files…
); } if (fetchError) { return (
{fetchError}
); } if (!hetznerConfigured) { return (
Hetzner object storage is not configured. Set{" "} HETZNER_ENDPOINT,{" "} HETZNER_ACCESS_KEY,{" "} HETZNER_SECRET_KEY, and{" "} HETZNER_BUCKET_NAME in your environment, or configure them in Settings.
); } const allFilteredSelected = filtered.length > 0 && filtered.every((f) => selected.has(f.key)); return (
{/* Header */}

Storage Migration

{stats.total} local files · {formatBytes(stats.totalBytes)} total ·{" "} {stats.synced} already on Hetzner

{/* Toolbar */}
{/* Search */}
setSearch(e.target.value)} className="pl-8 h-8 text-sm" />
{/* Folder filter */}
{folderMenuOpen && (
{folders.map((f) => ( ))}
)}
{/* Bulk actions */}
{/* Table */}
{filtered.length === 0 && ( )} {filtered.map((file) => { const status = statuses.get(file.key); const err = errors.get(file.key); const isSelected = selected.has(file.key); return ( !migrating && toggleOne(file.key)} className={cn( "transition-colors cursor-pointer", isSelected ? "bg-primary/5 hover:bg-primary/10" : "hover:bg-muted/40" )} > {/* Checkbox */} {/* Filename */} {/* Folder */} {/* Size */} {/* Status */} ); })}
Filename Folder Size Status
No files match your filters.
e.stopPropagation()}> !migrating && toggleOne(file.key)} disabled={migrating} className="rounded accent-primary" /> {file.filename} {err && ( {err} )} {file.folder} {formatBytes(file.sizeBytes)} {status === "uploading" ? ( Uploading… ) : status === "done" || (status === undefined && file.hetznerExists) ? ( Synced ) : status === "error" ? ( Failed ) : ( Local only )}
{/* Footer action bar */} {selected.size > 0 && (
{selected.size} files selected ·{" "} {formatBytes(stats.selectedBytes)}
)}
); }