From 69390cd20d1f93b15a629f70e3c8705a1043cdad Mon Sep 17 00:00:00 2001 From: elysemukamisha Date: Mon, 18 May 2026 00:11:07 -0600 Subject: [PATCH 1/2] Add MCP-backed graph dashboard auth flow --- .../app/api/graph-search/route.ts | 45 ++ .../app/api/graph/route.ts | 208 +++++++ .../app/audit/page.tsx | 6 +- .../app/auth/callback/route.ts | 42 ++ .../app/graph/page.tsx | 29 + .../app/login/LoginForm.tsx | 152 ++++-- .../app/login/page.tsx | 117 +++- .../app/thoughts/[id]/page.tsx | 53 +- .../components/ConnectionsPanel.tsx | 31 +- .../components/GraphExplorer.tsx | 508 ++++++++++++++++++ .../components/KanbanCard.tsx | 4 +- .../components/KanbanCardModal.tsx | 40 +- .../components/KanbanColumn.tsx | 13 +- .../components/ReflectionComposer.tsx | 1 - .../components/RestrictedToggle.tsx | 6 +- .../components/Sidebar.tsx | 12 + .../components/ThoughtEditor.tsx | 18 + .../open-brain-dashboard-next/lib/api.ts | 212 +++++++- .../open-brain-dashboard-next/lib/auth.ts | 23 +- .../lib/openBrainMcp.ts | 183 +++++++ .../lib/supabaseAuth.ts | 141 +++++ .../open-brain-dashboard-next/lib/types.ts | 35 +- .../open-brain-dashboard-next/middleware.ts | 1 + .../package-lock.json | 94 ++++ .../open-brain-dashboard-next/package.json | 1 + 25 files changed, 1846 insertions(+), 129 deletions(-) create mode 100644 dashboards/open-brain-dashboard-next/app/api/graph-search/route.ts create mode 100644 dashboards/open-brain-dashboard-next/app/api/graph/route.ts create mode 100644 dashboards/open-brain-dashboard-next/app/auth/callback/route.ts create mode 100644 dashboards/open-brain-dashboard-next/app/graph/page.tsx create mode 100644 dashboards/open-brain-dashboard-next/components/GraphExplorer.tsx create mode 100644 dashboards/open-brain-dashboard-next/lib/openBrainMcp.ts create mode 100644 dashboards/open-brain-dashboard-next/lib/supabaseAuth.ts diff --git a/dashboards/open-brain-dashboard-next/app/api/graph-search/route.ts b/dashboards/open-brain-dashboard-next/app/api/graph-search/route.ts new file mode 100644 index 00000000..0519411a --- /dev/null +++ b/dashboards/open-brain-dashboard-next/app/api/graph-search/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireSession, AuthError } from "@/lib/auth"; +import { mcpSearchThoughts } from "@/lib/openBrainMcp"; + +export async function GET(request: NextRequest) { + let apiKey: string; + try { + ({ apiKey } = await requireSession()); + } catch (err) { + if (err instanceof AuthError) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + throw err; + } + + const q = request.nextUrl.searchParams.get("q"); + if (!q?.trim()) { + return NextResponse.json({ error: "Query required" }, { status: 400 }); + } + + try { + const documents = await mcpSearchThoughts(apiKey, q.trim()); + const results = documents.map((doc) => { + const metadata = doc.metadata || {}; + return { + id: doc.id, + content: doc.text, + type: + typeof metadata.type === "string" ? metadata.type : "reference", + created_at: + typeof metadata.created_at === "string" + ? metadata.created_at + : new Date().toISOString(), + metadata, + }; + }); + + return NextResponse.json({ results }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Search failed" }, + { status: 500 } + ); + } +} diff --git a/dashboards/open-brain-dashboard-next/app/api/graph/route.ts b/dashboards/open-brain-dashboard-next/app/api/graph/route.ts new file mode 100644 index 00000000..f2a2a409 --- /dev/null +++ b/dashboards/open-brain-dashboard-next/app/api/graph/route.ts @@ -0,0 +1,208 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireSession, AuthError } from "@/lib/auth"; +import { mcpFetchThought, mcpSearchThoughts, type McpThoughtDocument } from "@/lib/openBrainMcp"; +import type { GraphEdge, GraphNode } from "@/lib/types"; + +const MAX_FIRST_RING = 10; +const MAX_GRAPH_NODES = 24; + +type Meta = { + type?: string; + topics?: string[]; + people?: string[]; + created_at?: string; +}; + +function clampDepth(value: number): 1 | 2 { + return value >= 2 ? 2 : 1; +} + +function normalizeMeta(document: McpThoughtDocument): Meta { + const metadata = document.metadata || {}; + return { + type: typeof metadata.type === "string" ? metadata.type : "reference", + topics: Array.isArray(metadata.topics) ? metadata.topics.filter((v): v is string => typeof v === "string") : [], + people: Array.isArray(metadata.people) ? metadata.people.filter((v): v is string => typeof v === "string") : [], + created_at: + typeof metadata.created_at === "string" + ? metadata.created_at + : undefined, + }; +} + +function toGraphNode( + document: McpThoughtDocument, + ring: 0 | 1 | 2 +): GraphNode { + const meta = normalizeMeta(document); + return { + id: document.id, + type: meta.type || "reference", + importance: 0, + content: document.text, + preview: + document.text.length > 180 + ? `${document.text.slice(0, 180)}...` + : document.text, + created_at: meta.created_at || new Date().toISOString(), + metadata: { + topics: meta.topics, + people: meta.people, + }, + ring, + }; +} + +function overlap(a: string[], b: string[]) { + const right = new Set(b); + return a.filter((item) => right.has(item)); +} + +function buildEdge( + source: McpThoughtDocument, + target: McpThoughtDocument +): GraphEdge { + const sourceMeta = normalizeMeta(source); + const targetMeta = normalizeMeta(target); + const sharedTopics = overlap(sourceMeta.topics || [], targetMeta.topics || []); + const sharedPeople = overlap(sourceMeta.people || [], targetMeta.people || []); + + return { + source: source.id, + target: target.id, + overlap_count: sharedTopics.length + sharedPeople.length, + shared_topics: sharedTopics, + shared_people: sharedPeople, + }; +} + +function buildQueries(document: McpThoughtDocument) { + const meta = normalizeMeta(document); + const queries = [ + ...(meta.topics || []).slice(0, 3), + ...(meta.people || []).slice(0, 3), + ]; + + if (queries.length === 0) { + const fallback = document.text + .replace(/\s+/g, " ") + .trim() + .split(" ") + .slice(0, 8) + .join(" "); + if (fallback) queries.push(fallback); + } + + return Array.from(new Set(queries)); +} + +async function collectNeighbors( + apiKey: string, + seed: McpThoughtDocument, + excludeIds: Set, + maxNeighbors: number +) { + const queries = buildQueries(seed); + const candidates = new Map(); + + for (const query of queries) { + const results = await mcpSearchThoughts(apiKey, query).catch(() => []); + for (const result of results) { + if (excludeIds.has(result.id) || result.id === seed.id) continue; + candidates.set(result.id, result); + } + } + + return Array.from(candidates.values()) + .map((candidate) => ({ + document: candidate, + edge: buildEdge(seed, candidate), + })) + .filter((entry) => (entry.edge.overlap_count || 0) > 0 || queries.length === 1) + .sort((a, b) => (b.edge.overlap_count || 0) - (a.edge.overlap_count || 0)) + .slice(0, maxNeighbors); +} + +export async function GET(request: NextRequest) { + let apiKey: string; + try { + ({ apiKey } = await requireSession()); + } catch (err) { + if (err instanceof AuthError) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + throw err; + } + + const thoughtId = request.nextUrl.searchParams.get("thought_id"); + const depth = clampDepth( + Number(request.nextUrl.searchParams.get("depth") || "1") + ); + + if (!thoughtId?.trim()) { + return NextResponse.json( + { error: "Valid thought_id is required" }, + { status: 400 } + ); + } + + try { + const centerThought = await mcpFetchThought(apiKey, thoughtId.trim()); + const nodes = new Map(); + const edges = new Map(); + let truncated = false; + + nodes.set(centerThought.id, toGraphNode(centerThought, 0)); + + const firstRing = await collectNeighbors( + apiKey, + centerThought, + new Set([centerThought.id]), + MAX_FIRST_RING + ); + + for (const entry of firstRing) { + nodes.set(entry.document.id, toGraphNode(entry.document, 1)); + edges.set(`${centerThought.id}->${entry.document.id}`, entry.edge); + } + + if (depth === 2) { + for (const entry of firstRing) { + const usedIds = new Set(nodes.keys()); + const secondRing = await collectNeighbors( + apiKey, + entry.document, + usedIds, + 3 + ); + + for (const second of secondRing) { + if (!nodes.has(second.document.id)) { + if (nodes.size >= MAX_GRAPH_NODES) { + truncated = true; + continue; + } + nodes.set(second.document.id, toGraphNode(second.document, 2)); + } + + edges.set(`${entry.document.id}->${second.document.id}`, second.edge); + } + } + } + + return NextResponse.json({ + centerId: centerThought.id, + depth, + truncated, + nodes: Array.from(nodes.values()).sort((a, b) => + a.ring === b.ring ? a.content.localeCompare(b.content) : a.ring - b.ring + ), + edges: Array.from(edges.values()), + }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Failed to build graph" }, + { status: 500 } + ); + } +} diff --git a/dashboards/open-brain-dashboard-next/app/audit/page.tsx b/dashboards/open-brain-dashboard-next/app/audit/page.tsx index 463eb4d1..659ea79c 100644 --- a/dashboards/open-brain-dashboard-next/app/audit/page.tsx +++ b/dashboards/open-brain-dashboard-next/app/audit/page.tsx @@ -47,7 +47,7 @@ export default function AuditPage() { if (selected.size === data.data.length) { setSelected(new Set()); } else { - setSelected(new Set(data.data.map((t) => t.id))); + setSelected(new Set(data.data.map((t) => Number(t.id)))); } }; @@ -131,8 +131,8 @@ export default function AuditPage() { toggleSelect(t.id)} + checked={selected.has(Number(t.id))} + onChange={() => toggleSelect(Number(t.id))} className="accent-violet" /> diff --git a/dashboards/open-brain-dashboard-next/app/auth/callback/route.ts b/dashboards/open-brain-dashboard-next/app/auth/callback/route.ts new file mode 100644 index 00000000..c94d7df6 --- /dev/null +++ b/dashboards/open-brain-dashboard-next/app/auth/callback/route.ts @@ -0,0 +1,42 @@ +import { redirect } from "next/navigation"; +import type { EmailOtpType } from "@supabase/supabase-js"; +import { getSession } from "@/lib/auth"; +import { exchangeAuthCodeForUser, verifyMagicLink } from "@/lib/supabaseAuth"; + +function sanitizeNext(next: string | null) { + if (!next || !next.startsWith("/")) { + return "/"; + } + return next; +} + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const code = searchParams.get("code"); + const tokenHash = searchParams.get("token_hash"); + const type = searchParams.get("type") as EmailOtpType | null; + const next = sanitizeNext(searchParams.get("next")); + + try { + const user = code + ? await exchangeAuthCodeForUser(code) + : tokenHash && type + ? await verifyMagicLink(tokenHash, type) + : null; + + if (!user) { + redirect("/login?error=Invalid%20or%20expired%20sign-in%20link"); + } + + const session = await getSession(); + session.userId = user.id; + session.email = user.email ?? ""; + session.loggedIn = true; + session.restrictedUnlocked = false; + await session.save(); + } catch { + redirect("/login?error=Authentication%20callback%20failed"); + } + + redirect(next); +} diff --git a/dashboards/open-brain-dashboard-next/app/graph/page.tsx b/dashboards/open-brain-dashboard-next/app/graph/page.tsx new file mode 100644 index 00000000..30584ccd --- /dev/null +++ b/dashboards/open-brain-dashboard-next/app/graph/page.tsx @@ -0,0 +1,29 @@ +import { requireSessionOrRedirect } from "@/lib/auth"; +import { GraphExplorer } from "@/components/GraphExplorer"; + +export const dynamic = "force-dynamic"; + +function parseThoughtId(value: string | undefined) { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function parseDepth(value: string | undefined): 1 | 2 { + return value === "2" ? 2 : 1; +} + +export default async function GraphPage({ + searchParams, +}: { + searchParams: Promise>; +}) { + await requireSessionOrRedirect(); + const params = await searchParams; + + return ( + + ); +} diff --git a/dashboards/open-brain-dashboard-next/app/login/LoginForm.tsx b/dashboards/open-brain-dashboard-next/app/login/LoginForm.tsx index 73ef3101..0b96a46f 100644 --- a/dashboards/open-brain-dashboard-next/app/login/LoginForm.tsx +++ b/dashboards/open-brain-dashboard-next/app/login/LoginForm.tsx @@ -2,49 +2,125 @@ import { useActionState } from "react"; +type FormState = { + error?: string; + success?: string; +}; + export function LoginForm({ - action, + credentialAction, + githubAction, + googleAction, }: { - action: (formData: FormData) => Promise<{ error: string } | undefined>; + credentialAction: (formData: FormData) => Promise; + githubAction: () => Promise; + googleAction: () => Promise; }) { - const [state, formAction, pending] = useActionState( - async (_prev: { error: string } | undefined, formData: FormData) => { - return await action(formData); - }, - undefined - ); + const [credentialState, credentialFormAction, credentialPending] = + useActionState( + async (_prev: FormState | undefined, formData: FormData) => { + return credentialAction(formData); + }, + undefined + ); return ( -
-
- - -
+
+ +
+ + +
+ +
+ + +
+ + {credentialState?.error && ( +

{credentialState.error}

+ )} + {credentialState?.success && ( +

{credentialState.success}

+ )} - {state?.error && ( -

{state.error}

- )} - - - +
+ + +
+ + +
+
+
+
+
+ + Or continue with Google or GitHub + +
+
+ +
+
+ +
+ +
+ +
+
+
); } diff --git a/dashboards/open-brain-dashboard-next/app/login/page.tsx b/dashboards/open-brain-dashboard-next/app/login/page.tsx index 0e44281a..d296c085 100644 --- a/dashboards/open-brain-dashboard-next/app/login/page.tsx +++ b/dashboards/open-brain-dashboard-next/app/login/page.tsx @@ -1,42 +1,107 @@ import { redirect } from "next/navigation"; import { getSession } from "@/lib/auth"; +import { + getGithubSignInUrl, + getGoogleSignInUrl, + signInWithPassword, + signUpWithPassword, +} from "@/lib/supabaseAuth"; import { LoginForm } from "./LoginForm"; -async function loginAction(formData: FormData) { +type LoginState = { + error?: string; + success?: string; +}; + +function normalizeEmail(value: FormDataEntryValue | null) { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + +function validatePassword(value: FormDataEntryValue | null) { + return typeof value === "string" ? value : ""; +} + +async function credentialAction(formData: FormData): Promise { "use server"; - const apiKey = formData.get("apiKey") as string; - if (!apiKey?.trim()) { - return { error: "API key is required" }; + const intent = formData.get("intent"); + const email = normalizeEmail(formData.get("email")); + const password = validatePassword(formData.get("password")); + + if (!email) { + return { error: "Email is required" }; + } + + if (!password) { + return { error: "Password is required" }; } - // Validate key against health endpoint - const apiUrl = process.env.NEXT_PUBLIC_API_URL; try { - const res = await fetch(`${apiUrl}/health`, { - headers: { "x-brain-key": apiKey }, - }); - if (!res.ok) { - return { error: "Invalid API key or service unavailable" }; + if (intent === "sign-up") { + const data = await signUpWithPassword(email, password); + + if (data.user && data.session) { + const session = await getSession(); + session.userId = data.user.id; + session.email = data.user.email ?? email; + session.loggedIn = true; + session.restrictedUnlocked = false; + await session.save(); + redirect("/"); + } + + return { + success: + "Account created. Check your email to confirm your address before signing in.", + }; } - } catch { - return { error: "Could not reach API. Check your connection." }; + + const user = await signInWithPassword(email, password); + const session = await getSession(); + session.userId = user.id; + session.email = user.email ?? email; + session.loggedIn = true; + session.restrictedUnlocked = false; + await session.save(); + + redirect("/"); + } catch (error) { + return { + error: + error instanceof Error + ? error.message + : "Authentication failed", + }; } +} - const session = await getSession(); - session.apiKey = apiKey; - session.loggedIn = true; - await session.save(); +async function githubAction() { + "use server"; - redirect("/"); + const url = await getGithubSignInUrl("/"); + redirect(url); } -export default async function LoginPage() { +async function googleAction() { + "use server"; + + const url = await getGoogleSignInUrl("/"); + redirect(url); +} + +export default async function LoginPage({ + searchParams, +}: { + searchParams: Promise>; +}) { const session = await getSession(); - if (session.loggedIn && session.apiKey) { + if (session.loggedIn && session.userId) { redirect("/"); } + const params = await searchParams; + const callbackError = params.error; + return (
@@ -48,11 +113,19 @@ export default async function LoginPage() { Open Brain

- Enter your API key to continue + Sign in with GitHub, Google, or your Google / Gmail email

- + {callbackError && ( +

{callbackError}

+ )} + +
); diff --git a/dashboards/open-brain-dashboard-next/app/thoughts/[id]/page.tsx b/dashboards/open-brain-dashboard-next/app/thoughts/[id]/page.tsx index c23f1148..f814834f 100644 --- a/dashboards/open-brain-dashboard-next/app/thoughts/[id]/page.tsx +++ b/dashboards/open-brain-dashboard-next/app/thoughts/[id]/page.tsx @@ -16,6 +16,7 @@ import { FormattedDate } from "@/components/FormattedDate"; import Link from "next/link"; export const dynamic = "force-dynamic"; +const MCP_MODE = Boolean(process.env.OPEN_BRAIN_MCP_URL); export default async function ThoughtDetailPage({ params, @@ -26,8 +27,7 @@ export default async function ThoughtDetailPage({ const session = await getSession(); const excludeRestricted = !session.restrictedUnlocked; const { id } = await params; - const thoughtId = parseInt(id, 10); - if (isNaN(thoughtId)) notFound(); + const thoughtId = id; let thought; try { @@ -55,7 +55,7 @@ export default async function ThoughtDetailPage({ let reflections: Awaited> = []; try { - reflections = await fetchReflections(apiKey, thoughtId); + reflections = await fetchReflections(apiKey, Number(thoughtId)); } catch { reflections = []; } @@ -66,24 +66,27 @@ export default async function ThoughtDetailPage({ async function editAction(formData: FormData) { "use server"; + if (MCP_MODE) return; const { apiKey } = await requireSessionOrRedirect(); const content = formData.get("content") as string; const type = formData.get("type") as string; const importance = parseInt(formData.get("importance") as string, 10); - await updateThought(apiKey, thoughtId, { content, type, importance }); + await updateThought(apiKey, Number(thoughtId), { content, type, importance }); } async function deleteAction() { "use server"; + if (MCP_MODE) return; const { apiKey } = await requireSessionOrRedirect(); - await deleteThought(apiKey, thoughtId); + await deleteThought(apiKey, Number(thoughtId)); + redirect("/thoughts"); } return (
{/* Header */} -
+
@@ -117,11 +120,25 @@ export default async function ThoughtDetailPage({ ` | Sensitivity: ${thought.sensitivity_tier}`}

- +
+ + View graph + + {!MCP_MODE && } +
+ {MCP_MODE && ( +
+ This thought is being shown in MCP read-only mode. Editing, deleting, reflections, and legacy REST-based connections are still being migrated. +
+ )} + {/* Content + Edit */} - + {/* Metadata panel */} {(topics.length > 0 || @@ -173,13 +190,15 @@ export default async function ThoughtDetailPage({ )} {/* Connections */} - 0 || - ((meta.people as string[]) || []).length > 0 - } - /> + {!MCP_MODE && ( + 0 || + ((meta.people as string[]) || []).length > 0 + } + /> + )} {/* Reflections */} {reflections.length > 0 && ( @@ -211,7 +230,9 @@ export default async function ThoughtDetailPage({
)} - + {!MCP_MODE && typeof thought.id === "number" && ( + + )}
); } diff --git a/dashboards/open-brain-dashboard-next/components/ConnectionsPanel.tsx b/dashboards/open-brain-dashboard-next/components/ConnectionsPanel.tsx index 8eaf4ad9..3fc26178 100644 --- a/dashboards/open-brain-dashboard-next/components/ConnectionsPanel.tsx +++ b/dashboards/open-brain-dashboard-next/components/ConnectionsPanel.tsx @@ -6,7 +6,7 @@ import { TypeBadge } from "./ThoughtCard"; import { FormattedDate } from "./FormattedDate"; interface Connection { - id: number; + id: string | number; type: string; importance: number; preview: string; @@ -23,23 +23,40 @@ export function ConnectionsPanel({ thoughtId, hasMetadata, }: { - thoughtId: number; + thoughtId: string | number; hasMetadata: boolean; }) { const [connections, setConnections] = useState([]); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(hasMetadata); useEffect(() => { if (!hasMetadata) { - setLoading(false); return; } + let cancelled = false; + fetch(`/api/thoughts/${thoughtId}/connections`) .then((res) => res.json()) - .then((data) => setConnections(data.connections ?? [])) - .catch(() => setConnections([])) - .finally(() => setLoading(false)); + .then((data) => { + if (!cancelled) { + setConnections(data.connections ?? []); + } + }) + .catch(() => { + if (!cancelled) { + setConnections([]); + } + }) + .finally(() => { + if (!cancelled) { + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; }, [thoughtId, hasMetadata]); if (!hasMetadata || (!loading && connections.length === 0)) return null; diff --git a/dashboards/open-brain-dashboard-next/components/GraphExplorer.tsx b/dashboards/open-brain-dashboard-next/components/GraphExplorer.tsx new file mode 100644 index 00000000..8231555a --- /dev/null +++ b/dashboards/open-brain-dashboard-next/components/GraphExplorer.tsx @@ -0,0 +1,508 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { FormattedDate } from "@/components/FormattedDate"; +import { SearchBar } from "@/components/SearchBar"; +import { TypeBadge } from "@/components/ThoughtCard"; +import type { GraphNode, GraphResponse } from "@/lib/types"; + +type SearchResult = { + id: string; + content: string; + type: string; + created_at: string; + metadata?: { + topics?: string[]; + people?: string[]; + }; +}; + +const CANVAS_WIDTH = 1120; +const CANVAS_HEIGHT = 680; +const CENTER_X = CANVAS_WIDTH / 2; +const CENTER_Y = CANVAS_HEIGHT / 2; + +const NODE_COLORS: Record< + string, + { fill: string; stroke: string; text: string } +> = { + idea: { fill: "rgba(251, 191, 36, 0.18)", stroke: "#fbbf24", text: "#fde68a" }, + task: { fill: "rgba(96, 165, 250, 0.18)", stroke: "#60a5fa", text: "#bfdbfe" }, + person_note: { fill: "rgba(52, 211, 153, 0.18)", stroke: "#34d399", text: "#a7f3d0" }, + reference: { fill: "rgba(148, 163, 184, 0.18)", stroke: "#94a3b8", text: "#cbd5e1" }, + decision: { fill: "rgba(139, 92, 246, 0.18)", stroke: "#8b5cf6", text: "#ddd6fe" }, + lesson: { fill: "rgba(251, 146, 60, 0.18)", stroke: "#fb923c", text: "#fdba74" }, + meeting: { fill: "rgba(34, 211, 238, 0.18)", stroke: "#22d3ee", text: "#a5f3fc" }, + journal: { fill: "rgba(244, 114, 182, 0.18)", stroke: "#f472b6", text: "#fbcfe8" }, +}; + +function clampDepth(value: number): 1 | 2 { + return value >= 2 ? 2 : 1; +} + +function getNodeColors(type: string) { + return NODE_COLORS[type] ?? NODE_COLORS.reference; +} + +function truncateLabel(value: string, max = 22) { + const compact = value.replace(/\s+/g, " ").trim(); + if (compact.length <= max) return compact; + return `${compact.slice(0, max - 1)}...`; +} + +function polarPosition( + index: number, + total: number, + radiusX: number, + radiusY: number +) { + const angle = (-Math.PI / 2) + (index / Math.max(total, 1)) * Math.PI * 2; + return { + x: CENTER_X + Math.cos(angle) * radiusX, + y: CENTER_Y + Math.sin(angle) * radiusY, + }; +} + +function buildNodePositions(graph: GraphResponse) { + const positions = new Map(); + positions.set(graph.centerId, { x: CENTER_X, y: CENTER_Y }); + + const ringOne = graph.nodes.filter((node) => node.ring === 1); + const ringTwo = graph.nodes.filter((node) => node.ring === 2); + + ringOne.forEach((node, index) => { + positions.set(node.id, polarPosition(index, ringOne.length, 250, 180)); + }); + + ringTwo.forEach((node, index) => { + positions.set(node.id, polarPosition(index, ringTwo.length, 430, 300)); + }); + + return positions; +} + +function graphNodeSize(node: GraphNode) { + const base = node.ring === 0 ? 24 : node.ring === 1 ? 18 : 14; + const importanceBoost = Math.min(Math.max(node.importance, 0), 100) / 25; + return base + importanceBoost; +} + +function GraphCanvas({ + graph, + selectedNodeId, + onSelectNode, +}: { + graph: GraphResponse; + selectedNodeId: string; + onSelectNode: (nodeId: string) => void; +}) { + const positions = buildNodePositions(graph); + + return ( +
+ + + + + + + + + + + + {graph.edges.map((edge) => { + const source = positions.get(edge.source); + const target = positions.get(edge.target); + if (!source || !target) return null; + const isSelected = + edge.source === selectedNodeId || edge.target === selectedNodeId; + + return ( + + + + ); + })} + + {graph.nodes.map((node) => { + const position = positions.get(node.id); + if (!position) return null; + const colors = getNodeColors(node.type); + const isCenter = node.id === graph.centerId; + const isSelected = node.id === selectedNodeId; + const radius = graphNodeSize(node); + + return ( + onSelectNode(node.id)} + className="cursor-pointer" + > + + + {isCenter && ( + + )} + + {truncateLabel(node.content)} + + + ); + })} + +
+ ); +} + +export function GraphExplorer({ + initialThoughtId, + initialDepth = 1, +}: { + initialThoughtId?: string; + initialDepth?: 1 | 2; +}) { + const router = useRouter(); + const [graph, setGraph] = useState(null); + const [searchResults, setSearchResults] = useState([]); + const [selectedNodeId, setSelectedNodeId] = useState( + initialThoughtId ?? null + ); + const [depth, setDepth] = useState<1 | 2>(clampDepth(initialDepth)); + const [searchError, setSearchError] = useState(null); + const [graphError, setGraphError] = useState(null); + const [searching, setSearching] = useState(false); + const [loadingGraph, setLoadingGraph] = useState(false); + + async function loadGraph(thoughtId: string, nextDepth = depth) { + setLoadingGraph(true); + setGraphError(null); + + try { + const response = await fetch( + `/api/graph?thought_id=${encodeURIComponent(thoughtId)}&depth=${nextDepth}` + ); + const data = (await response.json()) as GraphResponse & { + error?: string; + }; + + if (!response.ok) { + throw new Error(data.error || "Failed to load graph"); + } + + setGraph(data); + setSelectedNodeId(thoughtId); + router.replace(`/graph?thought=${encodeURIComponent(thoughtId)}&depth=${nextDepth}`, { + scroll: false, + }); + } catch (error) { + setGraphError( + error instanceof Error ? error.message : "Failed to load graph" + ); + } finally { + setLoadingGraph(false); + } + } + + async function handleSearch(query: string, mode: "semantic" | "text") { + setSearching(true); + setSearchError(null); + + try { + const response = await fetch( + `/api/graph-search?q=${encodeURIComponent(query)}&mode=${mode}&page=1` + ); + const data = (await response.json()) as { + results?: SearchResult[]; + error?: string; + }; + + if (!response.ok) { + throw new Error(data.error || "Search failed"); + } + + setSearchResults((data.results ?? []).slice(0, 8)); + } catch (error) { + setSearchResults([]); + setSearchError( + error instanceof Error ? error.message : "Search failed" + ); + } finally { + setSearching(false); + } + } + + useEffect(() => { + if (initialThoughtId) { + void loadGraph(initialThoughtId, clampDepth(initialDepth)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialThoughtId, initialDepth]); + + const selectedNode = + graph?.nodes.find((node) => node.id === selectedNodeId) ?? null; + const selectedConnections = + graph?.edges.filter( + (edge) => edge.source === selectedNodeId || edge.target === selectedNodeId + ).length ?? 0; + + return ( +
+
+
+

Graph

+

+ Explore a local thought graph around any note in your brain. Start + with search, then pivot node by node the way you would in an + Obsidian local graph. +

+
+ +
+ + Depth + + {[1, 2].map((value) => { + const active = depth === value; + return ( + + ); + })} +
+
+ + + + {(searching || searchError || searchResults.length > 0) && ( +
+
+

+ Search Results +

+ {searching && ( + Searching... + )} +
+ + {searchError &&

{searchError}

} + +
+ {searchResults.map((result) => ( + + ))} +
+
+ )} + + {graphError && ( +
+ {graphError} +
+ )} + + {!graph && !loadingGraph && ( +
+

+ Pick a thought to generate its local graph +

+

+ This first pass uses the project's existing connection signals, + so we can visualize relationship neighborhoods immediately without + adding a separate graph service. +

+
+ )} + + {loadingGraph && ( +
+
+ Building graph... +
+ )} + + {graph && ( +
+ + + +
+ )} +
+ ); +} diff --git a/dashboards/open-brain-dashboard-next/components/KanbanCard.tsx b/dashboards/open-brain-dashboard-next/components/KanbanCard.tsx index 20edf1f1..7f1e305e 100644 --- a/dashboards/open-brain-dashboard-next/components/KanbanCard.tsx +++ b/dashboards/open-brain-dashboard-next/components/KanbanCard.tsx @@ -68,7 +68,7 @@ export function KanbanCard({
onPriorityChange(thought.id, val)} + onPriorityChange={(val) => onPriorityChange(Number(thought.id), val)} />
@@ -97,7 +97,7 @@ export function KanbanCard({ type="button" onClick={(e) => { e.stopPropagation(); - onArchive(thought.id); + onArchive(Number(thought.id)); }} className="text-[10px] text-text-muted hover:text-text-secondary transition-colors" title="Archive" diff --git a/dashboards/open-brain-dashboard-next/components/KanbanCardModal.tsx b/dashboards/open-brain-dashboard-next/components/KanbanCardModal.tsx index 5042919d..1cdf4349 100644 --- a/dashboards/open-brain-dashboard-next/components/KanbanCardModal.tsx +++ b/dashboards/open-brain-dashboard-next/components/KanbanCardModal.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import { createPortal } from "react-dom"; import type { Thought, KanbanStatus } from "@/lib/types"; import { KANBAN_STATUSES, KANBAN_LABELS, PRIORITY_LEVELS, getPriorityLevel, THOUGHT_TYPES, KANBAN_TYPES } from "@/lib/types"; @@ -27,20 +27,24 @@ export function KanbanCardModal({ const [status, setStatus] = useState(thought.status ?? "new"); const [importance, setImportance] = useState(thought.importance); const [type, setType] = useState(thought.type); - const [hasChanges, setHasChanges] = useState(false); const [showDiscardConfirm, setShowDiscardConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const backdropRef = useRef(null); const textareaRef = useRef(null); - useEffect(() => { - const isChanged = - content !== thought.content || - status !== (thought.status ?? "new") || - importance !== thought.importance || - type !== thought.type; - setHasChanges(isChanged); - }, [content, status, importance, type, thought]); + const hasChanges = + content !== thought.content || + status !== (thought.status ?? "new") || + importance !== thought.importance || + type !== thought.type; + + const tryClose = useCallback(() => { + if (hasChanges) { + setShowDiscardConfirm(true); + } else { + onClose(); + } + }, [hasChanges, onClose]); useEffect(() => { function handleEscape(e: KeyboardEvent) { @@ -50,7 +54,7 @@ export function KanbanCardModal({ } document.addEventListener("keydown", handleEscape); return () => document.removeEventListener("keydown", handleEscape); - }, [hasChanges]); + }, [tryClose]); // Lock body scroll useEffect(() => { @@ -70,14 +74,6 @@ export function KanbanCardModal({ } }, [content]); - function tryClose() { - if (hasChanges) { - setShowDiscardConfirm(true); - } else { - onClose(); - } - } - function handleBackdropClick(e: React.MouseEvent) { if (e.target === backdropRef.current) { tryClose(); @@ -98,7 +94,7 @@ export function KanbanCardModal({ } if (Object.keys(updates).length > 0) { - onSave(thought.id, updates); + onSave(Number(thought.id), updates); } onClose(); } @@ -249,7 +245,7 @@ export function KanbanCardModal({