@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.
Files changed (83) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/CHANGELOG.md +63 -0
  3. package/CLAUDE.md +9 -18
  4. package/GEMINI.md +6 -0
  5. package/README.md +44 -0
  6. package/TODO.md +15 -3
  7. package/bun.lock +5 -3
  8. package/content/about.mdx +64 -10
  9. package/content/about.zh.mdx +66 -9
  10. package/content/books/sample-book/index.mdx +3 -3
  11. package/content/flows/2026/02/05.md +0 -1
  12. package/content/flows/2026/02/10.mdx +2 -1
  13. package/content/flows/2026/02/15.md +2 -1
  14. package/content/flows/2026/02/18.mdx +2 -1
  15. package/content/flows/2026/02/20.md +0 -1
  16. package/content/notes/algorithms-and-data-structures.mdx +51 -0
  17. package/content/notes/digital-garden-philosophy.mdx +36 -0
  18. package/content/notes/react-server-components.mdx +49 -0
  19. package/content/notes/tailwind-v4.mdx +45 -0
  20. package/content/notes/zettelkasten-method.mdx +33 -0
  21. package/content/series/digital-garden/01-philosophy.mdx +25 -12
  22. package/docs/ARCHITECTURE.md +9 -1
  23. package/docs/CONTRIBUTING.md +26 -0
  24. package/docs/DIGITAL_GARDEN.md +72 -0
  25. package/imports/README.md +45 -0
  26. package/package.json +12 -5
  27. package/scripts/generate-knowledge-graph.ts +162 -0
  28. package/scripts/import-book.ts +176 -0
  29. package/scripts/new-flow-from-chat.ts +238 -0
  30. package/scripts/new-flow.ts +0 -5
  31. package/scripts/new-note.ts +53 -0
  32. package/scripts/sync-book-chapters.ts +210 -0
  33. package/site.config.ts +30 -7
  34. package/src/app/authors/[author]/page.tsx +3 -1
  35. package/src/app/books/[slug]/[chapter]/page.tsx +2 -1
  36. package/src/app/books/[slug]/page.tsx +6 -5
  37. package/src/app/flows/[year]/[month]/[day]/page.tsx +35 -29
  38. package/src/app/flows/[year]/[month]/page.tsx +18 -13
  39. package/src/app/flows/[year]/page.tsx +25 -15
  40. package/src/app/flows/page/[page]/page.tsx +5 -9
  41. package/src/app/flows/page.tsx +5 -8
  42. package/src/app/globals.css +41 -0
  43. package/src/app/graph/page.tsx +21 -0
  44. package/src/app/layout.tsx +4 -2
  45. package/src/app/notes/[slug]/page.tsx +129 -0
  46. package/src/app/notes/page/[page]/page.tsx +60 -0
  47. package/src/app/notes/page.tsx +33 -0
  48. package/src/app/page/[page]/page.tsx +1 -0
  49. package/src/app/page.tsx +4 -5
  50. package/src/app/posts/[slug]/page.tsx +5 -2
  51. package/src/app/posts/page/[page]/page.tsx +4 -1
  52. package/src/app/search.json/route.ts +17 -3
  53. package/src/app/series/[slug]/page/[page]/page.tsx +1 -0
  54. package/src/app/series/[slug]/page.tsx +3 -3
  55. package/src/app/sitemap.ts +1 -1
  56. package/src/app/tags/[tag]/page.tsx +3 -3
  57. package/src/components/Backlinks.tsx +39 -0
  58. package/src/components/BookMobileNav.tsx +11 -11
  59. package/src/components/BookSidebar.tsx +17 -25
  60. package/src/components/BrowserDetectionBanner.tsx +96 -0
  61. package/src/components/FeaturedStoriesSection.tsx +1 -1
  62. package/src/components/FlowCalendarSidebar.tsx +4 -2
  63. package/src/components/FlowContent.tsx +4 -3
  64. package/src/components/FlowHubTabs.tsx +50 -0
  65. package/src/components/FlowTimelineEntry.tsx +7 -9
  66. package/src/components/KnowledgeGraph.tsx +324 -0
  67. package/src/components/LanguageProvider.tsx +14 -5
  68. package/src/components/MarkdownRenderer.tsx +13 -2
  69. package/src/components/Navbar.tsx +237 -10
  70. package/src/components/NoteContent.tsx +123 -0
  71. package/src/components/NoteSidebar.tsx +132 -0
  72. package/src/components/RecentNotesSection.tsx +6 -11
  73. package/src/components/Search.tsx +7 -3
  74. package/src/components/TagContentTabs.tsx +0 -1
  75. package/src/i18n/translations.ts +43 -17
  76. package/src/layouts/BookLayout.tsx +3 -3
  77. package/src/layouts/PostLayout.tsx +8 -3
  78. package/src/lib/i18n.ts +83 -6
  79. package/src/lib/markdown.ts +306 -19
  80. package/src/lib/remark-wikilinks.ts +59 -0
  81. package/src/lib/search-utils.ts +2 -1
  82. package/tests/unit/static-params.test.ts +238 -0
  83. 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': '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="fixed top-0 left-0 w-full z-50 border-b border-muted/10 bg-background/80 backdrop-blur-md transition-all duration-300">
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="text-sm font-sans font-medium text-foreground/80 hover:text-heading no-underline transition-colors duration-200 flex items-center gap-1 py-4"
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="text-sm font-sans font-medium text-foreground/80 hover:text-heading no-underline transition-colors duration-200 flex items-center gap-1 py-4"
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="text-sm font-sans font-medium text-foreground/80 hover:text-heading no-underline transition-colors duration-200 flex items-center gap-1"
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={() => setIsMenuOpen(false)}
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="flex items-center gap-2 px-3 py-3 text-base font-sans font-medium text-foreground/80 hover:text-accent hover:bg-muted/5 rounded-lg no-underline transition-colors"
246
- onClick={() => setIsMenuOpen(false)}
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
+ &ldquo;{bl.context}&rdquo;
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
- <div className="flex items-baseline gap-3 mb-1">
42
- <time className="text-xs font-mono text-accent shrink-0">{note.date}</time>
43
- <Link
44
- href={`/flows/${note.slug}`}
45
- className="text-base font-serif font-bold text-heading hover:text-accent transition-colors no-underline truncate"
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?.flows?.enabled !== false ? ['Flow' as ContentType] : []),
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: 'flows',
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]);