@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/GEMINI.md +6 -0
  3. package/README.md +14 -0
  4. package/TODO.md +15 -3
  5. package/bun.lock +5 -3
  6. package/content/flows/2026/02/05.md +0 -1
  7. package/content/flows/2026/02/10.mdx +2 -1
  8. package/content/flows/2026/02/15.md +2 -1
  9. package/content/flows/2026/02/18.mdx +2 -1
  10. package/content/flows/2026/02/20.md +0 -1
  11. package/content/notes/algorithms-and-data-structures.mdx +51 -0
  12. package/content/notes/digital-garden-philosophy.mdx +36 -0
  13. package/content/notes/react-server-components.mdx +49 -0
  14. package/content/notes/tailwind-v4.mdx +45 -0
  15. package/content/notes/zettelkasten-method.mdx +33 -0
  16. package/docs/ARCHITECTURE.md +8 -0
  17. package/docs/CONTRIBUTING.md +11 -0
  18. package/docs/DIGITAL_GARDEN.md +64 -0
  19. package/package.json +7 -3
  20. package/scripts/generate-knowledge-graph.ts +162 -0
  21. package/scripts/new-flow.ts +0 -5
  22. package/scripts/new-note.ts +53 -0
  23. package/site.config.ts +21 -1
  24. package/src/app/flows/[year]/[month]/[day]/page.tsx +32 -29
  25. package/src/app/flows/[year]/[month]/page.tsx +15 -13
  26. package/src/app/flows/[year]/page.tsx +22 -15
  27. package/src/app/flows/page/[page]/page.tsx +3 -9
  28. package/src/app/flows/page.tsx +3 -8
  29. package/src/app/globals.css +41 -0
  30. package/src/app/graph/page.tsx +19 -0
  31. package/src/app/notes/[slug]/page.tsx +128 -0
  32. package/src/app/notes/page/[page]/page.tsx +58 -0
  33. package/src/app/notes/page.tsx +31 -0
  34. package/src/app/page.tsx +0 -1
  35. package/src/app/posts/[slug]/page.tsx +4 -2
  36. package/src/app/search.json/route.ts +15 -1
  37. package/src/components/Backlinks.tsx +39 -0
  38. package/src/components/FlowCalendarSidebar.tsx +4 -2
  39. package/src/components/FlowContent.tsx +4 -3
  40. package/src/components/FlowHubTabs.tsx +50 -0
  41. package/src/components/FlowTimelineEntry.tsx +7 -9
  42. package/src/components/KnowledgeGraph.tsx +324 -0
  43. package/src/components/MarkdownRenderer.tsx +13 -2
  44. package/src/components/Navbar.tsx +235 -9
  45. package/src/components/NoteContent.tsx +123 -0
  46. package/src/components/NoteSidebar.tsx +132 -0
  47. package/src/components/RecentNotesSection.tsx +6 -11
  48. package/src/components/Search.tsx +5 -1
  49. package/src/components/TagContentTabs.tsx +0 -1
  50. package/src/i18n/translations.ts +21 -1
  51. package/src/layouts/PostLayout.tsx +8 -3
  52. package/src/lib/markdown.ts +276 -3
  53. package/src/lib/remark-wikilinks.ts +59 -0
  54. 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="fixed top-0 left-0 w-full z-50 border-b border-muted/10 bg-background/80 backdrop-blur-md transition-all duration-300">
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="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"
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="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"
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="text-sm font-sans font-medium text-foreground/80 hover:text-heading no-underline transition-colors duration-200 flex items-center gap-1"
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={() => setIsMenuOpen(false)}
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="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)}
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
+ &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>
@@ -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]);
@@ -89,7 +89,6 @@ export default function TagContentTabs({ posts, flows }: TagContentTabsProps) {
89
89
  <FlowTimelineEntry
90
90
  key={flow.slug}
91
91
  date={flow.date}
92
- title={flow.title}
93
92
  excerpt={flow.excerpt}
94
93
  tags={flow.tags}
95
94
  slug={flow.slug}