| "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> |
| ); |
| } |
|
|