diff --git a/app/(manage)/manage/storage/page.tsx b/app/(manage)/manage/storage/page.tsx new file mode 100644 index 0000000..13efccd --- /dev/null +++ b/app/(manage)/manage/storage/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next" +import { StorageClient } from "./storage-client" + +export const metadata: Metadata = { + title: "Storage", +} + +export default function StoragePage() { + return +} diff --git a/app/(manage)/manage/storage/storage-client.tsx b/app/(manage)/manage/storage/storage-client.tsx new file mode 100644 index 0000000..ea367a6 --- /dev/null +++ b/app/(manage)/manage/storage/storage-client.tsx @@ -0,0 +1,448 @@ +"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) => ( + + + + + + + + + + )) + )} + +
KeyTypeSizeUploaded ByDateStatusActions
+ +
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} + /> + )} +
+ ) +}