diff --git a/next.config.ts b/next.config.ts
index a2dd57ac..7a38b878 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -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 [
{
diff --git a/src/app/admin/(console)/activity/page.tsx b/src/app/admin/(console)/activity/page.tsx
new file mode 100644
index 00000000..2036291c
--- /dev/null
+++ b/src/app/admin/(console)/activity/page.tsx
@@ -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: () =>
Loading…
}
+)
+
+export default function ActivityPage() {
+ return
+}
diff --git a/src/app/admin/(console)/appearance/page.tsx b/src/app/admin/(console)/appearance/page.tsx
new file mode 100644
index 00000000..be2cfa21
--- /dev/null
+++ b/src/app/admin/(console)/appearance/page.tsx
@@ -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: () => Loading…
}
+)
+
+export default function AppearancePage() {
+ return
+}
diff --git a/src/app/admin/(console)/audit/page.tsx b/src/app/admin/(console)/audit/page.tsx
new file mode 100644
index 00000000..ccd150d8
--- /dev/null
+++ b/src/app/admin/(console)/audit/page.tsx
@@ -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: () => Loading…
}
+)
+
+export default function AuditPage() {
+ return
+}
diff --git a/src/app/admin/(console)/general/page.tsx b/src/app/admin/(console)/general/page.tsx
new file mode 100644
index 00000000..65fceff6
--- /dev/null
+++ b/src/app/admin/(console)/general/page.tsx
@@ -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 (
+
+
General
+
+
+
+ setName(e.target.value)}
+ maxLength={MAX_LENGTHS.GRAPH_NAME}
+ className="bg-muted/50 border-border/50 focus:border-primary/40"
+ />
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/app/admin/(console)/janitors/page.tsx b/src/app/admin/(console)/janitors/page.tsx
new file mode 100644
index 00000000..b808385b
--- /dev/null
+++ b/src/app/admin/(console)/janitors/page.tsx
@@ -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: () => Loading…
}
+)
+
+export default function JanitorsPage() {
+ return
+}
diff --git a/src/app/admin/(console)/layout.tsx b/src/app/admin/(console)/layout.tsx
new file mode 100644
index 00000000..ed8a6619
--- /dev/null
+++ b/src/app/admin/(console)/layout.tsx
@@ -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 (
+
+ {/* Sidebar */}
+
+
+ {/* Content */}
+
+ {children}
+
+
+ )
+}
diff --git a/src/app/admin/(console)/page.tsx b/src/app/admin/(console)/page.tsx
new file mode 100644
index 00000000..535b0732
--- /dev/null
+++ b/src/app/admin/(console)/page.tsx
@@ -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 (
+
+
+
Admin Console
+
+ Manage the graph, its schema, automation, and moderation.
+
+
+
+
+ {CARDS.map((card) => {
+ const Icon = card.icon
+ return (
+
+
+
+
{card.label}
+
{card.description}
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/src/app/admin/(console)/schedule/page.tsx b/src/app/admin/(console)/schedule/page.tsx
new file mode 100644
index 00000000..10aa424e
--- /dev/null
+++ b/src/app/admin/(console)/schedule/page.tsx
@@ -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: () => Loading…
}
+)
+
+export default function SchedulePage() {
+ return
+}
diff --git a/src/app/domains/domain-panel.tsx b/src/app/admin/domains/domain-panel.tsx
similarity index 78%
rename from src/app/domains/domain-panel.tsx
rename to src/app/admin/domains/domain-panel.tsx
index e4fef56f..9c13a150 100644
--- a/src/app/domains/domain-panel.tsx
+++ b/src/app/admin/domains/domain-panel.tsx
@@ -9,7 +9,8 @@ import { Switch } from "@/components/ui/switch"
import { Separator } from "@/components/ui/separator"
import { MultiSelectCustom } from "@/components/ui/multi-select-custom"
import { MAX_LENGTHS } from "@/lib/input-limits"
-import type { SchemaNode } from "@/app/ontology/page"
+import { cn } from "@/lib/utils"
+import type { SchemaNode } from "@/lib/schema-types"
export interface DomainRow {
/** Lowercased domain identifier (the canonical key). */
@@ -29,6 +30,9 @@ interface DomainPanelProps {
onAddTypes: (typeNames: string[]) => void
onRemoveType: (typeName: string) => void
onToggleHidden: (hidden: boolean) => void
+ /** Node types currently hidden from search (hidden_types). */
+ hiddenTypes: Set
+ onToggleTypeHidden: (typeName: string, hidden: boolean) => void
onDelete: () => void
onClose: () => void
busy?: boolean
@@ -48,6 +52,8 @@ export function DomainPanel({
onAddTypes,
onRemoveType,
onToggleHidden,
+ hiddenTypes,
+ onToggleTypeHidden,
onDelete,
onClose,
busy,
@@ -181,7 +187,7 @@ export function DomainPanel({
) : (
)}
- Hidden from search
+ Hide entire domain from search
) : (
- {domain.members.map((m) => (
-
+ {domain.members.map((m) => {
+ const typeHidden = hiddenTypes.has(m.type)
+ return (
-
-
{m.type}
- {m.parent && (
-
- ↳ {m.parent}
-
- )}
-
-
-
- ))}
+
+
+
{m.type}
+ {m.parent && (
+
+ ↳ {m.parent}
+
+ )}
+
+
+
+
+ )
+ })}
)}
diff --git a/src/app/domains/page.tsx b/src/app/admin/domains/page.tsx
similarity index 92%
rename from src/app/domains/page.tsx
rename to src/app/admin/domains/page.tsx
index 09fcd40f..90f8b9d1 100644
--- a/src/app/domains/page.tsx
+++ b/src/app/admin/domains/page.tsx
@@ -8,12 +8,11 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { MultiSelectCustom } from "@/components/ui/multi-select-custom"
import { DomainPanel, type DomainRow } from "./domain-panel"
-import type { SchemaNode } from "@/app/ontology/page"
+import type { SchemaNode } from "@/lib/schema-types"
import { useSchemaStore } from "@/stores/schema-store"
-import { useUserStore } from "@/stores/user-store"
import { useAppStore } from "@/stores/app-store"
import { isMocksEnabled, MOCK_DOMAINS } from "@/lib/mock-data"
-import { SMALL_SCHEMAS, SMALL_EDGES } from "@/app/ontology/mock-small"
+import { SMALL_SCHEMAS, SMALL_EDGES } from "@/app/admin/ontology/mock-small"
import {
getSchemaDomains,
updateHiddenLists,
@@ -35,8 +34,6 @@ function capitalize(s: string): string {
export default function DomainsPage() {
const router = useRouter()
- const isAdmin = useUserStore((s) => s.isAdmin)
- const isAuthenticated = useUserStore((s) => s.isAuthenticated)
const store = useSchemaStore()
const { graphName, graphDescription } = useAppStore()
@@ -52,10 +49,6 @@ export default function DomainsPage() {
const [createName, setCreateName] = useState("")
const [createTypes, setCreateTypes] = useState([])
- useEffect(() => {
- if (isAuthenticated && !isAdmin) router.replace("/")
- }, [isAdmin, isAuthenticated, router])
-
const reloadDomains = useCallback(async () => {
setLoadingDomains(true)
try {
@@ -199,6 +192,35 @@ export default function DomainsPage() {
[domainsInfo, graphName, graphDescription]
)
+ // Per-type visibility (hidden_types). Mirrors handleToggleHidden but operates
+ // on individual node types rather than whole domains.
+ const handleToggleTypeHidden = useCallback(
+ async (typeName: string, hidden: boolean) => {
+ const current = new Set(domainsInfo?.hidden_types ?? [])
+ if (hidden) current.add(typeName)
+ else current.delete(typeName)
+ const next = Array.from(current).sort()
+ setBusy(true)
+ setError(null)
+ try {
+ if (!isMocksEnabled()) {
+ await updateHiddenLists(graphName, graphDescription, next, undefined)
+ }
+ setDomainsInfo((prev) => (prev ? { ...prev, hidden_types: next } : prev))
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to update visibility")
+ } finally {
+ setBusy(false)
+ }
+ },
+ [domainsInfo, graphName, graphDescription]
+ )
+
+ const hiddenTypes = useMemo(
+ () => new Set(domainsInfo?.hidden_types ?? []),
+ [domainsInfo]
+ )
+
const handleDelete = useCallback(
async (row: DomainRow) => {
// Empty domains are derived away on reload; just clean up a stale hidden entry.
@@ -241,8 +263,6 @@ export default function DomainsPage() {
[store.schemas]
)
- if (isAuthenticated && !isAdmin) return null
-
const loading = loadingDomains && store.loading
return (
@@ -253,7 +273,7 @@ export default function DomainsPage() {