diff --git a/testdata/projects/mind-map.md b/testdata/projects/mind-map.md index d05ccac..20f5d58 100644 --- a/testdata/projects/mind-map.md +++ b/testdata/projects/mind-map.md @@ -1,10 +1,34 @@ --- +status: active title: mind-map type: project -status: active --- # mind-map A wiki engine for AI agents and humans. Built with [[Go]]. Links back to [[index]]. + +## Feature Status + +| Feature | Status | Notes | +|---------|--------|-------| +| Wiki engine | ✅ Done | SQLite-backed, FTS5 search | +| Wikilinks | ✅ Done | [[target]] and [[target|display]] | +| Backlinks | ✅ Done | Auto-tracked in DB | +| MCP server | ✅ Done | read, write, search tools | +| Web UI | 🔧 WIP | Sidebar, markdown, mermaid | +| Git sync | 🔧 WIP | Push/pull with remotes | +| Auth | ⏳ Planned | Token-based access | + +## Architecture + +```mermaid +graph TD + A[Web UI] -->|REST API| B[Go Server] + C[MCP Client] -->|stdio/SSE| B + B --> D[(SQLite + FTS5)] + B --> E[Markdown Files] + B --> F[Git Sync] + F --> G[Remote Repos] +``` diff --git a/webui/src/App.tsx b/webui/src/App.tsx index a1792f9..f246cdb 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -5,8 +5,6 @@ import mermaid from 'mermaid'; mermaid.initialize({ startOnLoad: false, theme: 'default' }); -let mermaidCounter = 0; - interface SyncSettings { enabled: boolean; default: string; @@ -59,21 +57,125 @@ export function App() { return window.matchMedia('(prefers-color-scheme: dark)').matches; }); + // Sidebar resize/collapse state + const [sidebarWidth, setSidebarWidth] = useState(() => { + const saved = localStorage.getItem('mm-sidebar-width'); + return saved ? parseInt(saved, 10) : 240; + }); + const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { + return localStorage.getItem('mm-sidebar-collapsed') === 'true'; + }); + const isResizing = useRef(false); + + useEffect(() => { + localStorage.setItem('mm-sidebar-width', String(sidebarWidth)); + }, [sidebarWidth]); + + useEffect(() => { + localStorage.setItem('mm-sidebar-collapsed', String(sidebarCollapsed)); + }, [sidebarCollapsed]); + + const startResize = (e: MouseEvent) => { + e.preventDefault(); + isResizing.current = true; + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + + const onMouseMove = (ev: MouseEvent) => { + if (!isResizing.current) return; + const newWidth = Math.max(160, Math.min(480, ev.clientX)); + setSidebarWidth(newWidth); + }; + const onMouseUp = () => { + isResizing.current = false; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + }; + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }; + + // Backlinks collapse state + const [backlinksCollapsed, setBacklinksCollapsed] = useState(() => { + return localStorage.getItem('mm-backlinks-collapsed') === 'true'; + }); + + useEffect(() => { + localStorage.setItem('mm-backlinks-collapsed', String(backlinksCollapsed)); + }, [backlinksCollapsed]); + + // Sort mode: 'recent' | 'path' | 'title' + type SortMode = 'recent' | 'path' | 'title'; + const sortModes: SortMode[] = ['recent', 'path', 'title']; + const sortLabels: Record = { recent: 'Recent', path: 'A→Z path', title: 'A→Z title' }; + + const SortIcon = ({ mode }: { mode: SortMode }) => { + const props = { width: 16, height: 16, fill: 'currentColor', viewBox: '' as string }; + switch (mode) { + case 'recent': + return ; + case 'path': + return ; + case 'title': + return ; + } + }; + + const [sortMode, setSortMode] = useState(() => { + const saved = localStorage.getItem('mm-sort-mode'); + return (saved === 'path' || saved === 'title') ? saved : 'recent'; + }); + + useEffect(() => { + localStorage.setItem('mm-sort-mode', sortMode); + }, [sortMode]); + + const cycleSortMode = () => { + const idx = sortModes.indexOf(sortMode); + setSortMode(sortModes[(idx + 1) % sortModes.length]); + }; + + const sortPages = (list: Page[]): Page[] => { + const sorted = [...list]; + switch (sortMode) { + case 'path': + sorted.sort((a, b) => a.path.localeCompare(b.path)); + break; + case 'title': + sorted.sort((a, b) => (a.title || a.path).localeCompare(b.title || b.path)); + break; + case 'recent': + default: + // API already returns modified DESC; preserve that order + break; + } + return sorted; + }; + useEffect(() => { document.documentElement.classList.toggle('dark', isDark); localStorage.setItem('mm-theme', isDark ? 'dark' : 'light'); }, [isDark]); // Load page list + const [rawPages, setRawPages] = useState([]); + const loadPages = async () => { try { const list = await api.listPages(); - setPages(list); + setRawPages(list); } catch (e) { console.error('Failed to load pages:', e); } }; + // Re-sort whenever rawPages or sortMode changes + useEffect(() => { + setPages(sortPages(rawPages)); + }, [rawPages, sortMode]); + useEffect(() => { loadPages(); }, []); // Hash routing @@ -144,7 +246,7 @@ export function App() { } try { const results = await api.searchPages(searchQuery); - setPages(results.map(r => ({ path: r.path, title: r.title, body: '', modified_at: '' }))); + setRawPages(results.map(r => ({ path: r.path, title: r.title, body: '', modified_at: '' }))); } catch (e) { console.error('Search failed:', e); } @@ -205,8 +307,9 @@ export function App() { // Extract mermaid blocks before marked processing to prevent HTML escaping const mermaidBlocks: Record = {}; + let localCounter = 0; const withPlaceholders = withLinks.replace(/```mermaid\s*\n([\s\S]*?)```/g, (_, code) => { - const id = `mermaid-${++mermaidCounter}`; + const id = `mermaid-${++localCounter}`; mermaidBlocks[id] = code.trim(); return `
MERMAID_PLACEHOLDER_${id}
`; }); @@ -243,40 +346,68 @@ export function App() { return (
{/* Sidebar */} -