@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.
- package/CHANGELOG.md +49 -0
- package/GEMINI.md +12 -2
- package/README.md +14 -0
- package/TODO.md +24 -16
- package/bun.lock +8 -3
- package/content/about.mdx +1 -0
- package/content/about.zh.mdx +21 -0
- package/content/flows/2026/02/05.md +0 -1
- package/content/flows/2026/02/10.mdx +2 -1
- package/content/flows/2026/02/15.md +2 -1
- package/content/flows/2026/02/18.mdx +2 -1
- package/content/flows/2026/02/20.md +15 -0
- package/content/links.mdx +42 -0
- package/content/links.zh.mdx +41 -0
- package/content/notes/algorithms-and-data-structures.mdx +51 -0
- package/content/notes/digital-garden-philosophy.mdx +36 -0
- package/content/notes/react-server-components.mdx +49 -0
- package/content/notes/tailwind-v4.mdx +45 -0
- package/content/notes/zettelkasten-method.mdx +33 -0
- package/content/posts/2026-02-20-i18n-routing-considerations.mdx +150 -0
- package/content/posts/multimedia-showcase/index.mdx +261 -0
- package/content/privacy.mdx +32 -0
- package/content/privacy.zh.mdx +32 -0
- package/docs/ARCHITECTURE.md +16 -0
- package/docs/CONTRIBUTING.md +11 -0
- package/docs/DIGITAL_GARDEN.md +64 -0
- package/package.json +8 -3
- package/scripts/copy-assets.ts +1 -1
- package/scripts/generate-knowledge-graph.ts +162 -0
- package/scripts/new-flow.ts +0 -5
- package/scripts/new-note.ts +53 -0
- package/site.config.ts +146 -44
- package/src/app/[slug]/page.tsx +0 -10
- package/src/app/archive/page.tsx +38 -10
- package/src/app/books/[slug]/page.tsx +18 -0
- package/src/app/flows/[year]/[month]/[day]/page.tsx +51 -31
- package/src/app/flows/[year]/[month]/page.tsx +15 -13
- package/src/app/flows/[year]/page.tsx +22 -15
- package/src/app/flows/page/[page]/page.tsx +3 -9
- package/src/app/flows/page.tsx +3 -8
- package/src/app/globals.css +41 -0
- package/src/app/graph/page.tsx +19 -0
- package/src/app/layout.tsx +47 -21
- package/src/app/notes/[slug]/page.tsx +128 -0
- package/src/app/notes/page/[page]/page.tsx +58 -0
- package/src/app/notes/page.tsx +31 -0
- package/src/app/page.tsx +134 -72
- package/src/app/posts/[slug]/page.tsx +8 -12
- package/src/app/search.json/route.ts +15 -1
- package/src/app/series/[slug]/page.tsx +18 -0
- package/src/app/subscribe/page.tsx +17 -0
- package/src/app/tags/[tag]/page.tsx +9 -26
- package/src/app/tags/page.tsx +3 -8
- package/src/components/AuthorCard.tsx +43 -0
- package/src/components/Backlinks.tsx +39 -0
- package/src/components/Comments.tsx +20 -4
- package/src/components/ExternalLinks.tsx +6 -2
- package/src/components/FlowCalendarSidebar.tsx +4 -2
- package/src/components/FlowContent.tsx +4 -3
- package/src/components/FlowHubTabs.tsx +50 -0
- package/src/components/FlowTimelineEntry.tsx +7 -9
- package/src/components/Footer.tsx +35 -26
- package/src/components/KnowledgeGraph.tsx +324 -0
- package/src/components/LanguageProvider.tsx +0 -5
- package/src/components/LanguageSwitch.tsx +117 -6
- package/src/components/LocaleSwitch.tsx +33 -0
- package/src/components/MarkdownRenderer.tsx +13 -2
- package/src/components/Navbar.tsx +266 -17
- package/src/components/NoteContent.tsx +123 -0
- package/src/components/NoteSidebar.tsx +132 -0
- package/src/components/PostNavigation.tsx +55 -0
- package/src/components/PostSidebar.tsx +172 -126
- package/src/components/ReadingProgressBar.tsx +6 -21
- package/src/components/RecentNotesSection.tsx +6 -11
- package/src/components/RelatedPosts.tsx +1 -1
- package/src/components/Search.tsx +29 -5
- package/src/components/SelectedBooksSection.tsx +12 -6
- package/src/components/ShareBar.tsx +115 -0
- package/src/components/SimpleLayoutHeader.tsx +5 -14
- package/src/components/SubscribePage.tsx +298 -0
- package/src/components/TagContentTabs.tsx +102 -0
- package/src/components/TagPageHeader.tsx +7 -13
- package/src/components/TagSidebar.tsx +142 -0
- package/src/components/TagsIndexClient.tsx +156 -0
- package/src/hooks/useScrollY.ts +41 -0
- package/src/i18n/translations.ts +105 -1
- package/src/layouts/PostLayout.tsx +40 -8
- package/src/layouts/SimpleLayout.tsx +53 -15
- package/src/lib/markdown.ts +347 -18
- package/src/lib/remark-wikilinks.ts +59 -0
- package/src/lib/search-utils.ts +2 -1
- 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,
|
|
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
|
-
<
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
</
|
|
23
|
-
|
|
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.
|
|
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.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
<
|
|
97
|
-
|
|
98
|
-
|
|
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
|
});
|