Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@ import type { NextConfig } from "next";

const nextConfig: NextConfig = {
output: "standalone",
async redirects() {
// Admin surfaces were consolidated under /admin. Keep old bookmarks working.
return [
{ source: "/settings", destination: "/admin", permanent: false },
{ source: "/ontology", destination: "/admin/ontology", permanent: false },
{ source: "/domains", destination: "/admin/domains", permanent: false },
]
},
async headers() {
return [
{
Expand Down
12 changes: 12 additions & 0 deletions src/app/admin/(console)/activity/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"use client"

import dynamic from "next/dynamic"

const ActivitySettings = dynamic(
() => import("@/components/admin/activity-settings").then((m) => m.ActivitySettings),
{ ssr: false, loading: () => <p className="text-sm text-muted-foreground">Loading…</p> }
)

export default function ActivityPage() {
return <ActivitySettings open />
}
12 changes: 12 additions & 0 deletions src/app/admin/(console)/appearance/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"use client"

import dynamic from "next/dynamic"

const AppearanceSettings = dynamic(
() => import("@/components/admin/appearance-settings").then((m) => m.AppearanceSettings),
{ ssr: false, loading: () => <p className="text-sm text-muted-foreground">Loading…</p> }
)

export default function AppearancePage() {
return <AppearanceSettings open />
}
12 changes: 12 additions & 0 deletions src/app/admin/(console)/audit/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"use client"

import dynamic from "next/dynamic"

const SchemaAuditSettings = dynamic(
() => import("@/components/admin/schema-audit").then((m) => m.SchemaAuditSettings),
{ ssr: false, loading: () => <p className="text-sm text-muted-foreground">Loading…</p> }
)

export default function AuditPage() {
return <SchemaAuditSettings open />
}
86 changes: 86 additions & 0 deletions src/app/admin/(console)/general/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"use client"

import { useCallback, useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { MAX_LENGTHS } from "@/lib/input-limits"
import { useAppStore } from "@/stores/app-store"
import { api } from "@/lib/api"

export default function GeneralSettingsPage() {
const { graphName, graphDescription, setGraphMeta } = useAppStore()

const [name, setName] = useState(graphName)
const [description, setDescription] = useState(graphDescription)
const [saving, setSaving] = useState(false)

// Sync local form state when global meta changes (e.g. after load)
useEffect(() => {
setName(graphName)
setDescription(graphDescription)
}, [graphName, graphDescription])

const handleSave = useCallback(async () => {
setSaving(true)
try {
await api.post("/about", { title: name, description })
setGraphMeta(name, description)
} catch (err) {
console.error("Failed to save settings:", err)
} finally {
setSaving(false)
}
}, [name, description, setGraphMeta])

const handleCancel = useCallback(() => {
setName(graphName)
setDescription(graphDescription)
}, [graphName, graphDescription])

return (
<div className="space-y-4">
<h2 className="text-lg font-heading font-semibold">General</h2>

<div className="space-y-2">
<Label htmlFor="graph-name" className="text-xs uppercase tracking-wider font-heading">
Graph Name
</Label>
<Input
id="graph-name"
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={MAX_LENGTHS.GRAPH_NAME}
className="bg-muted/50 border-border/50 focus:border-primary/40"
/>
</div>

<div className="space-y-2">
<Label htmlFor="graph-desc" className="text-xs uppercase tracking-wider font-heading">
Description
</Label>
<textarea
id="graph-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
maxLength={MAX_LENGTHS.GRAPH_DESCRIPTION}
className="w-full rounded-md border border-border/50 bg-muted/50 px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary/40 focus:outline-none disabled:opacity-50 resize-none"
/>
</div>

<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={handleCancel} className="text-xs">
Cancel
</Button>
<Button
onClick={handleSave}
disabled={saving}
className="text-xs bg-primary text-primary-foreground hover:bg-primary/90"
>
{saving ? "Saving..." : "Save Changes"}
</Button>
</div>
</div>
)
}
12 changes: 12 additions & 0 deletions src/app/admin/(console)/janitors/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"use client"

import dynamic from "next/dynamic"

const JanitorSettings = dynamic(
() => import("@/components/modals/janitor-settings").then((m) => m.JanitorSettings),
{ ssr: false, loading: () => <p className="text-sm text-muted-foreground">Loading…</p> }
)

export default function JanitorsPage() {
return <JanitorSettings open />
}
145 changes: 145 additions & 0 deletions src/app/admin/(console)/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"use client"

import { useEffect } from "react"
import Link from "next/link"
import { usePathname, useRouter } from "next/navigation"
import {
ArrowLeft,
SlidersHorizontal,
Palette,
Network,
Boxes,
Stethoscope,
CalendarClock,
Wrench,
Activity,
ClipboardList,
type LucideIcon,
} from "lucide-react"
import { useReviewStore } from "@/stores/review-store"
import { useUserStore } from "@/stores/user-store"
import { listReviews } from "@/lib/graph-api"
import { cn } from "@/lib/utils"

interface NavItem {
label: string
href: string
icon: LucideIcon
/** Full-screen tools live outside the console shell. */
external?: boolean
}

interface NavGroup {
title: string
items: NavItem[]
}

const NAV_GROUPS: NavGroup[] = [
{
title: "Graph",
items: [
{ label: "General", href: "/admin/general", icon: SlidersHorizontal },
{ label: "Appearance", href: "/admin/appearance", icon: Palette },
],
},
{
title: "Schema",
items: [
{ label: "Ontology", href: "/admin/ontology", icon: Network, external: true },
{ label: "Domains", href: "/admin/domains", icon: Boxes, external: true },
{ label: "Audit", href: "/admin/audit", icon: Stethoscope },
],
},
{
title: "Automation",
items: [
{ label: "Schedule", href: "/admin/schedule", icon: CalendarClock },
{ label: "Janitors", href: "/admin/janitors", icon: Wrench },
{ label: "Activity", href: "/admin/activity", icon: Activity },
],
},
{
title: "Moderation",
items: [
{ label: "Reviews", href: "/admin/reviews", icon: ClipboardList, external: true },
],
},
]

export default function ConsoleLayout({ children }: { children: React.ReactNode }) {
const router = useRouter()
const pathname = usePathname()
const isAdmin = useUserStore((s) => s.isAdmin)
const { pendingCount, setPendingCount } = useReviewStore()

// Keep the Reviews badge fresh (same pattern as the toolkit).
useEffect(() => {
if (!isAdmin) return
let cancelled = false
listReviews({ status: "pending", limit: 1 })
.then((res) => { if (!cancelled) setPendingCount(res.total) })
.catch(() => {})
return () => { cancelled = true }
}, [isAdmin, setPendingCount])

return (
<div className="flex h-screen bg-background text-foreground overflow-hidden">
{/* Sidebar */}
<aside className="flex w-56 shrink-0 flex-col border-r border-border bg-background/60">
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
<button
type="button"
aria-label="Back to graph"
onClick={() => router.push("/")}
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted/40 hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
</button>
<h1 className="text-sm font-heading font-semibold tracking-wide uppercase">
Admin
</h1>
</div>

<nav className="flex-1 overflow-y-auto px-2 py-3">
{NAV_GROUPS.map((group) => (
<div key={group.title} className="mb-4">
<p className="px-2 pb-1 text-[10px] font-heading uppercase tracking-wider text-muted-foreground/70">
{group.title}
</p>
{group.items.map((item) => {
const active = pathname === item.href
const Icon = item.icon
const badge = item.href === "/admin/reviews" ? pendingCount : 0
return (
<Link
key={item.href}
href={item.href}
className={cn(
"group flex items-center gap-2.5 rounded-md px-2 py-1.5 text-sm transition-colors",
active
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted/40 hover:text-foreground"
)}
>
<Icon className="h-4 w-4 shrink-0" />
<span className="flex-1 truncate">{item.label}</span>
{badge > 0 && (
<span className="flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[9px] font-bold text-primary-foreground">
{badge > 99 ? "99+" : badge}
</span>
)}
</Link>
)
})}
</div>
))}
</nav>
</aside>

{/* Content */}
<main className="flex-1 overflow-auto">
<div className="mx-auto max-w-2xl px-6 py-8">{children}</div>
</main>
</div>
)
}
66 changes: 66 additions & 0 deletions src/app/admin/(console)/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"use client"

import Link from "next/link"
import {
SlidersHorizontal,
Palette,
Network,
Boxes,
Stethoscope,
CalendarClock,
Wrench,
Activity,
ClipboardList,
type LucideIcon,
} from "lucide-react"

interface Card {
label: string
href: string
icon: LucideIcon
description: string
}

const CARDS: Card[] = [
{ label: "General", href: "/admin/general", icon: SlidersHorizontal, description: "Graph name and description" },
{ label: "Appearance", href: "/admin/appearance", icon: Palette, description: "UI skin / theme" },
{ label: "Ontology", href: "/admin/ontology", icon: Network, description: "Edit schema types and edges" },
{ label: "Domains", href: "/admin/domains", icon: Boxes, description: "Organize and hide domains and types" },
{ label: "Audit", href: "/admin/audit", icon: Stethoscope, description: "Schema health diagnostics" },
{ label: "Schedule", href: "/admin/schedule", icon: CalendarClock, description: "Source polling cadence" },
{ label: "Janitors", href: "/admin/janitors", icon: Wrench, description: "Automated graph maintenance" },
{ label: "Activity", href: "/admin/activity", icon: Activity, description: "Recent runs and source activity" },
{ label: "Reviews", href: "/admin/reviews", icon: ClipboardList, description: "Moderation queue" },
]

export default function AdminOverviewPage() {
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-heading font-semibold">Admin Console</h2>
<p className="text-sm text-muted-foreground">
Manage the graph, its schema, automation, and moderation.
</p>
</div>

<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{CARDS.map((card) => {
const Icon = card.icon
return (
<Link
key={card.href}
href={card.href}
className="group flex items-start gap-3 rounded-lg border border-border/60 bg-muted/20 p-4 transition-colors hover:border-primary/40 hover:bg-muted/40"
>
<Icon className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground group-hover:text-primary" />
<div className="min-w-0">
<p className="text-sm font-medium">{card.label}</p>
<p className="text-xs text-muted-foreground">{card.description}</p>
</div>
</Link>
)
})}
</div>
</div>
)
}
12 changes: 12 additions & 0 deletions src/app/admin/(console)/schedule/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"use client"

import dynamic from "next/dynamic"

const RadarSettings = dynamic(
() => import("@/components/modals/radar-settings").then((m) => m.RadarSettings),
{ ssr: false, loading: () => <p className="text-sm text-muted-foreground">Loading…</p> }
)

export default function SchedulePage() {
return <RadarSettings open />
}
Loading
Loading