@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
package/src/app/page.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { getAllPosts, getAllSeries, getSeriesData, getFeaturedPosts, getFeaturedBooks, getRecentFlows } from '@/lib/markdown';
1
+ import { getAllPosts, getFeaturedSeries, getSeriesData, getFeaturedPosts, getFeaturedBooks, getRecentFlows } from '@/lib/markdown';
2
2
  import { siteConfig } from '../../site.config';
3
3
  import Hero from '@/components/Hero';
4
4
  import CuratedSeriesSection, { SeriesItem } from '@/components/CuratedSeriesSection';
@@ -16,94 +16,157 @@ export const metadata: Metadata = {
16
16
  title: resolveLocale(siteConfig.title),
17
17
  description: resolveLocale(siteConfig.description),
18
18
  siteName: resolveLocale(siteConfig.title),
19
+ url: siteConfig.baseUrl,
19
20
  type: 'website',
21
+ images: [{ url: siteConfig.ogImage, width: 1200, height: 630 }],
22
+ },
23
+ twitter: {
24
+ card: 'summary',
25
+ title: resolveLocale(siteConfig.title),
26
+ description: resolveLocale(siteConfig.description),
20
27
  },
21
28
  };
22
29
 
30
+ type HomepageSection = {
31
+ id: string;
32
+ enabled?: boolean;
33
+ weight: number;
34
+ maxItems?: number;
35
+ scrollThreshold?: number;
36
+ };
37
+
23
38
  export default function Home() {
24
- const allPosts = getAllPosts();
25
- const allSeries = getAllSeries();
26
- const featuredPosts = getFeaturedPosts();
27
- const featuredBooks = getFeaturedBooks();
28
- const recentFlows = getRecentFlows(siteConfig.flows?.recentCount ?? 5);
39
+ const features = siteConfig.features;
29
40
 
30
- const pageSize = siteConfig.pagination.posts;
31
- const posts = allPosts.slice(0, pageSize);
41
+ // Resolve ordered, enabled homepage sections from config
42
+ const sections = ([...(siteConfig.homepage?.sections as HomepageSection[] ?? [])])
43
+ .filter(s => s.enabled !== false)
44
+ .sort((a, b) => a.weight - b.weight);
32
45
 
33
- const featuredConfig = siteConfig.featured || { series: { scrollThreshold: 2, maxItems: 6 }, stories: { scrollThreshold: 1, maxItems: 5 } };
46
+ const has = (id: string) => sections.some(s => s.id === id);
34
47
 
35
- // Prepare serializable series data for the client component
36
- const seriesItems: SeriesItem[] = Object.keys(allSeries).map(name => {
37
- const seriesPosts = allSeries[name];
38
- const slug = name.toLowerCase().replace(/ /g, '-');
39
- const seriesData = getSeriesData(slug);
40
- return {
41
- name,
42
- title: seriesData?.title || name,
43
- excerpt: seriesData?.excerpt || "A growing collection of related thoughts.",
44
- coverImage: seriesData?.coverImage,
45
- url: `/series/${slug}`,
46
- postCount: seriesPosts.length,
47
- topPosts: seriesPosts.slice(0, 3).map(p => ({ slug: p.slug, title: p.title })),
48
- };
49
- });
48
+ // Derive per-section maxItems upfront for data loading
49
+ const recentFlowsMax = sections.find(s => s.id === 'recent-flows')?.maxItems ?? siteConfig.flows?.recentCount ?? 5;
50
+ const latestPostsMax = sections.find(s => s.id === 'latest-posts')?.maxItems ?? siteConfig.pagination.posts;
50
51
 
51
- // Prepare serializable books data
52
- const bookItems: BookItem[] = featuredBooks.map(b => ({
53
- slug: b.slug,
54
- title: b.title,
55
- excerpt: b.excerpt,
56
- coverImage: b.coverImage,
57
- authors: b.authors,
58
- chapterCount: b.chapters.length,
59
- firstChapter: b.chapters[0]?.file,
60
- }));
52
+ // Load data only for sections that are both enabled on homepage and globally
53
+ const allSeries = has('featured-series') && features?.series?.enabled !== false ? getFeaturedSeries() : {};
54
+ const featuredBooks = has('featured-books') && features?.books?.enabled !== false ? getFeaturedBooks() : [];
55
+ const recentFlows = has('recent-flows') && features?.flows?.enabled !== false
56
+ ? getRecentFlows(recentFlowsMax)
57
+ : [];
58
+ const needsPosts = has('featured-posts') || has('latest-posts');
59
+ const allPosts = needsPosts && features?.posts?.enabled !== false ? getAllPosts() : [];
60
+ const featuredPosts = has('featured-posts') && features?.posts?.enabled !== false ? getFeaturedPosts() : [];
61
61
 
62
- // Prepare serializable flow data
63
- const recentNoteItems: RecentNoteItem[] = recentFlows.map(f => ({
64
- slug: f.slug,
65
- date: f.date,
66
- title: f.title,
67
- excerpt: f.excerpt,
68
- }));
62
+ const posts = allPosts.slice(0, latestPostsMax);
69
63
 
70
- // Prepare serializable featured posts data
71
- const featuredItems: FeaturedPost[] = featuredPosts.map(p => ({
72
- slug: p.slug,
73
- title: p.title,
74
- excerpt: p.excerpt,
75
- date: p.date,
76
- category: p.category,
77
- readingTime: p.readingTime,
78
- coverImage: p.coverImage,
79
- }));
64
+ // Prepare serializable data for client components
65
+ const seriesItems: SeriesItem[] = has('featured-series') && features?.series?.enabled !== false
66
+ ? Object.keys(allSeries).map(name => {
67
+ const seriesPosts = allSeries[name];
68
+ const slug = name; // name is already the series directory slug
69
+ const seriesData = getSeriesData(slug);
70
+ return {
71
+ name,
72
+ title: seriesData?.title || name,
73
+ excerpt: seriesData?.excerpt || "A growing collection of related thoughts.",
74
+ coverImage: seriesData?.coverImage,
75
+ url: `/series/${slug}`,
76
+ postCount: seriesPosts.length,
77
+ topPosts: seriesPosts.slice(0, 3).map(p => ({ slug: p.slug, title: p.title })),
78
+ };
79
+ })
80
+ : [];
80
81
 
81
- return (
82
- <div>
83
- <Hero
84
- tagline={siteConfig.hero.tagline}
85
- title={siteConfig.hero.title}
86
- subtitle={siteConfig.hero.subtitle}
87
- />
82
+ const bookItems: BookItem[] = has('featured-books') && features?.books?.enabled !== false
83
+ ? featuredBooks.map(b => ({
84
+ slug: b.slug,
85
+ title: b.title,
86
+ excerpt: b.excerpt,
87
+ coverImage: b.coverImage,
88
+ authors: b.authors,
89
+ chapterCount: b.chapters.length,
90
+ firstChapter: b.chapters[0]?.file,
91
+ }))
92
+ : [];
88
93
 
89
- <div className="layout-main pt-0 md:pt-0">
90
- <CuratedSeriesSection
91
- allSeries={seriesItems}
92
- maxItems={featuredConfig.series.maxItems}
93
- scrollThreshold={featuredConfig.series.scrollThreshold}
94
- />
94
+ const recentNoteItems: RecentNoteItem[] = has('recent-flows') && features?.flows?.enabled !== false
95
+ ? recentFlows.map(f => ({
96
+ slug: f.slug,
97
+ date: f.date,
98
+ title: f.title,
99
+ excerpt: f.excerpt,
100
+ }))
101
+ : [];
95
102
 
96
- <SelectedBooksSection books={bookItems} />
103
+ const featuredItems: FeaturedPost[] = has('featured-posts') && features?.posts?.enabled !== false
104
+ ? featuredPosts.map(p => ({
105
+ slug: p.slug,
106
+ title: p.title,
107
+ excerpt: p.excerpt,
108
+ date: p.date,
109
+ category: p.category,
110
+ readingTime: p.readingTime,
111
+ coverImage: p.coverImage,
112
+ }))
113
+ : [];
97
114
 
98
- <FeaturedStoriesSection
99
- allFeatured={featuredItems}
100
- maxItems={featuredConfig.stories.maxItems}
101
- scrollThreshold={featuredConfig.stories.scrollThreshold}
102
- />
115
+ const renderSection = (section: HomepageSection) => {
116
+ switch (section.id) {
117
+ case 'featured-series':
118
+ if (features?.series?.enabled === false) return null;
119
+ return (
120
+ <CuratedSeriesSection
121
+ key="featured-series"
122
+ allSeries={seriesItems}
123
+ maxItems={section.maxItems ?? 6}
124
+ scrollThreshold={section.scrollThreshold ?? 2}
125
+ />
126
+ );
127
+ case 'featured-books':
128
+ if (features?.books?.enabled === false) return null;
129
+ return (
130
+ <SelectedBooksSection
131
+ key="featured-books"
132
+ books={bookItems}
133
+ maxItems={section.maxItems ?? 4}
134
+ scrollThreshold={section.scrollThreshold ?? 2}
135
+ />
136
+ );
137
+ case 'featured-posts':
138
+ if (features?.posts?.enabled === false) return null;
139
+ return (
140
+ <FeaturedStoriesSection
141
+ key="featured-posts"
142
+ allFeatured={featuredItems}
143
+ maxItems={section.maxItems ?? 4}
144
+ scrollThreshold={section.scrollThreshold ?? 1}
145
+ />
146
+ );
147
+ case 'latest-posts':
148
+ if (features?.posts?.enabled === false) return null;
149
+ return <LatestWritingSection key="latest-posts" posts={posts} totalCount={allPosts.length} />;
150
+ case 'recent-flows':
151
+ if (features?.flows?.enabled === false) return null;
152
+ return <RecentNotesSection key="recent-flows" notes={recentNoteItems} />;
153
+ default:
154
+ return null;
155
+ }
156
+ };
103
157
 
104
- <LatestWritingSection posts={posts} totalCount={allPosts.length} />
158
+ return (
159
+ <div>
160
+ {has('hero') && (
161
+ <Hero
162
+ tagline={siteConfig.hero.tagline}
163
+ title={siteConfig.hero.title}
164
+ subtitle={siteConfig.hero.subtitle}
165
+ />
166
+ )}
105
167
 
106
- <RecentNotesSection notes={recentNoteItems} />
168
+ <div className="layout-main pt-0 md:pt-0">
169
+ {sections.filter(s => s.id !== 'hero').map(renderSection)}
107
170
  </div>
108
171
  </div>
109
172
  );
@@ -1,4 +1,4 @@
1
- import { getPostBySlug, getAllPosts, getRelatedPosts, getSeriesPosts, getSeriesData, PostData } from '@/lib/markdown';
1
+ import { getPostBySlug, getAllPosts, getRelatedPosts, getSeriesPosts, getSeriesData, getAdjacentPosts, PostData } from '@/lib/markdown';
2
2
  import { notFound } from 'next/navigation';
3
3
  import PostLayout from '@/layouts/PostLayout';
4
4
  import SimpleLayout from '@/layouts/SimpleLayout';
@@ -30,14 +30,7 @@ function resolvePostFromParam(rawSlug: string) {
30
30
  */
31
31
  export async function generateStaticParams() {
32
32
  const posts = getAllPosts();
33
- const slugs = new Set<string>();
34
-
35
- for (const post of posts) {
36
- slugs.add(post.slug);
37
- slugs.add(encodeURIComponent(post.slug));
38
- }
39
-
40
- return [...slugs].map((slug) => ({ slug }));
33
+ return posts.map((post) => ({ slug: post.slug }));
41
34
  }
42
35
 
43
36
  export const dynamicParams = false;
@@ -52,9 +45,9 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
52
45
  };
53
46
  }
54
47
 
55
- const ogImage = post.coverImage && !post.coverImage.startsWith('text:')
48
+ const ogImage = post.coverImage && !post.coverImage.startsWith('text:') && !post.coverImage.startsWith('./')
56
49
  ? post.coverImage
57
- : `/icon.svg`;
50
+ : siteConfig.ogImage;
58
51
 
59
52
  return {
60
53
  title: `${post.title} | ${resolveLocale(siteConfig.title)}`,
@@ -105,6 +98,7 @@ export default async function PostPage({
105
98
  }
106
99
 
107
100
  const relatedPosts = getRelatedPosts(slug);
101
+ const { prev, next } = getAdjacentPosts(slug);
108
102
  let seriesPosts: PostData[] = [];
109
103
  let seriesTitle: string | undefined;
110
104
 
@@ -115,5 +109,5 @@ export default async function PostPage({
115
109
  }
116
110
 
117
111
  // Default to standard post layout
118
- return <PostLayout post={post} relatedPosts={relatedPosts} seriesPosts={seriesPosts} seriesTitle={seriesTitle} />;
112
+ return <PostLayout post={post} relatedPosts={relatedPosts} seriesPosts={seriesPosts} seriesTitle={seriesTitle} prevPost={prev} nextPost={next} />;
119
113
  }
@@ -1,4 +1,5 @@
1
1
  import { getAllPosts, getAllBooks, getBookChapter, getAllFlows } from '@/lib/markdown';
2
+ import { stripMarkdown } from '@/lib/search-utils';
2
3
 
3
4
  export const dynamic = 'force-static';
4
5
 
@@ -12,6 +13,7 @@ export async function GET() {
12
13
  excerpt: post.excerpt,
13
14
  category: post.category,
14
15
  tags: post.tags,
16
+ content: stripMarkdown(post.content),
15
17
  }));
16
18
 
17
19
  // Add book chapters to search index
@@ -27,6 +29,7 @@ export async function GET() {
27
29
  excerpt: chapter.excerpt || '',
28
30
  category: 'Book',
29
31
  tags: [],
32
+ content: stripMarkdown(chapter.content),
30
33
  });
31
34
  }
32
35
  }
@@ -42,6 +45,7 @@ export async function GET() {
42
45
  excerpt: flow.excerpt,
43
46
  category: 'Flow',
44
47
  tags: flow.tags,
48
+ content: stripMarkdown(flow.content),
45
49
  });
46
50
  }
47
51
 
@@ -36,9 +36,27 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
36
36
  return { title: 'Series Not Found' };
37
37
  }
38
38
 
39
+ const ogImage = seriesData.coverImage && !seriesData.coverImage.startsWith('text:') && !seriesData.coverImage.startsWith('./')
40
+ ? seriesData.coverImage
41
+ : siteConfig.ogImage;
42
+
39
43
  return {
40
44
  title: `${seriesData.title} - ${t('series')} | ${resolveLocale(siteConfig.title)}`,
41
45
  description: seriesData.excerpt,
46
+ openGraph: {
47
+ title: seriesData.title,
48
+ description: seriesData.excerpt,
49
+ type: 'website',
50
+ url: `${siteConfig.baseUrl}/series/${slug}`,
51
+ siteName: resolveLocale(siteConfig.title),
52
+ images: [{ url: ogImage, width: 1200, height: 630, alt: seriesData.title }],
53
+ },
54
+ twitter: {
55
+ card: ogImage !== siteConfig.ogImage ? 'summary_large_image' : 'summary',
56
+ title: seriesData.title,
57
+ description: seriesData.excerpt,
58
+ images: [ogImage],
59
+ },
42
60
  };
43
61
  }
44
62
 
@@ -0,0 +1,17 @@
1
+ import { Metadata } from 'next';
2
+ import { siteConfig } from '../../../site.config';
3
+ import { resolveLocale } from '@/lib/i18n';
4
+ import SubscribePage from '@/components/SubscribePage';
5
+
6
+ export const metadata: Metadata = {
7
+ title: `Subscribe | ${resolveLocale(siteConfig.title)}`,
8
+ description: 'Stay updated with new posts and notes. Subscribe via RSS, email, Telegram, or WeChat.',
9
+ };
10
+
11
+ export default function SubscribeRoute() {
12
+ return (
13
+ <div className="layout-main">
14
+ <SubscribePage />
15
+ </div>
16
+ );
17
+ }
@@ -1,11 +1,11 @@
1
1
  import { getAllTags, getPostsByTag, getFlowsByTag } from '@/lib/markdown';
2
- import PostCard from '@/components/PostCard';
3
- import FlowTimelineEntry from '@/components/FlowTimelineEntry';
4
2
  import { notFound } from 'next/navigation';
5
3
  import { siteConfig } from '../../../../site.config';
6
4
  import { Metadata } from 'next';
7
5
  import { resolveLocale } from '@/lib/i18n';
8
6
  import TagPageHeader from '@/components/TagPageHeader';
7
+ import TagSidebar from '@/components/TagSidebar';
8
+ import TagContentTabs from '@/components/TagContentTabs';
9
9
 
10
10
  export async function generateStaticParams() {
11
11
  const tags = getAllTags();
@@ -38,6 +38,7 @@ export default async function TagPage({
38
38
  const decodedTag = decodeURIComponent(tag);
39
39
  const posts = getPostsByTag(decodedTag);
40
40
  const flows = getFlowsByTag(decodedTag);
41
+ const allTags = getAllTags();
41
42
 
42
43
  if (posts.length === 0 && flows.length === 0) {
43
44
  notFound();
@@ -45,32 +46,14 @@ export default async function TagPage({
45
46
 
46
47
  return (
47
48
  <div className="layout-container">
48
- <TagPageHeader tag={decodedTag} postCount={posts.length + flows.length} />
49
+ <div className="grid grid-cols-1 lg:grid-cols-[280px_minmax(0,1fr)] gap-8 items-start">
50
+ <TagSidebar key={decodedTag} tags={allTags} activeTag={decodedTag} />
49
51
 
50
- {posts.length > 0 && (
51
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
52
- {posts.map(post => (
53
- <PostCard key={post.slug} post={post} />
54
- ))}
52
+ <div className="flex-1 min-w-0">
53
+ <TagPageHeader tag={decodedTag} />
54
+ <TagContentTabs posts={posts} flows={flows} />
55
55
  </div>
56
- )}
57
-
58
- {flows.length > 0 && (
59
- <div className={posts.length > 0 ? 'mt-12' : ''}>
60
- <div className="space-y-0">
61
- {flows.map(flow => (
62
- <FlowTimelineEntry
63
- key={flow.slug}
64
- date={flow.date}
65
- title={flow.title}
66
- excerpt={flow.excerpt}
67
- tags={flow.tags}
68
- slug={flow.slug}
69
- />
70
- ))}
71
- </div>
72
- </div>
73
- )}
56
+ </div>
74
57
  </div>
75
58
  );
76
59
  }
@@ -1,9 +1,9 @@
1
1
  import { getAllTags } from '@/lib/markdown';
2
- import Tag from '@/components/Tag';
3
2
  import { siteConfig } from '../../../site.config';
4
3
  import { Metadata } from 'next';
5
4
  import { t, resolveLocale } from '@/lib/i18n';
6
5
  import PageHeader from '@/components/PageHeader';
6
+ import TagsIndexClient from '@/components/TagsIndexClient';
7
7
 
8
8
  export const metadata: Metadata = {
9
9
  title: `${t('tags')} | ${resolveLocale(siteConfig.title)}`,
@@ -12,8 +12,7 @@ export const metadata: Metadata = {
12
12
 
13
13
  export default function TagsPage() {
14
14
  const tags = getAllTags();
15
- const sortedTags = Object.keys(tags).sort((a, b) => tags[b] - tags[a]);
16
- const totalTags = sortedTags.length;
15
+ const totalTags = Object.keys(tags).length;
17
16
 
18
17
  return (
19
18
  <div className="layout-main">
@@ -26,11 +25,7 @@ export default function TagsPage() {
26
25
  />
27
26
 
28
27
  <main>
29
- <div className="flex flex-wrap justify-center gap-3 md:gap-4">
30
- {sortedTags.map((tag) => (
31
- <Tag key={tag} tag={tag} count={tags[tag]} variant="large" showHash={false} />
32
- ))}
33
- </div>
28
+ <TagsIndexClient tags={tags} />
34
29
  </main>
35
30
  </div>
36
31
  );
@@ -0,0 +1,43 @@
1
+ import Link from 'next/link';
2
+ import { getAuthorSlug } from '@/lib/markdown';
3
+ import { siteConfig } from '../../site.config';
4
+ import { t } from '@/lib/i18n';
5
+
6
+ export default function AuthorCard({ authors }: { authors: string[] }) {
7
+ if (!authors || authors.length === 0) return null;
8
+
9
+ return (
10
+ <div className="mt-12 pt-12 border-t border-muted/20">
11
+ <div className="flex flex-col gap-6">
12
+ {authors.map((author) => {
13
+ const slug = getAuthorSlug(author);
14
+ const profile = siteConfig.authors?.[author];
15
+
16
+ return (
17
+ <div key={author} className="flex items-start gap-4">
18
+ <div className="w-10 h-10 rounded-full bg-accent/10 flex items-center justify-center flex-shrink-0 text-accent font-serif font-bold text-lg select-none">
19
+ {author.charAt(0).toUpperCase()}
20
+ </div>
21
+ <div className="min-w-0">
22
+ <p className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted mb-1">
23
+ {t('written_by')}
24
+ </p>
25
+ <Link
26
+ href={`/authors/${slug}`}
27
+ className="font-serif font-semibold text-heading hover:text-accent transition-colors no-underline"
28
+ >
29
+ {author}
30
+ </Link>
31
+ {profile?.bio && (
32
+ <p className="text-sm text-foreground/70 mt-1.5 leading-relaxed">
33
+ {profile.bio}
34
+ </p>
35
+ )}
36
+ </div>
37
+ </div>
38
+ );
39
+ })}
40
+ </div>
41
+ </div>
42
+ );
43
+ }
@@ -4,15 +4,31 @@ import Giscus from '@giscus/react';
4
4
  import { siteConfig } from '../../site.config';
5
5
  import { useTheme } from 'next-themes';
6
6
 
7
+ // Maps site locale codes to Giscus-supported language codes.
8
+ // Full list: https://github.com/giscus/giscus/tree/main/locales
9
+ const GISCUS_LANG: Record<string, string> = {
10
+ en: 'en',
11
+ zh: 'zh-CN',
12
+ 'zh-TW': 'zh-TW',
13
+ ja: 'ja',
14
+ ko: 'ko',
15
+ de: 'de',
16
+ fr: 'fr',
17
+ es: 'es',
18
+ pt: 'pt',
19
+ ru: 'ru',
20
+ };
21
+
7
22
  export default function Comments({ slug }: { slug: string }) {
8
23
  const { provider, giscus, disqus } = siteConfig.comments;
9
24
  const { theme, systemTheme } = useTheme();
10
-
25
+
11
26
  const currentTheme = theme === 'system' ? systemTheme : theme;
27
+ const giscusLang = GISCUS_LANG[siteConfig.i18n.defaultLocale] ?? siteConfig.i18n.defaultLocale;
12
28
 
13
29
  if (provider === 'giscus' && giscus.repo) {
14
30
  return (
15
- <div className="mt-16 pt-12 border-t border-muted/20">
31
+ <div className="mt-12 pt-12 border-t border-muted/20">
16
32
  <Giscus
17
33
  id="comments"
18
34
  repo={giscus.repo as `${string}/${string}`}
@@ -25,7 +41,7 @@ export default function Comments({ slug }: { slug: string }) {
25
41
  emitMetadata="0"
26
42
  inputPosition="top"
27
43
  theme={currentTheme === 'dark' ? 'transparent_dark' : 'light'}
28
- lang="en"
44
+ lang={giscusLang}
29
45
  loading="lazy"
30
46
  />
31
47
  </div>
@@ -34,7 +50,7 @@ export default function Comments({ slug }: { slug: string }) {
34
50
 
35
51
  if (provider === 'disqus' && disqus.shortname) {
36
52
  return (
37
- <div className="mt-16 pt-12 border-t border-muted/20">
53
+ <div className="mt-12 pt-12 border-t border-muted/20">
38
54
  <div id="disqus_thread"></div>
39
55
  <script
40
56
  dangerouslySetInnerHTML={{
@@ -1,18 +1,22 @@
1
+ 'use client';
2
+
1
3
  import { ExternalLink } from '@/lib/markdown';
4
+ import { useLanguage } from './LanguageProvider';
2
5
 
3
6
  interface ExternalLinksProps {
4
7
  links: ExternalLink[];
5
8
  }
6
9
 
7
10
  export default function ExternalLinks({ links }: ExternalLinksProps) {
11
+ const { t } = useLanguage();
8
12
  if (!links || links.length === 0) {
9
13
  return null;
10
14
  }
11
15
 
12
16
  return (
13
- <div className="mt-12 pt-8 border-t border-muted/20">
17
+ <div className="mt-12 pt-12 border-t border-muted/20">
14
18
  <h3 className="text-sm font-sans font-semibold uppercase tracking-widest text-muted mb-4">
15
- Discuss this post
19
+ {t('discuss_post')}
16
20
  </h3>
17
21
  <div className="flex flex-wrap gap-3">
18
22
  {links.map((link) => (
@@ -43,7 +43,7 @@ export default function Footer() {
43
43
  <div>
44
44
  <h4 className="font-sans font-bold text-xs uppercase tracking-widest text-muted/80 mb-6">{t('explore')}</h4>
45
45
  <ul className="space-y-3 text-sm">
46
- {[...siteConfig.nav].sort((a, b) => a.weight - b.weight).map((item) => {
46
+ {[...(siteConfig.footer?.explore ?? [])].sort((a, b) => a.weight - b.weight).map((item) => {
47
47
  const key = item.name.toLowerCase() as TranslationKey;
48
48
  const translated = t(key);
49
49
  const label = translated !== key ? translated : item.name;
@@ -62,25 +62,26 @@ export default function Footer() {
62
62
  <div>
63
63
  <h4 className="font-sans font-bold text-xs uppercase tracking-widest text-muted/80 mb-6">{t('connect')}</h4>
64
64
  <ul className="space-y-3 text-sm">
65
- {siteConfig.social.github && (
66
- <li>
67
- <a href={siteConfig.social.github} target="_blank" rel="noopener noreferrer" className="text-foreground/80 hover:text-accent transition-colors no-underline flex items-center gap-2">
68
- GitHub
69
- </a>
70
- </li>
71
- )}
72
- {siteConfig.social.twitter && (
73
- <li>
74
- <a href={siteConfig.social.twitter} target="_blank" rel="noopener noreferrer" className="text-foreground/80 hover:text-accent transition-colors no-underline flex items-center gap-2">
75
- Twitter
76
- </a>
77
- </li>
78
- )}
79
- <li>
80
- <a href="/feed.xml" className="text-foreground/80 hover:text-accent transition-colors no-underline flex items-center gap-2">
81
- RSS Feed
82
- </a>
83
- </li>
65
+ {[...(siteConfig.footer?.connect ?? [])].sort((a, b) => a.weight - b.weight).map((item) => {
66
+ const isExternal = item.url.startsWith('http');
67
+ const key = item.name.toLowerCase() as TranslationKey;
68
+ const translated = t(key);
69
+ const label = translated !== key ? translated : item.name;
70
+ const className = "text-foreground/80 hover:text-accent transition-colors no-underline flex items-center gap-2";
71
+ return (
72
+ <li key={item.url}>
73
+ {isExternal ? (
74
+ <a href={item.url} target="_blank" rel="noopener noreferrer" className={className}>
75
+ {label}
76
+ </a>
77
+ ) : (
78
+ <Link href={item.url} className={className}>
79
+ {label}
80
+ </Link>
81
+ )}
82
+ </li>
83
+ );
84
+ })}
84
85
  </ul>
85
86
  </div>
86
87
  </div>
@@ -89,13 +90,21 @@ export default function Footer() {
89
90
  <div className="pt-8 border-t border-muted/10 flex flex-col md:flex-row justify-between items-center gap-4 text-xs text-muted">
90
91
  <span>{resolveLocaleValue(siteConfig.footerText, language)}</span>
91
92
  <div className="flex items-center gap-6">
92
- <LanguageSwitch />
93
- <span className="opacity-20">|</span>
94
- <Link href="/privacy" className="hover:text-foreground transition-colors no-underline">Privacy</Link>
93
+ <LanguageSwitch variant="text" />
95
94
  <span className="opacity-20">|</span>
96
- <a href="https://github.com/hutusi/amytis" target="_blank" rel="noreferrer" className="hover:text-foreground transition-colors no-underline">
97
- Built with Amytis
98
- </a>
95
+ <Link href="/privacy" className="hover:text-foreground transition-colors no-underline">{t('privacy')}</Link>
96
+ {siteConfig.footer?.builtWith?.show && (() => {
97
+ const cfg = siteConfig.footer.builtWith;
98
+ const label = cfg.text ? resolveLocaleValue(cfg.text, language) : t('built_with');
99
+ return (
100
+ <>
101
+ <span className="opacity-20">|</span>
102
+ <a href={cfg.url ?? 'https://github.com/hutusi/amytis'} target="_blank" rel="noreferrer" className="hover:text-foreground transition-colors no-underline">
103
+ {label}
104
+ </a>
105
+ </>
106
+ );
107
+ })()}
99
108
  </div>
100
109
  </div>
101
110
  </div>