From c0c624c2aed0913284615e6f1ec72aad7fe94e12 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Mon, 27 Apr 2026 21:20:01 +0800 Subject: [PATCH] feat: add admin hardware management page with CRUD - Device list fetched from /api/hardware/stats - Create/edit devices via modal form - Delete with confirmation dialog - Uses existing hardware CRUD API endpoints --- .../admin/hardware/hardware-client.tsx | 354 ++++++++++++++++++ app/(admin)/admin/hardware/page.tsx | 10 + 2 files changed, 364 insertions(+) create mode 100644 app/(admin)/admin/hardware/hardware-client.tsx create mode 100644 app/(admin)/admin/hardware/page.tsx diff --git a/app/(admin)/admin/hardware/hardware-client.tsx b/app/(admin)/admin/hardware/hardware-client.tsx new file mode 100644 index 0000000..5e278cd --- /dev/null +++ b/app/(admin)/admin/hardware/hardware-client.tsx @@ -0,0 +1,354 @@ +"use client" + +import { useEffect, useState, FormEvent } from "react" +import { + Gamepad2Icon, + PlusIcon, + PencilIcon, + TrashIcon, + Loader2, + XIcon, +} from "lucide-react" +import { getDeviceColor } from "@/components/charts/EChartWrapper" + +interface DeviceItem { + slug: string + name: string + deviceType: string + image: string | null + sortOrder: number + totalBenchmarks?: number + avgFps?: number | null + gameCount?: number +} + +interface DeviceFormData { + slug: string + name: string + deviceType: "handheld" | "console" + image: string + sortOrder: number +} + +export function HardwareClient() { + const [devices, setDevices] = useState([]) + const [loading, setLoading] = useState(true) + const [showModal, setShowModal] = useState(false) + const [editingDevice, setEditingDevice] = useState(null) + const [form, setForm] = useState({ + slug: "", + name: "", + deviceType: "handheld", + image: "", + sortOrder: 0, + }) + const [saving, setSaving] = useState(false) + const [deleting, setDeleting] = useState(false) + + const fetchDevices = async () => { + setLoading(true) + try { + const res = await fetch("/api/hardware/stats") + if (res.ok) { + const data = await res.json() + setDevices( + (data as DeviceItem[]).map((item) => ({ + ...item, + image: item.image ?? null, + })) + ) + } + } catch { + // ignore + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchDevices() + }, []) + + const openCreate = () => { + setEditingDevice(null) + setForm({ + slug: "", + name: "", + deviceType: "handheld", + image: "", + sortOrder: 0, + }) + setShowModal(true) + } + + const openEdit = (device: DeviceItem) => { + setEditingDevice(device) + setForm({ + slug: device.slug, + name: device.name, + deviceType: device.deviceType as "handheld" | "console", + image: device.image ?? "", + sortOrder: device.sortOrder, + }) + setShowModal(true) + } + + const closeModal = () => { + setShowModal(false) + setEditingDevice(null) + } + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault() + setSaving(true) + try { + const body = editingDevice + ? { + name: form.name, + deviceType: form.deviceType, + image: form.image || null, + sortOrder: form.sortOrder, + } + : { + slug: form.slug, + name: form.name, + deviceType: form.deviceType, + image: form.image || null, + sortOrder: form.sortOrder, + } + + const url = editingDevice + ? `/api/hardware/${editingDevice.slug}` + : "/api/hardware" + const method = editingDevice ? "PATCH" : "POST" + + const res = await fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + + if (res.ok) { + closeModal() + await fetchDevices() + } + } finally { + setSaving(false) + } + } + + const handleDelete = async (slug: string) => { + if (!confirm("Are you sure you want to delete this device?")) return + setDeleting(true) + try { + const res = await fetch(`/api/hardware/${slug}`, { method: "DELETE" }) + if (res.ok) { + await fetchDevices() + } + } finally { + setDeleting(false) + } + } + + return ( +
+
+

Hardware

+ +
+ + {loading ? ( +
+ +
+ ) : devices.length === 0 ? ( +
+ +

No devices found

+
+ ) : ( +
+ {devices.map((device, index) => { + const color = getDeviceColor(index) + return ( +
+
+
+
+ +
+
+

+ {device.name} +

+

/{device.slug}

+
+
+ + {device.deviceType} + +
+
+ {device.totalBenchmarks ?? 0} benchmarks +
+
+ + +
+
+ ) + })} +
+ )} + + {showModal && ( +
+
+
+
+

+ {editingDevice ? "Edit Device" : "Add Device"} +

+ +
+
+
+ + + setForm((f) => ({ ...f, slug: e.target.value })) + } + disabled={!!editingDevice} + required + className="w-full px-3 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 disabled:opacity-50 disabled:cursor-not-allowed" + /> +
+
+ + + setForm((f) => ({ ...f, name: e.target.value })) + } + required + className="w-full px-3 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" + /> +
+
+ + +
+
+ + + setForm((f) => ({ + ...f, + sortOrder: Number(e.target.value), + })) + } + className="w-full px-3 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" + /> +
+
+ + + setForm((f) => ({ ...f, image: e.target.value })) + } + placeholder="https://..." + className="w-full px-3 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" + /> +
+
+ + +
+
+
+
+ )} +
+ ) +} diff --git a/app/(admin)/admin/hardware/page.tsx b/app/(admin)/admin/hardware/page.tsx new file mode 100644 index 0000000..e3100f9 --- /dev/null +++ b/app/(admin)/admin/hardware/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next" +import { HardwareClient } from "./hardware-client" + +export const metadata: Metadata = { + title: "Hardware", +} + +export default function HardwarePage() { + return +}