@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
@@ -0,0 +1,31 @@
1
+ import { getAllNotes, getNoteTags } from '@/lib/markdown';
2
+ import { siteConfig } from '../../../site.config';
3
+ import { Metadata } from 'next';
4
+ import { t, tWith, resolveLocale } from '@/lib/i18n';
5
+ import NoteContent from '@/components/NoteContent';
6
+ import FlowHubTabs from '@/components/FlowHubTabs';
7
+
8
+ const PAGE_SIZE = siteConfig.pagination.notes ?? 20;
9
+
10
+ export const metadata: Metadata = {
11
+ title: `${t('notes')} | ${resolveLocale(siteConfig.title)}`,
12
+ description: 'Knowledge base notes.',
13
+ };
14
+
15
+ export default function NotesPage() {
16
+ const allNotes = getAllNotes();
17
+ const totalPages = Math.ceil(allNotes.length / PAGE_SIZE);
18
+ const notes = allNotes.slice(0, PAGE_SIZE);
19
+ const tags = getNoteTags();
20
+
21
+ return (
22
+ <div className="layout-main">
23
+ <FlowHubTabs subtitle={tWith('notes_subtitle', { count: allNotes.length })} />
24
+ <NoteContent
25
+ notes={notes}
26
+ tags={tags}
27
+ pagination={totalPages > 1 ? { currentPage: 1, totalPages, basePath: '/notes' } : undefined}
28
+ />
29
+ </div>
30
+ );
31
+ }
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,156 @@ 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
+ excerpt: f.excerpt,
99
+ }))
100
+ : [];
95
101
 
96
- <SelectedBooksSection books={bookItems} />
102
+ const featuredItems: FeaturedPost[] = has('featured-posts') && features?.posts?.enabled !== false
103
+ ? featuredPosts.map(p => ({
104
+ slug: p.slug,
105
+ title: p.title,
106
+ excerpt: p.excerpt,
107
+ date: p.date,
108
+ category: p.category,
109
+ readingTime: p.readingTime,
110
+ coverImage: p.coverImage,
111
+ }))
112
+ : [];
97
113
 
98
- <FeaturedStoriesSection
99
- allFeatured={featuredItems}
100
- maxItems={featuredConfig.stories.maxItems}
101
- scrollThreshold={featuredConfig.stories.scrollThreshold}
102
- />
114
+ const renderSection = (section: HomepageSection) => {
115
+ switch (section.id) {
116
+ case 'featured-series':
117
+ if (features?.series?.enabled === false) return null;
118
+ return (
119
+ <CuratedSeriesSection
120
+ key="featured-series"
121
+ allSeries={seriesItems}
122
+ maxItems={section.maxItems ?? 6}
123
+ scrollThreshold={section.scrollThreshold ?? 2}
124
+ />
125
+ );
126
+ case 'featured-books':
127
+ if (features?.books?.enabled === false) return null;
128
+ return (
129
+ <SelectedBooksSection
130
+ key="featured-books"
131
+ books={bookItems}
132
+ maxItems={section.maxItems ?? 4}
133
+ scrollThreshold={section.scrollThreshold ?? 2}
134
+ />
135
+ );
136
+ case 'featured-posts':
137
+ if (features?.posts?.enabled === false) return null;
138
+ return (
139
+ <FeaturedStoriesSection
140
+ key="featured-posts"
141
+ allFeatured={featuredItems}
142
+ maxItems={section.maxItems ?? 4}
143
+ scrollThreshold={section.scrollThreshold ?? 1}
144
+ />
145
+ );
146
+ case 'latest-posts':
147
+ if (features?.posts?.enabled === false) return null;
148
+ return <LatestWritingSection key="latest-posts" posts={posts} totalCount={allPosts.length} />;
149
+ case 'recent-flows':
150
+ if (features?.flows?.enabled === false) return null;
151
+ return <RecentNotesSection key="recent-flows" notes={recentNoteItems} />;
152
+ default:
153
+ return null;
154
+ }
155
+ };
103
156
 
104
- <LatestWritingSection posts={posts} totalCount={allPosts.length} />
157
+ return (
158
+ <div>
159
+ {has('hero') && (
160
+ <Hero
161
+ tagline={siteConfig.hero.tagline}
162
+ title={siteConfig.hero.title}
163
+ subtitle={siteConfig.hero.subtitle}
164
+ />
165
+ )}
105
166
 
106
- <RecentNotesSection notes={recentNoteItems} />
167
+ <div className="layout-main pt-0 md:pt-0">
168
+ {sections.filter(s => s.id !== 'hero').map(renderSection)}
107
169
  </div>
108
170
  </div>
109
171
  );
@@ -1,4 +1,4 @@
1
- import { getPostBySlug, getAllPosts, getRelatedPosts, getSeriesPosts, getSeriesData, PostData } from '@/lib/markdown';
1
+ import { getPostBySlug, getAllPosts, getRelatedPosts, getSeriesPosts, getSeriesData, getAdjacentPosts, buildSlugRegistry, getBacklinks, 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,9 @@ export default async function PostPage({
105
98
  }
106
99
 
107
100
  const relatedPosts = getRelatedPosts(slug);
101
+ const { prev, next } = getAdjacentPosts(slug);
102
+ const slugRegistry = buildSlugRegistry();
103
+ const backlinks = getBacklinks(slug);
108
104
  let seriesPosts: PostData[] = [];
109
105
  let seriesTitle: string | undefined;
110
106
 
@@ -115,5 +111,5 @@ export default async function PostPage({
115
111
  }
116
112
 
117
113
  // Default to standard post layout
118
- return <PostLayout post={post} relatedPosts={relatedPosts} seriesPosts={seriesPosts} seriesTitle={seriesTitle} />;
114
+ return <PostLayout post={post} relatedPosts={relatedPosts} seriesPosts={seriesPosts} seriesTitle={seriesTitle} prevPost={prev} nextPost={next} backlinks={backlinks} slugRegistry={slugRegistry} />;
119
115
  }
@@ -1,4 +1,4 @@
1
- import { getAllPosts, getAllBooks, getBookChapter, getAllFlows } from '@/lib/markdown';
1
+ import { getAllPosts, getAllBooks, getBookChapter, getAllFlows, getAllNotes } from '@/lib/markdown';
2
2
  import { stripMarkdown } from '@/lib/search-utils';
3
3
 
4
4
  export const dynamic = 'force-static';
@@ -49,5 +49,19 @@ export async function GET() {
49
49
  });
50
50
  }
51
51
 
52
+ // Add notes to search index
53
+ const notes = getAllNotes();
54
+ for (const note of notes) {
55
+ searchIndex.push({
56
+ title: note.title,
57
+ slug: `notes/${note.slug}`,
58
+ date: note.date,
59
+ excerpt: note.excerpt,
60
+ category: 'Note',
61
+ tags: note.tags,
62
+ content: stripMarkdown(note.content),
63
+ });
64
+ }
65
+
52
66
  return Response.json(searchIndex);
53
67
  }
@@ -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
+ }
@@ -0,0 +1,39 @@
1
+ import Link from 'next/link';
2
+ import { BacklinkSource } from '@/lib/markdown';
3
+ import { t } from '@/lib/i18n';
4
+
5
+ interface BacklinksProps {
6
+ backlinks: BacklinkSource[];
7
+ }
8
+
9
+ export default function Backlinks({ backlinks }: BacklinksProps) {
10
+ if (!backlinks.length) return null;
11
+
12
+ return (
13
+ <div className="mt-12 pt-12 border-t border-muted/20">
14
+ <h3 className="text-sm font-sans font-semibold uppercase tracking-widest text-muted mb-4">
15
+ {t('backlinks')}
16
+ </h3>
17
+ <div className="flex flex-col gap-4">
18
+ {backlinks.map(bl => (
19
+ <div key={`${bl.type}-${bl.slug}`} className="flex flex-col gap-1">
20
+ <div className="flex items-center gap-2">
21
+ <span className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted/60 border border-muted/20 rounded px-1.5 py-0.5">
22
+ {bl.type}
23
+ </span>
24
+ <Link
25
+ href={bl.url}
26
+ className="text-sm font-medium text-heading hover:text-accent no-underline transition-colors"
27
+ >
28
+ {bl.title}
29
+ </Link>
30
+ </div>
31
+ {bl.context && (
32
+ <p className="text-sm text-muted leading-relaxed">&ldquo;{bl.context}&rdquo;</p>
33
+ )}
34
+ </div>
35
+ ))}
36
+ </div>
37
+ </div>
38
+ );
39
+ }
@@ -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) => (