| "use client"; | |
| import { useEffect, useMemo, useState } from "react"; | |
| import Link from "next/link"; | |
| import Navbar from "@/components/Navbar"; | |
| import Footer from "@/components/Footer"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; | |
| import { Badge } from "@/components/ui/badge"; | |
| import { Label } from "@/components/ui/label"; | |
| import { Textarea } from "@/components/ui/textarea"; | |
| import { toast } from "@/components/ui/toaster"; | |
| import { | |
| Select, | |
| SelectContent, | |
| SelectItem, | |
| SelectTrigger, | |
| SelectValue, | |
| } from "@/components/ui/select"; | |
| type RequestStatus = "pending" | "in_progress" | "completed"; | |
| type DistillationRequest = { | |
| id: string; | |
| sourceDataset: string; | |
| studentModel: string; | |
| submitterName?: string; | |
| additionalNotes: string; | |
| upvotes: number; | |
| createdAt: string; | |
| status: RequestStatus; | |
| }; | |
| type DatasetRequest = { | |
| id: string; | |
| sourceModel: string; | |
| submitterName?: string; | |
| datasetSize: string; | |
| reasoningDepth: string; | |
| topics: string[]; | |
| additionalNotes: string; | |
| upvotes: number; | |
| createdAt: string; | |
| status: RequestStatus; | |
| }; | |
| const STATUS_OPTIONS: RequestStatus[] = ["pending", "in_progress", "completed"]; | |
| function StatusBadge({ status }: { status: RequestStatus }) { | |
| if (status === "completed") return <Badge variant="success">Completed</Badge>; | |
| if (status === "in_progress") return <Badge variant="warning">In Progress</Badge>; | |
| return <Badge variant="secondary">Pending</Badge>; | |
| } | |
| export default function AdminPage() { | |
| const [checking, setChecking] = useState(true); | |
| const [admin, setAdmin] = useState(false); | |
| const [password, setPassword] = useState(""); | |
| const [loginLoading, setLoginLoading] = useState(false); | |
| const [distillationRequests, setDistillationRequests] = useState<DistillationRequest[]>([]); | |
| const [datasetRequests, setDatasetRequests] = useState<DatasetRequest[]>([]); | |
| const [loading, setLoading] = useState(false); | |
| const [replyOpen, setReplyOpen] = useState<Record<string, boolean>>({}); | |
| const [replyBody, setReplyBody] = useState<Record<string, string>>({}); | |
| const [replySubmitting, setReplySubmitting] = useState<Record<string, boolean>>({}); | |
| const allRequests = useMemo(() => { | |
| const dist = distillationRequests.map((r) => ({ type: "distillation" as const, request: r })); | |
| const data = datasetRequests.map((r) => ({ type: "dataset" as const, request: r })); | |
| return [...dist, ...data].sort((a, b) => b.request.upvotes - a.request.upvotes); | |
| }, [distillationRequests, datasetRequests]); | |
| useEffect(() => { | |
| checkAdmin(); | |
| }, []); | |
| useEffect(() => { | |
| if (admin) { | |
| fetchAll(); | |
| } | |
| }, [admin]); | |
| async function checkAdmin() { | |
| setChecking(true); | |
| try { | |
| const res = await fetch("/api/admin/me", { cache: "no-store" }); | |
| const data = await res.json(); | |
| setAdmin(Boolean(data?.admin)); | |
| } catch { | |
| setAdmin(false); | |
| } finally { | |
| setChecking(false); | |
| } | |
| } | |
| async function fetchAll() { | |
| setLoading(true); | |
| try { | |
| const [distillRes, datasetRes] = await Promise.all([ | |
| fetch("/api/distillation", { cache: "no-store" }), | |
| fetch("/api/dataset", { cache: "no-store" }), | |
| ]); | |
| const [distillData, datasetData] = await Promise.all([distillRes.json(), datasetRes.json()]); | |
| setDistillationRequests(Array.isArray(distillData) ? distillData : []); | |
| setDatasetRequests(Array.isArray(datasetData) ? datasetData : []); | |
| } catch (error) { | |
| console.error(error); | |
| toast({ title: "Error", description: "Failed to fetch requests", variant: "destructive" }); | |
| } finally { | |
| setLoading(false); | |
| } | |
| } | |
| async function login() { | |
| setLoginLoading(true); | |
| try { | |
| const res = await fetch("/api/admin/login", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ password }), | |
| }); | |
| const data = await res.json(); | |
| if (!res.ok) { | |
| toast({ title: "Login failed", description: data?.error || "Invalid password", variant: "destructive" }); | |
| return; | |
| } | |
| toast({ title: "Logged in", description: "Admin session started" }); | |
| setPassword(""); | |
| await checkAdmin(); | |
| } catch (error) { | |
| console.error(error); | |
| toast({ title: "Login failed", description: "Unexpected error", variant: "destructive" }); | |
| } finally { | |
| setLoginLoading(false); | |
| } | |
| } | |
| async function logout() { | |
| try { | |
| await fetch("/api/admin/logout", { method: "POST" }); | |
| setAdmin(false); | |
| toast({ title: "Logged out" }); | |
| } catch { | |
| toast({ title: "Error", description: "Failed to logout", variant: "destructive" }); | |
| } | |
| } | |
| async function updateStatus(type: "distillation" | "dataset", id: string, status: RequestStatus) { | |
| try { | |
| const res = await fetch(`/api/admin/requests/${type}/${id}`, { | |
| method: "PATCH", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ status }), | |
| }); | |
| const data = await res.json(); | |
| if (!res.ok) { | |
| toast({ title: "Error", description: data?.error || "Failed to update status", variant: "destructive" }); | |
| return; | |
| } | |
| toast({ title: "Updated", description: "Status updated" }); | |
| await fetchAll(); | |
| } catch { | |
| toast({ title: "Error", description: "Failed to update status", variant: "destructive" }); | |
| } | |
| } | |
| async function removeRequest(type: "distillation" | "dataset", id: string) { | |
| try { | |
| const res = await fetch(`/api/admin/requests/${type}/${id}`, { method: "DELETE" }); | |
| const data = await res.json(); | |
| if (!res.ok) { | |
| toast({ title: "Error", description: data?.error || "Failed to delete", variant: "destructive" }); | |
| return; | |
| } | |
| toast({ title: "Deleted", description: "Request removed" }); | |
| await fetchAll(); | |
| } catch { | |
| toast({ title: "Error", description: "Failed to delete", variant: "destructive" }); | |
| } | |
| } | |
| async function submitReply(type: "distillation" | "dataset", id: string) { | |
| const key = `${type}:${id}`; | |
| const body = (replyBody[key] || "").trim(); | |
| if (!body) { | |
| toast({ title: "Error", description: "Reply cannot be empty", variant: "destructive" }); | |
| return; | |
| } | |
| setReplySubmitting((prev) => ({ ...prev, [key]: true })); | |
| try { | |
| const res = await fetch(`/api/admin/requests/${type}/${id}/comments`, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ body }), | |
| }); | |
| const data = await res.json(); | |
| if (!res.ok) { | |
| toast({ title: "Error", description: data?.error || "Failed to reply", variant: "destructive" }); | |
| return; | |
| } | |
| toast({ title: "Replied", description: "Comment posted" }); | |
| setReplyBody((prev) => ({ ...prev, [key]: "" })); | |
| setReplyOpen((prev) => ({ ...prev, [key]: false })); | |
| } catch { | |
| toast({ title: "Error", description: "Failed to reply", variant: "destructive" }); | |
| } finally { | |
| setReplySubmitting((prev) => ({ ...prev, [key]: false })); | |
| } | |
| } | |
| return ( | |
| <main className="min-h-screen bg-background"> | |
| <Navbar /> | |
| <section className="pt-24 pb-10"> | |
| <div className="mx-auto max-w-6xl px-4 sm:px-6"> | |
| <div className="flex items-start justify-between gap-4"> | |
| <div> | |
| <h1 className="text-3xl font-bold tracking-tight text-foreground">Admin</h1> | |
| <p className="mt-1 text-muted-foreground">Manage requests, update status, and reply.</p> | |
| </div> | |
| {admin && ( | |
| <Button variant="outline" onClick={logout}> | |
| Logout | |
| </Button> | |
| )} | |
| </div> | |
| <div className="mt-6"> | |
| {checking ? ( | |
| <Card> | |
| <CardContent className="p-6 text-muted-foreground">Checking session…</CardContent> | |
| </Card> | |
| ) : !admin ? ( | |
| <Card> | |
| <CardHeader> | |
| <CardTitle>Admin Login</CardTitle> | |
| <CardDescription>Password is set via ADMIN_PASSWORD</CardDescription> | |
| </CardHeader> | |
| <CardContent className="space-y-4"> | |
| <div className="space-y-2"> | |
| <Label htmlFor="password">Password</Label> | |
| <input | |
| id="password" | |
| type="password" | |
| placeholder="Enter admin password" | |
| title="Admin password" | |
| className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2" | |
| value={password} | |
| onChange={(e) => setPassword(e.target.value)} | |
| /> | |
| </div> | |
| <Button onClick={login} disabled={loginLoading}> | |
| {loginLoading ? "Logging in…" : "Login"} | |
| </Button> | |
| </CardContent> | |
| </Card> | |
| ) : ( | |
| <div className="space-y-4"> | |
| <div className="flex items-center justify-between"> | |
| <div className="text-sm text-muted-foreground"> | |
| Total: {allRequests.length} | |
| </div> | |
| <Button variant="outline" onClick={fetchAll} disabled={loading}> | |
| {loading ? "Refreshing…" : "Refresh"} | |
| </Button> | |
| </div> | |
| {allRequests.length === 0 ? ( | |
| <Card> | |
| <CardContent className="p-6 text-muted-foreground">No requests yet.</CardContent> | |
| </Card> | |
| ) : ( | |
| <div className="grid gap-4"> | |
| {allRequests.map(({ type, request }) => { | |
| const key = `${type}:${request.id}`; | |
| const isReplyOpen = Boolean(replyOpen[key]); | |
| const title = | |
| type === "distillation" | |
| ? `${(request as DistillationRequest).sourceDataset} → ${(request as DistillationRequest).studentModel}` | |
| : `${(request as DatasetRequest).sourceModel} Dataset (${(request as DatasetRequest).datasetSize})`; | |
| return ( | |
| <Card key={key} className="overflow-hidden"> | |
| <CardContent className="p-5"> | |
| <div className="flex flex-wrap items-start justify-between gap-4"> | |
| <div className="min-w-[280px] flex-1"> | |
| <div className="flex flex-wrap items-center gap-2"> | |
| <h3 className="font-medium text-foreground">{title}</h3> | |
| <StatusBadge status={request.status} /> | |
| <Badge variant="outline">{type}</Badge> | |
| <Badge variant="secondary">{request.upvotes} upvotes</Badge> | |
| </div> | |
| {request.additionalNotes ? ( | |
| <p className="mt-2 text-sm text-muted-foreground">{request.additionalNotes}</p> | |
| ) : null} | |
| {(request as any).submitterName ? ( | |
| <p className="mt-2 text-sm text-muted-foreground">By {(request as any).submitterName}</p> | |
| ) : null} | |
| {type === "dataset" ? ( | |
| <div className="mt-2 flex flex-wrap gap-1"> | |
| <Badge variant="outline">{(request as DatasetRequest).reasoningDepth} reasoning</Badge> | |
| {(request as DatasetRequest).topics?.slice(0, 6)?.map((t) => ( | |
| <Badge key={t} variant="secondary" className="text-xs"> | |
| {t} | |
| </Badge> | |
| ))} | |
| </div> | |
| ) : null} | |
| </div> | |
| <div className="flex flex-wrap items-center gap-2"> | |
| <div className="w-[180px]"> | |
| <Select | |
| value={request.status} | |
| onValueChange={(v) => updateStatus(type, request.id, v as RequestStatus)} | |
| > | |
| <SelectTrigger> | |
| <SelectValue /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| {STATUS_OPTIONS.map((s) => ( | |
| <SelectItem key={s} value={s}> | |
| {s} | |
| </SelectItem> | |
| ))} | |
| </SelectContent> | |
| </Select> | |
| </div> | |
| <Button variant="outline" asChild> | |
| <Link href={`/requests/${type}/${request.id}`}>Discussion</Link> | |
| </Button> | |
| <Button | |
| variant="outline" | |
| onClick={() => setReplyOpen((prev) => ({ ...prev, [key]: !prev[key] }))} | |
| > | |
| {isReplyOpen ? "Close Reply" : "Reply"} | |
| </Button> | |
| <Button | |
| variant="destructive" | |
| onClick={() => removeRequest(type, request.id)} | |
| > | |
| Delete | |
| </Button> | |
| </div> | |
| </div> | |
| {isReplyOpen && ( | |
| <div className="mt-4 space-y-2"> | |
| <Label>Admin Reply</Label> | |
| <Textarea | |
| value={replyBody[key] || ""} | |
| onChange={(e) => setReplyBody((prev) => ({ ...prev, [key]: e.target.value }))} | |
| placeholder="Write a reply as TeichAI…" | |
| /> | |
| <div className="flex gap-2"> | |
| <Button | |
| onClick={() => submitReply(type, request.id)} | |
| disabled={Boolean(replySubmitting[key])} | |
| > | |
| {replySubmitting[key] ? "Posting…" : "Post Reply"} | |
| </Button> | |
| <Button | |
| variant="outline" | |
| onClick={() => setReplyOpen((prev) => ({ ...prev, [key]: false }))} | |
| > | |
| Cancel | |
| </Button> | |
| </div> | |
| </div> | |
| )} | |
| </CardContent> | |
| </Card> | |
| ); | |
| })} | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </section> | |
| <Footer /> | |
| </main> | |
| ); | |
| } | |