diff --git a/README.md b/README.md index afa9bfa7..beb04892 100644 --- a/README.md +++ b/README.md @@ -83,13 +83,15 @@ Standalone capabilities that make your Open Brain smarter. | ------ | ------------ | ----------- | | [Auto-Capture Protocol](recipes/auto-capture/) | Stores ACT NOW items and session summaries in Open Brain at session close using the reusable Auto-Capture skill | [@jaredirish](https://github.com/jaredirish) | | [Panning for Gold](recipes/panning-for-gold/) | Mine brain dumps and voice transcripts for actionable ideas — battle-tested across 13+ sessions | [@jaredirish](https://github.com/jaredirish) | -| [Claudeception](recipes/claudeception/) | Self-improving system that creates new skills from work sessions — skills that create other skills | [@jaredirish](https://github.com/jaredirish) | +| [Aiception (formerly Claudeception)](recipes/claudeception/) | Self-improving system that creates new skills from work sessions — skills that create other skills | [@jaredirish](https://github.com/jaredirish) | | [Schema-Aware Routing](recipes/schema-aware-routing/) | LLM-powered routing that distributes unstructured text across multiple database tables | [@claydunker-yalc](https://github.com/claydunker-yalc) | | [Fingerprint Dedup Backfill](recipes/fingerprint-dedup-backfill/) | Backfill content fingerprints and safely remove duplicate thoughts | [@alanshurafa](https://github.com/alanshurafa) | | [Source Filtering](recipes/source-filtering/) | Filter thoughts by source and backfill missing metadata for early imports | [@matthallett1](https://github.com/matthallett1) | | [Life Engine](recipes/life-engine/) | Self-improving personal assistant — calendar, habits, health, proactive briefings via Telegram or Discord | [@justfinethanku](https://github.com/justfinethanku) | | [Life Engine Video](recipes/life-engine-video/) | Add-on that renders Life Engine briefings as short animated videos with voiceover | [@justfinethanku](https://github.com/justfinethanku) | | [Daily Digest](recipes/daily-digest/) | Automated daily summary of recent thoughts delivered via email or Slack | OB1 Team | +| [Bring Your Own Context](recipes/bring-your-own-context/) | Portable context workflow that packages extraction prompts, profile generation, and remote MCP deployment into one entrypoint | [@jonathanedwards](https://github.com/jonathanedwards) | +| [Work Operating Model Activation](recipes/work-operating-model-activation/) | Conversation-first workflow that turns tacit work patterns into structured Open Brain records and agent-ready operating files | [@jonathanedwards](https://github.com/jonathanedwards) | | [Research-to-Decision Workflow](recipes/research-to-decision-workflow/) | Composition recipe that chains canonical skills into operator and investor research, synthesis, meeting, and memo workflows | [@NateBJones](https://github.com/NateBJones) | ### [`/skills`](skills/) — Agent Skills @@ -105,7 +107,8 @@ Plain-text skill packs you can drop into Claude Code, Codex, or other AI clients | [Research Synthesis Skill Pack](skills/research-synthesis/) | Synthesizes source sets into findings, contradictions, confidence markers, and next questions | [@NateBJones](https://github.com/NateBJones) | | [Meeting Synthesis Skill Pack](skills/meeting-synthesis/) | Converts meeting notes or transcripts into decisions, action items, risks, and follow-up artifacts | [@NateBJones](https://github.com/NateBJones) | | [Panning for Gold Skill Pack](skills/panning-for-gold/) | Turns brain dumps and transcripts into evaluated idea inventories | [@jaredirish](https://github.com/jaredirish) | -| [Claudeception Skill Pack](skills/claudeception/) | Extracts reusable lessons from work sessions into new skills | [@jaredirish](https://github.com/jaredirish) | +| [Aiception Skill Pack (formerly Claudeception)](skills/claudeception/) | Extracts reusable lessons from work sessions into new skills | [@jaredirish](https://github.com/jaredirish) | +| [Work Operating Model Skill Pack](skills/work-operating-model/) | Runs a five-layer elicitation interview and saves the approved operating model into Open Brain | [@jonathanedwards](https://github.com/jonathanedwards) | ### [`/dashboards`](dashboards/) — Frontend Templates diff --git a/dashboards/open-brain-dashboard-next/.gitignore b/dashboards/open-brain-dashboard-next/.gitignore index 84d19278..450ca170 100644 --- a/dashboards/open-brain-dashboard-next/.gitignore +++ b/dashboards/open-brain-dashboard-next/.gitignore @@ -6,3 +6,4 @@ node_modules/ .env.local.example next-env.d.ts tsconfig.tsbuildinfo +.vercel diff --git a/dashboards/open-brain-dashboard-next/README.md b/dashboards/open-brain-dashboard-next/README.md index d38ea4ad..87e19fb5 100644 --- a/dashboards/open-brain-dashboard-next/README.md +++ b/dashboards/open-brain-dashboard-next/README.md @@ -14,11 +14,12 @@ A full-featured web dashboard for your Open Brain second brain. Browse, search, ## What It Does -Provides 8 pages for managing your thoughts: +Provides 9 pages for managing your thoughts: | Page | Description | |------|-------------| -| **Dashboard** | Stats overview (total thoughts, type distribution, top topics), recent activity, quick capture | +| **Dashboard** | Stats overview (total thoughts, type distribution, top topics), recent activity, quick capture, workflow summary widget | +| **Workflow** | Kanban board for tasks and ideas with drag-and-drop status management (New → Planning → Active → Review → Done → Archived) | | **Browse** | Paginated thought table with filters for type, source, and importance | | **Detail** | Full thought view with inline editing, delete, linked reflections, and related connections | | **Search** | Semantic (vector similarity) and full-text search with match scores and pagination | @@ -103,6 +104,43 @@ When working correctly: - **Add to Brain** auto-routes short text (< 500 chars, single paragraph) to single capture, and long/structured text to extraction with dry-run preview - **Detail page** shows full thought content with metadata, inline edit for content/type/importance, and linked reflections +## Workflow Board + +The Workflow page adds a visual kanban board for managing `task` and `idea` thoughts through status stages. + +### Features + +- **Drag-and-drop** between status columns using @dnd-kit (touch-friendly with 200ms hold delay) +- **Collapsible columns** — click the arrow to collapse any column to a slim vertical bar (persisted in localStorage) +- **Auto-adjusting widths** — expanded columns share available space equally, no horizontal scrollbar +- **Inline editing** — tap a card to open the edit modal (status, priority, type, content) +- **Priority dots** — click to change priority (Critical/High/Medium/Low mapped from importance 0-100) +- **Dashboard widget** — summary of active workflow items on the main dashboard +- **Mobile-first** — responsive layout, pinch-to-zoom enabled, full-screen edit modal on small screens + +### Status Flow + +``` +New → Planning → Active → Review → Done → (Archived) +``` + +Cards auto-archive from Done after 30 days. Archived cards are hidden by default (toggle with "Show archived"). + +### Database Requirements + +The Workflow board requires two additional columns on the `thoughts` table. See the [workflow-status schema](../../schemas/workflow-status/) for the migration SQL. + +### MCP Integration + +The `progress_task` tool in the Open Brain MCP server allows AI assistants to update task status and priority conversationally: + +``` +"Move the API redesign task to active" +"Set priority on thought 42 to high" +``` + +When a new task or idea is captured, the MCP server auto-assigns `status: "new"`. + ## REST API Endpoints Required The dashboard calls these endpoints on your Open Brain REST API: @@ -121,6 +159,8 @@ The dashboard calls these endpoints on your Open Brain REST API: | `/ingest` | POST | Smart ingest (extraction) | | `/ingestion-jobs` | GET | Ingest page (job history) | | `/duplicates` | GET | Duplicates page | +| `/thoughts?type=task` | GET | Workflow board (filtered by type) | +| `/thought/:id` | PUT | Workflow board (status/priority updates) | > [!NOTE] > If your Open Brain instance doesn't have all these endpoints (e.g., no smart-ingest or duplicates), those pages will show errors but the core pages (dashboard, browse, search, detail) will still work. @@ -154,6 +194,7 @@ No API key is stored in environment variables or exposed to the browser. - **React 19** with TypeScript - **Tailwind CSS 4** (dark theme) - **iron-session 8** (encrypted cookies) +- **@dnd-kit** (drag-and-drop for workflow board) - Zero external runtime dependencies beyond these ## Troubleshooting diff --git a/dashboards/open-brain-dashboard-next/app/api/kanban/delete/route.ts b/dashboards/open-brain-dashboard-next/app/api/kanban/delete/route.ts new file mode 100644 index 00000000..cee60527 --- /dev/null +++ b/dashboards/open-brain-dashboard-next/app/api/kanban/delete/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireSession, AuthError } from "@/lib/auth"; +import { deleteThought } from "@/lib/api"; + +export async function POST(request: NextRequest) { + let apiKey: string; + try { + ({ apiKey } = await requireSession()); + } catch (err) { + if (err instanceof AuthError) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + throw err; + } + + try { + const body = await request.json(); + const { thoughtId } = body; + + if (!thoughtId || typeof thoughtId !== "number") { + return NextResponse.json( + { error: "thoughtId (number) is required" }, + { status: 400 } + ); + } + + await deleteThought(apiKey, thoughtId); + return NextResponse.json({ success: true }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Delete failed" }, + { status: 500 } + ); + } +} diff --git a/dashboards/open-brain-dashboard-next/app/api/kanban/route.ts b/dashboards/open-brain-dashboard-next/app/api/kanban/route.ts new file mode 100644 index 00000000..9e8f8cc5 --- /dev/null +++ b/dashboards/open-brain-dashboard-next/app/api/kanban/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireSession, AuthError } from "@/lib/auth"; +import { getSession } from "@/lib/auth"; +import { fetchKanbanThoughts } from "@/lib/api"; + +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 session = await getSession(); + const excludeRestricted = session.restrictedUnlocked !== true; + const includeArchived = + request.nextUrl.searchParams.get("archived") === "true"; + + const statusFilter = includeArchived + ? "new,planning,active,review,done,archived" + : "new,planning,active,review,done"; + + try { + const thoughts = await fetchKanbanThoughts(apiKey, { + status: statusFilter, + exclude_restricted: excludeRestricted, + }); + return NextResponse.json({ thoughts }); + } catch (err) { + return NextResponse.json( + { + error: + err instanceof Error ? err.message : "Failed to fetch kanban data", + }, + { status: 500 } + ); + } +} diff --git a/dashboards/open-brain-dashboard-next/app/api/kanban/update/route.ts b/dashboards/open-brain-dashboard-next/app/api/kanban/update/route.ts new file mode 100644 index 00000000..532f69af --- /dev/null +++ b/dashboards/open-brain-dashboard-next/app/api/kanban/update/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireSession, AuthError } from "@/lib/auth"; +import { updateThought } from "@/lib/api"; + +const VALID_STATUSES = [ + "new", + "planning", + "active", + "review", + "done", + "archived", +]; + +export async function POST(request: NextRequest) { + let apiKey: string; + try { + ({ apiKey } = await requireSession()); + } catch (err) { + if (err instanceof AuthError) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + throw err; + } + + try { + const body = await request.json(); + const { thoughtId, status, importance, content, type } = body; + + if (!thoughtId || typeof thoughtId !== "number") { + return NextResponse.json( + { error: "thoughtId (number) is required" }, + { status: 400 } + ); + } + + if (status !== undefined && status !== null && !VALID_STATUSES.includes(status)) { + return NextResponse.json( + { error: `Invalid status. Must be one of: ${VALID_STATUSES.join(", ")}` }, + { status: 400 } + ); + } + + const updates: Record = {}; + if (status !== undefined) updates.status = status; + if (importance !== undefined) updates.importance = importance; + if (content !== undefined) updates.content = content; + if (type !== undefined) updates.type = type; + + if (Object.keys(updates).length === 0) { + return NextResponse.json( + { error: "No fields to update" }, + { status: 400 } + ); + } + + const result = await updateThought(apiKey, thoughtId, updates); + return NextResponse.json(result); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Update failed" }, + { status: 500 } + ); + } +} diff --git a/dashboards/open-brain-dashboard-next/app/globals.css b/dashboards/open-brain-dashboard-next/app/globals.css index 90a1d8f9..ab4324b2 100644 --- a/dashboards/open-brain-dashboard-next/app/globals.css +++ b/dashboards/open-brain-dashboard-next/app/globals.css @@ -49,3 +49,18 @@ body { background: var(--color-violet-glow); color: var(--color-text-primary); } + +/* Phone landscape: hide desktop sidebar, keep mobile topbar, remove sidebar margin */ +@media (orientation: landscape) and (max-height: 600px) { + aside { + display: none !important; + } + main { + margin-left: 0 !important; + padding-top: 48px !important; + } + /* Force mobile topbar visible in landscape */ + .md\:hidden { + display: flex !important; + } +} diff --git a/dashboards/open-brain-dashboard-next/app/kanban/page.tsx b/dashboards/open-brain-dashboard-next/app/kanban/page.tsx new file mode 100644 index 00000000..5fd3d4e1 --- /dev/null +++ b/dashboards/open-brain-dashboard-next/app/kanban/page.tsx @@ -0,0 +1,21 @@ +import { requireSessionOrRedirect } from "@/lib/auth"; +import { KanbanBoard } from "@/components/KanbanBoard"; + +export const dynamic = "force-dynamic"; + + +export default async function KanbanPage() { + await requireSessionOrRedirect(); + + return ( +
+
+

Workflow

+

+ Track tasks and ideas through your workflow +

+
+ +
+ ); +} diff --git a/dashboards/open-brain-dashboard-next/app/layout.tsx b/dashboards/open-brain-dashboard-next/app/layout.tsx index 9bf9c838..43619b11 100644 --- a/dashboards/open-brain-dashboard-next/app/layout.tsx +++ b/dashboards/open-brain-dashboard-next/app/layout.tsx @@ -1,7 +1,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; -import { Sidebar } from "@/components/Sidebar"; +import { SidebarShell } from "@/components/SidebarShell"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -27,11 +27,12 @@ export default function RootLayout({ - -
-
+ +
+
{children}
diff --git a/dashboards/open-brain-dashboard-next/app/page.tsx b/dashboards/open-brain-dashboard-next/app/page.tsx index 8b0b6f74..e4622b25 100644 --- a/dashboards/open-brain-dashboard-next/app/page.tsx +++ b/dashboards/open-brain-dashboard-next/app/page.tsx @@ -1,6 +1,7 @@ import { fetchStats, fetchThoughts } from "@/lib/api"; import { requireSessionOrRedirect, getSession } from "@/lib/auth"; import { StatsWidget } from "@/components/StatsWidget"; +import { KanbanSummary } from "@/components/KanbanSummary"; import { ThoughtCard } from "@/components/ThoughtCard"; import { AddToBrain } from "@/components/AddToBrain"; @@ -43,6 +44,8 @@ export default async function DashboardPage() { + + {/* Add to Brain */}

Add to Brain

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 3e6725bd..c23f1148 100644 --- a/dashboards/open-brain-dashboard-next/app/thoughts/[id]/page.tsx +++ b/dashboards/open-brain-dashboard-next/app/thoughts/[id]/page.tsx @@ -1,4 +1,4 @@ -import { notFound } from "next/navigation"; +import { notFound, redirect } from "next/navigation"; import { fetchThought, updateThought, @@ -77,6 +77,7 @@ export default async function ThoughtDetailPage({ "use server"; const { apiKey } = await requireSessionOrRedirect(); await deleteThought(apiKey, thoughtId); + redirect("/thoughts"); } return ( @@ -86,6 +87,11 @@ export default async function ThoughtDetailPage({
+ {thought.status && ( + + {thought.status} + + )} ID: {thought.id} diff --git a/dashboards/open-brain-dashboard-next/components/ConnectionsPanel.tsx b/dashboards/open-brain-dashboard-next/components/ConnectionsPanel.tsx index 55ca647b..8eaf4ad9 100644 --- a/dashboards/open-brain-dashboard-next/components/ConnectionsPanel.tsx +++ b/dashboards/open-brain-dashboard-next/components/ConnectionsPanel.tsx @@ -10,10 +10,13 @@ interface Connection { type: string; importance: number; preview: string; + content: string; created_at: string; - shared_topics: string[]; - shared_people: string[]; - overlap_count: number; + similarity: number; + metadata: { topics?: string[]; people?: string[] }; + shared_topics?: string[]; + shared_people?: string[]; + overlap_count?: number; } export function ConnectionsPanel({ @@ -61,28 +64,40 @@ export function ConnectionsPanel({ + {c.similarity > 0 && ( + + {(c.similarity * 100).toFixed(0)}% match + + )}

- {c.preview} + {c.preview || c.content}

-
- {c.shared_topics.map((t) => ( - - {t} - - ))} - {c.shared_people.map((p) => ( - - {p} - - ))} -
+ {(() => { + const topics = c.shared_topics ?? c.metadata?.topics ?? []; + const people = c.shared_people ?? c.metadata?.people ?? []; + if (topics.length === 0 && people.length === 0) return null; + return ( +
+ {topics.map((t) => ( + + {t} + + ))} + {people.map((p) => ( + + {p} + + ))} +
+ ); + })()} ))}
diff --git a/dashboards/open-brain-dashboard-next/components/KanbanBoard.tsx b/dashboards/open-brain-dashboard-next/components/KanbanBoard.tsx new file mode 100644 index 00000000..8026fc6a --- /dev/null +++ b/dashboards/open-brain-dashboard-next/components/KanbanBoard.tsx @@ -0,0 +1,331 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import { + DndContext, + DragOverlay, + MouseSensor, + TouchSensor, + useSensor, + useSensors, + type DragEndEvent, + type DragStartEvent, +} from "@dnd-kit/core"; +import type { Thought } from "@/lib/types"; +import { KANBAN_STATUSES, KANBAN_TYPES } from "@/lib/types"; +import { KanbanColumn } from "@/components/KanbanColumn"; +import { KanbanCard } from "@/components/KanbanCard"; +import { KanbanCardModal } from "@/components/KanbanCardModal"; + +const AUTO_ARCHIVE_DAYS = 30; + +async function apiUpdateKanban( + thoughtId: number, + updates: Record +): Promise { + const res = await fetch("/api/kanban/update", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ thoughtId, ...updates }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || "Update failed"); + } +} + +export function KanbanBoard() { + const [thoughts, setThoughts] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [showArchived, setShowArchived] = useState(false); + const [selectedThought, setSelectedThought] = useState(null); + const [activeDragThought, setActiveDragThought] = useState(null); + const previousThoughts = useRef([]); + + const sensors = useSensors( + useSensor(MouseSensor, { activationConstraint: { distance: 5 } }), + useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 10 } }) + ); + + const fetchData = useCallback(async () => { + try { + setError(null); + const res = await fetch( + `/api/kanban${showArchived ? "?archived=true" : ""}` + ); + if (!res.ok) throw new Error("Failed to load kanban data"); + const data = await res.json(); + setThoughts(data.thoughts || []); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load"); + } finally { + setIsLoading(false); + } + }, [showArchived]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + // Group thoughts by status, with auto-archive for old done items + function groupByStatus(): Record { + const groups: Record = {}; + for (const s of KANBAN_STATUSES) groups[s] = []; + if (showArchived) groups["archived"] = []; + + for (const t of thoughts) { + const thoughtStatus = t.status ?? "new"; + + // Auto-archive: done items older than 30 days + if ( + thoughtStatus === "done" && + t.status_updated_at && + Date.now() - new Date(t.status_updated_at).getTime() > + AUTO_ARCHIVE_DAYS * 24 * 60 * 60 * 1000 + ) { + if (showArchived) groups["archived"].push(t); + continue; + } + + if (thoughtStatus === "archived") { + if (showArchived) groups["archived"].push(t); + continue; + } + + if (groups[thoughtStatus]) { + groups[thoughtStatus].push(t); + } else { + groups["new"].push(t); + } + } + return groups; + } + + function handleDragStart(event: DragStartEvent) { + const thought = thoughts.find((t) => t.id === event.active.id); + setActiveDragThought(thought ?? null); + } + + function handleDragEnd(event: DragEndEvent) { + setActiveDragThought(null); + const { active, over } = event; + if (!over) return; + + const thoughtId = active.id as number; + const newStatus = over.id as string; + + // Find which column the thought is currently in + const thought = thoughts.find((t) => t.id === thoughtId); + if (!thought || thought.status === newStatus) return; + + // Optimistic update + previousThoughts.current = [...thoughts]; + setThoughts((prev) => + prev.map((t) => + t.id === thoughtId + ? { ...t, status: newStatus, status_updated_at: new Date().toISOString() } + : t + ) + ); + + // API call in background + apiUpdateKanban(thoughtId, { status: newStatus }).catch(() => { + // Revert on failure + setThoughts(previousThoughts.current); + setError("Failed to update status. Reverted."); + setTimeout(() => setError(null), 5000); + }); + } + + async function handlePriorityChange(thoughtId: number, newImportance: number) { + previousThoughts.current = [...thoughts]; + setThoughts((prev) => + prev.map((t) => + t.id === thoughtId ? { ...t, importance: newImportance } : t + ) + ); + + try { + await apiUpdateKanban(thoughtId, { importance: newImportance }); + } catch { + setThoughts(previousThoughts.current); + setError("Failed to update priority. Reverted."); + setTimeout(() => setError(null), 5000); + } + } + + async function handleArchive(thoughtId: number) { + previousThoughts.current = [...thoughts]; + setThoughts((prev) => + prev.map((t) => + t.id === thoughtId + ? { ...t, status: "archived", status_updated_at: new Date().toISOString() } + : t + ) + ); + + try { + await apiUpdateKanban(thoughtId, { status: "archived" }); + } catch { + setThoughts(previousThoughts.current); + setError("Failed to archive. Reverted."); + setTimeout(() => setError(null), 5000); + } + } + + async function handleDelete(thoughtId: number) { + previousThoughts.current = [...thoughts]; + setThoughts((prev) => prev.filter((t) => t.id !== thoughtId)); + + try { + const res = await fetch("/api/kanban/delete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ thoughtId }), + }); + if (!res.ok) throw new Error("Delete failed"); + } catch { + setThoughts(previousThoughts.current); + setError("Failed to delete. Reverted."); + setTimeout(() => setError(null), 5000); + } + } + + async function handleModalSave( + thoughtId: number, + updates: Record + ) { + previousThoughts.current = [...thoughts]; + + // If type changed to non-kanban, remove from board entirely + const isLeavingKanban = + typeof updates.type === "string" && !KANBAN_TYPES.includes(updates.type); + + if (isLeavingKanban) { + setThoughts((prev) => prev.filter((t) => t.id !== thoughtId)); + } else { + setThoughts((prev) => + prev.map((t) => { + if (t.id !== thoughtId) return t; + const updated = { ...t, ...updates }; + if (updates.status) updated.status_updated_at = new Date().toISOString(); + return updated as Thought; + }) + ); + } + + try { + await apiUpdateKanban(thoughtId, updates); + } catch { + setThoughts(previousThoughts.current); + setError("Failed to save changes. Reverted."); + setTimeout(() => setError(null), 5000); + } + } + + // Loading skeleton + if (isLoading) { + return ( +
+ {KANBAN_STATUSES.map((s) => ( +
+
+
+
+
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+
+ ))} +
+ ); + } + + const grouped = groupByStatus(); + const columns = showArchived + ? [...KANBAN_STATUSES, "archived" as const] + : [...KANBAN_STATUSES]; + + return ( + <> + {/* Error banner */} + {error && ( +
+ {error} +
+ )} + + {/* Controls */} +
+
+ +
+ +
+ + {/* Board */} + +
+ {columns.map((status) => ( + + ))} +
+ + {activeDragThought && ( +
+ {}} + onPriorityChange={() => {}} + /> +
+ )} +
+
+ + {/* Modal */} + {selectedThought && ( + setSelectedThought(null)} + /> + )} + + ); +} diff --git a/dashboards/open-brain-dashboard-next/components/KanbanCard.tsx b/dashboards/open-brain-dashboard-next/components/KanbanCard.tsx new file mode 100644 index 00000000..20edf1f1 --- /dev/null +++ b/dashboards/open-brain-dashboard-next/components/KanbanCard.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import type { Thought } from "@/lib/types"; +import { TypeBadge } from "@/components/ThoughtCard"; +import { PriorityDot } from "@/components/PriorityDot"; + +function formatAge(dateString: string): string { + const diffMs = Date.now() - new Date(dateString).getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + if (diffDays < 1) return "today"; + if (diffDays === 1) return "1d"; + if (diffDays < 7) return `${diffDays}d`; + if (diffDays < 30) return `${Math.floor(diffDays / 7)}w`; + if (diffDays < 365) return `${Math.floor(diffDays / 30)}mo`; + return `${Math.floor(diffDays / 365)}y`; +} + +interface KanbanCardProps { + thought: Thought; + onCardClick: (thought: Thought) => void; + onPriorityChange: (thoughtId: number, importance: number) => void; + showArchiveButton?: boolean; + onArchive?: (thoughtId: number) => void; +} + +export function KanbanCard({ + thought, + onCardClick, + onPriorityChange, + showArchiveButton = false, + onArchive, +}: KanbanCardProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: thought.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + touchAction: "pan-y pinch-zoom", + }; + + const title = thought.content.split("\n")[0].slice(0, 60); + const topics = Array.isArray(thought.metadata?.topics) + ? (thought.metadata.topics as string[]).slice(0, 2) + : []; + + return ( +
onCardClick(thought)} + className={`bg-bg-surface border rounded-lg p-3 cursor-pointer select-none transition-all ${ + isDragging + ? "border-violet/40 shadow-lg opacity-80 scale-[1.02]" + : "border-border hover:border-violet/30" + }`} + > +
+ onPriorityChange(thought.id, val)} + /> + +
+ +

+ {title} +

+ +
+
+ {topics.map((topic) => ( + + {topic} + + ))} +
+
+ + {formatAge(thought.created_at)} + + {showArchiveButton && onArchive && ( + + )} +
+
+
+ ); +} diff --git a/dashboards/open-brain-dashboard-next/components/KanbanCardModal.tsx b/dashboards/open-brain-dashboard-next/components/KanbanCardModal.tsx new file mode 100644 index 00000000..5042919d --- /dev/null +++ b/dashboards/open-brain-dashboard-next/components/KanbanCardModal.tsx @@ -0,0 +1,335 @@ +"use client"; + +import { useState, useEffect, useRef } 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"; + +interface KanbanCardModalProps { + thought: Thought; + onSave: ( + thoughtId: number, + updates: { content?: string; status?: string; importance?: number; type?: string } + ) => void; + onArchive: (thoughtId: number) => void; + onDelete: (thoughtId: number) => void; + onClose: () => void; +} + +export function KanbanCardModal({ + thought, + onSave, + onArchive, + onDelete, + onClose, +}: KanbanCardModalProps) { + const [content, setContent] = useState(thought.content); + 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]); + + useEffect(() => { + function handleEscape(e: KeyboardEvent) { + if (e.key === "Escape") { + tryClose(); + } + } + document.addEventListener("keydown", handleEscape); + return () => document.removeEventListener("keydown", handleEscape); + }, [hasChanges]); + + // Lock body scroll + useEffect(() => { + document.body.style.overflow = "hidden"; + // Scroll to top so fixed positioning works on mobile + window.scrollTo(0, 0); + return () => { + document.body.style.overflow = ""; + }; + }, []); + + // Auto-resize textarea + useEffect(() => { + if (textareaRef.current) { + textareaRef.current.style.height = "auto"; + textareaRef.current.style.height = textareaRef.current.scrollHeight + "px"; + } + }, [content]); + + function tryClose() { + if (hasChanges) { + setShowDiscardConfirm(true); + } else { + onClose(); + } + } + + function handleBackdropClick(e: React.MouseEvent) { + if (e.target === backdropRef.current) { + tryClose(); + } + } + + function handleSave() { + const updates: Record = {}; + if (content !== thought.content) updates.content = content; + if (status !== (thought.status ?? "new")) updates.status = status; + if (importance !== thought.importance) updates.importance = importance; + if (type !== thought.type) { + updates.type = type; + // Changing to a non-kanban type removes it from the board + if (!KANBAN_TYPES.includes(type)) { + updates.status = null; + } + } + + if (Object.keys(updates).length > 0) { + onSave(thought.id, updates); + } + onClose(); + } + + const topics = Array.isArray(thought.metadata?.topics) + ? (thought.metadata.topics as string[]) + : []; + const createdDate = new Date(thought.created_at).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); + const currentPriority = getPriorityLevel(importance); + + return <>{createPortal( +
+
e.stopPropagation()} + className="bg-bg-surface border border-border rounded-xl w-full max-h-full max-w-lg flex flex-col shadow-2xl overflow-hidden mx-auto" + > + {/* Discard confirmation banner */} + {showDiscardConfirm && ( +
+ Unsaved changes. Discard? +
+ + +
+
+ )} + + {/* Header */} +
+

+ Edit +

+ +
+ + {/* Fields — scrollable */} +
+ {/* Status + Priority + Type row */} +
+
+ + +
+ +
+ + +
+ +
+ + + {!KANBAN_TYPES.includes(type) && ( +

+ This type won't appear on the kanban board +

+ )} +
+
+ + {/* Content */} +
+ +