@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.
- package/CHANGELOG.md +94 -0
- package/CLAUDE.md +3 -2
- package/GEMINI.md +13 -6
- package/README.md +1 -1
- package/TODO.md +21 -76
- package/bun.lock +18 -3
- package/content/about.mdx +1 -0
- package/content/about.zh.mdx +21 -0
- package/content/flows/2026/02/20.md +16 -0
- package/content/links.mdx +42 -0
- package/content/links.zh.mdx +41 -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 +11 -2
- package/docs/CONTRIBUTING.md +4 -2
- package/docs/deployment.md +9 -1
- package/eslint.config.mjs +2 -0
- package/package.json +5 -4
- package/public/next-image-export-optimizer-hashes.json +0 -3
- package/scripts/copy-assets.ts +1 -1
- package/site.config.ts +126 -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 +21 -4
- package/src/app/layout.tsx +48 -21
- package/src/app/page.tsx +135 -72
- package/src/app/posts/[slug]/page.tsx +6 -12
- package/src/app/search.json/route.ts +4 -0
- 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/Comments.tsx +20 -4
- package/src/components/ExternalLinks.tsx +6 -2
- package/src/components/Footer.tsx +35 -26
- package/src/components/LanguageProvider.tsx +0 -5
- package/src/components/LanguageSwitch.tsx +117 -6
- package/src/components/LocaleSwitch.tsx +33 -0
- package/src/components/Navbar.tsx +31 -8
- package/src/components/PostNavigation.tsx +55 -0
- package/src/components/PostSidebar.tsx +172 -126
- package/src/components/ReadingProgressBar.tsx +6 -21
- package/src/components/RelatedPosts.tsx +1 -1
- package/src/components/Search.tsx +420 -70
- 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 +103 -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 +110 -2
- package/src/layouts/PostLayout.tsx +34 -7
- package/src/layouts/SimpleLayout.tsx +53 -15
- package/src/lib/markdown.ts +71 -15
- package/src/lib/search-utils.test.ts +163 -0
- package/src/lib/search-utils.ts +39 -0
- package/src/types/pagefind.d.ts +42 -0
- package/src/components/TableOfContents.tsx +0 -158
package/src/app/page.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getAllPosts,
|
|
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
|
|
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
|
-
|
|
31
|
-
const
|
|
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
|
|
46
|
+
const has = (id: string) => sections.some(s => s.id === id);
|
|
34
47
|
|
|
35
|
-
//
|
|
36
|
-
const
|
|
37
|
-
|
|
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
|
-
//
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
-
<
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
{posts
|
|
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
|
}
|
package/src/app/tags/page.tsx
CHANGED
|
@@ -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
|
|
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
|
-
<
|
|
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-
|
|
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=
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
<
|
|
97
|
-
|
|
98
|
-
|
|
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>
|