@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.
- package/CHANGELOG.md +49 -0
- package/GEMINI.md +12 -2
- package/README.md +14 -0
- package/TODO.md +24 -16
- package/bun.lock +8 -3
- package/content/about.mdx +1 -0
- package/content/about.zh.mdx +21 -0
- 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 +15 -0
- package/content/links.mdx +42 -0
- package/content/links.zh.mdx +41 -0
- package/content/notes/algorithms-and-data-structures.mdx +51 -0
- package/content/notes/digital-garden-philosophy.mdx +36 -0
- package/content/notes/react-server-components.mdx +49 -0
- package/content/notes/tailwind-v4.mdx +45 -0
- package/content/notes/zettelkasten-method.mdx +33 -0
- package/content/posts/2026-02-20-i18n-routing-considerations.mdx +150 -0
- package/content/posts/multimedia-showcase/index.mdx +261 -0
- package/content/privacy.mdx +32 -0
- package/content/privacy.zh.mdx +32 -0
- package/docs/ARCHITECTURE.md +16 -0
- package/docs/CONTRIBUTING.md +11 -0
- package/docs/DIGITAL_GARDEN.md +64 -0
- package/package.json +8 -3
- package/scripts/copy-assets.ts +1 -1
- 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 +146 -44
- package/src/app/[slug]/page.tsx +0 -10
- package/src/app/archive/page.tsx +38 -10
- package/src/app/books/[slug]/page.tsx +18 -0
- package/src/app/flows/[year]/[month]/[day]/page.tsx +51 -31
- 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/layout.tsx +47 -21
- 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 +134 -72
- package/src/app/posts/[slug]/page.tsx +8 -12
- package/src/app/search.json/route.ts +15 -1
- package/src/app/series/[slug]/page.tsx +18 -0
- package/src/app/subscribe/page.tsx +17 -0
- package/src/app/tags/[tag]/page.tsx +9 -26
- package/src/app/tags/page.tsx +3 -8
- package/src/components/AuthorCard.tsx +43 -0
- package/src/components/Backlinks.tsx +39 -0
- package/src/components/Comments.tsx +20 -4
- package/src/components/ExternalLinks.tsx +6 -2
- 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/Footer.tsx +35 -26
- package/src/components/KnowledgeGraph.tsx +324 -0
- package/src/components/LanguageProvider.tsx +0 -5
- package/src/components/LanguageSwitch.tsx +117 -6
- package/src/components/LocaleSwitch.tsx +33 -0
- package/src/components/MarkdownRenderer.tsx +13 -2
- package/src/components/Navbar.tsx +266 -17
- package/src/components/NoteContent.tsx +123 -0
- package/src/components/NoteSidebar.tsx +132 -0
- package/src/components/PostNavigation.tsx +55 -0
- package/src/components/PostSidebar.tsx +172 -126
- package/src/components/ReadingProgressBar.tsx +6 -21
- package/src/components/RecentNotesSection.tsx +6 -11
- package/src/components/RelatedPosts.tsx +1 -1
- package/src/components/Search.tsx +29 -5
- package/src/components/SelectedBooksSection.tsx +12 -6
- package/src/components/ShareBar.tsx +115 -0
- package/src/components/SimpleLayoutHeader.tsx +5 -14
- package/src/components/SubscribePage.tsx +298 -0
- package/src/components/TagContentTabs.tsx +102 -0
- package/src/components/TagPageHeader.tsx +7 -13
- package/src/components/TagSidebar.tsx +142 -0
- package/src/components/TagsIndexClient.tsx +156 -0
- package/src/hooks/useScrollY.ts +41 -0
- package/src/i18n/translations.ts +105 -1
- package/src/layouts/PostLayout.tsx +40 -8
- package/src/layouts/SimpleLayout.tsx +53 -15
- package/src/lib/markdown.ts +347 -18
- package/src/lib/remark-wikilinks.ts +59 -0
- package/src/lib/search-utils.ts +2 -1
- 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
|
-
|
|
6
|
-
|
|
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(
|
|
11
|
-
className="
|
|
12
|
-
aria-label=
|
|
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
|
|
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
|
|
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=
|
|
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.
|
|
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=
|
|
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.
|
|
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=
|
|
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=
|
|
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={() =>
|
|
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=
|
|
226
|
-
|
|
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
|
</>
|