40b99f3add
Deploy / deploy (push) Successful in 3m19s
At phone width the sidebar stayed 240px wide, leaving pages a ~135px strip. Below md it is now hidden behind a menu button in the header and opens as a drawer (closes on navigation, backdrop, X or Escape); from md up the sidebar is unchanged. Page wrappers use p-4 on phones and keep p-6/p-8 from md. Shot detail: on phones the thumbnail takes the full width with the details below it instead of pushing them off screen, the action buttons wrap, and the tab bar scrolls sideways. Desktop sizing is kept as it was. Every change is behind a md: breakpoint, so the desktop layout is the same. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
412 lines
16 KiB
TypeScript
412 lines
16 KiB
TypeScript
"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<FileEntry[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [hetznerConfigured, setHetznerConfigured] = useState(true);
|
|
const [fetchError, setFetchError] = useState<string | null>(null);
|
|
|
|
// Selection: Set of keys
|
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
|
|
// Per-file migration status
|
|
const [statuses, setStatuses] = useState<Map<string, MigrateStatus>>(new Map());
|
|
const [errors, setErrors] = useState<Map<string, string>>(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<string>(
|
|
(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 (
|
|
<div className="flex items-center justify-center h-64">
|
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
<span className="ml-2 text-sm text-muted-foreground">Scanning files…</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (fetchError) {
|
|
return (
|
|
<div className="p-4 md:p-6 max-w-2xl">
|
|
<div className="flex items-center gap-2 text-sm text-red-400 rounded-md border border-red-500/20 bg-red-500/10 px-3 py-2">
|
|
<AlertCircle className="h-4 w-4 shrink-0" />
|
|
{fetchError}
|
|
</div>
|
|
<Button variant="outline" size="sm" className="mt-3" onClick={loadFiles}>
|
|
Retry
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!hetznerConfigured) {
|
|
return (
|
|
<div className="p-4 md:p-6 max-w-2xl">
|
|
<div className="flex items-center gap-2 text-sm text-amber-400 rounded-md border border-amber-500/20 bg-amber-500/10 px-3 py-2">
|
|
<AlertCircle className="h-4 w-4 shrink-0" />
|
|
Hetzner object storage is not configured. Set{" "}
|
|
<code className="font-mono text-xs">HETZNER_ENDPOINT</code>,{" "}
|
|
<code className="font-mono text-xs">HETZNER_ACCESS_KEY</code>,{" "}
|
|
<code className="font-mono text-xs">HETZNER_SECRET_KEY</code>, and{" "}
|
|
<code className="font-mono text-xs">HETZNER_BUCKET_NAME</code> in your environment,
|
|
or configure them in Settings.
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const allFilteredSelected = filtered.length > 0 && filtered.every((f) => selected.has(f.key));
|
|
|
|
return (
|
|
<div className="p-4 md:p-6 space-y-4 max-w-5xl">
|
|
{/* Header */}
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div>
|
|
<h1 className="text-xl font-semibold">Storage Migration</h1>
|
|
<p className="text-sm text-muted-foreground mt-0.5">
|
|
{stats.total} local files · {formatBytes(stats.totalBytes)} total ·{" "}
|
|
<span className="text-emerald-400">{stats.synced} already on Hetzner</span>
|
|
</p>
|
|
</div>
|
|
<Button variant="outline" size="sm" onClick={loadFiles} disabled={migrating} className="gap-1.5 shrink-0">
|
|
<RefreshCw className="h-3.5 w-3.5" />
|
|
Refresh
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Toolbar */}
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{/* Search */}
|
|
<div className="relative flex-1 min-w-[180px]">
|
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
|
|
<Input
|
|
placeholder="Search filenames…"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="pl-8 h-8 text-sm"
|
|
/>
|
|
</div>
|
|
|
|
{/* Folder filter */}
|
|
<div className="relative">
|
|
<button
|
|
onClick={() => setFolderMenuOpen((o) => !o)}
|
|
className="flex items-center gap-1.5 h-8 px-3 text-sm rounded-md border border-input bg-background hover:bg-muted transition-colors"
|
|
>
|
|
{folderFilter === "all" ? "All folders" : folderFilter}
|
|
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
|
</button>
|
|
{folderMenuOpen && (
|
|
<div className="absolute top-full left-0 mt-1 z-10 rounded-md border border-border bg-popover shadow-md py-1 min-w-[140px]">
|
|
{folders.map((f) => (
|
|
<button
|
|
key={f}
|
|
onClick={() => { setFolderFilter(f); setFolderMenuOpen(false); }}
|
|
className={cn(
|
|
"w-full text-left px-3 py-1.5 text-sm hover:bg-muted transition-colors",
|
|
folderFilter === f && "text-primary font-medium"
|
|
)}
|
|
>
|
|
{f === "all" ? "All folders" : f}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Bulk actions */}
|
|
<Button variant="outline" size="sm" className="h-8" onClick={allFilteredSelected ? deselectAll : selectAll}>
|
|
{allFilteredSelected ? "Deselect all" : "Select all"}
|
|
</Button>
|
|
<Button variant="outline" size="sm" className="h-8" onClick={selectUnsynced}>
|
|
Select unsynced
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<div className="rounded-md border border-border overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-muted/50">
|
|
<tr>
|
|
<th className="w-10 px-3 py-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={allFilteredSelected}
|
|
onChange={allFilteredSelected ? deselectAll : selectAll}
|
|
className="rounded accent-primary"
|
|
/>
|
|
</th>
|
|
<th className="text-left px-3 py-2 font-medium text-muted-foreground">Filename</th>
|
|
<th className="text-left px-3 py-2 font-medium text-muted-foreground w-24">Folder</th>
|
|
<th className="text-right px-3 py-2 font-medium text-muted-foreground w-24">Size</th>
|
|
<th className="text-right px-3 py-2 font-medium text-muted-foreground w-28">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{filtered.length === 0 && (
|
|
<tr>
|
|
<td colSpan={5} className="text-center py-10 text-muted-foreground text-sm">
|
|
No files match your filters.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
{filtered.map((file) => {
|
|
const status = statuses.get(file.key);
|
|
const err = errors.get(file.key);
|
|
const isSelected = selected.has(file.key);
|
|
|
|
return (
|
|
<tr
|
|
key={file.key}
|
|
onClick={() => !migrating && toggleOne(file.key)}
|
|
className={cn(
|
|
"transition-colors cursor-pointer",
|
|
isSelected ? "bg-primary/5 hover:bg-primary/10" : "hover:bg-muted/40"
|
|
)}
|
|
>
|
|
{/* Checkbox */}
|
|
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
|
|
<input
|
|
type="checkbox"
|
|
checked={isSelected}
|
|
onChange={() => !migrating && toggleOne(file.key)}
|
|
disabled={migrating}
|
|
className="rounded accent-primary"
|
|
/>
|
|
</td>
|
|
|
|
{/* Filename */}
|
|
<td className="px-3 py-2 font-mono text-xs text-foreground max-w-xs truncate">
|
|
{file.filename}
|
|
{err && (
|
|
<span className="ml-2 text-red-400 font-sans not-italic">{err}</span>
|
|
)}
|
|
</td>
|
|
|
|
{/* Folder */}
|
|
<td className="px-3 py-2 text-muted-foreground">{file.folder}</td>
|
|
|
|
{/* Size */}
|
|
<td className="px-3 py-2 text-right text-muted-foreground tabular-nums">
|
|
{formatBytes(file.sizeBytes)}
|
|
</td>
|
|
|
|
{/* Status */}
|
|
<td className="px-3 py-2 text-right">
|
|
{status === "uploading" ? (
|
|
<span className="inline-flex items-center gap-1 text-amber-400 text-xs">
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
Uploading…
|
|
</span>
|
|
) : status === "done" || (status === undefined && file.hetznerExists) ? (
|
|
<span className="inline-flex items-center gap-1 text-emerald-400 text-xs">
|
|
<CheckCircle2 className="h-3 w-3" />
|
|
Synced
|
|
</span>
|
|
) : status === "error" ? (
|
|
<span className="inline-flex items-center gap-1 text-red-400 text-xs">
|
|
<AlertCircle className="h-3 w-3" />
|
|
Failed
|
|
</span>
|
|
) : (
|
|
<Badge variant="outline" className="text-xs text-muted-foreground">
|
|
Local only
|
|
</Badge>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Footer action bar */}
|
|
{selected.size > 0 && (
|
|
<div className="sticky bottom-4 flex items-center justify-between gap-4 rounded-lg border border-border bg-background/95 backdrop-blur px-4 py-3 shadow-lg">
|
|
<span className="text-sm text-muted-foreground">
|
|
<span className="font-semibold text-foreground">{selected.size} files</span> selected ·{" "}
|
|
{formatBytes(stats.selectedBytes)}
|
|
</span>
|
|
<Button
|
|
onClick={handleMigrate}
|
|
disabled={migrating}
|
|
className="gap-2"
|
|
>
|
|
{migrating ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
Migrating…
|
|
</>
|
|
) : (
|
|
<>
|
|
<Upload className="h-4 w-4" />
|
|
Migrate {selected.size} {selected.size === 1 ? "file" : "files"} to Hetzner
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|