Requests / src /app /admin /page.tsx
armand0e's picture
Optionally add name upon submission + update source models with Qwen3 2507 specifications
f15ff24
"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>
);
}