@hutusi/amytis 1.5.6 → 1.7.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 (65) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/CLAUDE.md +3 -2
  3. package/GEMINI.md +13 -6
  4. package/README.md +1 -1
  5. package/TODO.md +21 -76
  6. package/bun.lock +18 -3
  7. package/content/about.mdx +1 -0
  8. package/content/about.zh.mdx +21 -0
  9. package/content/flows/2026/02/20.md +16 -0
  10. package/content/links.mdx +42 -0
  11. package/content/links.zh.mdx +41 -0
  12. package/content/posts/2026-02-20-i18n-routing-considerations.mdx +150 -0
  13. package/content/posts/multimedia-showcase/index.mdx +261 -0
  14. package/content/privacy.mdx +32 -0
  15. package/content/privacy.zh.mdx +32 -0
  16. package/docs/ARCHITECTURE.md +11 -2
  17. package/docs/CONTRIBUTING.md +4 -2
  18. package/docs/deployment.md +9 -1
  19. package/eslint.config.mjs +2 -0
  20. package/package.json +5 -4
  21. package/public/next-image-export-optimizer-hashes.json +0 -3
  22. package/scripts/copy-assets.ts +1 -1
  23. package/site.config.ts +126 -44
  24. package/src/app/[slug]/page.tsx +0 -10
  25. package/src/app/archive/page.tsx +38 -10
  26. package/src/app/books/[slug]/page.tsx +18 -0
  27. package/src/app/flows/[year]/[month]/[day]/page.tsx +21 -4
  28. package/src/app/layout.tsx +48 -21
  29. package/src/app/page.tsx +135 -72
  30. package/src/app/posts/[slug]/page.tsx +6 -12
  31. package/src/app/search.json/route.ts +4 -0
  32. package/src/app/series/[slug]/page.tsx +18 -0
  33. package/src/app/subscribe/page.tsx +17 -0
  34. package/src/app/tags/[tag]/page.tsx +9 -26
  35. package/src/app/tags/page.tsx +3 -8
  36. package/src/components/AuthorCard.tsx +43 -0
  37. package/src/components/Comments.tsx +20 -4
  38. package/src/components/ExternalLinks.tsx +6 -2
  39. package/src/components/Footer.tsx +35 -26
  40. package/src/components/LanguageProvider.tsx +0 -5
  41. package/src/components/LanguageSwitch.tsx +117 -6
  42. package/src/components/LocaleSwitch.tsx +33 -0
  43. package/src/components/Navbar.tsx +31 -8
  44. package/src/components/PostNavigation.tsx +55 -0
  45. package/src/components/PostSidebar.tsx +172 -126
  46. package/src/components/ReadingProgressBar.tsx +6 -21
  47. package/src/components/RelatedPosts.tsx +1 -1
  48. package/src/components/Search.tsx +420 -70
  49. package/src/components/SelectedBooksSection.tsx +12 -6
  50. package/src/components/ShareBar.tsx +115 -0
  51. package/src/components/SimpleLayoutHeader.tsx +5 -14
  52. package/src/components/SubscribePage.tsx +298 -0
  53. package/src/components/TagContentTabs.tsx +103 -0
  54. package/src/components/TagPageHeader.tsx +7 -13
  55. package/src/components/TagSidebar.tsx +142 -0
  56. package/src/components/TagsIndexClient.tsx +156 -0
  57. package/src/hooks/useScrollY.ts +41 -0
  58. package/src/i18n/translations.ts +110 -2
  59. package/src/layouts/PostLayout.tsx +34 -7
  60. package/src/layouts/SimpleLayout.tsx +53 -15
  61. package/src/lib/markdown.ts +71 -15
  62. package/src/lib/search-utils.test.ts +163 -0
  63. package/src/lib/search-utils.ts +39 -0
  64. package/src/types/pagefind.d.ts +42 -0
  65. package/src/components/TableOfContents.tsx +0 -158
@@ -27,11 +27,6 @@ export function LanguageProvider({ children }: { children: React.ReactNode }) {
27
27
  const rafId = requestAnimationFrame(() => {
28
28
  if (savedLang && translations[savedLang]) {
29
29
  setLanguageState(savedLang);
30
- } else {
31
- const browserLang = navigator.language.split('-')[0] as Language;
32
- if (translations[browserLang]) {
33
- setLanguageState(browserLang);
34
- }
35
30
  }
36
31
  setIsHydrated(true);
37
32
  });
@@ -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
+ }
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
4
4
  import Link from 'next/link';
5
5
  import { siteConfig } from '../../site.config';
6
6
  import ThemeToggle from './ThemeToggle';
7
+ import LanguageSwitch from './LanguageSwitch';
7
8
  import Search from '@/components/Search';
8
9
  import { useLanguage } from '@/components/LanguageProvider';
9
10
  import { resolveLocaleValue } from '@/lib/i18n';
@@ -19,12 +20,30 @@ interface NavbarProps {
19
20
  booksList?: NavItem[];
20
21
  }
21
22
 
23
+ // Map from nav URL to feature key
24
+ const FEATURE_URLS: Partial<Record<string, keyof typeof siteConfig.features>> = {
25
+ '/posts': 'posts',
26
+ '/flows': 'flows',
27
+ '/series': 'series',
28
+ '/books': 'books',
29
+ };
30
+
22
31
  export default function Navbar({ seriesList = [], booksList = [] }: NavbarProps) {
23
32
  const { t, language } = useLanguage();
24
33
  const [isMenuOpen, setIsMenuOpen] = useState(false);
25
- const navItems = [...siteConfig.nav].sort((a, b) => a.weight - b.weight);
34
+ const navItems = [...siteConfig.nav]
35
+ .filter(item => {
36
+ const featureKey = FEATURE_URLS[item.url];
37
+ if (!featureKey) return true; // not a feature-gated item, always show
38
+ return siteConfig.features?.[featureKey]?.enabled !== false;
39
+ })
40
+ .sort((a, b) => a.weight - b.weight);
26
41
 
27
- const getLabel = (name: string): string => {
42
+ const getLabel = (name: string, url: string): string => {
43
+ const featureKey = FEATURE_URLS[url];
44
+ if (featureKey && siteConfig.features?.[featureKey]?.name) {
45
+ return resolveLocaleValue(siteConfig.features[featureKey].name, language);
46
+ }
28
47
  const key = name.toLowerCase() as TranslationKey;
29
48
  const translated = t(key);
30
49
  return translated !== key ? translated : name;
@@ -74,14 +93,14 @@ export default function Navbar({ seriesList = [], booksList = [] }: NavbarProps)
74
93
  const Component = isExternal ? 'a' : Link;
75
94
  const props = isExternal ? { target: "_blank", rel: "noopener noreferrer" } : {};
76
95
 
77
- if (item.name === 'Books' && booksList.length > 0) {
96
+ if (item.url === '/books' && booksList.length > 0) {
78
97
  return (
79
98
  <div key={item.url} className="relative group">
80
99
  <Link
81
100
  href={item.url}
82
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"
83
102
  >
84
- {getLabel(item.name)}
103
+ {getLabel(item.name, item.url)}
85
104
  <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
105
  <path d="M6 9l6 6 6-6"/>
87
106
  </svg>
@@ -110,14 +129,14 @@ export default function Navbar({ seriesList = [], booksList = [] }: NavbarProps)
110
129
  );
111
130
  }
112
131
 
113
- if (item.name === 'Series' && seriesList.length > 0) {
132
+ if (item.url === '/series' && seriesList.length > 0) {
114
133
  return (
115
134
  <div key={item.url} className="relative group">
116
135
  <Link
117
136
  href={item.url}
118
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"
119
138
  >
120
- {getLabel(item.name)}
139
+ {getLabel(item.name, item.url)}
121
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">
122
141
  <path d="M6 9l6 6 6-6"/>
123
142
  </svg>
@@ -153,7 +172,7 @@ export default function Navbar({ seriesList = [], booksList = [] }: NavbarProps)
153
172
  {...props}
154
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"
155
174
  >
156
- {getLabel(item.name)}
175
+ {getLabel(item.name, item.url)}
157
176
  {isExternal && (
158
177
  <svg
159
178
  width="12"
@@ -197,6 +216,7 @@ export default function Navbar({ seriesList = [], booksList = [] }: NavbarProps)
197
216
  </svg>
198
217
  </button>
199
218
  <Search />
219
+ <LanguageSwitch />
200
220
  <ThemeToggle />
201
221
  </div>
202
222
  </div>
@@ -225,7 +245,7 @@ export default function Navbar({ seriesList = [], booksList = [] }: NavbarProps)
225
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"
226
246
  onClick={() => setIsMenuOpen(false)}
227
247
  >
228
- {getLabel(item.name)}
248
+ {getLabel(item.name, item.url)}
229
249
  {isExternal && (
230
250
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="opacity-70">
231
251
  <path d="M7 17l9.2-9.2M17 17V7H7" />
@@ -234,6 +254,9 @@ export default function Navbar({ seriesList = [], booksList = [] }: NavbarProps)
234
254
  </Component>
235
255
  );
236
256
  })}
257
+ <div className="mt-2 pt-3 border-t border-muted/10 px-3">
258
+ <LanguageSwitch />
259
+ </div>
237
260
  </div>
238
261
  </div>
239
262
  </>
@@ -0,0 +1,55 @@
1
+ import Link from 'next/link';
2
+ import { PostData } from '@/lib/markdown';
3
+ import { t } from '@/lib/i18n';
4
+
5
+ interface PostNavigationProps {
6
+ prev: PostData | null;
7
+ next: PostData | null;
8
+ }
9
+
10
+ export default function PostNavigation({ prev, next }: PostNavigationProps) {
11
+ if (!prev && !next) return null;
12
+
13
+ return (
14
+ <nav
15
+ className="mt-12 pt-12 border-t border-muted/20 grid grid-cols-1 sm:grid-cols-2 gap-3"
16
+ aria-label={t('post_navigation')}
17
+ >
18
+ {prev ? (
19
+ <Link
20
+ href={`/posts/${prev.slug}`}
21
+ className="group flex flex-col gap-1.5 p-4 rounded-xl border border-muted/15 hover:border-accent/30 hover:bg-accent/5 transition-all no-underline"
22
+ >
23
+ <span className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted flex items-center gap-1.5">
24
+ <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
25
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
26
+ </svg>
27
+ {t('prev')}
28
+ </span>
29
+ <span className="text-sm font-serif font-semibold text-heading group-hover:text-accent transition-colors line-clamp-2 leading-snug">
30
+ {prev.title}
31
+ </span>
32
+ <span className="text-xs font-mono text-muted/60">{prev.date}</span>
33
+ </Link>
34
+ ) : <div />}
35
+
36
+ {next ? (
37
+ <Link
38
+ href={`/posts/${next.slug}`}
39
+ className="group flex flex-col gap-1.5 p-4 rounded-xl border border-muted/15 hover:border-accent/30 hover:bg-accent/5 transition-all no-underline sm:items-end sm:text-right"
40
+ >
41
+ <span className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted flex items-center gap-1.5">
42
+ {t('next')}
43
+ <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
44
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
45
+ </svg>
46
+ </span>
47
+ <span className="text-sm font-serif font-semibold text-heading group-hover:text-accent transition-colors line-clamp-2 leading-snug">
48
+ {next.title}
49
+ </span>
50
+ <span className="text-xs font-mono text-muted/60">{next.date}</span>
51
+ </Link>
52
+ ) : <div />}
53
+ </nav>
54
+ );
55
+ }