@hutusi/amytis 1.6.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/GEMINI.md +12 -2
  3. package/README.md +14 -0
  4. package/TODO.md +24 -16
  5. package/bun.lock +8 -3
  6. package/content/about.mdx +1 -0
  7. package/content/about.zh.mdx +21 -0
  8. package/content/flows/2026/02/05.md +0 -1
  9. package/content/flows/2026/02/10.mdx +2 -1
  10. package/content/flows/2026/02/15.md +2 -1
  11. package/content/flows/2026/02/18.mdx +2 -1
  12. package/content/flows/2026/02/20.md +15 -0
  13. package/content/links.mdx +42 -0
  14. package/content/links.zh.mdx +41 -0
  15. package/content/notes/algorithms-and-data-structures.mdx +51 -0
  16. package/content/notes/digital-garden-philosophy.mdx +36 -0
  17. package/content/notes/react-server-components.mdx +49 -0
  18. package/content/notes/tailwind-v4.mdx +45 -0
  19. package/content/notes/zettelkasten-method.mdx +33 -0
  20. package/content/posts/2026-02-20-i18n-routing-considerations.mdx +150 -0
  21. package/content/posts/multimedia-showcase/index.mdx +261 -0
  22. package/content/privacy.mdx +32 -0
  23. package/content/privacy.zh.mdx +32 -0
  24. package/docs/ARCHITECTURE.md +16 -0
  25. package/docs/CONTRIBUTING.md +11 -0
  26. package/docs/DIGITAL_GARDEN.md +64 -0
  27. package/package.json +8 -3
  28. package/scripts/copy-assets.ts +1 -1
  29. package/scripts/generate-knowledge-graph.ts +162 -0
  30. package/scripts/new-flow.ts +0 -5
  31. package/scripts/new-note.ts +53 -0
  32. package/site.config.ts +146 -44
  33. package/src/app/[slug]/page.tsx +0 -10
  34. package/src/app/archive/page.tsx +38 -10
  35. package/src/app/books/[slug]/page.tsx +18 -0
  36. package/src/app/flows/[year]/[month]/[day]/page.tsx +51 -31
  37. package/src/app/flows/[year]/[month]/page.tsx +15 -13
  38. package/src/app/flows/[year]/page.tsx +22 -15
  39. package/src/app/flows/page/[page]/page.tsx +3 -9
  40. package/src/app/flows/page.tsx +3 -8
  41. package/src/app/globals.css +41 -0
  42. package/src/app/graph/page.tsx +19 -0
  43. package/src/app/layout.tsx +47 -21
  44. package/src/app/notes/[slug]/page.tsx +128 -0
  45. package/src/app/notes/page/[page]/page.tsx +58 -0
  46. package/src/app/notes/page.tsx +31 -0
  47. package/src/app/page.tsx +134 -72
  48. package/src/app/posts/[slug]/page.tsx +8 -12
  49. package/src/app/search.json/route.ts +15 -1
  50. package/src/app/series/[slug]/page.tsx +18 -0
  51. package/src/app/subscribe/page.tsx +17 -0
  52. package/src/app/tags/[tag]/page.tsx +9 -26
  53. package/src/app/tags/page.tsx +3 -8
  54. package/src/components/AuthorCard.tsx +43 -0
  55. package/src/components/Backlinks.tsx +39 -0
  56. package/src/components/Comments.tsx +20 -4
  57. package/src/components/ExternalLinks.tsx +6 -2
  58. package/src/components/FlowCalendarSidebar.tsx +4 -2
  59. package/src/components/FlowContent.tsx +4 -3
  60. package/src/components/FlowHubTabs.tsx +50 -0
  61. package/src/components/FlowTimelineEntry.tsx +7 -9
  62. package/src/components/Footer.tsx +35 -26
  63. package/src/components/KnowledgeGraph.tsx +324 -0
  64. package/src/components/LanguageProvider.tsx +0 -5
  65. package/src/components/LanguageSwitch.tsx +117 -6
  66. package/src/components/LocaleSwitch.tsx +33 -0
  67. package/src/components/MarkdownRenderer.tsx +13 -2
  68. package/src/components/Navbar.tsx +266 -17
  69. package/src/components/NoteContent.tsx +123 -0
  70. package/src/components/NoteSidebar.tsx +132 -0
  71. package/src/components/PostNavigation.tsx +55 -0
  72. package/src/components/PostSidebar.tsx +172 -126
  73. package/src/components/ReadingProgressBar.tsx +6 -21
  74. package/src/components/RecentNotesSection.tsx +6 -11
  75. package/src/components/RelatedPosts.tsx +1 -1
  76. package/src/components/Search.tsx +29 -5
  77. package/src/components/SelectedBooksSection.tsx +12 -6
  78. package/src/components/ShareBar.tsx +115 -0
  79. package/src/components/SimpleLayoutHeader.tsx +5 -14
  80. package/src/components/SubscribePage.tsx +298 -0
  81. package/src/components/TagContentTabs.tsx +102 -0
  82. package/src/components/TagPageHeader.tsx +7 -13
  83. package/src/components/TagSidebar.tsx +142 -0
  84. package/src/components/TagsIndexClient.tsx +156 -0
  85. package/src/hooks/useScrollY.ts +41 -0
  86. package/src/i18n/translations.ts +105 -1
  87. package/src/layouts/PostLayout.tsx +40 -8
  88. package/src/layouts/SimpleLayout.tsx +53 -15
  89. package/src/lib/markdown.ts +347 -18
  90. package/src/lib/remark-wikilinks.ts +59 -0
  91. package/src/lib/search-utils.ts +2 -1
  92. package/src/components/TableOfContents.tsx +0 -158
@@ -1,17 +1,128 @@
1
1
  'use client';
2
2
 
3
3
  import { useLanguage } from './LanguageProvider';
4
+ import { Language } from '@/i18n/translations';
5
+ import { siteConfig } from '../../site.config';
4
6
 
5
- export default function LanguageSwitch() {
6
- const { language, setLanguage } = useLanguage();
7
+ const LOCALE_LABELS: Record<string, string> = {
8
+ en: 'EN',
9
+ zh: '中文',
10
+ };
7
11
 
12
+ // Short labels for the compact navbar pill
13
+ const LOCALE_LABELS_SHORT: Record<string, string> = {
14
+ en: 'EN',
15
+ zh: '中',
16
+ };
17
+
18
+ interface LanguageSwitchProps {
19
+ /** pill — compact segmented pill for the navbar (default)
20
+ * text — lightweight typographic links for the footer */
21
+ variant?: 'pill' | 'text';
22
+ }
23
+
24
+ export default function LanguageSwitch({ variant = 'pill' }: LanguageSwitchProps) {
25
+ const { language, setLanguage, isHydrated } = useLanguage();
26
+ const locales = siteConfig.i18n.locales;
27
+
28
+ if (locales.length < 2) return null;
29
+
30
+ // SSR placeholder — reserve space to avoid layout shift
31
+ if (!isHydrated) {
32
+ return <div className={variant === 'pill' ? 'w-[52px] h-8' : 'w-16 h-4'} aria-hidden="true" />;
33
+ }
34
+
35
+ const currentIndex = locales.indexOf(language);
36
+ const nextLocale = locales[(currentIndex + 1) % locales.length] as Language;
37
+
38
+ // ── Text variant: quiet typographic links for the footer ──────────────────
39
+ if (variant === 'text') {
40
+ if (locales.length === 2) {
41
+ const [a, b] = locales as Language[];
42
+ return (
43
+ <span className="flex items-center gap-1.5" role="group" aria-label="Language">
44
+ <button
45
+ type="button"
46
+ onClick={() => setLanguage(a)}
47
+ aria-pressed={language === a}
48
+ className={`text-xs font-sans tracking-wide transition-colors duration-150 ${
49
+ language === a
50
+ ? 'text-foreground/80 font-medium cursor-default'
51
+ : 'text-muted/50 hover:text-foreground/70 cursor-pointer'
52
+ }`}
53
+ >
54
+ {LOCALE_LABELS[a] ?? a.toUpperCase()}
55
+ </button>
56
+ <span className="text-muted/25 select-none" aria-hidden="true">·</span>
57
+ <button
58
+ type="button"
59
+ onClick={() => setLanguage(b)}
60
+ aria-pressed={language === b}
61
+ className={`text-xs font-sans tracking-wide transition-colors duration-150 ${
62
+ language === b
63
+ ? 'text-foreground/80 font-medium cursor-default'
64
+ : 'text-muted/50 hover:text-foreground/70 cursor-pointer'
65
+ }`}
66
+ >
67
+ {LOCALE_LABELS[b] ?? b.toUpperCase()}
68
+ </button>
69
+ </span>
70
+ );
71
+ }
72
+ // 3+ locales text fallback: cycle on click
73
+ return (
74
+ <button
75
+ type="button"
76
+ onClick={() => setLanguage(nextLocale)}
77
+ className="text-xs font-sans text-muted/60 hover:text-foreground/80 transition-colors duration-150"
78
+ aria-label={`Switch to ${LOCALE_LABELS[nextLocale] ?? nextLocale}`}
79
+ >
80
+ {LOCALE_LABELS[language] ?? language.toUpperCase()}
81
+ </button>
82
+ );
83
+ }
84
+
85
+ // ── Pill variant: compact segmented pill for the navbar ───────────────────
86
+ if (locales.length === 2) {
87
+ const [a, b] = locales as Language[];
88
+ return (
89
+ <button
90
+ onClick={() => setLanguage(nextLocale)}
91
+ className="group flex items-center rounded-full border border-muted/20 bg-transparent hover:border-accent/40 transition-all duration-200"
92
+ aria-label={`Switch language to ${LOCALE_LABELS[nextLocale] ?? nextLocale}`}
93
+ title={`Switch to ${LOCALE_LABELS[nextLocale] ?? nextLocale}`}
94
+ >
95
+ <span
96
+ className={`px-2 py-1 rounded-full text-[11px] font-sans font-bold tracking-wider transition-all duration-200 ${
97
+ language === a
98
+ ? 'text-accent bg-accent/10'
99
+ : 'text-muted/50 group-hover:text-foreground/60'
100
+ }`}
101
+ >
102
+ {LOCALE_LABELS_SHORT[a] ?? a.toUpperCase()}
103
+ </span>
104
+ <span className="text-muted/25 text-[10px] select-none -mx-0.5" aria-hidden="true">·</span>
105
+ <span
106
+ className={`px-2 py-1 rounded-full text-[11px] font-sans font-bold tracking-wider transition-all duration-200 ${
107
+ language === b
108
+ ? 'text-accent bg-accent/10'
109
+ : 'text-muted/50 group-hover:text-foreground/60'
110
+ }`}
111
+ >
112
+ {LOCALE_LABELS_SHORT[b] ?? b.toUpperCase()}
113
+ </span>
114
+ </button>
115
+ );
116
+ }
117
+
118
+ // 3+ locales pill fallback: show current, click cycles
8
119
  return (
9
120
  <button
10
- onClick={() => setLanguage(language === 'en' ? 'zh' : 'en')}
11
- className="p-2 rounded-md hover:bg-muted/20 transition-colors text-xs"
12
- aria-label="Toggle Language"
121
+ onClick={() => setLanguage(nextLocale)}
122
+ className="w-8 h-8 flex items-center justify-center text-foreground/80 hover:text-accent transition-colors duration-200 text-[11px] font-sans font-bold tracking-wider"
123
+ aria-label={`Language: ${LOCALE_LABELS_SHORT[language] ?? language}. Click to switch to ${LOCALE_LABELS_SHORT[nextLocale] ?? nextLocale}`}
13
124
  >
14
- {language === 'en' ? '中文' : 'EN'}
125
+ {LOCALE_LABELS_SHORT[language] ?? language.toUpperCase()}
15
126
  </button>
16
127
  );
17
128
  }
@@ -0,0 +1,33 @@
1
+ 'use client';
2
+
3
+ import { useLanguage } from './LanguageProvider';
4
+ import { useEffect, useRef, ReactNode } from 'react';
5
+
6
+ /**
7
+ * Shows the [data-locale] child matching the active language, hiding all others.
8
+ * Keeps MarkdownRenderer fully server-rendered — no fs/Node modules in client bundle.
9
+ */
10
+ export default function LocaleSwitch({
11
+ children,
12
+ }: {
13
+ children: ReactNode;
14
+ }) {
15
+ const { language } = useLanguage();
16
+ const ref = useRef<HTMLDivElement>(null);
17
+
18
+ useEffect(() => {
19
+ const container = ref.current;
20
+ if (!container) return;
21
+ const elements = container.querySelectorAll<HTMLElement>('[data-locale]');
22
+ let hasMatch = false;
23
+ elements.forEach((el) => {
24
+ if (el.dataset.locale === language) hasMatch = true;
25
+ });
26
+ const effectiveLang = hasMatch ? language : elements[0]?.dataset.locale;
27
+ elements.forEach((el) => {
28
+ el.style.display = el.dataset.locale === effectiveLang ? '' : 'none';
29
+ });
30
+ }, [language]);
31
+
32
+ return <div ref={ref}>{children}</div>;
33
+ }
@@ -7,19 +7,26 @@ import remarkMath from 'remark-math';
7
7
  import rehypeKatex from 'rehype-katex';
8
8
  import rehypeSlug from 'rehype-slug';
9
9
  import rehypeImageMetadata from '@/lib/rehype-image-metadata';
10
+ import remarkWikilinks from '@/lib/remark-wikilinks';
10
11
  import ExportedImage from 'next-image-export-optimizer';
11
12
  import { PluggableList } from 'unified';
13
+ import type { SlugRegistryEntry } from '@/lib/markdown';
12
14
 
13
15
  interface MarkdownRendererProps {
14
16
  content: string;
15
17
  latex?: boolean;
16
18
  slug?: string;
19
+ slugRegistry?: Map<string, SlugRegistryEntry>;
17
20
  }
18
21
 
19
- export default function MarkdownRenderer({ content, latex = false, slug }: MarkdownRendererProps) {
22
+ export default function MarkdownRenderer({ content, latex = false, slug, slugRegistry }: MarkdownRendererProps) {
20
23
  const remarkPlugins: PluggableList = [remarkGfm];
21
24
  const rehypePlugins: PluggableList = [rehypeRaw, rehypeSlug, [rehypeImageMetadata, { slug }]];
22
25
 
26
+ if (slugRegistry && slugRegistry.size > 0) {
27
+ remarkPlugins.push([remarkWikilinks, { slugRegistry }]);
28
+ }
29
+
23
30
  if (latex) {
24
31
  remarkPlugins.push(remarkMath);
25
32
  rehypePlugins.push(rehypeKatex);
@@ -58,7 +65,11 @@ export default function MarkdownRenderer({ content, latex = false, slug }: Markd
58
65
  // Style links individually to avoid hover-all issue
59
66
  a: (props) => {
60
67
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
61
- const { node: _node, ...rest } = props as React.AnchorHTMLAttributes<HTMLAnchorElement> & ExtraProps;
68
+ const { node: _node, className, ...rest } = props as React.AnchorHTMLAttributes<HTMLAnchorElement> & ExtraProps;
69
+ // Preserve wikilink classes injected by remark-wikilinks — they have their own CSS styling
70
+ if (className?.includes('wikilink')) {
71
+ return <a {...rest} className={className} />;
72
+ }
62
73
  return <a {...rest} className="text-accent no-underline hover:underline transition-colors duration-200" />;
63
74
  },
64
75
  // Custom code renderer: handles 'mermaid' blocks and syntax highlighting
@@ -1,9 +1,12 @@
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';
9
+ import LanguageSwitch from './LanguageSwitch';
7
10
  import Search from '@/components/Search';
8
11
  import { useLanguage } from '@/components/LanguageProvider';
9
12
  import { resolveLocaleValue } from '@/lib/i18n';
@@ -19,17 +22,61 @@ interface NavbarProps {
19
22
  booksList?: NavItem[];
20
23
  }
21
24
 
25
+ // Map from nav URL to feature key
26
+ const FEATURE_URLS: Partial<Record<string, keyof typeof siteConfig.features>> = {
27
+ '/posts': 'posts',
28
+ '/flows': 'flows',
29
+ '/series': 'series',
30
+ '/books': 'books',
31
+ '/notes': 'notes',
32
+ };
33
+
22
34
  export default function Navbar({ seriesList = [], booksList = [] }: NavbarProps) {
23
35
  const { t, language } = useLanguage();
36
+ const pathname = usePathname();
24
37
  const [isMenuOpen, setIsMenuOpen] = useState(false);
25
- const navItems = [...siteConfig.nav].sort((a, b) => a.weight - b.weight);
38
+ const [isScrolled, setIsScrolled] = useState(false);
39
+ const [openDropdown, setOpenDropdown] = useState<string | null>(null);
40
+
41
+ const navItems = [...siteConfig.nav]
42
+ .filter(item => {
43
+ const featureKey = FEATURE_URLS[item.url];
44
+ if (!featureKey) return true; // not a feature-gated item, always show
45
+ return siteConfig.features?.[featureKey]?.enabled !== false;
46
+ })
47
+ .sort((a, b) => a.weight - b.weight);
26
48
 
27
- const getLabel = (name: string): string => {
49
+ const getLabel = (name: string, url: string): string => {
50
+ const featureKey = FEATURE_URLS[url];
51
+ if (featureKey && siteConfig.features?.[featureKey]?.name) {
52
+ return resolveLocaleValue(siteConfig.features[featureKey].name, language);
53
+ }
28
54
  const key = name.toLowerCase() as TranslationKey;
29
55
  const translated = t(key);
30
56
  return translated !== key ? translated : name;
31
57
  };
32
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
+
33
80
  // Prevent body scroll when menu is open
34
81
  useEffect(() => {
35
82
  if (isMenuOpen) {
@@ -43,7 +90,11 @@ export default function Navbar({ seriesList = [], booksList = [] }: NavbarProps)
43
90
  }, [isMenuOpen]);
44
91
 
45
92
  return (
46
- <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
+ }`}>
47
98
  <div className="max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
48
99
  <Link
49
100
  href="/"
@@ -73,15 +124,18 @@ export default function Navbar({ seriesList = [], booksList = [] }: NavbarProps)
73
124
  const isExternal = !!('external' in item && item.external);
74
125
  const Component = isExternal ? 'a' : Link;
75
126
  const props = isExternal ? { target: "_blank", rel: "noopener noreferrer" } : {};
127
+ const active = isActive(item.url);
76
128
 
77
- if (item.name === 'Books' && booksList.length > 0) {
129
+ if (item.url === '/books' && booksList.length > 0) {
78
130
  return (
79
131
  <div key={item.url} className="relative group">
80
132
  <Link
81
133
  href={item.url}
82
- 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
+ }`}
83
137
  >
84
- {getLabel(item.name)}
138
+ {getLabel(item.name, item.url)}
85
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">
86
140
  <path d="M6 9l6 6 6-6"/>
87
141
  </svg>
@@ -110,14 +164,16 @@ export default function Navbar({ seriesList = [], booksList = [] }: NavbarProps)
110
164
  );
111
165
  }
112
166
 
113
- if (item.name === 'Series' && seriesList.length > 0) {
167
+ if (item.url === '/series' && seriesList.length > 0) {
114
168
  return (
115
169
  <div key={item.url} className="relative group">
116
170
  <Link
117
171
  href={item.url}
118
- 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
+ }`}
119
175
  >
120
- {getLabel(item.name)}
176
+ {getLabel(item.name, item.url)}
121
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">
122
178
  <path d="M6 9l6 6 6-6"/>
123
179
  </svg>
@@ -146,14 +202,56 @@ export default function Navbar({ seriesList = [], booksList = [] }: NavbarProps)
146
202
  );
147
203
  }
148
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
+
149
245
  return (
150
246
  <Component
151
247
  key={item.url}
152
248
  href={item.url}
153
249
  {...props}
154
- 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
+ }`}
155
253
  >
156
- {getLabel(item.name)}
254
+ {getLabel(item.name, item.url)}
157
255
  {isExternal && (
158
256
  <svg
159
257
  width="12"
@@ -197,6 +295,7 @@ export default function Navbar({ seriesList = [], booksList = [] }: NavbarProps)
197
295
  </svg>
198
296
  </button>
199
297
  <Search />
298
+ <LanguageSwitch />
200
299
  <ThemeToggle />
201
300
  </div>
202
301
  </div>
@@ -207,25 +306,172 @@ export default function Navbar({ seriesList = [], booksList = [] }: NavbarProps)
207
306
  {/* Backdrop */}
208
307
  <div
209
308
  className="fixed inset-0 top-16 bg-background/60 backdrop-blur-sm md:hidden"
210
- onClick={() => setIsMenuOpen(false)}
309
+ onClick={() => closeMenu()}
211
310
  />
212
311
  {/* Menu */}
213
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">
214
313
  <div className="max-w-6xl mx-auto px-6 py-4 flex flex-col gap-1">
215
314
  {navItems.map((item) => {
216
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
217
462
  const Component = isExternal ? 'a' : Link;
218
463
  const props = isExternal ? { target: "_blank", rel: "noopener noreferrer" } : {};
219
-
220
464
  return (
221
465
  <Component
222
466
  key={item.url}
223
467
  href={item.url}
224
468
  {...props}
225
- 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"
226
- 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()}
227
473
  >
228
- {getLabel(item.name)}
474
+ {getLabel(item.name, item.url)}
229
475
  {isExternal && (
230
476
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="opacity-70">
231
477
  <path d="M7 17l9.2-9.2M17 17V7H7" />
@@ -234,6 +480,9 @@ export default function Navbar({ seriesList = [], booksList = [] }: NavbarProps)
234
480
  </Component>
235
481
  );
236
482
  })}
483
+ <div className="mt-2 pt-3 border-t border-muted/10 px-3">
484
+ <LanguageSwitch />
485
+ </div>
237
486
  </div>
238
487
  </div>
239
488
  </>