@hutusi/amytis 1.6.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +49 -0
- package/GEMINI.md +12 -2
- package/README.md +14 -0
- package/TODO.md +24 -16
- package/bun.lock +8 -3
- package/content/about.mdx +1 -0
- package/content/about.zh.mdx +21 -0
- package/content/flows/2026/02/05.md +0 -1
- package/content/flows/2026/02/10.mdx +2 -1
- package/content/flows/2026/02/15.md +2 -1
- package/content/flows/2026/02/18.mdx +2 -1
- package/content/flows/2026/02/20.md +15 -0
- package/content/links.mdx +42 -0
- package/content/links.zh.mdx +41 -0
- package/content/notes/algorithms-and-data-structures.mdx +51 -0
- package/content/notes/digital-garden-philosophy.mdx +36 -0
- package/content/notes/react-server-components.mdx +49 -0
- package/content/notes/tailwind-v4.mdx +45 -0
- package/content/notes/zettelkasten-method.mdx +33 -0
- package/content/posts/2026-02-20-i18n-routing-considerations.mdx +150 -0
- package/content/posts/multimedia-showcase/index.mdx +261 -0
- package/content/privacy.mdx +32 -0
- package/content/privacy.zh.mdx +32 -0
- package/docs/ARCHITECTURE.md +16 -0
- package/docs/CONTRIBUTING.md +11 -0
- package/docs/DIGITAL_GARDEN.md +64 -0
- package/package.json +8 -3
- package/scripts/copy-assets.ts +1 -1
- package/scripts/generate-knowledge-graph.ts +162 -0
- package/scripts/new-flow.ts +0 -5
- package/scripts/new-note.ts +53 -0
- package/site.config.ts +146 -44
- package/src/app/[slug]/page.tsx +0 -10
- package/src/app/archive/page.tsx +38 -10
- package/src/app/books/[slug]/page.tsx +18 -0
- package/src/app/flows/[year]/[month]/[day]/page.tsx +51 -31
- package/src/app/flows/[year]/[month]/page.tsx +15 -13
- package/src/app/flows/[year]/page.tsx +22 -15
- package/src/app/flows/page/[page]/page.tsx +3 -9
- package/src/app/flows/page.tsx +3 -8
- package/src/app/globals.css +41 -0
- package/src/app/graph/page.tsx +19 -0
- package/src/app/layout.tsx +47 -21
- package/src/app/notes/[slug]/page.tsx +128 -0
- package/src/app/notes/page/[page]/page.tsx +58 -0
- package/src/app/notes/page.tsx +31 -0
- package/src/app/page.tsx +134 -72
- package/src/app/posts/[slug]/page.tsx +8 -12
- package/src/app/search.json/route.ts +15 -1
- package/src/app/series/[slug]/page.tsx +18 -0
- package/src/app/subscribe/page.tsx +17 -0
- package/src/app/tags/[tag]/page.tsx +9 -26
- package/src/app/tags/page.tsx +3 -8
- package/src/components/AuthorCard.tsx +43 -0
- package/src/components/Backlinks.tsx +39 -0
- package/src/components/Comments.tsx +20 -4
- package/src/components/ExternalLinks.tsx +6 -2
- package/src/components/FlowCalendarSidebar.tsx +4 -2
- package/src/components/FlowContent.tsx +4 -3
- package/src/components/FlowHubTabs.tsx +50 -0
- package/src/components/FlowTimelineEntry.tsx +7 -9
- package/src/components/Footer.tsx +35 -26
- package/src/components/KnowledgeGraph.tsx +324 -0
- package/src/components/LanguageProvider.tsx +0 -5
- package/src/components/LanguageSwitch.tsx +117 -6
- package/src/components/LocaleSwitch.tsx +33 -0
- package/src/components/MarkdownRenderer.tsx +13 -2
- package/src/components/Navbar.tsx +266 -17
- package/src/components/NoteContent.tsx +123 -0
- package/src/components/NoteSidebar.tsx +132 -0
- package/src/components/PostNavigation.tsx +55 -0
- package/src/components/PostSidebar.tsx +172 -126
- package/src/components/ReadingProgressBar.tsx +6 -21
- package/src/components/RecentNotesSection.tsx +6 -11
- package/src/components/RelatedPosts.tsx +1 -1
- package/src/components/Search.tsx +29 -5
- package/src/components/SelectedBooksSection.tsx +12 -6
- package/src/components/ShareBar.tsx +115 -0
- package/src/components/SimpleLayoutHeader.tsx +5 -14
- package/src/components/SubscribePage.tsx +298 -0
- package/src/components/TagContentTabs.tsx +102 -0
- package/src/components/TagPageHeader.tsx +7 -13
- package/src/components/TagSidebar.tsx +142 -0
- package/src/components/TagsIndexClient.tsx +156 -0
- package/src/hooks/useScrollY.ts +41 -0
- package/src/i18n/translations.ts +105 -1
- package/src/layouts/PostLayout.tsx +40 -8
- package/src/layouts/SimpleLayout.tsx +53 -15
- package/src/lib/markdown.ts +347 -18
- package/src/lib/remark-wikilinks.ts +59 -0
- package/src/lib/search-utils.ts +2 -1
- package/src/components/TableOfContents.tsx +0 -158
|
@@ -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,
|
|
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
|
|
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
|
+
excerpt: f.excerpt,
|
|
99
|
+
}))
|
|
100
|
+
: [];
|
|
95
101
|
|
|
96
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,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
|
-
<
|
|
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
|
+
}
|
|
@@ -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">“{bl.context}”</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-
|
|
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) => (
|