@hutusi/amytis 1.7.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 +6 -0
- package/README.md +14 -0
- package/TODO.md +15 -3
- package/bun.lock +5 -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/docs/ARCHITECTURE.md +8 -0
- package/docs/CONTRIBUTING.md +11 -0
- package/docs/DIGITAL_GARDEN.md +64 -0
- package/package.json +7 -3
- 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 +21 -1
- package/src/app/flows/[year]/[month]/[day]/page.tsx +32 -29
- 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/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 +0 -1
- package/src/app/posts/[slug]/page.tsx +4 -2
- package/src/app/search.json/route.ts +15 -1
- package/src/components/Backlinks.tsx +39 -0
- 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/MarkdownRenderer.tsx +13 -2
- package/src/components/Navbar.tsx +235 -9
- 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 +5 -1
- package/src/components/TagContentTabs.tsx +0 -1
- package/src/i18n/translations.ts +21 -1
- package/src/layouts/PostLayout.tsx +8 -3
- package/src/lib/markdown.ts +276 -3
- package/src/lib/remark-wikilinks.ts +59 -0
- package/src/lib/search-utils.ts +2 -1
|
@@ -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';
|
|
@@ -26,11 +28,16 @@ const FEATURE_URLS: Partial<Record<string, keyof typeof siteConfig.features>> =
|
|
|
26
28
|
'/flows': 'flows',
|
|
27
29
|
'/series': 'series',
|
|
28
30
|
'/books': 'books',
|
|
31
|
+
'/notes': 'notes',
|
|
29
32
|
};
|
|
30
33
|
|
|
31
34
|
export default function Navbar({ seriesList = [], booksList = [] }: NavbarProps) {
|
|
32
35
|
const { t, language } = useLanguage();
|
|
36
|
+
const pathname = usePathname();
|
|
33
37
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
|
38
|
+
const [isScrolled, setIsScrolled] = useState(false);
|
|
39
|
+
const [openDropdown, setOpenDropdown] = useState<string | null>(null);
|
|
40
|
+
|
|
34
41
|
const navItems = [...siteConfig.nav]
|
|
35
42
|
.filter(item => {
|
|
36
43
|
const featureKey = FEATURE_URLS[item.url];
|
|
@@ -49,6 +56,27 @@ export default function Navbar({ seriesList = [], booksList = [] }: NavbarProps)
|
|
|
49
56
|
return translated !== key ? translated : name;
|
|
50
57
|
};
|
|
51
58
|
|
|
59
|
+
function isActive(url: string): boolean {
|
|
60
|
+
if (url === '/flows') {
|
|
61
|
+
return pathname.startsWith('/flows') || pathname.startsWith('/notes') || pathname.startsWith('/graph');
|
|
62
|
+
}
|
|
63
|
+
if (url === '/') return pathname === '/';
|
|
64
|
+
return pathname.startsWith(url);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Scroll-aware transparency
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
const handleScroll = () => setIsScrolled(window.scrollY > 8);
|
|
70
|
+
handleScroll(); // sync with scroll position at mount (e.g. after refresh while scrolled)
|
|
71
|
+
window.addEventListener('scroll', handleScroll, { passive: true });
|
|
72
|
+
return () => window.removeEventListener('scroll', handleScroll);
|
|
73
|
+
}, []);
|
|
74
|
+
|
|
75
|
+
function closeMenu() {
|
|
76
|
+
setIsMenuOpen(false);
|
|
77
|
+
setOpenDropdown(null);
|
|
78
|
+
}
|
|
79
|
+
|
|
52
80
|
// Prevent body scroll when menu is open
|
|
53
81
|
useEffect(() => {
|
|
54
82
|
if (isMenuOpen) {
|
|
@@ -62,7 +90,11 @@ export default function Navbar({ seriesList = [], booksList = [] }: NavbarProps)
|
|
|
62
90
|
}, [isMenuOpen]);
|
|
63
91
|
|
|
64
92
|
return (
|
|
65
|
-
<nav className=
|
|
93
|
+
<nav className={`fixed top-0 left-0 w-full z-50 border-b transition-all duration-300 ${
|
|
94
|
+
isScrolled
|
|
95
|
+
? 'border-muted/10 bg-background/90 backdrop-blur-md shadow-sm'
|
|
96
|
+
: 'border-transparent bg-transparent'
|
|
97
|
+
}`}>
|
|
66
98
|
<div className="max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
|
|
67
99
|
<Link
|
|
68
100
|
href="/"
|
|
@@ -92,13 +124,16 @@ export default function Navbar({ seriesList = [], booksList = [] }: NavbarProps)
|
|
|
92
124
|
const isExternal = !!('external' in item && item.external);
|
|
93
125
|
const Component = isExternal ? 'a' : Link;
|
|
94
126
|
const props = isExternal ? { target: "_blank", rel: "noopener noreferrer" } : {};
|
|
127
|
+
const active = isActive(item.url);
|
|
95
128
|
|
|
96
129
|
if (item.url === '/books' && booksList.length > 0) {
|
|
97
130
|
return (
|
|
98
131
|
<div key={item.url} className="relative group">
|
|
99
132
|
<Link
|
|
100
133
|
href={item.url}
|
|
101
|
-
className=
|
|
134
|
+
className={`text-sm font-sans font-medium no-underline transition-colors duration-200 flex items-center gap-1 py-4 ${
|
|
135
|
+
active ? 'text-accent' : 'text-foreground/80 hover:text-heading'
|
|
136
|
+
}`}
|
|
102
137
|
>
|
|
103
138
|
{getLabel(item.name, item.url)}
|
|
104
139
|
<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 +169,9 @@ export default function Navbar({ seriesList = [], booksList = [] }: NavbarProps)
|
|
|
134
169
|
<div key={item.url} className="relative group">
|
|
135
170
|
<Link
|
|
136
171
|
href={item.url}
|
|
137
|
-
className=
|
|
172
|
+
className={`text-sm font-sans font-medium no-underline transition-colors duration-200 flex items-center gap-1 py-4 ${
|
|
173
|
+
active ? 'text-accent' : 'text-foreground/80 hover:text-heading'
|
|
174
|
+
}`}
|
|
138
175
|
>
|
|
139
176
|
{getLabel(item.name, item.url)}
|
|
140
177
|
<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 +202,54 @@ export default function Navbar({ seriesList = [], booksList = [] }: NavbarProps)
|
|
|
165
202
|
);
|
|
166
203
|
}
|
|
167
204
|
|
|
205
|
+
// Static children dropdown (e.g., "More")
|
|
206
|
+
if (item.children && item.children.length > 0) {
|
|
207
|
+
const childActive = item.children.some(c => c.url && pathname.startsWith(c.url));
|
|
208
|
+
return (
|
|
209
|
+
<div key={item.url || item.name} className="relative group">
|
|
210
|
+
<button
|
|
211
|
+
type="button"
|
|
212
|
+
className={`text-sm font-sans font-medium transition-colors duration-200 flex items-center gap-1 py-4 bg-transparent border-0 cursor-pointer ${
|
|
213
|
+
childActive ? 'text-accent' : 'text-foreground/80 hover:text-heading'
|
|
214
|
+
}`}
|
|
215
|
+
>
|
|
216
|
+
{getLabel(item.name, item.url)}
|
|
217
|
+
<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">
|
|
218
|
+
<path d="M6 9l6 6 6-6"/>
|
|
219
|
+
</svg>
|
|
220
|
+
</button>
|
|
221
|
+
<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]">
|
|
222
|
+
<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">
|
|
223
|
+
{item.children.map((child: NavChildItem) => {
|
|
224
|
+
const ChildComp = child.external ? 'a' : Link;
|
|
225
|
+
const childProps = child.external ? { target: '_blank', rel: 'noopener noreferrer' } : {};
|
|
226
|
+
return (
|
|
227
|
+
<Fragment key={child.url}>
|
|
228
|
+
{child.dividerBefore && <div className="h-px bg-muted/10 my-1" />}
|
|
229
|
+
<ChildComp
|
|
230
|
+
href={child.url}
|
|
231
|
+
{...childProps}
|
|
232
|
+
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"
|
|
233
|
+
>
|
|
234
|
+
{getLabel(child.name, child.url)}
|
|
235
|
+
</ChildComp>
|
|
236
|
+
</Fragment>
|
|
237
|
+
);
|
|
238
|
+
})}
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
168
245
|
return (
|
|
169
246
|
<Component
|
|
170
247
|
key={item.url}
|
|
171
248
|
href={item.url}
|
|
172
249
|
{...props}
|
|
173
|
-
className=
|
|
250
|
+
className={`text-sm font-sans font-medium no-underline transition-colors duration-200 flex items-center gap-1 ${
|
|
251
|
+
active ? 'text-accent' : 'text-foreground/80 hover:text-heading'
|
|
252
|
+
}`}
|
|
174
253
|
>
|
|
175
254
|
{getLabel(item.name, item.url)}
|
|
176
255
|
{isExternal && (
|
|
@@ -227,23 +306,170 @@ export default function Navbar({ seriesList = [], booksList = [] }: NavbarProps)
|
|
|
227
306
|
{/* Backdrop */}
|
|
228
307
|
<div
|
|
229
308
|
className="fixed inset-0 top-16 bg-background/60 backdrop-blur-sm md:hidden"
|
|
230
|
-
onClick={() =>
|
|
309
|
+
onClick={() => closeMenu()}
|
|
231
310
|
/>
|
|
232
311
|
{/* Menu */}
|
|
233
312
|
<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
313
|
<div className="max-w-6xl mx-auto px-6 py-4 flex flex-col gap-1">
|
|
235
314
|
{navItems.map((item) => {
|
|
236
315
|
const isExternal = !!('external' in item && item.external);
|
|
316
|
+
const active = isActive(item.url);
|
|
317
|
+
|
|
318
|
+
// Series accordion for mobile
|
|
319
|
+
if (item.url === '/series' && seriesList.length > 0) {
|
|
320
|
+
const isOpen = openDropdown === '/series';
|
|
321
|
+
return (
|
|
322
|
+
<div key={item.url}>
|
|
323
|
+
<div className={`flex items-center rounded-lg transition-colors ${active ? 'text-accent' : 'text-foreground/80'}`}>
|
|
324
|
+
<Link
|
|
325
|
+
href={item.url}
|
|
326
|
+
className="flex-1 px-3 py-3 text-base font-sans font-medium no-underline hover:text-accent transition-colors"
|
|
327
|
+
onClick={() => closeMenu()}
|
|
328
|
+
>
|
|
329
|
+
{getLabel(item.name, item.url)}
|
|
330
|
+
</Link>
|
|
331
|
+
<button
|
|
332
|
+
className="px-3 py-3 text-foreground/60 hover:text-accent transition-colors"
|
|
333
|
+
onClick={() => setOpenDropdown(isOpen ? null : '/series')}
|
|
334
|
+
aria-label={isOpen ? 'Collapse series list' : 'Expand series list'}
|
|
335
|
+
aria-expanded={isOpen}
|
|
336
|
+
>
|
|
337
|
+
<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' : ''}`}>
|
|
338
|
+
<path d="M6 9l6 6 6-6"/>
|
|
339
|
+
</svg>
|
|
340
|
+
</button>
|
|
341
|
+
</div>
|
|
342
|
+
{isOpen && (
|
|
343
|
+
<div className="ml-4 pl-3 border-l-2 border-muted/10 flex flex-col gap-1 mb-1">
|
|
344
|
+
{seriesList.map(s => (
|
|
345
|
+
<Link
|
|
346
|
+
key={s.slug}
|
|
347
|
+
href={`/series/${s.slug}`}
|
|
348
|
+
className="block px-3 py-2 text-sm text-foreground/80 hover:text-accent hover:bg-muted/5 rounded-lg no-underline transition-colors"
|
|
349
|
+
onClick={() => closeMenu()}
|
|
350
|
+
>
|
|
351
|
+
{s.name}
|
|
352
|
+
</Link>
|
|
353
|
+
))}
|
|
354
|
+
<Link
|
|
355
|
+
href="/series"
|
|
356
|
+
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"
|
|
357
|
+
onClick={() => closeMenu()}
|
|
358
|
+
>
|
|
359
|
+
{t('all_series')} →
|
|
360
|
+
</Link>
|
|
361
|
+
</div>
|
|
362
|
+
)}
|
|
363
|
+
</div>
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Books accordion for mobile
|
|
368
|
+
if (item.url === '/books' && booksList.length > 0) {
|
|
369
|
+
const isOpen = openDropdown === '/books';
|
|
370
|
+
return (
|
|
371
|
+
<div key={item.url}>
|
|
372
|
+
<div className={`flex items-center rounded-lg transition-colors ${active ? 'text-accent' : 'text-foreground/80'}`}>
|
|
373
|
+
<Link
|
|
374
|
+
href={item.url}
|
|
375
|
+
className="flex-1 px-3 py-3 text-base font-sans font-medium no-underline hover:text-accent transition-colors"
|
|
376
|
+
onClick={() => closeMenu()}
|
|
377
|
+
>
|
|
378
|
+
{getLabel(item.name, item.url)}
|
|
379
|
+
</Link>
|
|
380
|
+
<button
|
|
381
|
+
className="px-3 py-3 text-foreground/60 hover:text-accent transition-colors"
|
|
382
|
+
onClick={() => setOpenDropdown(isOpen ? null : '/books')}
|
|
383
|
+
aria-label={isOpen ? 'Collapse books list' : 'Expand books list'}
|
|
384
|
+
aria-expanded={isOpen}
|
|
385
|
+
>
|
|
386
|
+
<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' : ''}`}>
|
|
387
|
+
<path d="M6 9l6 6 6-6"/>
|
|
388
|
+
</svg>
|
|
389
|
+
</button>
|
|
390
|
+
</div>
|
|
391
|
+
{isOpen && (
|
|
392
|
+
<div className="ml-4 pl-3 border-l-2 border-muted/10 flex flex-col gap-1 mb-1">
|
|
393
|
+
{booksList.map(b => (
|
|
394
|
+
<Link
|
|
395
|
+
key={b.slug}
|
|
396
|
+
href={`/books/${b.slug}`}
|
|
397
|
+
className="block px-3 py-2 text-sm text-foreground/80 hover:text-accent hover:bg-muted/5 rounded-lg no-underline transition-colors"
|
|
398
|
+
onClick={() => closeMenu()}
|
|
399
|
+
>
|
|
400
|
+
{b.name}
|
|
401
|
+
</Link>
|
|
402
|
+
))}
|
|
403
|
+
<Link
|
|
404
|
+
href="/books"
|
|
405
|
+
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"
|
|
406
|
+
onClick={() => closeMenu()}
|
|
407
|
+
>
|
|
408
|
+
{t('all_books')} →
|
|
409
|
+
</Link>
|
|
410
|
+
</div>
|
|
411
|
+
)}
|
|
412
|
+
</div>
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Static children accordion for mobile (e.g., "More")
|
|
417
|
+
if (item.children && item.children.length > 0) {
|
|
418
|
+
const dropdownKey = item.url || item.name;
|
|
419
|
+
const isOpen = openDropdown === dropdownKey;
|
|
420
|
+
const childActive = item.children.some(c => c.url && pathname.startsWith(c.url));
|
|
421
|
+
return (
|
|
422
|
+
<div key={dropdownKey}>
|
|
423
|
+
<button
|
|
424
|
+
type="button"
|
|
425
|
+
className={`w-full flex items-center justify-between px-3 py-3 text-base font-sans font-medium rounded-lg transition-colors ${
|
|
426
|
+
childActive ? 'text-accent' : 'text-foreground/80 hover:text-accent hover:bg-muted/5'
|
|
427
|
+
}`}
|
|
428
|
+
onClick={() => setOpenDropdown(isOpen ? null : dropdownKey)}
|
|
429
|
+
aria-expanded={isOpen}
|
|
430
|
+
>
|
|
431
|
+
{getLabel(item.name, item.url)}
|
|
432
|
+
<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' : ''}`}>
|
|
433
|
+
<path d="M6 9l6 6 6-6"/>
|
|
434
|
+
</svg>
|
|
435
|
+
</button>
|
|
436
|
+
{isOpen && (
|
|
437
|
+
<div className="ml-4 pl-3 border-l-2 border-muted/10 flex flex-col gap-1 mb-1">
|
|
438
|
+
{item.children.map((child: NavChildItem) => {
|
|
439
|
+
const ChildComp = child.external ? 'a' : Link;
|
|
440
|
+
const childProps = child.external ? { target: '_blank', rel: 'noopener noreferrer' } : {};
|
|
441
|
+
return (
|
|
442
|
+
<Fragment key={child.url}>
|
|
443
|
+
{child.dividerBefore && <div className="h-px bg-muted/10 my-1" />}
|
|
444
|
+
<ChildComp
|
|
445
|
+
href={child.url}
|
|
446
|
+
{...childProps}
|
|
447
|
+
className="block px-3 py-2 text-sm text-foreground/80 hover:text-accent hover:bg-muted/5 rounded-lg no-underline transition-colors"
|
|
448
|
+
onClick={() => closeMenu()}
|
|
449
|
+
>
|
|
450
|
+
{getLabel(child.name, child.url)}
|
|
451
|
+
</ChildComp>
|
|
452
|
+
</Fragment>
|
|
453
|
+
);
|
|
454
|
+
})}
|
|
455
|
+
</div>
|
|
456
|
+
)}
|
|
457
|
+
</div>
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Regular mobile nav item
|
|
237
462
|
const Component = isExternal ? 'a' : Link;
|
|
238
463
|
const props = isExternal ? { target: "_blank", rel: "noopener noreferrer" } : {};
|
|
239
|
-
|
|
240
464
|
return (
|
|
241
465
|
<Component
|
|
242
466
|
key={item.url}
|
|
243
467
|
href={item.url}
|
|
244
468
|
{...props}
|
|
245
|
-
className=
|
|
246
|
-
|
|
469
|
+
className={`flex items-center gap-2 px-3 py-3 text-base font-sans font-medium rounded-lg no-underline transition-colors ${
|
|
470
|
+
active ? 'text-accent' : 'text-foreground/80 hover:text-accent hover:bg-muted/5'
|
|
471
|
+
}`}
|
|
472
|
+
onClick={() => closeMenu()}
|
|
247
473
|
>
|
|
248
474
|
{getLabel(item.name, item.url)}
|
|
249
475
|
{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>
|
|
@@ -25,12 +25,14 @@ const CONTENT_TYPES: ContentType[] = [
|
|
|
25
25
|
...(siteConfig.features?.posts?.enabled !== false ? ['Post' as ContentType] : []),
|
|
26
26
|
...(siteConfig.features?.flows?.enabled !== false ? ['Flow' as ContentType] : []),
|
|
27
27
|
...(siteConfig.features?.books?.enabled !== false ? ['Book' as ContentType] : []),
|
|
28
|
+
...(siteConfig.features?.notes?.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
33
|
Flow: 'flows',
|
|
33
34
|
Book: 'books',
|
|
35
|
+
Note: 'notes',
|
|
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]);
|