@hutusi/amytis 1.6.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/GEMINI.md +12 -2
  3. package/README.md +14 -0
  4. package/TODO.md +24 -16
  5. package/bun.lock +8 -3
  6. package/content/about.mdx +1 -0
  7. package/content/about.zh.mdx +21 -0
  8. package/content/flows/2026/02/05.md +0 -1
  9. package/content/flows/2026/02/10.mdx +2 -1
  10. package/content/flows/2026/02/15.md +2 -1
  11. package/content/flows/2026/02/18.mdx +2 -1
  12. package/content/flows/2026/02/20.md +15 -0
  13. package/content/links.mdx +42 -0
  14. package/content/links.zh.mdx +41 -0
  15. package/content/notes/algorithms-and-data-structures.mdx +51 -0
  16. package/content/notes/digital-garden-philosophy.mdx +36 -0
  17. package/content/notes/react-server-components.mdx +49 -0
  18. package/content/notes/tailwind-v4.mdx +45 -0
  19. package/content/notes/zettelkasten-method.mdx +33 -0
  20. package/content/posts/2026-02-20-i18n-routing-considerations.mdx +150 -0
  21. package/content/posts/multimedia-showcase/index.mdx +261 -0
  22. package/content/privacy.mdx +32 -0
  23. package/content/privacy.zh.mdx +32 -0
  24. package/docs/ARCHITECTURE.md +16 -0
  25. package/docs/CONTRIBUTING.md +11 -0
  26. package/docs/DIGITAL_GARDEN.md +64 -0
  27. package/package.json +8 -3
  28. package/scripts/copy-assets.ts +1 -1
  29. package/scripts/generate-knowledge-graph.ts +162 -0
  30. package/scripts/new-flow.ts +0 -5
  31. package/scripts/new-note.ts +53 -0
  32. package/site.config.ts +146 -44
  33. package/src/app/[slug]/page.tsx +0 -10
  34. package/src/app/archive/page.tsx +38 -10
  35. package/src/app/books/[slug]/page.tsx +18 -0
  36. package/src/app/flows/[year]/[month]/[day]/page.tsx +51 -31
  37. package/src/app/flows/[year]/[month]/page.tsx +15 -13
  38. package/src/app/flows/[year]/page.tsx +22 -15
  39. package/src/app/flows/page/[page]/page.tsx +3 -9
  40. package/src/app/flows/page.tsx +3 -8
  41. package/src/app/globals.css +41 -0
  42. package/src/app/graph/page.tsx +19 -0
  43. package/src/app/layout.tsx +47 -21
  44. package/src/app/notes/[slug]/page.tsx +128 -0
  45. package/src/app/notes/page/[page]/page.tsx +58 -0
  46. package/src/app/notes/page.tsx +31 -0
  47. package/src/app/page.tsx +134 -72
  48. package/src/app/posts/[slug]/page.tsx +8 -12
  49. package/src/app/search.json/route.ts +15 -1
  50. package/src/app/series/[slug]/page.tsx +18 -0
  51. package/src/app/subscribe/page.tsx +17 -0
  52. package/src/app/tags/[tag]/page.tsx +9 -26
  53. package/src/app/tags/page.tsx +3 -8
  54. package/src/components/AuthorCard.tsx +43 -0
  55. package/src/components/Backlinks.tsx +39 -0
  56. package/src/components/Comments.tsx +20 -4
  57. package/src/components/ExternalLinks.tsx +6 -2
  58. package/src/components/FlowCalendarSidebar.tsx +4 -2
  59. package/src/components/FlowContent.tsx +4 -3
  60. package/src/components/FlowHubTabs.tsx +50 -0
  61. package/src/components/FlowTimelineEntry.tsx +7 -9
  62. package/src/components/Footer.tsx +35 -26
  63. package/src/components/KnowledgeGraph.tsx +324 -0
  64. package/src/components/LanguageProvider.tsx +0 -5
  65. package/src/components/LanguageSwitch.tsx +117 -6
  66. package/src/components/LocaleSwitch.tsx +33 -0
  67. package/src/components/MarkdownRenderer.tsx +13 -2
  68. package/src/components/Navbar.tsx +266 -17
  69. package/src/components/NoteContent.tsx +123 -0
  70. package/src/components/NoteSidebar.tsx +132 -0
  71. package/src/components/PostNavigation.tsx +55 -0
  72. package/src/components/PostSidebar.tsx +172 -126
  73. package/src/components/ReadingProgressBar.tsx +6 -21
  74. package/src/components/RecentNotesSection.tsx +6 -11
  75. package/src/components/RelatedPosts.tsx +1 -1
  76. package/src/components/Search.tsx +29 -5
  77. package/src/components/SelectedBooksSection.tsx +12 -6
  78. package/src/components/ShareBar.tsx +115 -0
  79. package/src/components/SimpleLayoutHeader.tsx +5 -14
  80. package/src/components/SubscribePage.tsx +298 -0
  81. package/src/components/TagContentTabs.tsx +102 -0
  82. package/src/components/TagPageHeader.tsx +7 -13
  83. package/src/components/TagSidebar.tsx +142 -0
  84. package/src/components/TagsIndexClient.tsx +156 -0
  85. package/src/hooks/useScrollY.ts +41 -0
  86. package/src/i18n/translations.ts +105 -1
  87. package/src/layouts/PostLayout.tsx +40 -8
  88. package/src/layouts/SimpleLayout.tsx +53 -15
  89. package/src/lib/markdown.ts +347 -18
  90. package/src/lib/remark-wikilinks.ts +59 -0
  91. package/src/lib/search-utils.ts +2 -1
  92. package/src/components/TableOfContents.tsx +0 -158
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useState, useMemo } from 'react';
3
+ import { useState, useMemo, type ReactNode } from 'react';
4
4
  import Link from 'next/link';
5
5
  import { useLanguage } from '@/components/LanguageProvider';
6
6
 
@@ -10,11 +10,12 @@ interface FlowCalendarSidebarProps {
10
10
  tags?: Record<string, number>;
11
11
  selectedTag?: string | null;
12
12
  onTagSelect?: (tag: string) => void;
13
+ breadcrumb?: ReactNode;
13
14
  }
14
15
 
15
16
  const WEEKDAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
16
17
 
17
- export default function FlowCalendarSidebar({ entryDates, currentDate, tags, selectedTag, onTagSelect }: FlowCalendarSidebarProps) {
18
+ export default function FlowCalendarSidebar({ entryDates, currentDate, tags, selectedTag, onTagSelect, breadcrumb }: FlowCalendarSidebarProps) {
18
19
  const { t } = useLanguage();
19
20
  const initialDate = currentDate ? new Date(currentDate + 'T00:00:00') : new Date();
20
21
  const [viewYear, setViewYear] = useState(initialDate.getFullYear());
@@ -77,6 +78,7 @@ export default function FlowCalendarSidebar({ entryDates, currentDate, tags, sel
77
78
 
78
79
  return (
79
80
  <aside className="hidden lg:block sticky top-20 self-start w-[280px] max-h-[calc(100vh-6rem)]">
81
+ {breadcrumb && <div className="mb-4">{breadcrumb}</div>}
80
82
  <div className="border border-muted/20 rounded-lg p-4">
81
83
  {/* Month navigation */}
82
84
  <div className="flex items-center justify-between mb-3">
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useState, useMemo } from 'react';
3
+ import { useState, useMemo, type ReactNode } from 'react';
4
4
  import { useLanguage } from '@/components/LanguageProvider';
5
5
  import FlowCalendarSidebar from '@/components/FlowCalendarSidebar';
6
6
  import FlowTimelineEntry from '@/components/FlowTimelineEntry';
@@ -24,9 +24,10 @@ interface FlowContentProps {
24
24
  totalPages: number;
25
25
  basePath: string;
26
26
  };
27
+ breadcrumb?: ReactNode;
27
28
  }
28
29
 
29
- export default function FlowContent({ flows, entryDates, tags, currentDate, pagination }: FlowContentProps) {
30
+ export default function FlowContent({ flows, entryDates, tags, currentDate, pagination, breadcrumb }: FlowContentProps) {
30
31
  const { t } = useLanguage();
31
32
  const [selectedTag, setSelectedTag] = useState<string | null>(null);
32
33
 
@@ -47,6 +48,7 @@ export default function FlowContent({ flows, entryDates, tags, currentDate, pagi
47
48
  tags={tags}
48
49
  selectedTag={selectedTag}
49
50
  onTagSelect={handleTagSelect}
51
+ breadcrumb={breadcrumb}
50
52
  />
51
53
 
52
54
  <div className="flex-1 min-w-0">
@@ -72,7 +74,6 @@ export default function FlowContent({ flows, entryDates, tags, currentDate, pagi
72
74
  <FlowTimelineEntry
73
75
  key={flow.slug}
74
76
  date={flow.date}
75
- title={flow.title}
76
77
  excerpt={flow.excerpt}
77
78
  tags={flow.tags}
78
79
  slug={flow.slug}
@@ -0,0 +1,50 @@
1
+ 'use client';
2
+
3
+ import { usePathname } from 'next/navigation';
4
+ import Link from 'next/link';
5
+ import { useLanguage } from './LanguageProvider';
6
+
7
+ interface FlowHubTabsProps {
8
+ subtitle?: string;
9
+ }
10
+
11
+ export default function FlowHubTabs({ subtitle }: FlowHubTabsProps) {
12
+ const pathname = usePathname();
13
+ const { t } = useLanguage();
14
+
15
+ // Normalize: strip trailing slash added by next.config trailingSlash:true
16
+ const path = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
17
+
18
+ const isFlowsActive = path === '/flows' || path.startsWith('/flows/page');
19
+ const isNotesActive = path === '/notes' || path.startsWith('/notes/page');
20
+ const isGraphActive = path.startsWith('/graph');
21
+
22
+ const tabs = [
23
+ { href: '/flows', label: t('tab_daily_flow'), active: isFlowsActive },
24
+ { href: '/notes', label: t('notes'), active: isNotesActive },
25
+ { href: '/graph', label: t('tab_graph'), active: isGraphActive },
26
+ ];
27
+
28
+ return (
29
+ <div className="mb-10">
30
+ <div className="flex items-end gap-8 border-b border-muted/20">
31
+ {tabs.map(tab => (
32
+ <Link
33
+ key={tab.href}
34
+ href={tab.href}
35
+ className={`pb-3 text-3xl font-bold no-underline border-b-2 -mb-px transition-colors ${
36
+ tab.active
37
+ ? 'border-accent text-heading'
38
+ : 'border-transparent text-muted/30 hover:text-muted/60'
39
+ }`}
40
+ >
41
+ {tab.label}
42
+ </Link>
43
+ ))}
44
+ </div>
45
+ {subtitle && (
46
+ <p className="mt-3 text-sm text-muted">{subtitle}</p>
47
+ )}
48
+ </div>
49
+ );
50
+ }
@@ -3,25 +3,23 @@ import Tag from './Tag';
3
3
 
4
4
  interface FlowTimelineEntryProps {
5
5
  date: string;
6
- title: string;
7
6
  excerpt: string;
8
7
  tags: string[];
9
8
  slug: string;
10
9
  }
11
10
 
12
- export default function FlowTimelineEntry({ date, title, excerpt, tags, slug }: FlowTimelineEntryProps) {
11
+ export default function FlowTimelineEntry({ date, excerpt, tags, slug }: FlowTimelineEntryProps) {
13
12
  return (
14
13
  <article className="relative pl-6 pb-8 border-l-2 border-muted/20 last:pb-0">
15
14
  {/* Timeline dot */}
16
15
  <div className="absolute -left-[5px] top-1.5 w-2 h-2 rounded-full bg-accent" />
17
16
 
18
- <time className="text-xs font-mono text-accent">{date}</time>
19
- <h3 className="mt-1 mb-2 font-serif text-xl font-bold text-heading">
20
- <Link href={`/flows/${slug}`} className="no-underline hover:text-accent transition-colors">
21
- {title}
22
- </Link>
23
- </h3>
24
- <p className="text-sm text-muted leading-relaxed line-clamp-3">{excerpt}</p>
17
+ <Link href={`/flows/${slug}`} className="no-underline group">
18
+ <time className="text-sm font-mono text-accent group-hover:text-accent/70 transition-colors">{date}</time>
19
+ </Link>
20
+ {excerpt && (
21
+ <p className="mt-1.5 text-sm text-muted leading-relaxed line-clamp-3">{excerpt}</p>
22
+ )}
25
23
  {tags.length > 0 && (
26
24
  <div className="mt-2 flex flex-wrap gap-2">
27
25
  {tags.map(tag => (
@@ -43,7 +43,7 @@ export default function Footer() {
43
43
  <div>
44
44
  <h4 className="font-sans font-bold text-xs uppercase tracking-widest text-muted/80 mb-6">{t('explore')}</h4>
45
45
  <ul className="space-y-3 text-sm">
46
- {[...siteConfig.nav].sort((a, b) => a.weight - b.weight).map((item) => {
46
+ {[...(siteConfig.footer?.explore ?? [])].sort((a, b) => a.weight - b.weight).map((item) => {
47
47
  const key = item.name.toLowerCase() as TranslationKey;
48
48
  const translated = t(key);
49
49
  const label = translated !== key ? translated : item.name;
@@ -62,25 +62,26 @@ export default function Footer() {
62
62
  <div>
63
63
  <h4 className="font-sans font-bold text-xs uppercase tracking-widest text-muted/80 mb-6">{t('connect')}</h4>
64
64
  <ul className="space-y-3 text-sm">
65
- {siteConfig.social.github && (
66
- <li>
67
- <a href={siteConfig.social.github} target="_blank" rel="noopener noreferrer" className="text-foreground/80 hover:text-accent transition-colors no-underline flex items-center gap-2">
68
- GitHub
69
- </a>
70
- </li>
71
- )}
72
- {siteConfig.social.twitter && (
73
- <li>
74
- <a href={siteConfig.social.twitter} target="_blank" rel="noopener noreferrer" className="text-foreground/80 hover:text-accent transition-colors no-underline flex items-center gap-2">
75
- Twitter
76
- </a>
77
- </li>
78
- )}
79
- <li>
80
- <a href="/feed.xml" className="text-foreground/80 hover:text-accent transition-colors no-underline flex items-center gap-2">
81
- RSS Feed
82
- </a>
83
- </li>
65
+ {[...(siteConfig.footer?.connect ?? [])].sort((a, b) => a.weight - b.weight).map((item) => {
66
+ const isExternal = item.url.startsWith('http');
67
+ const key = item.name.toLowerCase() as TranslationKey;
68
+ const translated = t(key);
69
+ const label = translated !== key ? translated : item.name;
70
+ const className = "text-foreground/80 hover:text-accent transition-colors no-underline flex items-center gap-2";
71
+ return (
72
+ <li key={item.url}>
73
+ {isExternal ? (
74
+ <a href={item.url} target="_blank" rel="noopener noreferrer" className={className}>
75
+ {label}
76
+ </a>
77
+ ) : (
78
+ <Link href={item.url} className={className}>
79
+ {label}
80
+ </Link>
81
+ )}
82
+ </li>
83
+ );
84
+ })}
84
85
  </ul>
85
86
  </div>
86
87
  </div>
@@ -89,13 +90,21 @@ export default function Footer() {
89
90
  <div className="pt-8 border-t border-muted/10 flex flex-col md:flex-row justify-between items-center gap-4 text-xs text-muted">
90
91
  <span>{resolveLocaleValue(siteConfig.footerText, language)}</span>
91
92
  <div className="flex items-center gap-6">
92
- <LanguageSwitch />
93
- <span className="opacity-20">|</span>
94
- <Link href="/privacy" className="hover:text-foreground transition-colors no-underline">Privacy</Link>
93
+ <LanguageSwitch variant="text" />
95
94
  <span className="opacity-20">|</span>
96
- <a href="https://github.com/hutusi/amytis" target="_blank" rel="noreferrer" className="hover:text-foreground transition-colors no-underline">
97
- Built with Amytis
98
- </a>
95
+ <Link href="/privacy" className="hover:text-foreground transition-colors no-underline">{t('privacy')}</Link>
96
+ {siteConfig.footer?.builtWith?.show && (() => {
97
+ const cfg = siteConfig.footer.builtWith;
98
+ const label = cfg.text ? resolveLocaleValue(cfg.text, language) : t('built_with');
99
+ return (
100
+ <>
101
+ <span className="opacity-20">|</span>
102
+ <a href={cfg.url ?? 'https://github.com/hutusi/amytis'} target="_blank" rel="noreferrer" className="hover:text-foreground transition-colors no-underline">
103
+ {label}
104
+ </a>
105
+ </>
106
+ );
107
+ })()}
99
108
  </div>
100
109
  </div>
101
110
  </div>
@@ -0,0 +1,324 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
4
+ import { useRouter } from 'next/navigation';
5
+ import Link from 'next/link';
6
+ import * as d3 from 'd3';
7
+
8
+ // Reactive mobile detection via useSyncExternalStore (avoids setState-in-effect lint rule)
9
+ function subscribeToResize(callback: () => void) {
10
+ window.addEventListener('resize', callback);
11
+ return () => window.removeEventListener('resize', callback);
12
+ }
13
+ function getIsMobile() { return window.innerWidth < 768; }
14
+ function getServerIsMobile() { return false; }
15
+
16
+ interface GraphNode extends d3.SimulationNodeDatum {
17
+ id: string;
18
+ title: string;
19
+ type: 'post' | 'note' | 'flow' | 'series';
20
+ url: string;
21
+ connections: number;
22
+ }
23
+
24
+ interface GraphEdge {
25
+ source: string | GraphNode;
26
+ target: string | GraphNode;
27
+ type: 'wikilink' | 'series';
28
+ }
29
+
30
+ interface GraphData {
31
+ nodes: GraphNode[];
32
+ edges: GraphEdge[];
33
+ }
34
+
35
+ const NODE_COLORS: Record<string, string> = {
36
+ note: 'var(--accent)',
37
+ post: '#2563eb',
38
+ flow: '#f59e0b',
39
+ series: '#10b981',
40
+ };
41
+
42
+ const TYPE_FILTERS = ['note', 'post', 'flow', 'series'] as const;
43
+
44
+ function nodeRadius(connections: number): number {
45
+ return Math.max(5, Math.min(20, 5 + connections * 1.5));
46
+ }
47
+
48
+ export default function KnowledgeGraph() {
49
+ const svgRef = useRef<SVGSVGElement>(null);
50
+ const router = useRouter();
51
+ const routerRef = useRef(router);
52
+ useEffect(() => { routerRef.current = router; });
53
+ const [loading, setLoading] = useState(true);
54
+ const [error, setError] = useState<string | null>(null);
55
+ const [activeTypes, setActiveTypes] = useState<Set<string>>(new Set(TYPE_FILTERS));
56
+ const isMobile = useSyncExternalStore(subscribeToResize, getIsMobile, getServerIsMobile);
57
+ const [graphData, setGraphData] = useState<GraphData | null>(null);
58
+ const [searchQuery, setSearchQuery] = useState('');
59
+
60
+ useEffect(() => {
61
+ fetch('/knowledge-graph.json')
62
+ .then(res => {
63
+ if (!res.ok) throw new Error('Graph data not found. Run `bun run build:graph` to generate it.');
64
+ return res.json();
65
+ })
66
+ .then((data: GraphData) => {
67
+ setGraphData(data);
68
+ setLoading(false);
69
+ })
70
+ .catch((err: Error) => {
71
+ setError(err.message);
72
+ setLoading(false);
73
+ });
74
+ }, []);
75
+
76
+ useEffect(() => {
77
+ if (!graphData || !svgRef.current || isMobile) return;
78
+
79
+ const svg = svgRef.current;
80
+ const width = svg.clientWidth || 800;
81
+ const height = svg.clientHeight || 600;
82
+
83
+ // Clear previous content
84
+ d3.select(svg).selectAll('*').remove();
85
+
86
+ // Deep-copy nodes so simulation can modify them
87
+ const filteredNodes: GraphNode[] = graphData.nodes
88
+ .filter(n => activeTypes.has(n.type))
89
+ .map(n => ({ ...n }));
90
+
91
+ const filteredNodeIds = new Set(filteredNodes.map(n => n.id));
92
+ const filteredEdges: GraphEdge[] = graphData.edges
93
+ .filter(e => {
94
+ const src = typeof e.source === 'string' ? e.source : e.source.id;
95
+ const tgt = typeof e.target === 'string' ? e.target : e.target.id;
96
+ return filteredNodeIds.has(src) && filteredNodeIds.has(tgt);
97
+ })
98
+ .map(e => ({ ...e }));
99
+
100
+ const svgEl = d3.select(svg);
101
+
102
+ // Tooltip
103
+ const tooltip = d3.select('body')
104
+ .append('div')
105
+ .style('position', 'fixed')
106
+ .style('pointer-events', 'none')
107
+ .style('background', 'var(--background)')
108
+ .style('border', '1px solid color-mix(in srgb, var(--muted) 20%, transparent)')
109
+ .style('border-radius', '6px')
110
+ .style('padding', '6px 10px')
111
+ .style('font-size', '12px')
112
+ .style('color', 'var(--foreground)')
113
+ .style('opacity', '0')
114
+ .style('z-index', '100')
115
+ .style('max-width', '200px')
116
+ .style('box-shadow', '0 4px 6px rgba(0,0,0,0.1)');
117
+
118
+ // Container group for zoom/pan
119
+ const g = svgEl.append('g');
120
+
121
+ // Zoom behavior
122
+ const zoom = d3.zoom<SVGSVGElement, unknown>()
123
+ .scaleExtent([0.2, 4])
124
+ .on('zoom', (event) => {
125
+ g.attr('transform', event.transform.toString());
126
+ });
127
+
128
+ svgEl.call(zoom);
129
+
130
+ // Simulation
131
+ const simulation = d3.forceSimulation<GraphNode>(filteredNodes)
132
+ .force('link',
133
+ d3.forceLink<GraphNode, GraphEdge>(filteredEdges)
134
+ .id(d => d.id)
135
+ .distance(80)
136
+ )
137
+ .force('charge', d3.forceManyBody<GraphNode>().strength(-150))
138
+ .force('center', d3.forceCenter(width / 2, height / 2))
139
+ .force('collide', d3.forceCollide<GraphNode>().radius(d => nodeRadius(d.connections) + 2));
140
+
141
+ // Edges
142
+ const link = g.append('g')
143
+ .selectAll<SVGLineElement, GraphEdge>('line')
144
+ .data(filteredEdges)
145
+ .join('line')
146
+ .attr('stroke', 'currentColor')
147
+ .attr('stroke-opacity', 0.2)
148
+ .attr('stroke-width', 1);
149
+
150
+ // Node groups
151
+ const node = g.append('g')
152
+ .selectAll<SVGGElement, GraphNode>('g')
153
+ .data(filteredNodes)
154
+ .join('g')
155
+ .style('cursor', 'pointer')
156
+ .call(
157
+ d3.drag<SVGGElement, GraphNode>()
158
+ .on('start', (event, d) => {
159
+ if (!event.active) simulation.alphaTarget(0.3).restart();
160
+ d.fx = d.x;
161
+ d.fy = d.y;
162
+ })
163
+ .on('drag', (event, d) => {
164
+ d.fx = event.x;
165
+ d.fy = event.y;
166
+ })
167
+ .on('end', (event, d) => {
168
+ if (!event.active) simulation.alphaTarget(0);
169
+ d.fx = null;
170
+ d.fy = null;
171
+ })
172
+ )
173
+ .on('click', (_, d) => {
174
+ routerRef.current.push(d.url);
175
+ })
176
+ .on('mouseenter', (event: MouseEvent, d) => {
177
+ tooltip.style('opacity', '1').text('');
178
+ tooltip.append('span').style('font-weight', 'bold').text(d.title);
179
+ tooltip.append('br');
180
+ tooltip.append('span').style('opacity', '0.6').text(`${d.type} · ${d.connections} links`);
181
+ })
182
+ .on('mousemove', (event: MouseEvent) => {
183
+ tooltip
184
+ .style('left', `${event.clientX + 12}px`)
185
+ .style('top', `${event.clientY - 20}px`);
186
+ })
187
+ .on('mouseleave', () => {
188
+ tooltip.style('opacity', '0');
189
+ });
190
+
191
+ node.append('circle')
192
+ .attr('r', d => nodeRadius(d.connections))
193
+ .attr('fill', d => NODE_COLORS[d.type] || '#888')
194
+ .attr('fill-opacity', 0.85)
195
+ .attr('stroke', '#fff')
196
+ .attr('stroke-opacity', 0.3)
197
+ .attr('stroke-width', 1.5);
198
+
199
+ // Labels for well-connected nodes
200
+ node.filter(d => d.connections >= 3)
201
+ .append('text')
202
+ .text(d => d.title.length > 20 ? d.title.slice(0, 18) + '…' : d.title)
203
+ .attr('x', d => nodeRadius(d.connections) + 4)
204
+ .attr('y', 4)
205
+ .attr('font-size', '10px')
206
+ .attr('fill', 'currentColor')
207
+ .attr('opacity', 0.7)
208
+ .attr('pointer-events', 'none');
209
+
210
+ simulation.on('tick', () => {
211
+ link
212
+ .attr('x1', d => (d.source as GraphNode).x ?? 0)
213
+ .attr('y1', d => (d.source as GraphNode).y ?? 0)
214
+ .attr('x2', d => (d.target as GraphNode).x ?? 0)
215
+ .attr('y2', d => (d.target as GraphNode).y ?? 0);
216
+
217
+ node.attr('transform', d => `translate(${d.x ?? 0},${d.y ?? 0})`);
218
+ });
219
+
220
+ return () => {
221
+ simulation.stop();
222
+ tooltip.remove();
223
+ };
224
+ }, [graphData, activeTypes, isMobile]);
225
+
226
+ function toggleType(type: string) {
227
+ setActiveTypes(prev => {
228
+ const next = new Set(prev);
229
+ if (next.has(type)) {
230
+ if (next.size > 1) next.delete(type);
231
+ } else {
232
+ next.add(type);
233
+ }
234
+ return next;
235
+ });
236
+ }
237
+
238
+ if (loading) {
239
+ return (
240
+ <div className="flex items-center justify-center h-64 text-muted text-sm">
241
+ Loading graph…
242
+ </div>
243
+ );
244
+ }
245
+
246
+ if (error) {
247
+ return (
248
+ <div className="rounded-lg border border-muted/20 bg-muted/5 p-8 text-center text-sm text-muted">
249
+ <p className="mb-2">{error}</p>
250
+ <code className="text-xs bg-muted/10 px-1.5 py-0.5 rounded">bun run build:graph</code>
251
+ </div>
252
+ );
253
+ }
254
+
255
+ // Mobile: render searchable list
256
+ if (isMobile && graphData) {
257
+ const filtered = graphData.nodes
258
+ .filter(n => activeTypes.has(n.type))
259
+ .filter(n => !searchQuery || n.title.toLowerCase().includes(searchQuery.toLowerCase()));
260
+
261
+ return (
262
+ <div className="space-y-4">
263
+ <input
264
+ type="text"
265
+ placeholder="Search nodes…"
266
+ value={searchQuery}
267
+ onChange={e => setSearchQuery(e.target.value)}
268
+ className="w-full px-3 py-2 text-sm border border-muted/20 rounded-lg bg-transparent outline-none focus:border-accent"
269
+ />
270
+ <div className="space-y-1">
271
+ {filtered.map(n => (
272
+ <Link
273
+ key={n.id}
274
+ href={n.url}
275
+ className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-muted/5 no-underline text-sm text-foreground"
276
+ >
277
+ <span
278
+ className="w-2 h-2 rounded-full shrink-0"
279
+ style={{ background: NODE_COLORS[n.type] }}
280
+ />
281
+ <span className="flex-1 truncate">{n.title}</span>
282
+ <span className="text-xs text-muted">{n.type}</span>
283
+ </Link>
284
+ ))}
285
+ </div>
286
+ </div>
287
+ );
288
+ }
289
+
290
+ return (
291
+ <div className="space-y-4">
292
+ {/* Filter strip */}
293
+ <div className="flex items-center gap-2 flex-wrap">
294
+ {TYPE_FILTERS.map(type => (
295
+ <button
296
+ key={type}
297
+ onClick={() => toggleType(type)}
298
+ aria-pressed={activeTypes.has(type)}
299
+ className={`flex items-center gap-1.5 px-3 py-1 text-xs rounded-full border transition-colors ${
300
+ activeTypes.has(type)
301
+ ? 'border-current text-foreground'
302
+ : 'border-muted/20 text-muted'
303
+ }`}
304
+ >
305
+ <span className="w-2 h-2 rounded-full" style={{ background: NODE_COLORS[type] }} />
306
+ {type}
307
+ </button>
308
+ ))}
309
+ {graphData && (
310
+ <span className="text-xs text-muted ml-auto">
311
+ {graphData.nodes.filter(n => activeTypes.has(n.type)).length} nodes
312
+ </span>
313
+ )}
314
+ </div>
315
+
316
+ {/* Graph SVG */}
317
+ <svg
318
+ ref={svgRef}
319
+ className="w-full rounded-lg border border-muted/20 bg-muted/5"
320
+ style={{ height: '600px' }}
321
+ />
322
+ </div>
323
+ );
324
+ }
@@ -27,11 +27,6 @@ export function LanguageProvider({ children }: { children: React.ReactNode }) {
27
27
  const rafId = requestAnimationFrame(() => {
28
28
  if (savedLang && translations[savedLang]) {
29
29
  setLanguageState(savedLang);
30
- } else {
31
- const browserLang = navigator.language.split('-')[0] as Language;
32
- if (translations[browserLang]) {
33
- setLanguageState(browserLang);
34
- }
35
30
  }
36
31
  setIsHydrated(true);
37
32
  });