diff --git a/src/app/admin/submissions/page.tsx b/src/app/admin/submissions/page.tsx index b9647591..fd2b8fb8 100644 --- a/src/app/admin/submissions/page.tsx +++ b/src/app/admin/submissions/page.tsx @@ -1,33 +1,61 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; +type SubmissionType = "contact" | "partner" | "contributor" | "kit-request" | "glossary"; +type SubmissionStatus = "new" | "reviewed" | "archived"; + type Submission = { id: string; - type: "skill" | "mcp" | "agent" | "llms-txt"; - name: string; - description: string; - url: string; - author: string; - tags: string[]; - install_cmd?: string; - status: "pending" | "approved" | "rejected"; - submitted_at: string; + type: SubmissionType; + status: SubmissionStatus; + submittedAt: string; + title: string; + summary: string; + submitter: string; + email?: string; + sourceFile: string; + data: Record; +}; + +type StatusFilter = SubmissionStatus | "all"; +type TypeFilter = SubmissionType | "all"; + +type ApiResponse = { + submissions?: Submission[]; + total?: number; + error?: string; }; -type StatusFilter = "pending" | "approved" | "rejected" | "all"; +const TYPE_OPTIONS: TypeFilter[] = [ + "all", + "contact", + "partner", + "contributor", + "kit-request", + "glossary", +]; + +const STATUS_OPTIONS: StatusFilter[] = ["all", "new", "reviewed", "archived"]; + +function formatDate(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "Unknown"; + return date.toLocaleString(); +} export default function AdminSubmissionsPage() { const [secret, setSecret] = useState(""); const [isAuthenticated, setIsAuthenticated] = useState(false); const [submissions, setSubmissions] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [typeFilter, setTypeFilter] = useState("all"); + const [statusFilter, setStatusFilter] = useState("all"); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const [statusFilter, setStatusFilter] = useState("pending"); const [actionLoading, setActionLoading] = useState(null); - // Load secret from localStorage on mount useEffect(() => { const stored = localStorage.getItem("foragents_admin_secret"); if (stored) { @@ -41,34 +69,49 @@ export default function AdminSubmissionsPage() { setError(null); try { - const res = await fetch(`/api/submissions?status=${statusFilter}`); - const data = await res.json(); + const params = new URLSearchParams(); + if (typeFilter !== "all") params.set("type", typeFilter); + if (statusFilter !== "all") params.set("status", statusFilter); + + const res = await fetch(`/api/admin/submissions?${params.toString()}`, { + cache: "no-store", + }); + + const payload = (await res.json()) as ApiResponse; if (!res.ok) { - throw new Error(data.error || "Failed to fetch submissions"); + throw new Error(payload.error || "Failed to load submissions"); } - setSubmissions(data.submissions || []); - } catch (err) { - setError(err instanceof Error ? err.message : "Unknown error"); + const nextSubmissions = payload.submissions || []; + setSubmissions(nextSubmissions); + + setSelectedId((current) => { + if (!nextSubmissions.length) return null; + if (current && nextSubmissions.some((item) => item.id === current)) return current; + return nextSubmissions[0]?.id ?? null; + }); + } catch (fetchError) { + setError(fetchError instanceof Error ? fetchError.message : "Unknown error"); + setSubmissions([]); + setSelectedId(null); } finally { setLoading(false); } - }, [statusFilter]); + }, [statusFilter, typeFilter]); - // Fetch submissions when authenticated useEffect(() => { if (isAuthenticated) { - fetchSubmissions(); + void fetchSubmissions(); } - }, [isAuthenticated, statusFilter, fetchSubmissions]); + }, [fetchSubmissions, isAuthenticated]); - const handleLogin = (e: React.FormEvent) => { - e.preventDefault(); - if (secret.trim()) { - localStorage.setItem("foragents_admin_secret", secret.trim()); - setIsAuthenticated(true); - } + const handleLogin = (event: React.FormEvent) => { + event.preventDefault(); + if (!secret.trim()) return; + + localStorage.setItem("foragents_admin_secret", secret.trim()); + setIsAuthenticated(true); }; const handleLogout = () => { @@ -76,89 +119,77 @@ export default function AdminSubmissionsPage() { setSecret(""); setIsAuthenticated(false); setSubmissions([]); + setSelectedId(null); + setError(null); }; - const handleAction = async ( - id: string, - action: "approve" | "reject", - reason?: string - ) => { - setActionLoading(id); + const updateStatus = async (submission: Submission, status: SubmissionStatus) => { + setActionLoading(submission.id); + setError(null); try { - const body = action === "reject" && reason ? { reason } : undefined; - - const res = await fetch(`/api/submissions/${id}/${action}`, { - method: "POST", + const res = await fetch("/api/admin/submissions", { + method: "PATCH", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${secret}`, }, - body: body ? JSON.stringify(body) : undefined, + body: JSON.stringify({ + id: submission.id, + type: submission.type, + status, + }), }); - const data = await res.json(); - + const payload = (await res.json()) as ApiResponse; if (!res.ok) { - if (res.status === 401) { - setError("Invalid admin secret. Please log out and try again."); - return; - } - throw new Error(data.error || `Failed to ${action} submission`); + throw new Error(payload.error || "Failed to update submission status"); } - // Refresh list await fetchSubmissions(); - } catch (err) { - setError(err instanceof Error ? err.message : "Unknown error"); + } catch (updateError) { + setError(updateError instanceof Error ? updateError.message : "Unknown error"); } finally { setActionLoading(null); } }; - const handleApprove = (id: string) => { - handleAction(id, "approve"); - }; - - const handleReject = (id: string) => { - const reason = prompt("Rejection reason (optional):"); - handleAction(id, "reject", reason || undefined); - }; + const selectedSubmission = useMemo( + () => submissions.find((submission) => submission.id === selectedId) ?? null, + [selectedId, submissions] + ); - // Login screen if (!isAuthenticated) { return (
-
-

🔐 Admin Login

+
+

🔐 Admin Login

+
- + setSecret(e.target.value)} - className="w-full px-4 py-2 bg-zinc-900 border border-zinc-700 rounded-lg text-zinc-100 focus:outline-none focus:border-emerald-500" + onChange={(event) => setSecret(event.target.value)} + className="w-full rounded-lg border border-zinc-700 bg-zinc-900 px-4 py-2 text-zinc-100 focus:border-emerald-500 focus:outline-none" placeholder="Enter ADMIN_SECRET" autoFocus />
+
-

+ +

The secret is stored in localStorage for convenience.

- + + ← Back to Home
@@ -166,154 +197,164 @@ export default function AdminSubmissionsPage() { ); } - // Admin dashboard return ( -
-
-
-

📋 Submission Review

-
- {/* Filter tabs */} -
- {(["pending", "approved", "rejected", "all"] as StatusFilter[]).map( - (status) => ( - - ) - )} +
+
+ + +
+ +
+ + +
{error && ( -
+
{error} -
)} {loading ? ( -
Loading submissions...
+
+ Loading submissions... +
) : submissions.length === 0 ? ( -
- No {statusFilter === "all" ? "" : statusFilter} submissions found. +
+ No submissions found for the selected filters.
) : ( -
- {submissions.map((sub) => ( -
-
-
-
- {sub.name} - - {sub.type} +
+
+ {submissions.map((submission) => { + const isSelected = submission.id === selectedSubmission?.id; + + return ( + + ); + })} +
+ +
+ {selectedSubmission ? ( + <> +
+
+

{selectedSubmission.title}

+

+ Submitted by {selectedSubmission.submitter} on {formatDate(selectedSubmission.submittedAt)} +

+ {selectedSubmission.email && ( +

{selectedSubmission.email}

)} -
- Tags:{" "} - {sub.tags.join(", ")} -
-
- Submitted:{" "} - {new Date(sub.submitted_at).toLocaleString()} -
-
- ID:{" "} - {sub.id} -
-
- {sub.status === "pending" && ( -
+
+
- )} -
-
- ))} +
+ +
+

Summary

+

{selectedSubmission.summary}

+
+ +
+

Submission details

+
+                      {JSON.stringify(selectedSubmission.data, null, 2)}
+                    
+
+ + ) : ( +

Select a submission to view details.

+ )} +
)} -
- +
+ ← Back to Home
diff --git a/src/app/api/admin/submissions/route.ts b/src/app/api/admin/submissions/route.ts new file mode 100644 index 00000000..9e2e8d5f --- /dev/null +++ b/src/app/api/admin/submissions/route.ts @@ -0,0 +1,358 @@ +import { NextRequest, NextResponse } from "next/server"; +import { promises as fs } from "node:fs"; +import path from "node:path"; + +export const runtime = "nodejs"; + +type SubmissionType = "contact" | "partner" | "contributor" | "kit-request" | "glossary"; +type SubmissionStatus = "new" | "reviewed" | "archived"; + +type JsonRecord = Record; + +type NormalizedSubmission = { + id: string; + type: SubmissionType; + status: SubmissionStatus; + submittedAt: string; + title: string; + summary: string; + submitter: string; + email?: string; + sourceFile: string; + data: JsonRecord; +}; + +type SourceConfig = { + type: SubmissionType; + fileName: string; + extractItems: (raw: unknown) => JsonRecord[]; + putItems: (raw: unknown, nextItems: JsonRecord[]) => unknown; + normalize: (item: JsonRecord) => NormalizedSubmission | null; +}; + +const DATA_DIR = path.join(process.cwd(), "data"); + +const SOURCE_CONFIGS: SourceConfig[] = [ + { + type: "contact", + fileName: "contact-submissions.json", + extractItems: (raw) => (Array.isArray(raw) ? (raw as JsonRecord[]) : []), + putItems: (_raw, nextItems) => nextItems, + normalize: normalizeContact, + }, + { + type: "partner", + fileName: "partner-applications.json", + extractItems: (raw) => (Array.isArray(raw) ? (raw as JsonRecord[]) : []), + putItems: (_raw, nextItems) => nextItems, + normalize: normalizePartner, + }, + { + type: "contributor", + fileName: "contributor-applications.json", + extractItems: (raw) => (Array.isArray(raw) ? (raw as JsonRecord[]) : []), + putItems: (_raw, nextItems) => nextItems, + normalize: normalizeContributor, + }, + { + type: "kit-request", + fileName: "kit-requests.json", + extractItems: (raw) => { + if (!raw || typeof raw !== "object") return []; + const requests = (raw as JsonRecord).requests; + return Array.isArray(requests) ? (requests as JsonRecord[]) : []; + }, + putItems: (raw, nextItems) => { + const base = raw && typeof raw === "object" ? (raw as JsonRecord) : {}; + return { ...base, requests: nextItems }; + }, + normalize: normalizeKitRequest, + }, + { + type: "glossary", + fileName: "glossary-suggestions.json", + extractItems: (raw) => (Array.isArray(raw) ? (raw as JsonRecord[]) : []), + putItems: (_raw, nextItems) => nextItems, + normalize: normalizeGlossary, + }, +]; + +const VALID_TYPES = SOURCE_CONFIGS.map((cfg) => cfg.type); +const VALID_STATUSES: SubmissionStatus[] = ["new", "reviewed", "archived"]; + +function toStringValue(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function normalizeStatus(status: unknown): SubmissionStatus { + if (status === "reviewed" || status === "archived") { + return status; + } + + return "new"; +} + +function extractIsoDate(item: JsonRecord, keys: string[]): string { + for (const key of keys) { + const value = item[key]; + if (typeof value === "string" && value) { + return value; + } + } + + return new Date(0).toISOString(); +} + +function normalizeContact(item: JsonRecord): NormalizedSubmission | null { + const id = toStringValue(item.id); + if (!id) return null; + + return { + id, + type: "contact", + status: normalizeStatus(item.status), + submittedAt: extractIsoDate(item, ["submittedAt", "createdAt"]), + title: toStringValue(item.subject, "Contact submission"), + summary: toStringValue(item.message), + submitter: toStringValue(item.name, "Unknown"), + email: toStringValue(item.email) || undefined, + sourceFile: "contact-submissions.json", + data: item, + }; +} + +function normalizePartner(item: JsonRecord): NormalizedSubmission | null { + const id = toStringValue(item.id); + if (!id) return null; + + const company = toStringValue(item.company); + const appType = toStringValue(item.type); + + return { + id, + type: "partner", + status: normalizeStatus(item.status), + submittedAt: extractIsoDate(item, ["submittedAt", "createdAt"]), + title: company ? `${company} ${appType || "partner"} application` : "Partner application", + summary: toStringValue(item.message), + submitter: toStringValue(item.name, "Unknown"), + email: toStringValue(item.email) || undefined, + sourceFile: "partner-applications.json", + data: item, + }; +} + +function normalizeContributor(item: JsonRecord): NormalizedSubmission | null { + const id = toStringValue(item.id); + if (!id) return null; + + const role = toStringValue(item.roleInterest); + + return { + id, + type: "contributor", + status: normalizeStatus(item.status), + submittedAt: extractIsoDate(item, ["submittedAt", "createdAt"]), + title: role ? `${role} contributor application` : "Contributor application", + summary: toStringValue(item.bio), + submitter: toStringValue(item.name, "Unknown"), + sourceFile: "contributor-applications.json", + data: item, + }; +} + +function normalizeKitRequest(item: JsonRecord): NormalizedSubmission | null { + const id = toStringValue(item.id); + if (!id) return null; + + return { + id, + type: "kit-request", + status: normalizeStatus(item.status), + submittedAt: extractIsoDate(item, ["createdAt", "submittedAt"]), + title: toStringValue(item.kitName, "Kit request"), + summary: toStringValue(item.useCase, toStringValue(item.description)), + submitter: toStringValue(item.requesterAgentId, "Anonymous"), + sourceFile: "kit-requests.json", + data: item, + }; +} + +function normalizeGlossary(item: JsonRecord): NormalizedSubmission | null { + const id = toStringValue(item.id); + if (!id) return null; + + const rawStatus = toStringValue(item.status); + const status: SubmissionStatus = + rawStatus === "reviewed" || rawStatus === "archived" ? rawStatus : "new"; + + return { + id, + type: "glossary", + status, + submittedAt: extractIsoDate(item, ["createdAt", "submittedAt"]), + title: toStringValue(item.term, "Glossary suggestion"), + summary: toStringValue(item.definition), + submitter: "Glossary contributor", + sourceFile: "glossary-suggestions.json", + data: item, + }; +} + +async function readJson(filePath: string): Promise { + try { + const raw = await fs.readFile(filePath, "utf-8"); + return JSON.parse(raw) as unknown; + } catch { + return null; + } +} + +async function writeJson(filePath: string, value: unknown): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf-8"); +} + +async function loadSubmissions(types: SubmissionType[] | null): Promise { + const configs = types ? SOURCE_CONFIGS.filter((cfg) => types.includes(cfg.type)) : SOURCE_CONFIGS; + + const rows = await Promise.all( + configs.map(async (config) => { + const filePath = path.join(DATA_DIR, config.fileName); + const raw = await readJson(filePath); + const items = config.extractItems(raw); + return items + .map((item) => config.normalize(item)) + .filter((submission): submission is NormalizedSubmission => Boolean(submission)); + }) + ); + + return rows + .flat() + .sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime()); +} + +function parseTypeFilter(value: string | null): SubmissionType[] | null { + if (!value) return null; + + const parts = value + .split(",") + .map((part) => part.trim()) + .filter(Boolean); + + if (parts.length === 0) return null; + + if (!parts.every((part): part is SubmissionType => VALID_TYPES.includes(part as SubmissionType))) { + return null; + } + + return parts as SubmissionType[]; +} + +export async function GET(request: NextRequest) { + const typeParam = request.nextUrl.searchParams.get("type"); + const statusParam = request.nextUrl.searchParams.get("status"); + + const types = parseTypeFilter(typeParam); + if (typeParam && !types) { + return NextResponse.json( + { + error: `Invalid type filter. Use one of: ${VALID_TYPES.join(", ")}`, + }, + { status: 400 } + ); + } + + if (statusParam && !VALID_STATUSES.includes(statusParam as SubmissionStatus)) { + return NextResponse.json( + { + error: "Invalid status filter. Use one of: new, reviewed, archived", + }, + { status: 400 } + ); + } + + const submissions = await loadSubmissions(types); + const filtered = statusParam + ? submissions.filter((submission) => submission.status === statusParam) + : submissions; + + return NextResponse.json({ + submissions: filtered, + total: filtered.length, + filters: { + type: typeParam, + status: statusParam, + }, + }); +} + +type PatchBody = { + id?: unknown; + type?: unknown; + status?: unknown; +}; + +export async function PATCH(request: NextRequest) { + let body: PatchBody; + + try { + body = (await request.json()) as PatchBody; + } catch { + return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 }); + } + + const id = toStringValue(body.id).trim(); + const type = toStringValue(body.type).trim() as SubmissionType; + const status = toStringValue(body.status).trim() as SubmissionStatus; + + if (!id) { + return NextResponse.json({ error: "id is required" }, { status: 400 }); + } + + if (!VALID_TYPES.includes(type)) { + return NextResponse.json( + { error: `type must be one of: ${VALID_TYPES.join(", ")}` }, + { status: 400 } + ); + } + + if (!VALID_STATUSES.includes(status)) { + return NextResponse.json( + { error: "status must be one of: new, reviewed, archived" }, + { status: 400 } + ); + } + + const config = SOURCE_CONFIGS.find((source) => source.type === type); + if (!config) { + return NextResponse.json({ error: "Unsupported type" }, { status: 400 }); + } + + const filePath = path.join(DATA_DIR, config.fileName); + const raw = await readJson(filePath); + const items = config.extractItems(raw); + const index = items.findIndex((item) => toStringValue(item.id) === id); + + if (index === -1) { + return NextResponse.json({ error: "Submission not found" }, { status: 404 }); + } + + const updatedItem: JsonRecord = { + ...items[index], + status, + }; + + const nextItems = [...items]; + nextItems[index] = updatedItem; + const nextRaw = config.putItems(raw, nextItems); + + await writeJson(filePath, nextRaw); + + const normalized = config.normalize(updatedItem); + + return NextResponse.json({ + success: true, + submission: normalized, + }); +}