"use client" import { useEffect, useRef, useState } from "react" import { Loader2, HardDriveIcon, TrashIcon, SearchIcon, RefreshCwIcon, AlertTriangleIcon, ChevronLeftIcon, ChevronRightIcon, FileIcon, CheckCircle2Icon, } from "lucide-react" import { ConfirmDialog } from "@/components/ui/modal" type EntityType = "all" | "avatar" | "entry_screenshot" | "game_cover" | "hardware_image" | "orphaned" interface StorageStats { configured: boolean bucketName: string totalObjects: number totalSizeBytes: number orphanedCount: number orphanedSizeBytes: number byEntityType: Record } interface StorageObject { id: string key: string bucket: string size: number mimeType: string entityType: string entityId: string | null uploadedBy: string uploaderName: string | null createdAt: string lastAccessedAt: string | null isOrphaned: boolean } interface ObjectsResponse { data: StorageObject[] total: number limit: number offset: number } const LIMIT = 50 function formatBytes(bytes: number): string { if (bytes === 0) return "0 B" const k = 1024 const sizes = ["B", "KB", "MB", "GB", "TB"] const i = Math.floor(Math.log(bytes) / Math.log(k)) return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}` } function formatDate(value: string | null | undefined) { if (!value) return "—" return new Date(value).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) } function entityTypeLabel(type: string) { const labels: Record = { avatar: "Avatar", entry_screenshot: "Screenshot", game_cover: "Game Cover", hardware_image: "Hardware Img", } return labels[type] ?? type } function entityTypeBadgeClass(type: string) { const classes: Record = { avatar: "bg-purple-500/10 text-purple-400", entry_screenshot: "bg-blue-500/10 text-blue-400", game_cover: "bg-green-500/10 text-green-400", hardware_image: "bg-amber-500/10 text-amber-400", } return classes[type] ?? "bg-text/5 text-text/50" } export function StorageClient() { const [stats, setStats] = useState(null) const [objects, setObjects] = useState([]) const [total, setTotal] = useState(0) const [loading, setLoading] = useState(true) const [statsLoading, setStatsLoading] = useState(true) const [search, setSearch] = useState("") const [offset, setOffset] = useState(0) const [entityFilter, setEntityFilter] = useState("all") const [deleting, setDeleting] = useState(null) const [confirmDelete, setConfirmDelete] = useState(null) const [cleanupLoading, setCleanupLoading] = useState(false) const [cleanupResult, setCleanupResult] = useState<{ deleted: number; errors: number } | null>(null) const isSearchChangeRef = useRef(false) useEffect(() => { setStatsLoading(true) fetch("/api/admin/storage/stats") .then((res) => res.json()) .then((data) => { setStats(data) setStatsLoading(false) }) .catch(() => setStatsLoading(false)) }, []) useEffect(() => { const delay = isSearchChangeRef.current ? 300 : 0 isSearchChangeRef.current = false let cancelled = false const timer = setTimeout(async () => { setLoading(true) try { const params = new URLSearchParams({ limit: String(LIMIT), offset: String(offset), }) if (entityFilter === "orphaned") { params.set("orphaned", "true") } else if (entityFilter !== "all") { params.set("entityType", entityFilter) } if (search.trim()) { params.set("search", search.trim()) } const res = await fetch(`/api/admin/storage/objects?${params.toString()}`) if (res.ok && !cancelled) { const json = (await res.json()) as ObjectsResponse setObjects(json.data) setTotal(json.total) } } catch { // ignore } finally { if (!cancelled) setLoading(false) } }, delay) return () => { cancelled = true clearTimeout(timer) } }, [search, offset, entityFilter]) const handleSearchChange = (value: string) => { setSearch(value) setOffset(0) isSearchChangeRef.current = true } const handleDelete = async (obj: StorageObject) => { setDeleting(obj.id) try { const res = await fetch(`/api/admin/storage/objects/${obj.id}`, { method: "DELETE", }) if (res.ok) { setObjects((prev) => prev.filter((o) => o.id !== obj.id)) setTotal((prev) => Math.max(0, prev - 1)) setConfirmDelete(null) const statsRes = await fetch("/api/admin/storage/stats") if (statsRes.ok) { setStats(await statsRes.json()) } } } finally { setDeleting(null) } } const handleCleanup = async () => { setCleanupLoading(true) setCleanupResult(null) try { const res = await fetch("/api/admin/storage/cleanup", { method: "POST" }) if (res.ok) { const data = await res.json() setCleanupResult({ deleted: data.deleted, errors: data.errors }) const statsRes = await fetch("/api/admin/storage/stats") if (statsRes.ok) setStats(await statsRes.json()) setOffset(0) } } finally { setCleanupLoading(false) } } const hasMore = offset + objects.length < total const filterTabs: { label: string; value: EntityType }[] = [ { label: "All", value: "all" }, { label: "Avatars", value: "avatar" }, { label: "Screenshots", value: "entry_screenshot" }, { label: "Covers", value: "game_cover" }, { label: "Hardware", value: "hardware_image" }, { label: "Orphaned", value: "orphaned" }, ] return (

Storage

{stats && !stats.configured && (

R2 Not Configured

Set the R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, and R2_PUBLIC_URL environment variables to enable storage management.

)} {statsLoading ? (
) : stats && stats.configured ? (
Total Objects

{stats.totalObjects.toLocaleString()}

Total Size

{formatBytes(stats.totalSizeBytes)}

Orphaned

{stats.orphanedCount.toLocaleString()}

{stats.orphanedSizeBytes > 0 && (

{formatBytes(stats.orphanedSizeBytes)}

)}
Bucket

{stats.bucketName}

) : null} {stats?.byEntityType && Object.keys(stats.byEntityType).length > 0 && (

Storage by Type

{Object.entries(stats.byEntityType).map(([type, data]) => (
{entityTypeLabel(type)} {data.count} · {formatBytes(data.totalSizeBytes)}
))}
)} {stats?.configured && (
{cleanupResult && (
Deleted {cleanupResult.deleted} objects {cleanupResult.errors > 0 && `, ${cleanupResult.errors} errors`}
)}
)}
handleSearchChange(e.target.value)} placeholder="Search by key..." className="w-full pl-9 pr-4 py-2 rounded-md bg-text/5 border border-border text-sm text-text placeholder:text-text/40 focus:outline-none focus:border-primary/60 transition-colors" />
{filterTabs.map((tab) => ( ))}
{loading ? ( ) : objects.length === 0 ? ( ) : ( objects.map((obj) => ( )) )}
Key Type Size Uploaded By Date Status Actions
No objects found.

{obj.key}

{entityTypeLabel(obj.entityType)} {formatBytes(obj.size)} {obj.uploaderName || "System"} {formatDate(obj.createdAt)} {obj.isOrphaned && ( Orphaned )}
{objects.length > 0 && (

Showing {offset + 1}–{Math.min(offset + objects.length, total)} of {total}

)} {confirmDelete && ( setConfirmDelete(null)} onConfirm={() => handleDelete(confirmDelete)} title="Delete Storage Object" message={`Are you sure you want to delete "${confirmDelete.key}"? This will remove the file from R2 storage and the database record. This action cannot be undone.`} confirmLabel="Delete" variant="destructive" loading={deleting === confirmDelete.id} /> )}
) }