diff --git a/src/app/globals.css b/src/app/globals.css index eb1f3ac5..ea35eb1f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -282,3 +282,30 @@ ::-webkit-scrollbar-thumb:hover { background: oklch(0.4 0.02 260); } + +/* ── Legal Skin Theme Tokens ───────────────────────────────────────────────── */ +.skin-legal { + --background: oklch(0.09 0.03 250); /* deep navy */ + --foreground: oklch(0.92 0.02 55); /* warm cream */ + --card: oklch(0.12 0.04 250); + --card-foreground: oklch(0.92 0.02 55); + --popover: oklch(0.11 0.04 250); + --popover-foreground: oklch(0.92 0.02 55); + --primary: oklch(0.72 0.15 75); /* gold */ + --primary-foreground: oklch(0.09 0.03 250); + --secondary: oklch(0.16 0.04 250); + --secondary-foreground:oklch(0.88 0.02 55); + --muted: oklch(0.15 0.035 250); + --muted-foreground: oklch(0.55 0.02 55); + --accent: oklch(0.16 0.04 250); + --accent-foreground: oklch(0.92 0.02 55); + --border: oklch(0.22 0.04 250); + --input: oklch(0.16 0.04 250); + --ring: oklch(0.65 0.13 75); /* gold ring */ + --cyan: oklch(0.72 0.15 75); /* remap cyan → gold */ + --cyan-muted: oklch(0.45 0.08 75); + --sidebar: oklch(0.07 0.03 250); + --sidebar-foreground: oklch(0.88 0.02 55); + --sidebar-primary: oklch(0.72 0.15 75); + --sidebar-border: oklch(0.20 0.04 250); +} diff --git a/src/app/settings/appearance-settings.tsx b/src/app/settings/appearance-settings.tsx new file mode 100644 index 00000000..0c1333b0 --- /dev/null +++ b/src/app/settings/appearance-settings.tsx @@ -0,0 +1,146 @@ +"use client" + +import { useState, useCallback } from "react" +import { Loader2 } from "lucide-react" +import { Button } from "@/components/ui/button" +import { useAppStore } from "@/stores/app-store" +import { useUserStore } from "@/stores/user-store" +import { api } from "@/lib/api" +import { SKINS, type SkinId } from "@/skins/index" +import { cn } from "@/lib/utils" + +// Hardcoded swatch colours per skin — do not rely on CSS vars here because +// the active skin overrides them, which would make all swatch previews look +// identical when a skin is already active. +const SKIN_SWATCHES: Record = { + default: { + bg: "oklch(0.08 0.022 260)", + primary: "oklch(0.72 0.14 200)", + fg: "oklch(0.92 0.015 260)", + }, + legal: { + bg: "oklch(0.09 0.03 250)", + primary: "oklch(0.72 0.15 75)", + fg: "oklch(0.92 0.02 55)", + }, +} + +export function AppearanceSettings({ open }: { open: boolean }) { + const isAdmin = useUserStore((s) => s.isAdmin) + const activeSkin = useAppStore((s) => s.activeSkin) + const setActiveSkin = useAppStore((s) => s.setActiveSkin) + const graphName = useAppStore((s) => s.graphName) + const graphDescription = useAppStore((s) => s.graphDescription) + + const [selectedSkin, setSelectedSkin] = useState(activeSkin) + const [saving, setSaving] = useState(false) + const [saved, setSaved] = useState(false) + + const handleSave = useCallback(async () => { + if (!isAdmin) return + setSaving(true) + setSaved(false) + try { + await api.post("/about", { + title: graphName, + description: graphDescription, + ui_skin: selectedSkin, + }) + setActiveSkin(selectedSkin) + setSaved(true) + } catch (err) { + console.error("[appearance-settings] save failed:", err) + } finally { + setSaving(false) + } + }, [selectedSkin, graphName, graphDescription, setActiveSkin]) + + if (!open) return null + + const skinEntries = Object.values(SKINS) + + return ( +
+
+

+ Interface Skin +

+

+ Choose a visual theme for the graph explorer. Saved server-side — all users see the selected skin. +

+
+ + {/* Skin cards — 2-column grid */} +
+ {skinEntries.map((skin) => { + const swatches = SKIN_SWATCHES[skin.id] + const isSelected = selectedSkin === skin.id + return ( + + ) + })} +
+ + {/* Save row */} +
+ {saved && ( + + Saved ✓ + + )} + +
+
+ ) +} diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index d5f766af..3a713a65 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -33,8 +33,13 @@ const SchemaAuditSettings = dynamic( { ssr: false, loading: () =>

Loading…

} ) -const VALID_TABS = ["general", "radar", "janitor", "domains", "audit"] as const -const ADMIN_ONLY_TABS = ["radar", "janitor", "domains", "audit"] as const +const AppearanceSettings = dynamic( + () => import("@/app/settings/appearance-settings").then((m) => m.AppearanceSettings), + { ssr: false, loading: () =>

Loading…

} +) + +const VALID_TABS = ["general", "radar", "janitor", "domains", "audit", "appearance"] as const +const ADMIN_ONLY_TABS = ["radar", "janitor", "domains", "audit", "appearance"] as const type TabId = (typeof VALID_TABS)[number] function resolveTab(raw: string | null, isAdmin: boolean): TabId { @@ -124,6 +129,7 @@ function SettingsContent() { {isAdmin && Janitors} {isAdmin && Domains} {isAdmin && Audit} + {isAdmin && Appearance} @@ -205,6 +211,12 @@ function SettingsContent() { )} + + {isAdmin && ( + + + + )} diff --git a/src/components/auth/auth-guard.tsx b/src/components/auth/auth-guard.tsx index 12e6854d..a029e850 100644 --- a/src/components/auth/auth-guard.tsx +++ b/src/components/auth/auth-guard.tsx @@ -16,6 +16,7 @@ export function AuthGuard({ children }: { children: React.ReactNode }) { const [loading, setLoading] = useState(true) const { setBudget, setIsAdmin, setPubKey, setRouteHint, setIsAuthenticated } = useUserStore() const setGraphMeta = useAppStore((s) => s.setGraphMeta) + const setActiveSkin = useAppStore((s) => s.setActiveSkin) const handleAuth = useCallback(async () => { try { @@ -101,12 +102,14 @@ export function AuthGuard({ children }: { children: React.ReactNode }) { depth?: number sort_by?: string top_node_count?: number + ui_skin?: string }>("/about") setGraphMeta(res.title ?? "Knowledge Graph", res.description ?? "") + setActiveSkin((res.ui_skin as import("@/skins/index").SkinId) ?? "default") } catch { setGraphMeta("Knowledge Graph", "") } - }, [setGraphMeta]) + }, [setGraphMeta, setActiveSkin]) useEffect(() => { const init = async () => { diff --git a/src/components/layout/app-layout.tsx b/src/components/layout/app-layout.tsx index ff30e565..3b76d6e4 100644 --- a/src/components/layout/app-layout.tsx +++ b/src/components/layout/app-layout.tsx @@ -1,8 +1,6 @@ "use client" import { useEffect } from "react" -import { LeftPane } from "./left-pane" -import { GraphPane } from "@/components/universe/graph-pane" import { AddModal } from "@/components/modals/add-modal" import { BudgetModal } from "@/components/modals/budget-modal" import { EditNodeModal } from "@/components/modals/edit-node-modal" @@ -10,11 +8,14 @@ import { MediaPlayer } from "@/components/player/media-player" import { useDefaultLayout } from "react-resizable-panels" import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable" import { useSchemaStore } from "@/stores/schema-store" +import { useAppStore } from "@/stores/app-store" import { useNeighborFetch } from "@/hooks/use-neighbor-fetch" import { useDeepLink } from "@/hooks/use-deep-link" import { usePanelGraphSync } from "@/hooks/use-panel-graph-sync" import { isMocksEnabled } from "@/lib/mock-data" import { SMALL_SCHEMAS } from "@/app/ontology/mock-small" +import { SKINS } from "@/skins/index" +import { cn } from "@/lib/utils" export function AppLayout() { useDeepLink() @@ -23,6 +24,8 @@ export function AppLayout() { const { defaultLayout, onLayoutChanged } = useDefaultLayout({ id: "graphmindset-main-layout" }) const schemas = useSchemaStore((s) => s.schemas) const fetchSchemas = useSchemaStore((s) => s.fetchAll) + const activeSkin = useAppStore((s) => s.activeSkin) + const skin = SKINS[activeSkin] ?? SKINS.default useEffect(() => { if (schemas.length > 0) return @@ -35,21 +38,23 @@ export function AppLayout() { return ( <> - - - - - - - - - +
+ + + + + + + + + +
diff --git a/src/lib/__tests__/auth-guard.test.tsx b/src/lib/__tests__/auth-guard.test.tsx index 1b200a14..3026d0bb 100644 --- a/src/lib/__tests__/auth-guard.test.tsx +++ b/src/lib/__tests__/auth-guard.test.tsx @@ -30,8 +30,16 @@ vi.mock("@/stores/user-store", () => ({ })) const mockSetGraphMeta = vi.fn() +const mockSetActiveSkin = vi.fn() + +const appStoreMockState = { + setGraphMeta: mockSetGraphMeta, + setActiveSkin: mockSetActiveSkin, +} vi.mock("@/stores/app-store", () => ({ - useAppStore: vi.fn(() => mockSetGraphMeta), + useAppStore: vi.fn((sel?: (s: typeof appStoreMockState) => unknown) => + sel ? sel(appStoreMockState) : appStoreMockState + ), })) const mockApiGet = vi.fn() @@ -143,3 +151,48 @@ describe("AuthGuard – handleIsAdmin", () => { expect(mockSetIsAuthenticated).toHaveBeenCalledWith(true) }) }) + +describe("AuthGuard – fetchGraphMeta ui_skin", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("calls setActiveSkin('legal') when /about returns ui_skin: 'legal'", async () => { + mockApiGet.mockImplementation((path: string) => { + if (path === "/about") return Promise.resolve({ title: "T", description: "", ui_skin: "legal" }) + return Promise.resolve({ data: { isPublic: true, isAdmin: false, isMember: false } }) + }) + + render(
App
) + + await waitFor(() => { + expect(mockSetActiveSkin).toHaveBeenCalledWith("legal") + }) + }) + + it("calls setActiveSkin('default') when /about returns no ui_skin", async () => { + mockApiGet.mockImplementation((path: string) => { + if (path === "/about") return Promise.resolve({ title: "T", description: "" }) + return Promise.resolve({ data: { isPublic: true, isAdmin: false, isMember: false } }) + }) + + render(
App
) + + await waitFor(() => { + expect(mockSetActiveSkin).toHaveBeenCalledWith("default") + }) + }) + + it("calls setActiveSkin('default') when /about returns ui_skin: null", async () => { + mockApiGet.mockImplementation((path: string) => { + if (path === "/about") return Promise.resolve({ title: "T", description: "", ui_skin: null }) + return Promise.resolve({ data: { isPublic: true, isAdmin: false, isMember: false } }) + }) + + render(
App
) + + await waitFor(() => { + expect(mockSetActiveSkin).toHaveBeenCalledWith("default") + }) + }) +}) diff --git a/src/lib/__tests__/settings-page.test.tsx b/src/lib/__tests__/settings-page.test.tsx index 28674b0f..d551429b 100644 --- a/src/lib/__tests__/settings-page.test.tsx +++ b/src/lib/__tests__/settings-page.test.tsx @@ -25,6 +25,8 @@ const appState = { graphName: "Test Graph", graphDescription: "A test description", setGraphMeta: vi.fn(), + activeSkin: "default" as string, + setActiveSkin: vi.fn(), } vi.mock("@/stores/app-store", () => ({ useAppStore: (sel?: (s: unknown) => unknown) => (sel ? sel(appState) : appState), @@ -64,6 +66,10 @@ vi.mock("@/app/settings/schema-audit", () => ({ SchemaAuditSettings: ({ open }: { open: boolean }) => open ?
Audit
: null, })) +vi.mock("@/app/settings/appearance-settings", () => ({ + AppearanceSettings: ({ open }: { open: boolean }) => + open ?
Appearance
: null, +})) // next/dynamic is used in the page; replace with a passthrough so mocked modules are used vi.mock("next/dynamic", () => ({ @@ -337,3 +343,31 @@ describe("SettingsPage – back button", () => { expect(mockPush).toHaveBeenCalledWith("/") }) }) + +// ── Appearance tab ──────────────────────────────────────────────────────────── + +describe("SettingsPage – Appearance tab", () => { + beforeEach(() => { + userState.isAuthenticated = true + userState.isAdmin = true + mockSearchParams = { get: () => null } + }) + + it("renders Appearance tab trigger for admin", async () => { + await renderPage() + expect(screen.getByRole("tab", { name: /Appearance/i })).toBeInTheDocument() + }) + + it("activates Appearance tab for ?tab=appearance", async () => { + mockSearchParams = { get: (k: string) => (k === "tab" ? "appearance" : null) } + await renderPage() + const tab = screen.getByRole("tab", { name: /Appearance/i }) + expect(tab).toHaveAttribute("aria-selected", "true") + }) + + it("does not render Appearance tab for non-admins", async () => { + userState.isAdmin = false + await renderPage() + expect(screen.queryByRole("tab", { name: /Appearance/i })).not.toBeInTheDocument() + }) +}) diff --git a/src/lib/__tests__/skin-system.test.tsx b/src/lib/__tests__/skin-system.test.tsx new file mode 100644 index 00000000..07f15ad7 --- /dev/null +++ b/src/lib/__tests__/skin-system.test.tsx @@ -0,0 +1,244 @@ +/** + * Tests for the frontend skin system: + * - Skin registry (src/skins/index.ts): required fields, graceful fallback + * - AppLayout: renders correct LeftPane/GraphPane for each skin + * - AppearanceSettings: card selection + Save POSTs correct payload + */ + +import { describe, it, expect, vi, beforeEach } from "vitest" +import { render, screen, fireEvent, waitFor } from "@testing-library/react" +import React from "react" + +// ─── User-store mock ─────────────────────────────────────────────────────────── +vi.mock("@/stores/user-store", () => ({ + useUserStore: (sel?: (s: { isAdmin: boolean }) => unknown) => { + const state = { isAdmin: true } + return sel ? sel(state) : state + }, +})) + +// ─── Shared app-store mock (single declaration — vi.mock is hoisted) ─────────── + +const appStoreMockState = { + activeSkin: "default" as string, + setActiveSkin: vi.fn(), + graphName: "My Graph", + graphDescription: "A description", +} +vi.mock("@/stores/app-store", () => ({ + useAppStore: (sel?: (s: typeof appStoreMockState) => unknown) => + sel ? sel(appStoreMockState) : appStoreMockState, +})) + +// ─── Stubs for panels – we only care which component is rendered ─────────────── +vi.mock("@/components/layout/left-pane", () => ({ + LeftPane: () =>
, +})) +vi.mock("@/components/universe/graph-pane", () => ({ + GraphPane: () =>
, +})) +vi.mock("@/skins/legal/legal-left-pane", () => ({ + LegalLeftPane: () =>
, +})) +vi.mock("@/skins/legal/legal-graph-pane", () => ({ + LegalGraphPane: () =>
, +})) + +// ─── AppLayout dependencies ──────────────────────────────────────────────────── +vi.mock("@/hooks/use-neighbor-fetch", () => ({ useNeighborFetch: vi.fn() })) +vi.mock("@/hooks/use-deep-link", () => ({ useDeepLink: vi.fn() })) +vi.mock("@/hooks/use-panel-graph-sync", () => ({ usePanelGraphSync: vi.fn() })) +vi.mock("@/lib/mock-data", () => ({ isMocksEnabled: vi.fn().mockReturnValue(false) })) +vi.mock("@/app/ontology/mock-small", () => ({ SMALL_SCHEMAS: [] })) +vi.mock("@/stores/schema-store", () => ({ + useSchemaStore: (sel?: (s: { schemas: unknown[]; fetchAll: () => void }) => unknown) => { + const state = { schemas: [], fetchAll: vi.fn() } + return sel ? sel(state) : state + }, +})) +vi.mock("@/components/modals/add-modal", () => ({ AddModal: () => null })) +vi.mock("@/components/modals/budget-modal", () => ({ BudgetModal: () => null })) +vi.mock("@/components/modals/edit-node-modal", () => ({ EditNodeModal: () => null })) +vi.mock("@/components/player/media-player", () => ({ MediaPlayer: () => null })) + +// Fix: include Panel, Group, Separator so resizable.tsx can import them +vi.mock("react-resizable-panels", () => { + const PanelGroupComp = ({ children }: { children: React.ReactNode }) =>
{children}
+ const PanelComp = ({ children }: { children: React.ReactNode }) =>
{children}
+ const SeparatorComp = () => null + return { + Group: PanelGroupComp, + Panel: PanelComp, + Separator: SeparatorComp, + useDefaultLayout: vi.fn().mockReturnValue({ defaultLayout: null, onLayoutChanged: vi.fn() }), + } +}) + +vi.mock("@/lib/utils", () => ({ + cn: (...args: (string | undefined | null | false)[]) => args.filter(Boolean).join(" "), +})) + +// ─── API mock ────────────────────────────────────────────────────────────────── +const mockApiPost = vi.fn().mockResolvedValue({}) +vi.mock("@/lib/api", () => ({ + api: { post: (...args: unknown[]) => mockApiPost(...args) }, +})) + +// ─── Skin registry ───────────────────────────────────────────────────────────── + +describe("Skin registry – SKINS", () => { + it("SKINS['default'] has all required fields", async () => { + const { SKINS } = await import("@/skins/index") + const s = SKINS["default"] + expect(s.id).toBe("default") + expect(s.label).toBeTruthy() + expect(s.description).toBeTruthy() + expect(typeof s.LeftPane).toBe("function") + expect(typeof s.GraphPane).toBe("function") + expect(s.themeClass).toBeUndefined() + }) + + it("SKINS['legal'] has all required fields and themeClass", async () => { + const { SKINS } = await import("@/skins/index") + const s = SKINS["legal"] + expect(s.id).toBe("legal") + expect(s.label).toBeTruthy() + expect(s.description).toBeTruthy() + expect(typeof s.LeftPane).toBe("function") + expect(typeof s.GraphPane).toBe("function") + expect(s.themeClass).toBe("skin-legal") + }) + + it("falls back to SKINS.default when activeSkin is undefined", async () => { + const { SKINS } = await import("@/skins/index") + const skin = SKINS[undefined as unknown as "default"] ?? SKINS.default + expect(skin.id).toBe("default") + }) +}) + +// ─── AppLayout skin wiring ───────────────────────────────────────────────────── + +describe("AppLayout – skin wiring", () => { + beforeEach(() => { + vi.clearAllMocks() + mockApiPost.mockResolvedValue({}) + }) + + it("renders DefaultLeftPane and DefaultGraphPane for 'default' skin", async () => { + appStoreMockState.activeSkin = "default" + const { AppLayout } = await import("@/components/layout/app-layout") + render() + expect(screen.getByTestId("default-left-pane")).toBeInTheDocument() + expect(screen.getByTestId("default-graph-pane")).toBeInTheDocument() + expect(screen.queryByTestId("legal-left-pane")).not.toBeInTheDocument() + }) + + it("renders LegalLeftPane and LegalGraphPane for 'legal' skin", async () => { + appStoreMockState.activeSkin = "legal" + const { AppLayout } = await import("@/components/layout/app-layout") + render() + expect(screen.getByTestId("legal-left-pane")).toBeInTheDocument() + expect(screen.getByTestId("legal-graph-pane")).toBeInTheDocument() + expect(screen.queryByTestId("default-left-pane")).not.toBeInTheDocument() + }) + + it("applies skin-legal themeClass to wrapper for 'legal' skin", async () => { + appStoreMockState.activeSkin = "legal" + const { AppLayout } = await import("@/components/layout/app-layout") + const { container } = render() + const wrapper = container.firstChild as HTMLElement + expect(wrapper.className).toContain("skin-legal") + }) + + it("does not apply any themeClass for 'default' skin", async () => { + appStoreMockState.activeSkin = "default" + const { AppLayout } = await import("@/components/layout/app-layout") + const { container } = render() + const wrapper = container.firstChild as HTMLElement + expect(wrapper.className).not.toContain("skin-legal") + }) +}) + +// ─── AppearanceSettings ───────────────────────────────────────────────────────── + +describe("AppearanceSettings – skin selection and save", () => { + beforeEach(() => { + vi.clearAllMocks() + appStoreMockState.activeSkin = "default" + mockApiPost.mockResolvedValue({}) + }) + + async function renderAppearance(skinOverride = "default") { + appStoreMockState.activeSkin = skinOverride + const { AppearanceSettings } = await import("@/app/settings/appearance-settings") + render() + } + + it("renders skin cards for all skins", async () => { + await renderAppearance() + expect(screen.getByText("Default")).toBeInTheDocument() + expect(screen.getByText("Legal")).toBeInTheDocument() + }) + + it("Save button is disabled when no change from active skin", async () => { + await renderAppearance("default") + const btn = screen.getByRole("button", { name: /Save Appearance/i }) + expect(btn).toBeDisabled() + }) + + it("enables Save button after selecting a different skin", async () => { + await renderAppearance("default") + // Click the "Legal" card button + const legalCard = screen.getByText("Legal").closest("button") as HTMLElement + fireEvent.click(legalCard) + const btn = screen.getByRole("button", { name: /Save Appearance/i }) + expect(btn).not.toBeDisabled() + }) + + it("POSTs { ui_skin: 'legal' } to /about on Save", async () => { + await renderAppearance("default") + const legalCard = screen.getByText("Legal").closest("button") as HTMLElement + fireEvent.click(legalCard) + fireEvent.click(screen.getByRole("button", { name: /Save Appearance/i })) + + await waitFor(() => { + expect(mockApiPost).toHaveBeenCalledWith("/about", { + title: "My Graph", + description: "A description", + ui_skin: "legal", + }) + }) + }) + + it("dispatches setActiveSkin('legal') after successful save", async () => { + await renderAppearance("default") + const legalCard = screen.getByText("Legal").closest("button") as HTMLElement + fireEvent.click(legalCard) + fireEvent.click(screen.getByRole("button", { name: /Save Appearance/i })) + + await waitFor(() => { + expect(appStoreMockState.setActiveSkin).toHaveBeenCalledWith("legal") + }) + }) + + it("POSTs { ui_skin: 'default' } when switching back to Default", async () => { + await renderAppearance("legal") + const defaultCard = screen.getByText("Default").closest("button") as HTMLElement + fireEvent.click(defaultCard) + fireEvent.click(screen.getByRole("button", { name: /Save Appearance/i })) + + await waitFor(() => { + expect(mockApiPost).toHaveBeenCalledWith("/about", { + title: "My Graph", + description: "A description", + ui_skin: "default", + }) + }) + }) + + it("renders nothing when open=false", async () => { + const { AppearanceSettings } = await import("@/app/settings/appearance-settings") + const { container } = render() + expect(container.firstChild).toBeNull() + }) +}) diff --git a/src/skins/index.ts b/src/skins/index.ts new file mode 100644 index 00000000..64784994 --- /dev/null +++ b/src/skins/index.ts @@ -0,0 +1,35 @@ +import type React from "react" +import { LeftPane } from "@/components/layout/left-pane" +import { GraphPane } from "@/components/universe/graph-pane" +import { LegalLeftPane } from "./legal/legal-left-pane" +import { LegalGraphPane } from "./legal/legal-graph-pane" + +export type SkinId = "default" | "legal" + +export interface Skin { + id: SkinId + label: string + description: string + LeftPane: React.ComponentType + GraphPane: React.ComponentType + themeClass?: string +} + +export const SKINS: Record = { + default: { + id: "default", + label: "Default", + description: "The standard GraphMindset experience.", + LeftPane, + GraphPane, + }, + legal: { + id: "legal", + label: "Legal", + description: + "Purpose-built for legal knowledge networks — navy/gold palette with document-card layout.", + LeftPane: LegalLeftPane, + GraphPane: LegalGraphPane, + themeClass: "skin-legal", + }, +} diff --git a/src/skins/legal/legal-case-files-feed.tsx b/src/skins/legal/legal-case-files-feed.tsx new file mode 100644 index 00000000..3c69c9ea --- /dev/null +++ b/src/skins/legal/legal-case-files-feed.tsx @@ -0,0 +1,229 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { useGraphStore } from "@/stores/graph-store" +import { useAppStore } from "@/stores/app-store" +import { useSchemaStore } from "@/stores/schema-store" +import { isMocksEnabled, MOCK_NODES, MOCK_EDGES } from "@/lib/mock-data" +import { getLatestNodes } from "@/lib/graph-api" +import { cn } from "@/lib/utils" +import type { GraphNode } from "@/lib/graph-api" + +const LEGAL_TYPE_MAP: Record = { + Topic: "BRIEF", + Person: "COUNSEL", + Episode: "PROCEEDING", + Clip: "EXCERPT", +} + +function legalLabel(nodeType: string | undefined): string { + return LEGAL_TYPE_MAP[nodeType ?? ""] ?? "FILING" +} + +function formatDate(dateStr: string | undefined): string { + if (!dateStr) return "" + try { + return new Date(dateStr).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }) + } catch { + return "" + } +} + +interface LegalDocCardProps { + node: GraphNode + onSelect: () => void +} + +function LegalDocCard({ node, onSelect }: LegalDocCardProps) { + const title = + (node.properties?.title as string | undefined) ?? + (node.properties?.name as string | undefined) ?? + node.ref_id + + const dateStr = + (node.properties?.date as string | undefined) ?? + (node.properties?.date_added as string | undefined) + + const edgeCount = (node.properties?.edge_count as number | undefined) ?? 0 + + return ( + + ) +} + +export function LegalCaseFilesFeed() { + const nodes = useGraphStore((s) => s.nodes) + const loading = useGraphStore((s) => s.loading) + const setSelectedNode = useGraphStore((s) => s.setSelectedNode) + const setSidebarSelectedNode = useGraphStore((s) => s.setSidebarSelectedNode) + const clearSelection = useGraphStore((s) => s.clearSelection) + const setGraphData = useGraphStore((s) => s.setGraphData) + const setLoading = useGraphStore((s) => s.setLoading) + const searchTerm = useAppStore((s) => s.searchTerm) + + const [activeType, setActiveType] = useState(null) + + useEffect(() => { + clearSelection() + setActiveType(null) + }, [searchTerm, clearSelection]) + + useEffect(() => { + if (useGraphStore.getState().nodes.length > 0) return + if (isMocksEnabled()) { + setGraphData(MOCK_NODES, MOCK_EDGES) + return + } + let cancelled = false + setLoading(true) + ;(async () => { + try { + const result = await getLatestNodes() + if (cancelled) return + setGraphData(result.nodes ?? [], result.edges ?? []) + } catch (err) { + console.error("[legal-feed] getLatestNodes failed:", err) + } finally { + if (!cancelled) setLoading(false) + } + })() + return () => { + cancelled = true + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const typeCounts = useMemo(() => { + const counts = new Map() + for (const n of nodes) { + const t = n.node_type ?? "Unknown" + counts.set(t, (counts.get(t) ?? 0) + 1) + } + return [...counts.entries()].sort((a, b) => b[1] - a[1]) + }, [nodes]) + + const filtered = useMemo( + () => (activeType === null ? nodes : nodes.filter((n) => (n.node_type ?? "Unknown") === activeType)), + [nodes, activeType] + ) + + const hasResults = nodes.length > 0 + + return ( +
+ {/* Header */} +
+
+ + Case Files + + + + {searchTerm ? "RESULTS" : "LATEST"} + +
+ + {/* Type filter chips */} + {typeCounts.length > 1 && ( +
+ + {typeCounts.map(([type, count]) => ( + + ))} +
+ )} +
+ + {/* Content */} +
+ {loading && !hasResults && ( +
+
+
+ )} + + {!loading && !hasResults && ( +
+

+ No documents found +

+

+ {searchTerm ? "Refine your search" : "Begin search to load filings"} +

+
+ )} + + {filtered.map((node) => ( + { + setSelectedNode(node) + setSidebarSelectedNode(node) + }} + /> + ))} + + {hasResults && ( +
+ — End of Docket — +
+ )} +
+
+ ) +} diff --git a/src/skins/legal/legal-graph-pane.tsx b/src/skins/legal/legal-graph-pane.tsx new file mode 100644 index 00000000..0e5e7735 --- /dev/null +++ b/src/skins/legal/legal-graph-pane.tsx @@ -0,0 +1,203 @@ +"use client" + +import { Network, Loader2 } from "lucide-react" +import { useGraphStore } from "@/stores/graph-store" +import { useAppStore } from "@/stores/app-store" +import { useSchemaStore } from "@/stores/schema-store" +import { GraphCanvas } from "@/components/universe/graph-canvas" +import { SearchBar } from "@/components/search/search-bar" +import { Toolkit, ToolkitFAB } from "@/components/layout/toolkit" +import type { GraphNode } from "@/lib/graph-api" + +// ── Legal Network Header ───────────────────────────────────────────────────── + +function LegalNetworkHeader() { + const graphName = useAppStore((s) => s.graphName) + const closeAllPanels = useAppStore((s) => s.closeAllPanels) + const setSearchTerm = useAppStore((s) => s.setSearchTerm) + const clearSelection = useGraphStore((s) => s.clearSelection) + const title = graphName || "Knowledge Graph" + + function handleClick() { + closeAllPanels() + clearSelection() + setSearchTerm("") + } + + return ( +
+ {/* Gold rule beneath header */} +
+ +

+ Legal Network +

+ + + +

+ {title} +

+
+ ) +} + +// ── Legal Graph Pane ───────────────────────────────────────────────────────── + +export function LegalGraphPane() { + const nodes = useGraphStore((s) => s.nodes) + const edges = useGraphStore((s) => s.edges) + const selectedNode = useGraphStore((s) => s.selectedNode) + const setSelectedNode = useGraphStore((s) => s.setSelectedNode) + const clearSelection = useGraphStore((s) => s.clearSelection) + const loadingNeighbors = useGraphStore((s) => s.loadingNeighborRefs.size > 0) + const schemas = useSchemaStore((s) => s.schemas) + + const sourcesOpen = useAppStore((s) => s.sourcesOpen) + const myContentOpen = useAppStore((s) => s.myContentOpen) + const followingOpen = useAppStore((s) => s.followingOpen) + const agentOpen = useAppStore((s) => s.agentOpen) + const clipsOpen = useAppStore((s) => s.clipsOpen) + const searchTerm = useAppStore((s) => s.searchTerm) + const graphName = useAppStore((s) => s.graphName) + const toggleSources = useAppStore((s) => s.toggleSources) + const toggleMyContent = useAppStore((s) => s.toggleMyContent) + const toggleFollowing = useAppStore((s) => s.toggleFollowing) + const toggleAgent = useAppStore((s) => s.toggleAgent) + const workflowsOpen = useAppStore((s) => s.workflowsOpen) + const toggleWorkflows = useAppStore((s) => s.toggleWorkflows) + + function onSelect(node: GraphNode) { + setSelectedNode(node) + } + + function openPanel(toggle: () => void) { + clearSelection() + toggle() + } + + const hasData = nodes.length > 0 + const isDefaultView = + !sourcesOpen && + !myContentOpen && + !followingOpen && + !agentOpen && + !clipsOpen && + !workflowsOpen && + !selectedNode && + !searchTerm + const title = graphName || "Knowledge Graph" + + return ( +
+
+ + {/* Navy vignette overlay (deeper than default) */} +
+ + + +
+ {hasData ? ( + + ) : ( + + )} + + {isDefaultView && hasData && ( +
+
+ + + + + + {title} + +
+
+ )} + +
+
+ {nodes.length}n · {edges.length}e +
+ {loadingNeighbors && ( +
+ + Loading connections +
+ )} +
+ +
+ openPanel(toggleSources)} + myContentOpen={myContentOpen} + onToggleMyContent={() => openPanel(toggleMyContent)} + followingOpen={followingOpen} + onToggleFollowing={() => openPanel(toggleFollowing)} + agentOpen={agentOpen} + onToggleAgent={() => openPanel(toggleAgent)} + workflowsOpen={workflowsOpen} + onToggleWorkflows={() => openPanel(toggleWorkflows)} + /> +
+ openPanel(toggleSources)} + myContentOpen={myContentOpen} + onToggleMyContent={() => openPanel(toggleMyContent)} + followingOpen={followingOpen} + onToggleFollowing={() => openPanel(toggleFollowing)} + agentOpen={agentOpen} + onToggleAgent={() => openPanel(toggleAgent)} + workflowsOpen={workflowsOpen} + onToggleWorkflows={() => openPanel(toggleWorkflows)} + /> +
+ +
+
+ +
+
+
+ ) +} + +function LegalEmptyState() { + return ( +
+
+ + +
+

+ No documents indexed +

+

+ Search to load the legal network +

+
+ ) +} diff --git a/src/skins/legal/legal-left-pane.tsx b/src/skins/legal/legal-left-pane.tsx new file mode 100644 index 00000000..17824235 --- /dev/null +++ b/src/skins/legal/legal-left-pane.tsx @@ -0,0 +1,80 @@ +"use client" + +import { useGraphStore } from "@/stores/graph-store" +import { useAppStore } from "@/stores/app-store" +import { useSchemaStore } from "@/stores/schema-store" +import { LegalCaseFilesFeed } from "./legal-case-files-feed" +import { SourcesPanel } from "@/components/layout/sources-panel" +import { MyContentPanel } from "@/components/layout/my-content-panel" +import { ClipsPanel } from "@/components/layout/clips-panel" +import { FollowingPanel } from "@/components/layout/following-panel" +import { NodePreviewPanel } from "@/components/layout/node-preview-panel" +import { AgentPanel } from "@/components/agent/agent-panel" +import { WorkflowsPanel } from "@/components/layout/workflows-panel" +import { cn } from "@/lib/utils" +import { useRouter } from "next/navigation" + +type Mode = "preview" | "sources" | "mycontent" | "clips" | "following" | "agent" | "workflows" | "feed" + +export function LegalLeftPane() { + const router = useRouter() + const selectedNode = useGraphStore((s) => s.selectedNode) + const clearSelection = useGraphStore((s) => s.clearSelection) + const returnTo = useGraphStore((s) => s.returnTo) + const setReturnTo = useGraphStore((s) => s.setReturnTo) + const sourcesOpen = useAppStore((s) => s.sourcesOpen) + const myContentOpen = useAppStore((s) => s.myContentOpen) + const clipsOpen = useAppStore((s) => s.clipsOpen) + const followingOpen = useAppStore((s) => s.followingOpen) + const agentOpen = useAppStore((s) => s.agentOpen) + const workflowsOpen = useAppStore((s) => s.workflowsOpen) + const setSourcesOpen = useAppStore((s) => s.setSourcesOpen) + const setMyContentOpen = useAppStore((s) => s.setMyContentOpen) + const setClipsOpen = useAppStore((s) => s.setClipsOpen) + const setFollowingOpen = useAppStore((s) => s.setFollowingOpen) + const setAgentOpen = useAppStore((s) => s.setAgentOpen) + const setWorkflowsOpen = useAppStore((s) => s.setWorkflowsOpen) + const schemas = useSchemaStore((s) => s.schemas) + + function handleBack() { + const dest = returnTo + setReturnTo(null) + clearSelection() + if (dest) router.push(dest) + } + + function pickMode(): Mode { + if (workflowsOpen) return "workflows" + if (agentOpen) return "agent" + if (sourcesOpen) return "sources" + if (myContentOpen) return "mycontent" + if (followingOpen) return "following" + if (selectedNode) return "preview" + if (clipsOpen) return "clips" + return "feed" + } + + const mode = pickMode() + + return ( + + ) +} diff --git a/src/stores/app-store.ts b/src/stores/app-store.ts index 30caa8f9..42fd1fbf 100644 --- a/src/stores/app-store.ts +++ b/src/stores/app-store.ts @@ -1,6 +1,7 @@ "use client" import { create } from "zustand" +import type { SkinId } from "@/skins/index" interface AppState { searchTerm: string @@ -14,6 +15,7 @@ interface AppState { graphName: string graphDescription: string myContentRefreshKey: number + activeSkin: SkinId } interface AppActions { @@ -33,6 +35,7 @@ interface AppActions { closeAllPanels: () => void setGraphMeta: (name: string, description: string) => void bumpMyContentRefresh: () => void + setActiveSkin: (skin: SkinId) => void } export type AppStore = AppState & AppActions @@ -49,6 +52,7 @@ export const useAppStore = create((set) => ({ graphName: "", graphDescription: "", myContentRefreshKey: 0, + activeSkin: "default" as SkinId, setSearchTerm: (searchTerm) => set({ searchTerm }), setSidebarOpen: (sidebarOpen) => set({ sidebarOpen }), setMyContentOpen: (myContentOpen) => set({ myContentOpen, sourcesOpen: false, clipsOpen: false, followingOpen: false, agentOpen: false, workflowsOpen: false }), @@ -71,4 +75,5 @@ export const useAppStore = create((set) => ({ setGraphMeta: (graphName, graphDescription) => set({ graphName, graphDescription }), bumpMyContentRefresh: () => set((s) => ({ myContentRefreshKey: s.myContentRefreshKey + 1 })), + setActiveSkin: (activeSkin) => set({ activeSkin }), }))