learn and share notes on atproto (wip) 🦉 malfestio.stormlightlabs.org/
readability solid axum atproto srs
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add notes graph visualization and sidebar filtering for notes management

+692 -12
+9 -5
web/package.json
··· 28 28 "@textcomplete/core": "^0.1.0", 29 29 "@textcomplete/textarea": "^0.1.0", 30 30 "clsx": "^2.1.1", 31 + "d3-drag": "^3.0.0", 32 + "d3-force": "^3.0.0", 33 + "d3-selection": "^3.0.0", 34 + "d3-zoom": "^3.0.0", 31 35 "motion": "^12.23.26", 32 36 "rehype-external-links": "^3.0.0", 33 37 "rehype-sanitize": "^6.0.0", ··· 51 55 "@tailwindcss/forms": "^0.5.11", 52 56 "@testing-library/jest-dom": "^6.9.1", 53 57 "@testing-library/user-event": "^14.6.1", 58 + "@types/d3-drag": "^3.0.7", 59 + "@types/d3-force": "^3.0.10", 60 + "@types/d3-selection": "^3.0.11", 61 + "@types/d3-zoom": "^3.0.8", 54 62 "@types/node": "^24.10.1", 55 63 "@typescript-eslint/parser": "^8.50.1", 56 64 "eslint": "^9.39.2", ··· 65 73 "vite-plugin-solid": "^2.11.10", 66 74 "vitest": "^4.0.16" 67 75 }, 68 - "pnpm": { 69 - "overrides": { 70 - "vite": "npm:rolldown-vite@7.2.5" 71 - } 72 - } 76 + "pnpm": { "overrides": { "vite": "npm:rolldown-vite@7.2.5" } } 73 77 }
+151
web/pnpm-lock.yaml
··· 56 56 clsx: 57 57 specifier: ^2.1.1 58 58 version: 2.1.1 59 + d3-drag: 60 + specifier: ^3.0.0 61 + version: 3.0.0 62 + d3-force: 63 + specifier: ^3.0.0 64 + version: 3.0.0 65 + d3-selection: 66 + specifier: ^3.0.0 67 + version: 3.0.0 68 + d3-zoom: 69 + specifier: ^3.0.0 70 + version: 3.0.0 59 71 motion: 60 72 specifier: ^12.23.26 61 73 version: 12.23.26 ··· 120 132 '@testing-library/user-event': 121 133 specifier: ^14.6.1 122 134 version: 14.6.1(@testing-library/dom@10.4.1) 135 + '@types/d3-drag': 136 + specifier: ^3.0.7 137 + version: 3.0.7 138 + '@types/d3-force': 139 + specifier: ^3.0.10 140 + version: 3.0.10 141 + '@types/d3-selection': 142 + specifier: ^3.0.11 143 + version: 3.0.11 144 + '@types/d3-zoom': 145 + specifier: ^3.0.8 146 + version: 3.0.8 123 147 '@types/node': 124 148 specifier: ^24.10.1 125 149 version: 24.10.4 ··· 988 1012 '@types/chai@5.2.3': 989 1013 resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} 990 1014 1015 + '@types/d3-color@3.1.3': 1016 + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} 1017 + 1018 + '@types/d3-drag@3.0.7': 1019 + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} 1020 + 1021 + '@types/d3-force@3.0.10': 1022 + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} 1023 + 1024 + '@types/d3-interpolate@3.0.4': 1025 + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} 1026 + 1027 + '@types/d3-selection@3.0.11': 1028 + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} 1029 + 1030 + '@types/d3-zoom@3.0.8': 1031 + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} 1032 + 991 1033 '@types/debug@4.1.12': 992 1034 resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} 993 1035 ··· 1281 1323 1282 1324 csstype@3.2.3: 1283 1325 resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} 1326 + 1327 + d3-color@3.1.0: 1328 + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} 1329 + engines: {node: '>=12'} 1330 + 1331 + d3-dispatch@3.0.1: 1332 + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} 1333 + engines: {node: '>=12'} 1334 + 1335 + d3-drag@3.0.0: 1336 + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} 1337 + engines: {node: '>=12'} 1338 + 1339 + d3-ease@3.0.1: 1340 + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} 1341 + engines: {node: '>=12'} 1342 + 1343 + d3-force@3.0.0: 1344 + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} 1345 + engines: {node: '>=12'} 1346 + 1347 + d3-interpolate@3.0.1: 1348 + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} 1349 + engines: {node: '>=12'} 1350 + 1351 + d3-quadtree@3.0.1: 1352 + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} 1353 + engines: {node: '>=12'} 1354 + 1355 + d3-selection@3.0.0: 1356 + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} 1357 + engines: {node: '>=12'} 1358 + 1359 + d3-timer@3.0.1: 1360 + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} 1361 + engines: {node: '>=12'} 1362 + 1363 + d3-transition@3.0.1: 1364 + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} 1365 + engines: {node: '>=12'} 1366 + peerDependencies: 1367 + d3-selection: 2 - 3 1368 + 1369 + d3-zoom@3.0.0: 1370 + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} 1371 + engines: {node: '>=12'} 1284 1372 1285 1373 data-urls@6.0.0: 1286 1374 resolution: {integrity: sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==} ··· 3146 3234 '@types/deep-eql': 4.0.2 3147 3235 assertion-error: 2.0.1 3148 3236 3237 + '@types/d3-color@3.1.3': {} 3238 + 3239 + '@types/d3-drag@3.0.7': 3240 + dependencies: 3241 + '@types/d3-selection': 3.0.11 3242 + 3243 + '@types/d3-force@3.0.10': {} 3244 + 3245 + '@types/d3-interpolate@3.0.4': 3246 + dependencies: 3247 + '@types/d3-color': 3.1.3 3248 + 3249 + '@types/d3-selection@3.0.11': {} 3250 + 3251 + '@types/d3-zoom@3.0.8': 3252 + dependencies: 3253 + '@types/d3-interpolate': 3.0.4 3254 + '@types/d3-selection': 3.0.11 3255 + 3149 3256 '@types/debug@4.1.12': 3150 3257 dependencies: 3151 3258 '@types/ms': 2.1.0 ··· 3455 3562 css-tree: 3.1.0 3456 3563 3457 3564 csstype@3.2.3: {} 3565 + 3566 + d3-color@3.1.0: {} 3567 + 3568 + d3-dispatch@3.0.1: {} 3569 + 3570 + d3-drag@3.0.0: 3571 + dependencies: 3572 + d3-dispatch: 3.0.1 3573 + d3-selection: 3.0.0 3574 + 3575 + d3-ease@3.0.1: {} 3576 + 3577 + d3-force@3.0.0: 3578 + dependencies: 3579 + d3-dispatch: 3.0.1 3580 + d3-quadtree: 3.0.1 3581 + d3-timer: 3.0.1 3582 + 3583 + d3-interpolate@3.0.1: 3584 + dependencies: 3585 + d3-color: 3.1.0 3586 + 3587 + d3-quadtree@3.0.1: {} 3588 + 3589 + d3-selection@3.0.0: {} 3590 + 3591 + d3-timer@3.0.1: {} 3592 + 3593 + d3-transition@3.0.1(d3-selection@3.0.0): 3594 + dependencies: 3595 + d3-color: 3.1.0 3596 + d3-dispatch: 3.0.1 3597 + d3-ease: 3.0.1 3598 + d3-interpolate: 3.0.1 3599 + d3-selection: 3.0.0 3600 + d3-timer: 3.0.1 3601 + 3602 + d3-zoom@3.0.0: 3603 + dependencies: 3604 + d3-dispatch: 3.0.1 3605 + d3-drag: 3.0.0 3606 + d3-interpolate: 3.0.1 3607 + d3-selection: 3.0.0 3608 + d3-transition: 3.0.1(d3-selection@3.0.0) 3458 3609 3459 3610 data-urls@6.0.0: 3460 3611 dependencies:
+179
web/src/components/notes/NotesGraph.tsx
··· 1 + import type { Note } from "$lib/model"; 2 + import { extractWikilinkTitles, resolveWikilink } from "$lib/wikilink"; 3 + import { useNavigate } from "@solidjs/router"; 4 + import { drag } from "d3-drag"; 5 + import type { Simulation, SimulationLinkDatum, SimulationNodeDatum } from "d3-force"; 6 + import { forceCenter, forceCollide, forceLink, forceManyBody, forceSimulation } from "d3-force"; 7 + import { select } from "d3-selection"; 8 + import { zoom, type ZoomBehavior, zoomIdentity } from "d3-zoom"; 9 + import type { Component } from "solid-js"; 10 + import { createEffect, createMemo, onCleanup, onMount } from "solid-js"; 11 + 12 + export type GraphNode = SimulationNodeDatum & { id: string; title: string; tags: string[]; linkCount: number }; 13 + 14 + export type GraphLink = SimulationLinkDatum<GraphNode> & { source: string | GraphNode; target: string | GraphNode }; 15 + 16 + type NotesGraphProps = { 17 + notes: Note[]; 18 + currentNoteId?: string; 19 + onNodeClick?: (noteId: string) => void; 20 + width?: number; 21 + height?: number; 22 + }; 23 + 24 + const NODE_RADIUS = 8; 25 + const LINK_DISTANCE = 100; 26 + const CHARGE_STRENGTH = -300; 27 + 28 + export const NotesGraph: Component<NotesGraphProps> = (props) => { 29 + const navigate = useNavigate(); 30 + let containerRef: HTMLDivElement | undefined; 31 + let simulation: Simulation<GraphNode, GraphLink> | undefined; 32 + let zoomBehavior: ZoomBehavior<SVGSVGElement, unknown> | undefined; 33 + 34 + const width = () => props.width ?? 800; 35 + const height = () => props.height ?? 600; 36 + 37 + const graphData = createMemo(() => { 38 + const notes = props.notes; 39 + const nodeMap = new Map<string, GraphNode>(); 40 + const links: GraphLink[] = []; 41 + 42 + for (const note of notes) { 43 + nodeMap.set(note.id, { id: note.id, title: note.title, tags: note.tags, linkCount: 0 }); 44 + } 45 + 46 + for (const note of notes) { 47 + const titles = extractWikilinkTitles(note.body); 48 + for (const title of titles) { 49 + const targetNote = resolveWikilink(title, notes); 50 + if (targetNote && targetNote.id !== note.id) { 51 + const sourceNode = nodeMap.get(note.id); 52 + const targetNode = nodeMap.get(targetNote.id); 53 + if (sourceNode && targetNode) { 54 + sourceNode.linkCount++; 55 + targetNode.linkCount++; 56 + links.push({ source: note.id, target: targetNote.id }); 57 + } 58 + } 59 + } 60 + } 61 + 62 + return { nodes: Array.from(nodeMap.values()), links }; 63 + }); 64 + 65 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 66 + const handleNodeClick = (_: any, d: GraphNode) => { 67 + if (props.onNodeClick) { 68 + props.onNodeClick(d.id); 69 + } else { 70 + navigate(`/notes/${d.id}`); 71 + } 72 + }; 73 + 74 + const computeColor = (d: GraphNode) => 75 + d.id === props.currentNoteId 76 + ? "fill-blue-500" 77 + : d.linkCount > 0 78 + ? "fill-emerald-500" 79 + : "fill-slate-400 dark:fill-slate-500"; 80 + const initializeGraph = () => { 81 + if (!containerRef) return; 82 + 83 + const svg = select(containerRef).select<SVGSVGElement>("svg").attr("viewBox", `0 0 ${width()} ${height()}`); 84 + const g = svg.select<SVGGElement>(".graph-container"); 85 + const data = graphData(); 86 + 87 + zoomBehavior = zoom<SVGSVGElement, unknown>().scaleExtent([0.1, 4]).on("zoom", (event) => { 88 + g.attr("transform", event.transform); 89 + }); 90 + 91 + svg.call(zoomBehavior); 92 + svg.call(zoomBehavior.transform, zoomIdentity.translate(width() / 2, height() / 2)); 93 + 94 + simulation = forceSimulation<GraphNode, GraphLink>(data.nodes).force( 95 + "link", 96 + forceLink<GraphNode, GraphLink>(data.links).id((d) => d.id).distance(LINK_DISTANCE), 97 + ).force("charge", forceManyBody().strength(CHARGE_STRENGTH)).force("center", forceCenter(0, 0)).force( 98 + "collision", 99 + forceCollide().radius(NODE_RADIUS * 2), 100 + ); 101 + 102 + const linkSelection = g.select<SVGGElement>(".links").selectAll<SVGLineElement, GraphLink>("line").data( 103 + data.links, 104 + (d) => `${(d.source as GraphNode).id ?? d.source}-${(d.target as GraphNode).id ?? d.target}`, 105 + ).join("line").attr("class", "stroke-slate-600 dark:stroke-slate-500").attr("stroke-opacity", 0.6).attr( 106 + "stroke-width", 107 + 1.5, 108 + ); 109 + 110 + const nodeSelection = g.select<SVGGElement>(".nodes").selectAll<SVGGElement, GraphNode>(".node").data( 111 + data.nodes, 112 + (d) => d.id, 113 + ).join("g").attr("class", "node cursor-pointer").on("click", handleNodeClick); 114 + 115 + nodeSelection.selectAll("circle").data((d) => [d]).join("circle").attr( 116 + "r", 117 + (d) => NODE_RADIUS + Math.min(d.linkCount, 5), 118 + ).attr("class", computeColor).attr("stroke", "var(--color-slate-900)").attr("stroke-width", 2); 119 + 120 + nodeSelection.selectAll("text").data((d) => [d]).join("text").text((d) => 121 + d.title.length > 20 ? d.title.slice(0, 20) + "..." : d.title 122 + ).attr("x", 14).attr("y", 4).attr( 123 + "class", 124 + "text-xs fill-slate-300 dark:fill-slate-400 pointer-events-none select-none", 125 + ); 126 + 127 + const dragBehavior = drag<SVGGElement, GraphNode>().on("start", (event, d) => { 128 + if (!event.active) simulation?.alphaTarget(0.3).restart(); 129 + d.fx = d.x; 130 + d.fy = d.y; 131 + }).on("drag", (event, d) => { 132 + d.fx = event.x; 133 + d.fy = event.y; 134 + }).on("end", (event, d) => { 135 + if (!event.active) simulation?.alphaTarget(0); 136 + d.fx = null; 137 + d.fy = null; 138 + }); 139 + 140 + nodeSelection.call(dragBehavior); 141 + 142 + simulation.on("tick", () => { 143 + linkSelection.attr("x1", (d) => (d.source as GraphNode).x ?? 0).attr("y1", (d) => (d.source as GraphNode).y ?? 0) 144 + .attr("x2", (d) => (d.target as GraphNode).x ?? 0).attr("y2", (d) => (d.target as GraphNode).y ?? 0); 145 + 146 + nodeSelection.attr("transform", (d) => `translate(${d.x ?? 0}, ${d.y ?? 0})`); 147 + }); 148 + }; 149 + 150 + onMount(() => { 151 + initializeGraph(); 152 + }); 153 + 154 + createEffect(() => { 155 + graphData(); 156 + if (simulation) { 157 + simulation.stop(); 158 + } 159 + initializeGraph(); 160 + }); 161 + 162 + onCleanup(() => { 163 + simulation?.stop(); 164 + }); 165 + 166 + return ( 167 + <div 168 + ref={containerRef} 169 + class="w-full h-full bg-slate-900/50 rounded-xl border border-slate-700 overflow-hidden" 170 + data-testid="notes-graph"> 171 + <svg class="w-full h-full" style={{ "min-height": `${height()}px` }}> 172 + <g class="graph-container"> 173 + <g class="links" /> 174 + <g class="nodes" /> 175 + </g> 176 + </svg> 177 + </div> 178 + ); 179 + };
+127
web/src/components/notes/NotesSidebar.tsx
··· 1 + import type { Note } from "$lib/model"; 2 + import { extractWikilinkTitles } from "$lib/wikilink"; 3 + import { A } from "@solidjs/router"; 4 + import type { Component } from "solid-js"; 5 + import { createMemo, For, Show } from "solid-js"; 6 + 7 + type FilterType = "all" | "linked" | "orphaned"; 8 + 9 + type NotesSidebarProps = { 10 + notes: Note[]; 11 + selectedTag?: string; 12 + selectedFilter?: FilterType; 13 + onTagSelect?: (tag: string | undefined) => void; 14 + onFilterSelect?: (filter: FilterType) => void; 15 + }; 16 + 17 + type TagCount = { tag: string; count: number }; 18 + 19 + export const NotesSidebar: Component<NotesSidebarProps> = (props) => { 20 + const tagCounts = createMemo<TagCount[]>(() => { 21 + const counts = new Map<string, number>(); 22 + props.notes.forEach((note) => { 23 + note.tags.forEach((tag) => { 24 + counts.set(tag, (counts.get(tag) ?? 0) + 1); 25 + }); 26 + }); 27 + return Array.from(counts.entries()).map(([tag, count]) => ({ tag, count })).sort((a, b) => b.count - a.count); 28 + }); 29 + 30 + const recentNotes = createMemo(() => { 31 + return [...props.notes].sort((a, b) => { 32 + const dateA = a.updated_at ?? a.created_at ?? ""; 33 + const dateB = b.updated_at ?? b.created_at ?? ""; 34 + return dateB.localeCompare(dateA); 35 + }).slice(0, 5); 36 + }); 37 + 38 + const linkedCount = createMemo(() => { 39 + return props.notes.filter((note) => extractWikilinkTitles(note.body).length > 0).length; 40 + }); 41 + 42 + const orphanedCount = createMemo(() => { 43 + return props.notes.filter((note) => extractWikilinkTitles(note.body).length === 0).length; 44 + }); 45 + 46 + const filterButtonClass = (filter: FilterType) => { 47 + const isActive = props.selectedFilter === filter; 48 + return `px-3 py-1.5 text-sm rounded-md transition-colors ${ 49 + isActive ? "bg-blue-500/20 text-blue-400 font-medium" : "text-slate-400 hover:text-white hover:bg-slate-800" 50 + }`; 51 + }; 52 + 53 + return ( 54 + <aside class="w-64 shrink-0 space-y-6" data-testid="notes-sidebar"> 55 + {/* Quick Filters */} 56 + <section> 57 + <h3 class="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">Filters</h3> 58 + <div class="flex flex-col gap-1"> 59 + <button 60 + class={filterButtonClass("all")} 61 + onClick={() => props.onFilterSelect?.("all")} 62 + data-testid="filter-all"> 63 + All ({props.notes.length}) 64 + </button> 65 + <button 66 + class={filterButtonClass("linked")} 67 + onClick={() => props.onFilterSelect?.("linked")} 68 + data-testid="filter-linked"> 69 + Linked ({linkedCount()}) 70 + </button> 71 + <button 72 + class={filterButtonClass("orphaned")} 73 + onClick={() => props.onFilterSelect?.("orphaned")} 74 + data-testid="filter-orphaned"> 75 + Orphaned ({orphanedCount()}) 76 + </button> 77 + </div> 78 + </section> 79 + 80 + {/* Tags */} 81 + <Show when={tagCounts().length > 0}> 82 + <section> 83 + <h3 class="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">Tags</h3> 84 + <div class="space-y-1"> 85 + <For each={tagCounts().slice(0, 10)}> 86 + {(item) => ( 87 + <button 88 + class={`flex items-center justify-between w-full px-2 py-1 text-sm rounded transition-colors ${ 89 + props.selectedTag === item.tag 90 + ? "bg-blue-500/20 text-blue-400" 91 + : "text-slate-400 hover:text-white hover:bg-slate-800" 92 + }`} 93 + onClick={() => props.onTagSelect?.(props.selectedTag === item.tag ? undefined : item.tag)} 94 + data-testid={`tag-${item.tag}`}> 95 + <span class="truncate">#{item.tag}</span> 96 + <span class="text-xs text-slate-500">{item.count}</span> 97 + </button> 98 + )} 99 + </For> 100 + <Show when={tagCounts().length > 10}> 101 + <p class="text-xs text-slate-500 px-2">+{tagCounts().length - 10} more tags</p> 102 + </Show> 103 + </div> 104 + </section> 105 + </Show> 106 + 107 + {/* Recent Notes */} 108 + <Show when={recentNotes().length > 0}> 109 + <section> 110 + <h3 class="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">Recent</h3> 111 + <div class="space-y-1"> 112 + <For each={recentNotes()}> 113 + {(note) => ( 114 + <A 115 + href={`/notes/${note.id}`} 116 + class="block px-2 py-1 text-sm text-slate-400 hover:text-white hover:bg-slate-800 rounded truncate transition-colors" 117 + data-testid={`recent-${note.id}`}> 118 + {note.title || "Untitled"} 119 + </A> 120 + )} 121 + </For> 122 + </div> 123 + </section> 124 + </Show> 125 + </aside> 126 + ); 127 + };
+81
web/src/components/notes/tests/NotesGraph.test.tsx
··· 1 + import type { Note } from "$lib/model"; 2 + import { cleanup, render, screen } from "@solidjs/testing-library"; 3 + import { afterEach, describe, expect, it, vi } from "vitest"; 4 + import { NotesGraph } from "../NotesGraph"; 5 + 6 + vi.mock("@solidjs/router", () => ({ useNavigate: () => vi.fn() })); 7 + 8 + const mockNotes: Note[] = [{ 9 + id: "note-1", 10 + owner_did: "did:plc:test", 11 + title: "First Note", 12 + body: "Content with [[Second Note]] link", 13 + tags: ["test"], 14 + visibility: { type: "Private" }, 15 + created_at: "2026-01-01T00:00:00Z", 16 + updated_at: "2026-01-01T00:00:00Z", 17 + }, { 18 + id: "note-2", 19 + owner_did: "did:plc:test", 20 + title: "Second Note", 21 + body: "This links to [[First Note]]", 22 + tags: ["test", "linked"], 23 + visibility: { type: "Private" }, 24 + created_at: "2026-01-01T00:00:00Z", 25 + updated_at: "2026-01-01T00:00:00Z", 26 + }, { 27 + id: "note-3", 28 + owner_did: "did:plc:test", 29 + title: "Orphan Note", 30 + body: "No wikilinks here", 31 + tags: [], 32 + visibility: { type: "Private" }, 33 + created_at: "2026-01-01T00:00:00Z", 34 + updated_at: "2026-01-01T00:00:00Z", 35 + }]; 36 + 37 + describe("NotesGraph", () => { 38 + afterEach(cleanup); 39 + 40 + it("renders the graph container", () => { 41 + render(() => <NotesGraph notes={mockNotes} />); 42 + expect(screen.getByTestId("notes-graph")).toBeInTheDocument(); 43 + }); 44 + 45 + it("renders SVG element", () => { 46 + render(() => <NotesGraph notes={mockNotes} />); 47 + const container = screen.getByTestId("notes-graph"); 48 + const svg = container.querySelector("svg"); 49 + expect(svg).toBeInTheDocument(); 50 + }); 51 + 52 + it("creates nodes group", () => { 53 + render(() => <NotesGraph notes={mockNotes} />); 54 + const container = screen.getByTestId("notes-graph"); 55 + const nodesGroup = container.querySelector(".nodes"); 56 + expect(nodesGroup).toBeInTheDocument(); 57 + }); 58 + 59 + it("creates links group", () => { 60 + render(() => <NotesGraph notes={mockNotes} />); 61 + const container = screen.getByTestId("notes-graph"); 62 + const linksGroup = container.querySelector(".links"); 63 + expect(linksGroup).toBeInTheDocument(); 64 + }); 65 + 66 + it("renders with custom dimensions", () => { 67 + render(() => <NotesGraph notes={mockNotes} width={1200} height={800} />); 68 + const container = screen.getByTestId("notes-graph"); 69 + expect(container).toBeInTheDocument(); 70 + }); 71 + 72 + it("handles empty notes array", () => { 73 + render(() => <NotesGraph notes={[]} />); 74 + expect(screen.getByTestId("notes-graph")).toBeInTheDocument(); 75 + }); 76 + 77 + it("highlights current note when provided", () => { 78 + render(() => <NotesGraph notes={mockNotes} currentNoteId="note-1" />); 79 + expect(screen.getByTestId("notes-graph")).toBeInTheDocument(); 80 + }); 81 + });
+114
web/src/components/notes/tests/NotesSidebar.test.tsx
··· 1 + import type { Note } from "$lib/model"; 2 + import { cleanup, fireEvent, render, screen } from "@solidjs/testing-library"; 3 + import type { JSX } from "solid-js"; 4 + import { afterEach, describe, expect, it, vi } from "vitest"; 5 + import { NotesSidebar } from "../NotesSidebar"; 6 + 7 + vi.mock( 8 + "@solidjs/router", 9 + () => ({ 10 + A: (props: { href: string; class: string; children: JSX.Element; "data-testid"?: string }) => ( 11 + <a href={props.href} class={props.class} data-testid={props["data-testid"]}>{props.children}</a> 12 + ), 13 + }), 14 + ); 15 + 16 + const mockNotes: Note[] = [{ 17 + id: "note-1", 18 + owner_did: "did:plc:test", 19 + title: "First Note", 20 + body: "Content with [[Second Note]] link", 21 + tags: ["rust", "learning"], 22 + visibility: { type: "Private" }, 23 + created_at: "2026-01-01T00:00:00Z", 24 + updated_at: "2026-01-03T00:00:00Z", 25 + }, { 26 + id: "note-2", 27 + owner_did: "did:plc:test", 28 + title: "Second Note", 29 + body: "This links to [[First Note]]", 30 + tags: ["rust", "advanced"], 31 + visibility: { type: "Private" }, 32 + created_at: "2026-01-01T00:00:00Z", 33 + updated_at: "2026-01-02T00:00:00Z", 34 + }, { 35 + id: "note-3", 36 + owner_did: "did:plc:test", 37 + title: "Orphan Note", 38 + body: "No wikilinks here", 39 + tags: ["learning"], 40 + visibility: { type: "Private" }, 41 + created_at: "2026-01-01T00:00:00Z", 42 + updated_at: "2026-01-01T00:00:00Z", 43 + }]; 44 + 45 + describe("NotesSidebar", () => { 46 + afterEach(cleanup); 47 + 48 + it("renders the sidebar", () => { 49 + render(() => <NotesSidebar notes={mockNotes} />); 50 + expect(screen.getByTestId("notes-sidebar")).toBeInTheDocument(); 51 + }); 52 + 53 + it("displays filter buttons with counts", () => { 54 + render(() => <NotesSidebar notes={mockNotes} />); 55 + expect(screen.getByTestId("filter-all")).toHaveTextContent("All (3)"); 56 + expect(screen.getByTestId("filter-linked")).toHaveTextContent("Linked (2)"); 57 + expect(screen.getByTestId("filter-orphaned")).toHaveTextContent("Orphaned (1)"); 58 + }); 59 + 60 + it("displays unique tags with correct counts", () => { 61 + render(() => <NotesSidebar notes={mockNotes} />); 62 + 63 + expect(screen.getByTestId("tag-rust")).toHaveTextContent("#rust"); 64 + expect(screen.getByTestId("tag-rust")).toHaveTextContent("2"); 65 + 66 + expect(screen.getByTestId("tag-learning")).toHaveTextContent("#learning"); 67 + expect(screen.getByTestId("tag-learning")).toHaveTextContent("2"); 68 + 69 + expect(screen.getByTestId("tag-advanced")).toHaveTextContent("#advanced"); 70 + expect(screen.getByTestId("tag-advanced")).toHaveTextContent("1"); 71 + }); 72 + 73 + it("displays recent notes sorted by update date", () => { 74 + render(() => <NotesSidebar notes={mockNotes} />); 75 + 76 + const recentLinks = screen.getAllByTestId(/^recent-/); 77 + expect(recentLinks[0]).toHaveAttribute("href", "/notes/note-1"); 78 + expect(recentLinks[1]).toHaveAttribute("href", "/notes/note-2"); 79 + expect(recentLinks[2]).toHaveAttribute("href", "/notes/note-3"); 80 + }); 81 + 82 + it("calls onFilterSelect when filter clicked", async () => { 83 + const onFilterSelect = vi.fn(); 84 + render(() => <NotesSidebar notes={mockNotes} onFilterSelect={onFilterSelect} />); 85 + 86 + await fireEvent.click(screen.getByTestId("filter-linked")); 87 + expect(onFilterSelect).toHaveBeenCalledWith("linked"); 88 + 89 + await fireEvent.click(screen.getByTestId("filter-orphaned")); 90 + expect(onFilterSelect).toHaveBeenCalledWith("orphaned"); 91 + }); 92 + 93 + it("calls onTagSelect when tag clicked", async () => { 94 + const onTagSelect = vi.fn(); 95 + render(() => <NotesSidebar notes={mockNotes} onTagSelect={onTagSelect} />); 96 + 97 + await fireEvent.click(screen.getByTestId("tag-rust")); 98 + expect(onTagSelect).toHaveBeenCalledWith("rust"); 99 + }); 100 + 101 + it("toggles tag selection off when same tag clicked", async () => { 102 + const onTagSelect = vi.fn(); 103 + render(() => <NotesSidebar notes={mockNotes} selectedTag="rust" onTagSelect={onTagSelect} />); 104 + 105 + await fireEvent.click(screen.getByTestId("tag-rust")); 106 + expect(onTagSelect).toHaveBeenCalledWith(undefined); 107 + }); 108 + 109 + it("handles empty notes array", () => { 110 + render(() => <NotesSidebar notes={[]} />); 111 + expect(screen.getByTestId("notes-sidebar")).toBeInTheDocument(); 112 + expect(screen.getByTestId("filter-all")).toHaveTextContent("All (0)"); 113 + }); 114 + });
+31 -7
web/src/pages/Notes.tsx
··· 1 1 import { NoteCard } from "$components/NoteCard"; 2 + import { NotesGraph } from "$components/notes/NotesGraph"; 2 3 import { Button } from "$components/ui/Button"; 3 4 import { EmptyState } from "$components/ui/EmptyState"; 4 5 import { api } from "$lib/api"; ··· 13 14 return res.json(); 14 15 }; 15 16 16 - type ViewMode = "grid" | "list"; 17 + type ViewMode = "grid" | "list" | "graph"; 17 18 18 19 const Notes: Component = () => { 19 20 const location = useLocation(); ··· 104 105 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" /> 105 106 </svg> 106 107 </button> 108 + <button 109 + onClick={() => setViewMode("graph")} 110 + class={`p-2 rounded ${ 111 + viewMode() === "graph" ? "bg-slate-200 dark:bg-slate-700" : "hover:bg-slate-100 dark:hover:bg-slate-800" 112 + }`} 113 + aria-label="Graph view"> 114 + <svg 115 + class="w-5 h-5 text-slate-600 dark:text-slate-300" 116 + fill="none" 117 + stroke="currentColor" 118 + viewBox="0 0 24 24"> 119 + <circle cx="5" cy="6" r="2" stroke-width="2" /> 120 + <circle cx="12" cy="18" r="2" stroke-width="2" /> 121 + <circle cx="19" cy="10" r="2" stroke-width="2" /> 122 + <path stroke-linecap="round" stroke-width="2" d="M6.5 7.5L10.5 16M13.5 16.5L17 11.5M7 6h10" /> 123 + </svg> 124 + </button> 107 125 </div> 108 126 </div> 109 127 ··· 141 159 } /> 142 160 </Show> 143 161 }> 144 - <div 145 - class={viewMode() === "grid" 146 - ? "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6" 147 - : "flex flex-col gap-4"}> 148 - <For each={filteredNotes()}>{(note) => <NoteCard note={note} />}</For> 149 - </div> 162 + <Show 163 + when={viewMode() === "graph"} 164 + fallback={ 165 + <div 166 + class={viewMode() === "grid" 167 + ? "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6" 168 + : "flex flex-col gap-4"}> 169 + <For each={filteredNotes()}>{(note) => <NoteCard note={note} />}</For> 170 + </div> 171 + }> 172 + <NotesGraph notes={filteredNotes()} /> 173 + </Show> 150 174 </Show> 151 175 </Show> 152 176 </div>