@hutusi/amytis 1.7.0 → 1.9.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/.github/workflows/ci.yml +1 -1
- package/CHANGELOG.md +63 -0
- package/CLAUDE.md +9 -18
- package/GEMINI.md +6 -0
- package/README.md +44 -0
- package/TODO.md +15 -3
- package/bun.lock +5 -3
- package/content/about.mdx +64 -10
- package/content/about.zh.mdx +66 -9
- package/content/books/sample-book/index.mdx +3 -3
- 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 +0 -1
- 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/series/digital-garden/01-philosophy.mdx +25 -12
- package/docs/ARCHITECTURE.md +9 -1
- package/docs/CONTRIBUTING.md +26 -0
- package/docs/DIGITAL_GARDEN.md +72 -0
- package/imports/README.md +45 -0
- package/package.json +12 -5
- package/scripts/generate-knowledge-graph.ts +162 -0
- package/scripts/import-book.ts +176 -0
- package/scripts/new-flow-from-chat.ts +238 -0
- package/scripts/new-flow.ts +0 -5
- package/scripts/new-note.ts +53 -0
- package/scripts/sync-book-chapters.ts +210 -0
- package/site.config.ts +30 -7
- package/src/app/authors/[author]/page.tsx +3 -1
- package/src/app/books/[slug]/[chapter]/page.tsx +2 -1
- package/src/app/books/[slug]/page.tsx +6 -5
- package/src/app/flows/[year]/[month]/[day]/page.tsx +35 -29
- package/src/app/flows/[year]/[month]/page.tsx +18 -13
- package/src/app/flows/[year]/page.tsx +25 -15
- package/src/app/flows/page/[page]/page.tsx +5 -9
- package/src/app/flows/page.tsx +5 -8
- package/src/app/globals.css +41 -0
- package/src/app/graph/page.tsx +21 -0
- package/src/app/layout.tsx +4 -2
- package/src/app/notes/[slug]/page.tsx +129 -0
- package/src/app/notes/page/[page]/page.tsx +60 -0
- package/src/app/notes/page.tsx +33 -0
- package/src/app/page/[page]/page.tsx +1 -0
- package/src/app/page.tsx +4 -5
- package/src/app/posts/[slug]/page.tsx +5 -2
- package/src/app/posts/page/[page]/page.tsx +4 -1
- package/src/app/search.json/route.ts +17 -3
- package/src/app/series/[slug]/page/[page]/page.tsx +1 -0
- package/src/app/series/[slug]/page.tsx +3 -3
- package/src/app/sitemap.ts +1 -1
- package/src/app/tags/[tag]/page.tsx +3 -3
- package/src/components/Backlinks.tsx +39 -0
- package/src/components/BookMobileNav.tsx +11 -11
- package/src/components/BookSidebar.tsx +17 -25
- package/src/components/BrowserDetectionBanner.tsx +96 -0
- package/src/components/FeaturedStoriesSection.tsx +1 -1
- 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/KnowledgeGraph.tsx +324 -0
- package/src/components/LanguageProvider.tsx +14 -5
- package/src/components/MarkdownRenderer.tsx +13 -2
- package/src/components/Navbar.tsx +237 -10
- package/src/components/NoteContent.tsx +123 -0
- package/src/components/NoteSidebar.tsx +132 -0
- package/src/components/RecentNotesSection.tsx +6 -11
- package/src/components/Search.tsx +7 -3
- package/src/components/TagContentTabs.tsx +0 -1
- package/src/i18n/translations.ts +43 -17
- package/src/layouts/BookLayout.tsx +3 -3
- package/src/layouts/PostLayout.tsx +8 -3
- package/src/lib/i18n.ts +83 -6
- package/src/lib/markdown.ts +306 -19
- package/src/lib/remark-wikilinks.ts +59 -0
- package/src/lib/search-utils.ts +2 -1
- package/tests/unit/static-params.test.ts +238 -0
- package/content/series/digital-garden/01-philosophy/index.mdx +0 -23
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState, useEffect } from 'react';
|
|
3
|
+
import { Fragment, useState, useEffect } from 'react';
|
|
4
4
|
import Link from 'next/link';
|
|
5
|
+
import { usePathname } from 'next/navigation';
|
|
5
6
|
import { siteConfig } from '../../site.config';
|
|
7
|
+
import type { NavChildItem } from '../../site.config';
|
|
6
8
|
import ThemeToggle from './ThemeToggle';
|
|
7
9
|
import LanguageSwitch from './LanguageSwitch';
|
|
8
10
|
import Search from '@/components/Search';
|
|
@@ -23,14 +25,20 @@ interface NavbarProps {
|
|
|
23
25
|
// Map from nav URL to feature key
|
|
24
26
|
const FEATURE_URLS: Partial<Record<string, keyof typeof siteConfig.features>> = {
|
|
25
27
|
'/posts': 'posts',
|
|
26
|
-
'/flows': '
|
|
28
|
+
'/flows': 'flow',
|
|
29
|
+
'/notes': 'flow',
|
|
30
|
+
'/graph': 'flow',
|
|
27
31
|
'/series': 'series',
|
|
28
32
|
'/books': 'books',
|
|
29
33
|
};
|
|
30
34
|
|
|
31
35
|
export default function Navbar({ seriesList = [], booksList = [] }: NavbarProps) {
|
|
32
36
|
const { t, language } = useLanguage();
|
|
37
|
+
const pathname = usePathname();
|
|
33
38
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
|
39
|
+
const [isScrolled, setIsScrolled] = useState(false);
|
|
40
|
+
const [openDropdown, setOpenDropdown] = useState<string | null>(null);
|
|
41
|
+
|
|
34
42
|
const navItems = [...siteConfig.nav]
|
|
35
43
|
.filter(item => {
|
|
36
44
|
const featureKey = FEATURE_URLS[item.url];
|
|
@@ -49,6 +57,27 @@ export default function Navbar({ seriesList = [], booksList = [] }: NavbarProps)
|
|
|
49
57
|
return translated !== key ? translated : name;
|
|
50
58
|
};
|
|
51
59
|
|
|
60
|
+
function isActive(url: string): boolean {
|
|
61
|
+
if (url === '/flows') {
|
|
62
|
+
return pathname.startsWith('/flows') || pathname.startsWith('/notes') || pathname.startsWith('/graph');
|
|
63
|
+
}
|
|
64
|
+
if (url === '/') return pathname === '/';
|
|
65
|
+
return pathname.startsWith(url);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Scroll-aware transparency
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
const handleScroll = () => setIsScrolled(window.scrollY > 8);
|
|
71
|
+
handleScroll(); // sync with scroll position at mount (e.g. after refresh while scrolled)
|
|
72
|
+
window.addEventListener('scroll', handleScroll, { passive: true });
|
|
73
|
+
return () => window.removeEventListener('scroll', handleScroll);
|
|
74
|
+
}, []);
|
|
75
|
+
|
|
76
|
+
function closeMenu() {
|
|
77
|
+
setIsMenuOpen(false);
|
|
78
|
+
setOpenDropdown(null);
|
|
79
|
+
}
|
|
80
|
+
|
|
52
81
|
// Prevent body scroll when menu is open
|
|
53
82
|
useEffect(() => {
|
|
54
83
|
if (isMenuOpen) {
|
|
@@ -62,7 +91,11 @@ export default function Navbar({ seriesList = [], booksList = [] }: NavbarProps)
|
|
|
62
91
|
}, [isMenuOpen]);
|
|
63
92
|
|
|
64
93
|
return (
|
|
65
|
-
<nav className=
|
|
94
|
+
<nav className={`fixed top-0 left-0 w-full z-50 border-b transition-all duration-300 ${
|
|
95
|
+
isScrolled
|
|
96
|
+
? 'border-muted/10 bg-background/90 backdrop-blur-md shadow-sm'
|
|
97
|
+
: 'border-transparent bg-transparent'
|
|
98
|
+
}`}>
|
|
66
99
|
<div className="max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
|
|
67
100
|
<Link
|
|
68
101
|
href="/"
|
|
@@ -92,13 +125,16 @@ export default function Navbar({ seriesList = [], booksList = [] }: NavbarProps)
|
|
|
92
125
|
const isExternal = !!('external' in item && item.external);
|
|
93
126
|
const Component = isExternal ? 'a' : Link;
|
|
94
127
|
const props = isExternal ? { target: "_blank", rel: "noopener noreferrer" } : {};
|
|
128
|
+
const active = isActive(item.url);
|
|
95
129
|
|
|
96
130
|
if (item.url === '/books' && booksList.length > 0) {
|
|
97
131
|
return (
|
|
98
132
|
<div key={item.url} className="relative group">
|
|
99
133
|
<Link
|
|
100
134
|
href={item.url}
|
|
101
|
-
className=
|
|
135
|
+
className={`text-sm font-sans font-medium no-underline transition-colors duration-200 flex items-center gap-1 py-4 ${
|
|
136
|
+
active ? 'text-accent' : 'text-foreground/80 hover:text-heading'
|
|
137
|
+
}`}
|
|
102
138
|
>
|
|
103
139
|
{getLabel(item.name, item.url)}
|
|
104
140
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="opacity-50 group-hover:rotate-180 transition-transform">
|
|
@@ -134,7 +170,9 @@ export default function Navbar({ seriesList = [], booksList = [] }: NavbarProps)
|
|
|
134
170
|
<div key={item.url} className="relative group">
|
|
135
171
|
<Link
|
|
136
172
|
href={item.url}
|
|
137
|
-
className=
|
|
173
|
+
className={`text-sm font-sans font-medium no-underline transition-colors duration-200 flex items-center gap-1 py-4 ${
|
|
174
|
+
active ? 'text-accent' : 'text-foreground/80 hover:text-heading'
|
|
175
|
+
}`}
|
|
138
176
|
>
|
|
139
177
|
{getLabel(item.name, item.url)}
|
|
140
178
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="opacity-50 group-hover:rotate-180 transition-transform">
|
|
@@ -165,12 +203,54 @@ export default function Navbar({ seriesList = [], booksList = [] }: NavbarProps)
|
|
|
165
203
|
);
|
|
166
204
|
}
|
|
167
205
|
|
|
206
|
+
// Static children dropdown (e.g., "More")
|
|
207
|
+
if (item.children && item.children.length > 0) {
|
|
208
|
+
const childActive = item.children.some(c => c.url && pathname.startsWith(c.url));
|
|
209
|
+
return (
|
|
210
|
+
<div key={item.url || item.name} className="relative group">
|
|
211
|
+
<button
|
|
212
|
+
type="button"
|
|
213
|
+
className={`text-sm font-sans font-medium transition-colors duration-200 flex items-center gap-1 py-4 bg-transparent border-0 cursor-pointer ${
|
|
214
|
+
childActive ? 'text-accent' : 'text-foreground/80 hover:text-heading'
|
|
215
|
+
}`}
|
|
216
|
+
>
|
|
217
|
+
{getLabel(item.name, item.url)}
|
|
218
|
+
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="opacity-50 group-hover:rotate-180 transition-transform">
|
|
219
|
+
<path d="M6 9l6 6 6-6"/>
|
|
220
|
+
</svg>
|
|
221
|
+
</button>
|
|
222
|
+
<div className="absolute top-full right-0 pt-2 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 min-w-[160px]">
|
|
223
|
+
<div className="bg-background/95 backdrop-blur-md border border-muted/10 rounded-xl shadow-xl p-2 flex flex-col gap-1 animate-slide-down">
|
|
224
|
+
{item.children.map((child: NavChildItem) => {
|
|
225
|
+
const ChildComp = child.external ? 'a' : Link;
|
|
226
|
+
const childProps = child.external ? { target: '_blank', rel: 'noopener noreferrer' } : {};
|
|
227
|
+
return (
|
|
228
|
+
<Fragment key={child.url}>
|
|
229
|
+
{child.dividerBefore && <div className="h-px bg-muted/10 my-1" />}
|
|
230
|
+
<ChildComp
|
|
231
|
+
href={child.url}
|
|
232
|
+
{...childProps}
|
|
233
|
+
className="block px-4 py-2.5 text-sm text-foreground/80 hover:text-accent hover:bg-muted/5 rounded-lg transition-colors no-underline whitespace-nowrap"
|
|
234
|
+
>
|
|
235
|
+
{getLabel(child.name, child.url)}
|
|
236
|
+
</ChildComp>
|
|
237
|
+
</Fragment>
|
|
238
|
+
);
|
|
239
|
+
})}
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
168
246
|
return (
|
|
169
247
|
<Component
|
|
170
248
|
key={item.url}
|
|
171
249
|
href={item.url}
|
|
172
250
|
{...props}
|
|
173
|
-
className=
|
|
251
|
+
className={`text-sm font-sans font-medium no-underline transition-colors duration-200 flex items-center gap-1 ${
|
|
252
|
+
active ? 'text-accent' : 'text-foreground/80 hover:text-heading'
|
|
253
|
+
}`}
|
|
174
254
|
>
|
|
175
255
|
{getLabel(item.name, item.url)}
|
|
176
256
|
{isExternal && (
|
|
@@ -227,23 +307,170 @@ export default function Navbar({ seriesList = [], booksList = [] }: NavbarProps)
|
|
|
227
307
|
{/* Backdrop */}
|
|
228
308
|
<div
|
|
229
309
|
className="fixed inset-0 top-16 bg-background/60 backdrop-blur-sm md:hidden"
|
|
230
|
-
onClick={() =>
|
|
310
|
+
onClick={() => closeMenu()}
|
|
231
311
|
/>
|
|
232
312
|
{/* Menu */}
|
|
233
313
|
<div className="md:hidden absolute top-16 left-0 w-full bg-background/95 backdrop-blur-md border-b border-muted/10 shadow-lg animate-slide-down">
|
|
234
314
|
<div className="max-w-6xl mx-auto px-6 py-4 flex flex-col gap-1">
|
|
235
315
|
{navItems.map((item) => {
|
|
236
316
|
const isExternal = !!('external' in item && item.external);
|
|
317
|
+
const active = isActive(item.url);
|
|
318
|
+
|
|
319
|
+
// Series accordion for mobile
|
|
320
|
+
if (item.url === '/series' && seriesList.length > 0) {
|
|
321
|
+
const isOpen = openDropdown === '/series';
|
|
322
|
+
return (
|
|
323
|
+
<div key={item.url}>
|
|
324
|
+
<div className={`flex items-center rounded-lg transition-colors ${active ? 'text-accent' : 'text-foreground/80'}`}>
|
|
325
|
+
<Link
|
|
326
|
+
href={item.url}
|
|
327
|
+
className="flex-1 px-3 py-3 text-base font-sans font-medium no-underline hover:text-accent transition-colors"
|
|
328
|
+
onClick={() => closeMenu()}
|
|
329
|
+
>
|
|
330
|
+
{getLabel(item.name, item.url)}
|
|
331
|
+
</Link>
|
|
332
|
+
<button
|
|
333
|
+
className="px-3 py-3 text-foreground/60 hover:text-accent transition-colors"
|
|
334
|
+
onClick={() => setOpenDropdown(isOpen ? null : '/series')}
|
|
335
|
+
aria-label={isOpen ? 'Collapse series list' : 'Expand series list'}
|
|
336
|
+
aria-expanded={isOpen}
|
|
337
|
+
>
|
|
338
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={`transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}>
|
|
339
|
+
<path d="M6 9l6 6 6-6"/>
|
|
340
|
+
</svg>
|
|
341
|
+
</button>
|
|
342
|
+
</div>
|
|
343
|
+
{isOpen && (
|
|
344
|
+
<div className="ml-4 pl-3 border-l-2 border-muted/10 flex flex-col gap-1 mb-1">
|
|
345
|
+
{seriesList.map(s => (
|
|
346
|
+
<Link
|
|
347
|
+
key={s.slug}
|
|
348
|
+
href={`/series/${s.slug}`}
|
|
349
|
+
className="block px-3 py-2 text-sm text-foreground/80 hover:text-accent hover:bg-muted/5 rounded-lg no-underline transition-colors"
|
|
350
|
+
onClick={() => closeMenu()}
|
|
351
|
+
>
|
|
352
|
+
{s.name}
|
|
353
|
+
</Link>
|
|
354
|
+
))}
|
|
355
|
+
<Link
|
|
356
|
+
href="/series"
|
|
357
|
+
className="block px-3 py-2 text-xs font-bold uppercase tracking-widest text-muted hover:text-accent hover:bg-muted/5 rounded-lg no-underline transition-colors"
|
|
358
|
+
onClick={() => closeMenu()}
|
|
359
|
+
>
|
|
360
|
+
{t('all_series')} →
|
|
361
|
+
</Link>
|
|
362
|
+
</div>
|
|
363
|
+
)}
|
|
364
|
+
</div>
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Books accordion for mobile
|
|
369
|
+
if (item.url === '/books' && booksList.length > 0) {
|
|
370
|
+
const isOpen = openDropdown === '/books';
|
|
371
|
+
return (
|
|
372
|
+
<div key={item.url}>
|
|
373
|
+
<div className={`flex items-center rounded-lg transition-colors ${active ? 'text-accent' : 'text-foreground/80'}`}>
|
|
374
|
+
<Link
|
|
375
|
+
href={item.url}
|
|
376
|
+
className="flex-1 px-3 py-3 text-base font-sans font-medium no-underline hover:text-accent transition-colors"
|
|
377
|
+
onClick={() => closeMenu()}
|
|
378
|
+
>
|
|
379
|
+
{getLabel(item.name, item.url)}
|
|
380
|
+
</Link>
|
|
381
|
+
<button
|
|
382
|
+
className="px-3 py-3 text-foreground/60 hover:text-accent transition-colors"
|
|
383
|
+
onClick={() => setOpenDropdown(isOpen ? null : '/books')}
|
|
384
|
+
aria-label={isOpen ? 'Collapse books list' : 'Expand books list'}
|
|
385
|
+
aria-expanded={isOpen}
|
|
386
|
+
>
|
|
387
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={`transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}>
|
|
388
|
+
<path d="M6 9l6 6 6-6"/>
|
|
389
|
+
</svg>
|
|
390
|
+
</button>
|
|
391
|
+
</div>
|
|
392
|
+
{isOpen && (
|
|
393
|
+
<div className="ml-4 pl-3 border-l-2 border-muted/10 flex flex-col gap-1 mb-1">
|
|
394
|
+
{booksList.map(b => (
|
|
395
|
+
<Link
|
|
396
|
+
key={b.slug}
|
|
397
|
+
href={`/books/${b.slug}`}
|
|
398
|
+
className="block px-3 py-2 text-sm text-foreground/80 hover:text-accent hover:bg-muted/5 rounded-lg no-underline transition-colors"
|
|
399
|
+
onClick={() => closeMenu()}
|
|
400
|
+
>
|
|
401
|
+
{b.name}
|
|
402
|
+
</Link>
|
|
403
|
+
))}
|
|
404
|
+
<Link
|
|
405
|
+
href="/books"
|
|
406
|
+
className="block px-3 py-2 text-xs font-bold uppercase tracking-widest text-muted hover:text-accent hover:bg-muted/5 rounded-lg no-underline transition-colors"
|
|
407
|
+
onClick={() => closeMenu()}
|
|
408
|
+
>
|
|
409
|
+
{t('all_books')} →
|
|
410
|
+
</Link>
|
|
411
|
+
</div>
|
|
412
|
+
)}
|
|
413
|
+
</div>
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Static children accordion for mobile (e.g., "More")
|
|
418
|
+
if (item.children && item.children.length > 0) {
|
|
419
|
+
const dropdownKey = item.url || item.name;
|
|
420
|
+
const isOpen = openDropdown === dropdownKey;
|
|
421
|
+
const childActive = item.children.some(c => c.url && pathname.startsWith(c.url));
|
|
422
|
+
return (
|
|
423
|
+
<div key={dropdownKey}>
|
|
424
|
+
<button
|
|
425
|
+
type="button"
|
|
426
|
+
className={`w-full flex items-center justify-between px-3 py-3 text-base font-sans font-medium rounded-lg transition-colors ${
|
|
427
|
+
childActive ? 'text-accent' : 'text-foreground/80 hover:text-accent hover:bg-muted/5'
|
|
428
|
+
}`}
|
|
429
|
+
onClick={() => setOpenDropdown(isOpen ? null : dropdownKey)}
|
|
430
|
+
aria-expanded={isOpen}
|
|
431
|
+
>
|
|
432
|
+
{getLabel(item.name, item.url)}
|
|
433
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={`transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}>
|
|
434
|
+
<path d="M6 9l6 6 6-6"/>
|
|
435
|
+
</svg>
|
|
436
|
+
</button>
|
|
437
|
+
{isOpen && (
|
|
438
|
+
<div className="ml-4 pl-3 border-l-2 border-muted/10 flex flex-col gap-1 mb-1">
|
|
439
|
+
{item.children.map((child: NavChildItem) => {
|
|
440
|
+
const ChildComp = child.external ? 'a' : Link;
|
|
441
|
+
const childProps = child.external ? { target: '_blank', rel: 'noopener noreferrer' } : {};
|
|
442
|
+
return (
|
|
443
|
+
<Fragment key={child.url}>
|
|
444
|
+
{child.dividerBefore && <div className="h-px bg-muted/10 my-1" />}
|
|
445
|
+
<ChildComp
|
|
446
|
+
href={child.url}
|
|
447
|
+
{...childProps}
|
|
448
|
+
className="block px-3 py-2 text-sm text-foreground/80 hover:text-accent hover:bg-muted/5 rounded-lg no-underline transition-colors"
|
|
449
|
+
onClick={() => closeMenu()}
|
|
450
|
+
>
|
|
451
|
+
{getLabel(child.name, child.url)}
|
|
452
|
+
</ChildComp>
|
|
453
|
+
</Fragment>
|
|
454
|
+
);
|
|
455
|
+
})}
|
|
456
|
+
</div>
|
|
457
|
+
)}
|
|
458
|
+
</div>
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Regular mobile nav item
|
|
237
463
|
const Component = isExternal ? 'a' : Link;
|
|
238
464
|
const props = isExternal ? { target: "_blank", rel: "noopener noreferrer" } : {};
|
|
239
|
-
|
|
240
465
|
return (
|
|
241
466
|
<Component
|
|
242
467
|
key={item.url}
|
|
243
468
|
href={item.url}
|
|
244
469
|
{...props}
|
|
245
|
-
className=
|
|
246
|
-
|
|
470
|
+
className={`flex items-center gap-2 px-3 py-3 text-base font-sans font-medium rounded-lg no-underline transition-colors ${
|
|
471
|
+
active ? 'text-accent' : 'text-foreground/80 hover:text-accent hover:bg-muted/5'
|
|
472
|
+
}`}
|
|
473
|
+
onClick={() => closeMenu()}
|
|
247
474
|
>
|
|
248
475
|
{getLabel(item.name, item.url)}
|
|
249
476
|
{isExternal && (
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useMemo } from 'react';
|
|
4
|
+
import { useLanguage } from '@/components/LanguageProvider';
|
|
5
|
+
import Link from 'next/link';
|
|
6
|
+
import Tag from '@/components/Tag';
|
|
7
|
+
import Pagination from '@/components/Pagination';
|
|
8
|
+
|
|
9
|
+
interface NoteItem {
|
|
10
|
+
slug: string;
|
|
11
|
+
date: string;
|
|
12
|
+
title: string;
|
|
13
|
+
excerpt: string;
|
|
14
|
+
tags: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface NoteContentProps {
|
|
18
|
+
notes: NoteItem[];
|
|
19
|
+
tags: Record<string, number>;
|
|
20
|
+
pagination?: {
|
|
21
|
+
currentPage: number;
|
|
22
|
+
totalPages: number;
|
|
23
|
+
basePath: string;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default function NoteContent({ notes, tags, pagination }: NoteContentProps) {
|
|
28
|
+
const { t } = useLanguage();
|
|
29
|
+
const [selectedTag, setSelectedTag] = useState<string | null>(null);
|
|
30
|
+
|
|
31
|
+
const filteredNotes = useMemo(() => {
|
|
32
|
+
if (!selectedTag) return notes;
|
|
33
|
+
return notes.filter(n => n.tags.map(t => t.toLowerCase()).includes(selectedTag));
|
|
34
|
+
}, [notes, selectedTag]);
|
|
35
|
+
|
|
36
|
+
const sortedTags = Object.entries(tags).sort((a, b) => b[1] - a[1]);
|
|
37
|
+
|
|
38
|
+
function handleTagSelect(tag: string) {
|
|
39
|
+
setSelectedTag(prev => (prev === tag ? null : tag));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className="flex gap-10">
|
|
44
|
+
{/* Tag sidebar */}
|
|
45
|
+
<aside className="hidden lg:block sticky top-20 self-start w-[280px] shrink-0">
|
|
46
|
+
<div className="border border-muted/20 rounded-lg p-4 space-y-1">
|
|
47
|
+
{sortedTags.length > 0 && (
|
|
48
|
+
<>
|
|
49
|
+
<p className="text-[10px] font-bold uppercase tracking-widest text-muted mb-3">{t('tags')}</p>
|
|
50
|
+
<button
|
|
51
|
+
onClick={() => setSelectedTag(null)}
|
|
52
|
+
className={`block w-full text-left text-sm px-2 py-1 rounded transition-colors ${!selectedTag ? 'text-accent font-medium' : 'text-muted hover:text-foreground'}`}
|
|
53
|
+
>
|
|
54
|
+
{t('all_notes')}
|
|
55
|
+
</button>
|
|
56
|
+
{sortedTags.map(([tag, count]) => (
|
|
57
|
+
<button
|
|
58
|
+
key={tag}
|
|
59
|
+
onClick={() => handleTagSelect(tag)}
|
|
60
|
+
className={`flex w-full items-center justify-between text-left text-sm px-2 py-1 rounded transition-colors ${selectedTag === tag ? 'text-accent font-medium' : 'text-muted hover:text-foreground'}`}
|
|
61
|
+
>
|
|
62
|
+
<span>{tag}</span>
|
|
63
|
+
<span className="text-xs opacity-50">{count}</span>
|
|
64
|
+
</button>
|
|
65
|
+
))}
|
|
66
|
+
</>
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
</aside>
|
|
70
|
+
|
|
71
|
+
{/* Note timeline */}
|
|
72
|
+
<div className="flex-1 min-w-0">
|
|
73
|
+
{selectedTag && (
|
|
74
|
+
<div className="flex items-center gap-2 mb-4 text-sm text-muted">
|
|
75
|
+
<span>{filteredNotes.length} / {notes.length}</span>
|
|
76
|
+
<button
|
|
77
|
+
onClick={() => setSelectedTag(null)}
|
|
78
|
+
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-muted/20 text-xs hover:border-accent hover:text-accent transition-colors"
|
|
79
|
+
>
|
|
80
|
+
✕ {t('clear')}
|
|
81
|
+
</button>
|
|
82
|
+
</div>
|
|
83
|
+
)}
|
|
84
|
+
|
|
85
|
+
{filteredNotes.length === 0 ? (
|
|
86
|
+
<p className="text-muted">{t('no_notes')}</p>
|
|
87
|
+
) : (
|
|
88
|
+
<div className="space-y-0">
|
|
89
|
+
{filteredNotes.map(note => (
|
|
90
|
+
<article key={note.slug} className="relative pl-6 pb-8 border-l-2 border-muted/20 last:pb-0">
|
|
91
|
+
<div className="absolute -left-[5px] top-1.5 w-2 h-2 rounded-full bg-accent" />
|
|
92
|
+
<time className="text-xs font-mono text-accent">{note.date}</time>
|
|
93
|
+
<h3 className="mt-1 mb-2 font-serif text-xl font-bold text-heading">
|
|
94
|
+
<Link href={`/notes/${note.slug}`} className="no-underline hover:text-accent transition-colors">
|
|
95
|
+
{note.title}
|
|
96
|
+
</Link>
|
|
97
|
+
</h3>
|
|
98
|
+
<p className="text-sm text-muted leading-relaxed line-clamp-3">{note.excerpt}</p>
|
|
99
|
+
{note.tags.length > 0 && (
|
|
100
|
+
<div className="mt-2 flex flex-wrap gap-2">
|
|
101
|
+
{note.tags.map(tag => (
|
|
102
|
+
<Tag key={tag} tag={tag} variant="compact" />
|
|
103
|
+
))}
|
|
104
|
+
</div>
|
|
105
|
+
)}
|
|
106
|
+
</article>
|
|
107
|
+
))}
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
|
|
111
|
+
{pagination && pagination.totalPages > 1 && (
|
|
112
|
+
<div className="mt-12">
|
|
113
|
+
<Pagination
|
|
114
|
+
currentPage={pagination.currentPage}
|
|
115
|
+
totalPages={pagination.totalPages}
|
|
116
|
+
basePath={pagination.basePath}
|
|
117
|
+
/>
|
|
118
|
+
</div>
|
|
119
|
+
)}
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, type ReactNode } from 'react';
|
|
4
|
+
import Link from 'next/link';
|
|
5
|
+
import { useScrollY } from '@/hooks/useScrollY';
|
|
6
|
+
import type { BacklinkSource, Heading } from '@/lib/markdown';
|
|
7
|
+
import { useLanguage } from './LanguageProvider';
|
|
8
|
+
|
|
9
|
+
interface NoteSidebarProps {
|
|
10
|
+
headings: Heading[];
|
|
11
|
+
showToc: boolean;
|
|
12
|
+
backlinks: BacklinkSource[];
|
|
13
|
+
breadcrumb?: ReactNode;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default function NoteSidebar({ headings, showToc, backlinks, breadcrumb }: NoteSidebarProps) {
|
|
17
|
+
const { t } = useLanguage();
|
|
18
|
+
const scrollY = useScrollY();
|
|
19
|
+
const [activeHeadingId, setActiveHeadingId] = useState('');
|
|
20
|
+
const [tocCollapsed, setTocCollapsed] = useState(false);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (!showToc || headings.length === 0) return;
|
|
24
|
+
const elements = headings
|
|
25
|
+
.map(h => document.getElementById(h.id))
|
|
26
|
+
.filter(Boolean) as HTMLElement[];
|
|
27
|
+
if (!elements.length) return;
|
|
28
|
+
const scrollPosition = scrollY + 100;
|
|
29
|
+
let current = elements[0];
|
|
30
|
+
for (const el of elements) {
|
|
31
|
+
if (el.offsetTop <= scrollPosition) current = el;
|
|
32
|
+
else break;
|
|
33
|
+
}
|
|
34
|
+
const rafId = requestAnimationFrame(() => { if (current) setActiveHeadingId(current.id); });
|
|
35
|
+
return () => cancelAnimationFrame(rafId);
|
|
36
|
+
}, [scrollY, headings, showToc]);
|
|
37
|
+
|
|
38
|
+
const scrollToHeading = (e: React.MouseEvent<HTMLAnchorElement>, id: string) => {
|
|
39
|
+
e.preventDefault();
|
|
40
|
+
const el = document.getElementById(id);
|
|
41
|
+
if (el) {
|
|
42
|
+
window.scrollTo({ top: el.getBoundingClientRect().top + window.scrollY - 80, behavior: 'smooth' });
|
|
43
|
+
history.pushState(null, '', `#${id}`);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<aside className="hidden lg:block sticky top-20 self-start w-[280px] max-h-[calc(100vh-6rem)] overflow-y-auto pr-4 scrollbar-hide hover:scrollbar-thin">
|
|
49
|
+
{breadcrumb && <div className="mb-4">{breadcrumb}</div>}
|
|
50
|
+
{/* TOC */}
|
|
51
|
+
{showToc && headings.length > 0 && (
|
|
52
|
+
<nav
|
|
53
|
+
aria-label="Table of contents"
|
|
54
|
+
className={`mb-6 ${backlinks.length > 0 ? 'pb-6 border-b border-muted/10' : ''}`}
|
|
55
|
+
>
|
|
56
|
+
<div className="flex items-center justify-between mb-3">
|
|
57
|
+
<span className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted">
|
|
58
|
+
{t('on_this_page')}
|
|
59
|
+
</span>
|
|
60
|
+
<button
|
|
61
|
+
onClick={() => setTocCollapsed(p => !p)}
|
|
62
|
+
className="text-muted hover:text-foreground transition-colors"
|
|
63
|
+
aria-label={tocCollapsed ? 'Expand' : 'Collapse'}
|
|
64
|
+
>
|
|
65
|
+
<svg
|
|
66
|
+
className={`w-3.5 h-3.5 transition-transform duration-200 ${tocCollapsed ? '' : 'rotate-180'}`}
|
|
67
|
+
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
|
|
68
|
+
>
|
|
69
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
|
70
|
+
</svg>
|
|
71
|
+
</button>
|
|
72
|
+
</div>
|
|
73
|
+
{!tocCollapsed && (
|
|
74
|
+
<ul className="space-y-0.5 border-l border-muted/15 animate-slide-down">
|
|
75
|
+
{headings.map(h => {
|
|
76
|
+
const isActive = h.id === activeHeadingId;
|
|
77
|
+
return (
|
|
78
|
+
<li key={h.id}>
|
|
79
|
+
<a
|
|
80
|
+
href={`#${h.id}`}
|
|
81
|
+
onClick={e => scrollToHeading(e, h.id)}
|
|
82
|
+
className={`block py-1 text-[13px] leading-snug no-underline transition-colors duration-200 ${
|
|
83
|
+
h.level === 3 ? 'pl-6' : 'pl-3'
|
|
84
|
+
} ${
|
|
85
|
+
isActive
|
|
86
|
+
? 'text-accent font-medium border-l-2 border-accent -ml-px'
|
|
87
|
+
: 'text-foreground/70 hover:text-foreground'
|
|
88
|
+
}`}
|
|
89
|
+
>
|
|
90
|
+
{h.text}
|
|
91
|
+
</a>
|
|
92
|
+
</li>
|
|
93
|
+
);
|
|
94
|
+
})}
|
|
95
|
+
</ul>
|
|
96
|
+
)}
|
|
97
|
+
</nav>
|
|
98
|
+
)}
|
|
99
|
+
|
|
100
|
+
{/* Backlinks */}
|
|
101
|
+
{backlinks.length > 0 && (
|
|
102
|
+
<div>
|
|
103
|
+
<span className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted block mb-3">
|
|
104
|
+
{t('backlinks')}
|
|
105
|
+
</span>
|
|
106
|
+
<div className="flex flex-col gap-3">
|
|
107
|
+
{backlinks.map(bl => (
|
|
108
|
+
<div key={`${bl.type}-${bl.slug}`} className="flex flex-col gap-0.5">
|
|
109
|
+
<div className="flex items-center gap-1.5 min-w-0">
|
|
110
|
+
<span className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted/60 border border-muted/20 rounded px-1.5 py-0.5 shrink-0">
|
|
111
|
+
{bl.type}
|
|
112
|
+
</span>
|
|
113
|
+
<Link
|
|
114
|
+
href={bl.url}
|
|
115
|
+
className="text-sm text-heading hover:text-accent no-underline transition-colors truncate"
|
|
116
|
+
>
|
|
117
|
+
{bl.title}
|
|
118
|
+
</Link>
|
|
119
|
+
</div>
|
|
120
|
+
{bl.context && (
|
|
121
|
+
<p className="text-xs text-muted leading-relaxed line-clamp-2 pl-0.5">
|
|
122
|
+
“{bl.context}”
|
|
123
|
+
</p>
|
|
124
|
+
)}
|
|
125
|
+
</div>
|
|
126
|
+
))}
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
)}
|
|
130
|
+
</aside>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
@@ -6,7 +6,6 @@ import { useLanguage } from './LanguageProvider';
|
|
|
6
6
|
export interface RecentNoteItem {
|
|
7
7
|
slug: string;
|
|
8
8
|
date: string;
|
|
9
|
-
title: string;
|
|
10
9
|
excerpt: string;
|
|
11
10
|
}
|
|
12
11
|
|
|
@@ -38,16 +37,12 @@ export default function RecentNotesSection({ notes }: RecentNotesSectionProps) {
|
|
|
38
37
|
{notes.map(note => (
|
|
39
38
|
<div key={note.slug} className="relative pl-6 pb-6 border-l-2 border-muted/20 last:pb-0">
|
|
40
39
|
<div className="absolute -left-[5px] top-1.5 w-2 h-2 rounded-full bg-accent" />
|
|
41
|
-
<
|
|
42
|
-
<time className="text-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
{note.title}
|
|
48
|
-
</Link>
|
|
49
|
-
</div>
|
|
50
|
-
<p className="text-sm text-muted line-clamp-2 pl-0">{note.excerpt}</p>
|
|
40
|
+
<Link href={`/flows/${note.slug}`} className="no-underline group">
|
|
41
|
+
<time className="text-sm font-mono text-accent group-hover:text-accent/70 transition-colors">{note.date}</time>
|
|
42
|
+
</Link>
|
|
43
|
+
{note.excerpt && (
|
|
44
|
+
<p className="mt-1.5 text-sm text-muted line-clamp-2">{note.excerpt}</p>
|
|
45
|
+
)}
|
|
51
46
|
</div>
|
|
52
47
|
))}
|
|
53
48
|
</div>
|
|
@@ -23,14 +23,16 @@ interface DisplayResult {
|
|
|
23
23
|
const CONTENT_TYPES: ContentType[] = [
|
|
24
24
|
'All',
|
|
25
25
|
...(siteConfig.features?.posts?.enabled !== false ? ['Post' as ContentType] : []),
|
|
26
|
-
...(siteConfig.features?.
|
|
26
|
+
...(siteConfig.features?.flow?.enabled !== false ? ['Flow' as ContentType] : []),
|
|
27
27
|
...(siteConfig.features?.books?.enabled !== false ? ['Book' as ContentType] : []),
|
|
28
|
+
...(siteConfig.features?.flow?.enabled !== false ? ['Note' as ContentType] : []),
|
|
28
29
|
];
|
|
29
30
|
|
|
30
31
|
const CONTENT_TYPE_FEATURE: Record<Exclude<ContentType, 'All'>, keyof typeof siteConfig.features> = {
|
|
31
32
|
Post: 'posts',
|
|
32
|
-
Flow: '
|
|
33
|
+
Flow: 'flow',
|
|
33
34
|
Book: 'books',
|
|
35
|
+
Note: 'flow',
|
|
34
36
|
};
|
|
35
37
|
const RECENT_KEY = 'amytis-recent-searches';
|
|
36
38
|
const MAX_RECENT = 5;
|
|
@@ -42,12 +44,14 @@ const TYPE_LABEL_KEYS: Record<Exclude<ContentType, 'All'>, TranslationKey> = {
|
|
|
42
44
|
Post: 'search_type_post',
|
|
43
45
|
Flow: 'search_type_flow',
|
|
44
46
|
Book: 'search_type_book',
|
|
47
|
+
Note: 'search_type_note',
|
|
45
48
|
};
|
|
46
49
|
|
|
47
50
|
const TYPE_STYLES: Record<string, string> = {
|
|
48
51
|
Flow: 'border-accent/30 text-accent',
|
|
49
52
|
Book: 'border-foreground/30 text-foreground/60',
|
|
50
53
|
Post: 'border-muted/30 text-muted',
|
|
54
|
+
Note: 'border-emerald-400/30 text-emerald-600 dark:text-emerald-400',
|
|
51
55
|
};
|
|
52
56
|
|
|
53
57
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
@@ -192,7 +196,7 @@ export default function Search() {
|
|
|
192
196
|
|
|
193
197
|
// Count per type for tab badges
|
|
194
198
|
const typeCounts = useMemo(() => {
|
|
195
|
-
const counts: Record<ContentType, number> = { All: allResults.length, Post: 0, Flow: 0, Book: 0 };
|
|
199
|
+
const counts: Record<ContentType, number> = { All: allResults.length, Post: 0, Flow: 0, Book: 0, Note: 0 };
|
|
196
200
|
for (const r of allResults) counts[r.type]++;
|
|
197
201
|
return counts;
|
|
198
202
|
}, [allResults]);
|