@hutusi/amytis 1.9.0 → 1.11.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/.entire/settings.json +4 -0
- package/AGENTS.md +13 -7
- package/CHANGELOG.md +41 -0
- package/CLAUDE.md +30 -2
- package/README.md +84 -58
- package/README.zh.md +195 -0
- package/TODO.md +6 -4
- package/bun.lock +30 -4
- package/content/books/sample-book/images/digital-garden.svg +25 -0
- package/content/books/sample-book/index.mdx +1 -1
- package/content/books/sample-book/introduction.mdx +2 -0
- package/content/posts/2026-01-21-kitchen-sink/index.mdx +26 -1
- package/content/posts/legacy-markdown.md +0 -2
- package/content/posts/markdown-features.mdx +1 -1
- package/content/posts/multilingual-test.mdx +0 -1
- package/content/posts/syntax-highlighting-showcase.mdx +1 -0
- package/content/posts/understanding-react-hooks.mdx +1 -0
- package/content/series/ai-nexus-weekly/index.mdx +1 -1
- package/content/series/digital-garden/01-philosophy.mdx +2 -0
- package/content/series/digital-garden/index.mdx +1 -1
- package/content/series/markdown-showcase/index.mdx +1 -1
- package/content/series/markdown-showcase/syntax-highlighting.mdx +2 -0
- package/content/series/nextjs-deep-dive/01-getting-started.mdx +2 -1
- package/content/series/nextjs-deep-dive/index.mdx +1 -1
- package/content/subscribe.mdx +27 -0
- package/docs/ARCHITECTURE.md +85 -105
- package/docs/CONTRIBUTING.md +11 -3
- package/docs/DIGITAL_GARDEN.md +11 -9
- package/docs/deployment.md +31 -13
- package/eslint.config.mjs +2 -0
- package/package.json +13 -4
- package/packages/create-amytis/package.json +13 -0
- package/packages/create-amytis/src/index.test.ts +187 -0
- package/packages/create-amytis/src/index.ts +230 -0
- package/packages/create-amytis/tsconfig.json +14 -0
- package/public/images/amytis-screenshot.jpg +0 -0
- package/public/images/antelope-canyon.jpg +0 -0
- package/public/images/avatar.jpg +0 -0
- package/public/images/cappadocia.jpg +0 -0
- package/public/images/flowers.jpg +0 -0
- package/public/images/galaxy.jpg +0 -0
- package/public/images/lake.jpg +0 -0
- package/public/images/mountains.jpg +0 -0
- package/public/images/vibrant-waves.avif +0 -0
- package/public/images/wechat-qr.jpg +0 -0
- package/public/next-image-export-optimizer-hashes.json +72 -2
- package/scripts/deploy.ts +77 -0
- package/site.config.example.ts +268 -0
- package/site.config.ts +82 -10
- package/src/app/[slug]/[postSlug]/page.tsx +161 -0
- package/src/app/[slug]/page/[page]/page.tsx +202 -0
- package/src/app/[slug]/page.tsx +162 -7
- package/src/app/archive/page.tsx +5 -8
- package/src/app/books/[slug]/[chapter]/page.tsx +45 -3
- package/src/app/books/[slug]/page.tsx +16 -2
- package/src/app/books/page.tsx +12 -8
- package/src/app/feed.atom/route.ts +62 -0
- package/src/app/feed.xml/route.ts +45 -20
- package/src/app/flows/[year]/[month]/[day]/page.tsx +9 -1
- package/src/app/flows/[year]/page.tsx +1 -1
- package/src/app/flows/page/[page]/page.tsx +2 -0
- package/src/app/flows/page.tsx +4 -0
- package/src/app/globals.css +9 -3
- package/src/app/layout.tsx +22 -1
- package/src/app/notes/[slug]/page.tsx +2 -3
- package/src/app/page.tsx +86 -8
- package/src/app/posts/[slug]/page.tsx +33 -7
- package/src/app/posts/page/[page]/page.tsx +9 -5
- package/src/app/posts/page.tsx +13 -7
- package/src/app/series/[slug]/page/[page]/page.tsx +8 -25
- package/src/app/series/[slug]/page.tsx +21 -19
- package/src/app/series/page.tsx +14 -28
- package/src/app/sitemap.ts +2 -1
- package/src/app/tags/[tag]/page.tsx +1 -1
- package/src/app/tags/page.tsx +1 -1
- package/src/components/Analytics.tsx +34 -36
- package/src/components/AuthorCard.tsx +56 -14
- package/src/components/BookMobileNav.tsx +8 -34
- package/src/components/BookSidebar.tsx +17 -107
- package/src/components/CodeBlock.test.tsx +19 -0
- package/src/components/CodeBlock.tsx +20 -16
- package/src/components/Comments.tsx +4 -3
- package/src/components/CoverImage.tsx +11 -3
- package/src/components/CuratedSeriesSection.tsx +17 -15
- package/src/components/ExternalLinks.tsx +1 -1
- package/src/components/FeaturedStoriesSection.tsx +124 -58
- package/src/components/FlowCalendarSidebar.tsx +10 -7
- package/src/components/FlowContent.tsx +33 -5
- package/src/components/Footer.tsx +28 -6
- package/src/components/Hero.tsx +44 -92
- package/src/components/InlineBookToc.tsx +56 -0
- package/src/components/KnowledgeGraph.tsx +26 -0
- package/src/components/LanguageProvider.tsx +7 -2
- package/src/components/LanguageSwitch.tsx +1 -1
- package/src/components/LatestWritingSection.tsx +12 -22
- package/src/components/MarkdownRenderer.test.tsx +41 -0
- package/src/components/MarkdownRenderer.tsx +43 -8
- package/src/components/Navbar.tsx +24 -15
- package/src/components/NoteSidebar.tsx +6 -76
- package/src/components/Pagination.tsx +2 -2
- package/src/components/PostCard.tsx +2 -1
- package/src/components/PostList.tsx +81 -62
- package/src/components/PostNavigation.tsx +8 -7
- package/src/components/PostSidebar.tsx +12 -107
- package/src/components/PrevNextNav.tsx +63 -0
- package/src/components/RecentNotesSection.tsx +2 -2
- package/src/components/RelatedPosts.tsx +3 -2
- package/src/components/RssFeedWidget.tsx +55 -0
- package/src/components/SectionLabel.tsx +16 -0
- package/src/components/SelectedBooksSection.tsx +12 -15
- package/src/components/SeriesCatalog.tsx +74 -69
- package/src/components/SeriesList.tsx +11 -36
- package/src/components/SeriesSidebar.tsx +4 -2
- package/src/components/TagContentTabs.tsx +17 -12
- package/src/components/TagPageHeader.tsx +12 -2
- package/src/components/TagSidebar.tsx +3 -2
- package/src/components/TagsIndexClient.tsx +1 -1
- package/src/components/TocPanel.tsx +67 -0
- package/src/hooks/useActiveHeading.ts +32 -0
- package/src/hooks/useSidebarAutoScroll.ts +19 -0
- package/src/i18n/translations.ts +26 -4
- package/src/layouts/BookLayout.tsx +10 -40
- package/src/layouts/PostLayout.tsx +37 -26
- package/src/layouts/SimpleLayout.tsx +5 -5
- package/src/lib/feed-utils.ts +68 -0
- package/src/lib/format-utils.test.ts +27 -0
- package/src/lib/format-utils.ts +3 -0
- package/src/lib/image-utils.ts +12 -0
- package/src/lib/json-ld.ts +236 -0
- package/src/lib/markdown.ts +62 -7
- package/src/lib/rehype-image-metadata.test.ts +124 -0
- package/src/lib/rehype-image-metadata.ts +21 -5
- package/src/lib/scroll-utils.ts +11 -0
- package/src/lib/shuffle.ts +21 -0
- package/src/lib/urls.ts +51 -0
- package/tests/e2e/navigation.test.ts +15 -1
- package/tests/integration/books.test.ts +61 -0
- package/tests/integration/feed-utils.test.ts +145 -0
- package/tests/integration/series.test.ts +9 -0
- package/tests/unit/static-params.test.ts +59 -2
- package/src/app/subscribe/page.tsx +0 -17
- package/src/components/SubscribePage.tsx +0 -298
- /package/public/{screenshot.png → images/screenshot.png} +0 -0
|
@@ -4,6 +4,9 @@ import { useState } from 'react';
|
|
|
4
4
|
import Link from 'next/link';
|
|
5
5
|
import { PostData } from '@/lib/markdown';
|
|
6
6
|
import { useLanguage } from './LanguageProvider';
|
|
7
|
+
import { getPostUrl } from '@/lib/urls';
|
|
8
|
+
import PrevNextNav from './PrevNextNav';
|
|
9
|
+
import { padNumber } from '@/lib/format-utils';
|
|
7
10
|
|
|
8
11
|
interface SeriesListProps {
|
|
9
12
|
seriesSlug: string;
|
|
@@ -52,39 +55,11 @@ export default function SeriesList({ seriesSlug, seriesTitle, posts, currentSlug
|
|
|
52
55
|
</div>
|
|
53
56
|
|
|
54
57
|
{/* Prev / Next navigation */}
|
|
55
|
-
<div className="
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
>
|
|
61
|
-
<svg className="w-4 h-4 flex-shrink-0 text-muted group-hover:text-accent transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
62
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
|
63
|
-
</svg>
|
|
64
|
-
<div className="min-w-0">
|
|
65
|
-
<span className="block text-[10px] font-sans font-bold uppercase tracking-widest text-muted mb-0.5">{t('prev')}</span>
|
|
66
|
-
<span className="block text-sm text-foreground/80 group-hover:text-foreground truncate transition-colors">{prevPost.title}</span>
|
|
67
|
-
</div>
|
|
68
|
-
</Link>
|
|
69
|
-
) : (
|
|
70
|
-
<div className="flex-1" />
|
|
71
|
-
)}
|
|
72
|
-
{nextPost ? (
|
|
73
|
-
<Link
|
|
74
|
-
href={`/posts/${nextPost.slug}`}
|
|
75
|
-
className="flex-1 flex items-center justify-end gap-2 py-2.5 px-3 rounded-lg bg-muted/5 hover:bg-muted/10 no-underline transition-colors group text-right"
|
|
76
|
-
>
|
|
77
|
-
<div className="min-w-0">
|
|
78
|
-
<span className="block text-[10px] font-sans font-bold uppercase tracking-widest text-muted mb-0.5">{t('next')}</span>
|
|
79
|
-
<span className="block text-sm text-foreground/80 group-hover:text-foreground truncate transition-colors">{nextPost.title}</span>
|
|
80
|
-
</div>
|
|
81
|
-
<svg className="w-4 h-4 flex-shrink-0 text-muted group-hover:text-accent transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
82
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
|
83
|
-
</svg>
|
|
84
|
-
</Link>
|
|
85
|
-
) : (
|
|
86
|
-
<div className="flex-1" />
|
|
87
|
-
)}
|
|
58
|
+
<div className="mb-3">
|
|
59
|
+
<PrevNextNav
|
|
60
|
+
prev={prevPost ? { href: getPostUrl(prevPost), title: prevPost.title } : null}
|
|
61
|
+
next={nextPost ? { href: getPostUrl(nextPost), title: nextPost.title } : null}
|
|
62
|
+
/>
|
|
88
63
|
</div>
|
|
89
64
|
|
|
90
65
|
{/* Toggle to expand full list */}
|
|
@@ -117,7 +92,7 @@ export default function SeriesList({ seriesSlug, seriesTitle, posts, currentSlug
|
|
|
117
92
|
{isCurrent ? (
|
|
118
93
|
<div className="flex items-center gap-3 py-1.5 px-2 -mx-2 rounded-lg bg-accent/5">
|
|
119
94
|
<span className="flex-shrink-0 w-5 h-5 rounded-full bg-accent text-white text-[10px] font-mono font-bold flex items-center justify-center">
|
|
120
|
-
{
|
|
95
|
+
{padNumber(index + 1)}
|
|
121
96
|
</span>
|
|
122
97
|
<span className="text-sm font-semibold text-accent truncate">
|
|
123
98
|
{post.title}
|
|
@@ -125,7 +100,7 @@ export default function SeriesList({ seriesSlug, seriesTitle, posts, currentSlug
|
|
|
125
100
|
</div>
|
|
126
101
|
) : (
|
|
127
102
|
<Link
|
|
128
|
-
href={
|
|
103
|
+
href={getPostUrl(post)}
|
|
129
104
|
className="group flex items-center gap-3 py-1.5 px-2 -mx-2 rounded-lg hover:bg-muted/5 no-underline transition-colors"
|
|
130
105
|
>
|
|
131
106
|
<span className={`flex-shrink-0 w-5 h-5 rounded-full text-[10px] font-mono font-bold flex items-center justify-center transition-colors ${
|
|
@@ -133,7 +108,7 @@ export default function SeriesList({ seriesSlug, seriesTitle, posts, currentSlug
|
|
|
133
108
|
? 'bg-accent/20 text-accent'
|
|
134
109
|
: 'bg-muted/10 text-muted group-hover:bg-muted/20'
|
|
135
110
|
}`}>
|
|
136
|
-
{
|
|
111
|
+
{padNumber(index + 1)}
|
|
137
112
|
</span>
|
|
138
113
|
<span className={`text-sm truncate transition-colors ${
|
|
139
114
|
isPast
|
|
@@ -4,6 +4,8 @@ import { useEffect, useRef } from 'react';
|
|
|
4
4
|
import Link from 'next/link';
|
|
5
5
|
import { PostData } from '@/lib/markdown';
|
|
6
6
|
import { useLanguage } from './LanguageProvider';
|
|
7
|
+
import { getPostUrl } from '@/lib/urls';
|
|
8
|
+
import { padNumber } from '@/lib/format-utils';
|
|
7
9
|
|
|
8
10
|
interface SeriesSidebarProps {
|
|
9
11
|
seriesSlug: string;
|
|
@@ -77,7 +79,7 @@ export default function SeriesSidebar({ seriesSlug, seriesTitle, posts, currentS
|
|
|
77
79
|
return (
|
|
78
80
|
<li key={post.slug} ref={isCurrent ? currentItemRef : undefined} className="relative">
|
|
79
81
|
<Link
|
|
80
|
-
href={
|
|
82
|
+
href={getPostUrl(post)}
|
|
81
83
|
className={`group flex items-start gap-3 py-2 px-2 -mx-2 rounded-lg no-underline transition-all duration-200 ${
|
|
82
84
|
isCurrent
|
|
83
85
|
? 'bg-accent/5'
|
|
@@ -93,7 +95,7 @@ export default function SeriesSidebar({ seriesSlug, seriesTitle, posts, currentS
|
|
|
93
95
|
? 'bg-accent/20 text-accent'
|
|
94
96
|
: 'bg-muted/10 text-muted group-hover:bg-muted/20 group-hover:text-foreground'
|
|
95
97
|
}`}>
|
|
96
|
-
{
|
|
98
|
+
{padNumber(index + 1)}
|
|
97
99
|
</div>
|
|
98
100
|
|
|
99
101
|
{/* Content */}
|
|
@@ -25,6 +25,7 @@ export default function TagContentTabs({ posts, flows }: TagContentTabsProps) {
|
|
|
25
25
|
const { t } = useLanguage();
|
|
26
26
|
const hasBoth = posts.length > 0 && flows.length > 0;
|
|
27
27
|
const [activeTab, setActiveTab] = useState<Tab>('all');
|
|
28
|
+
const showSectionHeaders = hasBoth && activeTab === 'all';
|
|
28
29
|
|
|
29
30
|
const showPosts = activeTab === 'all' || activeTab === 'posts';
|
|
30
31
|
const showFlows = activeTab === 'all' || activeTab === 'flows';
|
|
@@ -65,12 +66,14 @@ export default function TagContentTabs({ posts, flows }: TagContentTabsProps) {
|
|
|
65
66
|
{/* Posts section */}
|
|
66
67
|
{showPosts && posts.length > 0 && (
|
|
67
68
|
<div>
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
69
|
+
{showSectionHeaders && (
|
|
70
|
+
<h2 className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted mb-6">
|
|
71
|
+
{t('posts')}
|
|
72
|
+
<span className="ml-1.5 font-mono font-normal normal-case tracking-normal text-muted/50">
|
|
73
|
+
{posts.length}
|
|
74
|
+
</span>
|
|
75
|
+
</h2>
|
|
76
|
+
)}
|
|
74
77
|
<PostList posts={posts} />
|
|
75
78
|
</div>
|
|
76
79
|
)}
|
|
@@ -78,12 +81,14 @@ export default function TagContentTabs({ posts, flows }: TagContentTabsProps) {
|
|
|
78
81
|
{/* Flows section */}
|
|
79
82
|
{showFlows && flows.length > 0 && (
|
|
80
83
|
<div className={showPosts && posts.length > 0 ? 'mt-12' : ''}>
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
84
|
+
{showSectionHeaders && (
|
|
85
|
+
<h2 className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted mb-4">
|
|
86
|
+
{t('flow_notes')}
|
|
87
|
+
<span className="ml-1.5 font-mono font-normal normal-case tracking-normal text-muted/50">
|
|
88
|
+
{flows.length}
|
|
89
|
+
</span>
|
|
90
|
+
</h2>
|
|
91
|
+
)}
|
|
87
92
|
<div>
|
|
88
93
|
{flows.map(flow => (
|
|
89
94
|
<FlowTimelineEntry
|
|
@@ -5,10 +5,17 @@ import { useLanguage } from './LanguageProvider';
|
|
|
5
5
|
|
|
6
6
|
interface TagPageHeaderProps {
|
|
7
7
|
tag: string;
|
|
8
|
+
postCount?: number;
|
|
9
|
+
flowCount?: number;
|
|
8
10
|
}
|
|
9
11
|
|
|
10
|
-
export default function TagPageHeader({ tag }: TagPageHeaderProps) {
|
|
11
|
-
const { t } = useLanguage();
|
|
12
|
+
export default function TagPageHeader({ tag, postCount = 0, flowCount = 0 }: TagPageHeaderProps) {
|
|
13
|
+
const { t, tWith } = useLanguage();
|
|
14
|
+
|
|
15
|
+
const parts: string[] = [];
|
|
16
|
+
if (postCount > 0) parts.push(tWith(postCount === 1 ? 'tag_post_count_one' : 'tag_post_count', { count: postCount }));
|
|
17
|
+
if (flowCount > 0) parts.push(tWith(flowCount === 1 ? 'tag_flow_count_one' : 'tag_flow_count', { count: flowCount }));
|
|
18
|
+
const subtitle = parts.join(' · ');
|
|
12
19
|
|
|
13
20
|
return (
|
|
14
21
|
<>
|
|
@@ -26,6 +33,9 @@ export default function TagPageHeader({ tag }: TagPageHeaderProps) {
|
|
|
26
33
|
<h1 className="text-3xl md:text-4xl font-serif font-bold text-heading">
|
|
27
34
|
<span className="text-accent/50 mr-1">#</span>{tag}
|
|
28
35
|
</h1>
|
|
36
|
+
{subtitle && (
|
|
37
|
+
<p className="mt-2 text-sm text-muted">{subtitle}</p>
|
|
38
|
+
)}
|
|
29
39
|
</header>
|
|
30
40
|
</>
|
|
31
41
|
);
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import Link from 'next/link';
|
|
4
4
|
import { useState } from 'react';
|
|
5
|
-
import {
|
|
5
|
+
import { useLanguage } from './LanguageProvider';
|
|
6
6
|
import { LuTag, LuX, LuSearch } from 'react-icons/lu';
|
|
7
7
|
|
|
8
8
|
const INITIAL_SHOW = 12;
|
|
@@ -13,6 +13,7 @@ interface TagSidebarProps {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export default function TagSidebar({ tags, activeTag }: TagSidebarProps) {
|
|
16
|
+
const { t, tWith } = useLanguage();
|
|
16
17
|
const [filter, setFilter] = useState('');
|
|
17
18
|
const [expanded, setExpanded] = useState(false);
|
|
18
19
|
|
|
@@ -67,7 +68,7 @@ export default function TagSidebar({ tags, activeTag }: TagSidebarProps) {
|
|
|
67
68
|
type="text"
|
|
68
69
|
value={filter}
|
|
69
70
|
onChange={(e) => setFilter(e.target.value)}
|
|
70
|
-
placeholder=
|
|
71
|
+
placeholder={t('filter_tags')}
|
|
71
72
|
aria-label={t('filter_tags')}
|
|
72
73
|
className="w-full pl-8 pr-7 py-1.5 text-xs bg-muted/5 border border-muted/15 rounded-lg outline-none focus:border-accent/40 text-foreground placeholder:text-muted/40 transition-colors"
|
|
73
74
|
/>
|
|
@@ -75,7 +75,7 @@ export default function TagsIndexClient({ tags }: TagsIndexClientProps) {
|
|
|
75
75
|
type="text"
|
|
76
76
|
value={filter}
|
|
77
77
|
onChange={(e) => setFilter(e.target.value)}
|
|
78
|
-
placeholder=
|
|
78
|
+
placeholder={t('filter_tags')}
|
|
79
79
|
aria-label={t('filter_tags')}
|
|
80
80
|
className="w-full pl-9 pr-8 py-2 text-sm bg-muted/5 border border-muted/15 rounded-lg outline-none focus:border-accent/40 text-foreground placeholder:text-muted/40 transition-colors"
|
|
81
81
|
/>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import type { Heading } from '@/lib/markdown';
|
|
5
|
+
import { useLanguage } from './LanguageProvider';
|
|
6
|
+
import { useActiveHeading } from '@/hooks/useActiveHeading';
|
|
7
|
+
import { scrollToHeading } from '@/lib/scroll-utils';
|
|
8
|
+
|
|
9
|
+
interface TocPanelProps {
|
|
10
|
+
headings: Heading[];
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default function TocPanel({ headings, className = '' }: TocPanelProps) {
|
|
15
|
+
const { t } = useLanguage();
|
|
16
|
+
const [collapsed, setCollapsed] = useState(false);
|
|
17
|
+
const activeHeadingId = useActiveHeading(headings);
|
|
18
|
+
|
|
19
|
+
if (headings.length === 0) return null;
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<nav aria-label={t('on_this_page')} className={className}>
|
|
23
|
+
<div className="flex items-center justify-between mb-3">
|
|
24
|
+
<span className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted">
|
|
25
|
+
{t('on_this_page')}
|
|
26
|
+
</span>
|
|
27
|
+
<button
|
|
28
|
+
type="button"
|
|
29
|
+
onClick={() => setCollapsed(prev => !prev)}
|
|
30
|
+
className="text-muted hover:text-foreground transition-colors"
|
|
31
|
+
aria-expanded={!collapsed}
|
|
32
|
+
aria-label={collapsed ? t('toc_expand') : t('toc_collapse')}
|
|
33
|
+
>
|
|
34
|
+
<svg
|
|
35
|
+
className={`w-3.5 h-3.5 transition-transform duration-200 ${collapsed ? '' : 'rotate-180'}`}
|
|
36
|
+
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
|
|
37
|
+
>
|
|
38
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
|
39
|
+
</svg>
|
|
40
|
+
</button>
|
|
41
|
+
</div>
|
|
42
|
+
{!collapsed && (
|
|
43
|
+
<ul className="space-y-0.5 border-l border-muted/15 animate-slide-down">
|
|
44
|
+
{headings.map(h => {
|
|
45
|
+
const isActive = h.id === activeHeadingId;
|
|
46
|
+
return (
|
|
47
|
+
<li key={h.id}>
|
|
48
|
+
<a
|
|
49
|
+
href={`#${h.id}`}
|
|
50
|
+
onClick={e => scrollToHeading(e, h.id)}
|
|
51
|
+
className={`block py-1 text-[13px] leading-snug no-underline transition-colors duration-200 ${
|
|
52
|
+
isActive
|
|
53
|
+
? 'text-accent font-medium border-l-2 border-accent -ml-px'
|
|
54
|
+
: 'text-foreground/70 hover:text-foreground'
|
|
55
|
+
}`}
|
|
56
|
+
style={{ paddingLeft: `${(h.level - 1) * 0.75}rem` }}
|
|
57
|
+
>
|
|
58
|
+
{h.text}
|
|
59
|
+
</a>
|
|
60
|
+
</li>
|
|
61
|
+
);
|
|
62
|
+
})}
|
|
63
|
+
</ul>
|
|
64
|
+
)}
|
|
65
|
+
</nav>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
import type { Heading } from '@/lib/markdown';
|
|
5
|
+
import { useScrollY } from './useScrollY';
|
|
6
|
+
|
|
7
|
+
export function useActiveHeading(headings: Heading[], enabled = true): string {
|
|
8
|
+
const [activeId, setActiveId] = useState('');
|
|
9
|
+
const scrollY = useScrollY();
|
|
10
|
+
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
if (!enabled || headings.length === 0) return;
|
|
13
|
+
|
|
14
|
+
const elements = headings
|
|
15
|
+
.map(h => document.getElementById(h.id))
|
|
16
|
+
.filter(Boolean) as HTMLElement[];
|
|
17
|
+
|
|
18
|
+
if (elements.length === 0) return;
|
|
19
|
+
|
|
20
|
+
const scrollPosition = scrollY + 100;
|
|
21
|
+
let current = elements[0];
|
|
22
|
+
for (const el of elements) {
|
|
23
|
+
if (el.offsetTop <= scrollPosition) current = el;
|
|
24
|
+
else break;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const rafId = requestAnimationFrame(() => { if (current) setActiveId(current.id); });
|
|
28
|
+
return () => cancelAnimationFrame(rafId);
|
|
29
|
+
}, [scrollY, headings, enabled]);
|
|
30
|
+
|
|
31
|
+
return activeId;
|
|
32
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, type RefObject } from 'react';
|
|
4
|
+
|
|
5
|
+
export function useSidebarAutoScroll(
|
|
6
|
+
sidebarRef: RefObject<HTMLElement | null>,
|
|
7
|
+
itemRef: RefObject<HTMLElement | null>,
|
|
8
|
+
dep: unknown,
|
|
9
|
+
): void {
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
if (itemRef.current && sidebarRef.current) {
|
|
12
|
+
const item = itemRef.current;
|
|
13
|
+
const sidebar = sidebarRef.current;
|
|
14
|
+
sidebar.scrollTop = item.offsetTop - sidebar.clientHeight / 2 + item.offsetHeight / 2;
|
|
15
|
+
}
|
|
16
|
+
// refs are stable; only re-run when dep changes
|
|
17
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
18
|
+
}, [dep]);
|
|
19
|
+
}
|
package/src/i18n/translations.ts
CHANGED
|
@@ -39,8 +39,13 @@ export const translations = {
|
|
|
39
39
|
page_of_total: "Page {page} of {total}",
|
|
40
40
|
series_subtitle: "{count} collections of curated knowledge.",
|
|
41
41
|
series_subtitle_one: "1 collection of curated knowledge.",
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
series_default_excerpt: "A growing collection of related thoughts.",
|
|
43
|
+
shuffle_series: "Shuffle series",
|
|
44
|
+
shuffle_posts: "Shuffle featured stories",
|
|
45
|
+
books_subtitle: "{count} long-form books and structured guides.",
|
|
46
|
+
books_subtitle_one: "1 long-form book and structured guide.",
|
|
47
|
+
tags_subtitle: "{count} topics spanning all articles and notes.",
|
|
48
|
+
tags_subtitle_one: "1 topic spanning all articles and notes.",
|
|
44
49
|
tag_posts_found: "{count} articles found.",
|
|
45
50
|
tag_posts_found_one: "1 article found.",
|
|
46
51
|
about_title: "About Amytis",
|
|
@@ -61,6 +66,7 @@ export const translations = {
|
|
|
61
66
|
flow: "Flow",
|
|
62
67
|
recent_notes: "Recent Notes",
|
|
63
68
|
all_flows: "All Flows",
|
|
69
|
+
no_posts: "No posts found.",
|
|
64
70
|
no_flows: "No notes yet.",
|
|
65
71
|
flow_subtitle: "{count} daily notes.",
|
|
66
72
|
flows_in_year: "Notes in {year}",
|
|
@@ -130,6 +136,11 @@ export const translations = {
|
|
|
130
136
|
browser_outdated: "Your browser is outdated and may not display this site correctly.",
|
|
131
137
|
browser_update: "Update your browser",
|
|
132
138
|
browser_dismiss: "Dismiss",
|
|
139
|
+
toc_expand: "Expand table of contents",
|
|
140
|
+
toc_collapse: "Collapse table of contents",
|
|
141
|
+
archive_description: "A complete chronological archive of all articles.",
|
|
142
|
+
tags_description: "Explore topics spanning all articles and flow notes.",
|
|
143
|
+
posts_description: "Browse all articles.",
|
|
133
144
|
},
|
|
134
145
|
zh: {
|
|
135
146
|
home: "首页",
|
|
@@ -171,8 +182,13 @@ export const translations = {
|
|
|
171
182
|
page_of_total: "第 {page} 页,共 {total} 页",
|
|
172
183
|
series_subtitle: "{count} 个精选知识合集。",
|
|
173
184
|
series_subtitle_one: "1 个精选知识合集。",
|
|
174
|
-
|
|
175
|
-
|
|
185
|
+
series_default_excerpt: "一个持续更新的文章合集。",
|
|
186
|
+
shuffle_series: "随机排列系列",
|
|
187
|
+
shuffle_posts: "随机排列精选文章",
|
|
188
|
+
books_subtitle: "共 {count} 部长篇书籍与结构化指南。",
|
|
189
|
+
books_subtitle_one: "1 部长篇书籍与结构化指南。",
|
|
190
|
+
tags_subtitle: "共 {count} 个主题,横跨全部文章与随笔。",
|
|
191
|
+
tags_subtitle_one: "1 个主题,横跨全部文章与随笔。",
|
|
176
192
|
tag_posts_found: "找到 {count} 篇文章。",
|
|
177
193
|
tag_posts_found_one: "找到 1 篇文章。",
|
|
178
194
|
about_title: "关于 Amytis",
|
|
@@ -193,6 +209,7 @@ export const translations = {
|
|
|
193
209
|
flow: "随笔",
|
|
194
210
|
recent_notes: "近期随笔",
|
|
195
211
|
all_flows: "全部随笔",
|
|
212
|
+
no_posts: "暂无文章。",
|
|
196
213
|
no_flows: "暂无随笔。",
|
|
197
214
|
flow_subtitle: "共 {count} 条随笔。",
|
|
198
215
|
flows_in_year: "{year} 年随笔",
|
|
@@ -262,6 +279,11 @@ export const translations = {
|
|
|
262
279
|
browser_outdated: "您的浏览器版本过旧,可能无法正常显示本站内容。",
|
|
263
280
|
browser_update: "更新浏览器",
|
|
264
281
|
browser_dismiss: "关闭",
|
|
282
|
+
toc_expand: "展开目录",
|
|
283
|
+
toc_collapse: "收起目录",
|
|
284
|
+
archive_description: "全部文章的时间轴归档。",
|
|
285
|
+
tags_description: "浏览全部文章与随笔的主题标签。",
|
|
286
|
+
posts_description: "浏览全部文章。",
|
|
265
287
|
},
|
|
266
288
|
};
|
|
267
289
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import Link from 'next/link';
|
|
2
1
|
import { BookData, BookChapterData } from '@/lib/markdown';
|
|
3
2
|
import MarkdownRenderer from '@/components/MarkdownRenderer';
|
|
4
3
|
import BookSidebar from '@/components/BookSidebar';
|
|
5
4
|
import BookMobileNav from '@/components/BookMobileNav';
|
|
5
|
+
import PrevNextNav from '@/components/PrevNextNav';
|
|
6
6
|
import ReadingProgressBar from '@/components/ReadingProgressBar';
|
|
7
7
|
import { t } from '@/lib/i18n';
|
|
8
|
+
import { getBookChapterUrl } from '@/lib/urls';
|
|
8
9
|
|
|
9
10
|
interface BookLayoutProps {
|
|
10
11
|
book: BookData;
|
|
@@ -27,7 +28,7 @@ export default function BookLayout({ book, chapter }: BookLayoutProps) {
|
|
|
27
28
|
/>
|
|
28
29
|
|
|
29
30
|
{/* Main content */}
|
|
30
|
-
<article className="min-w-0 max-w-3xl">
|
|
31
|
+
<article className="min-w-0 w-full max-w-3xl overflow-x-hidden">
|
|
31
32
|
{/* Mobile nav */}
|
|
32
33
|
<div className="lg:hidden mb-8">
|
|
33
34
|
<BookMobileNav
|
|
@@ -64,44 +65,13 @@ export default function BookLayout({ book, chapter }: BookLayoutProps) {
|
|
|
64
65
|
<MarkdownRenderer content={chapter.content} latex={chapter.latex} slug={chapter.isFolder ? `books/${book.slug}/${chapter.slug}` : `books/${book.slug}`} />
|
|
65
66
|
|
|
66
67
|
{/* Prev/Next navigation */}
|
|
67
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
|
75
|
-
</svg>
|
|
76
|
-
<div className="min-w-0">
|
|
77
|
-
<span className="block text-[10px] font-sans font-bold uppercase tracking-widest text-muted mb-1">{t('prev')}</span>
|
|
78
|
-
<span className="block text-sm font-medium text-foreground/80 group-hover:text-foreground truncate transition-colors">
|
|
79
|
-
{chapter.prevChapter.title}
|
|
80
|
-
</span>
|
|
81
|
-
</div>
|
|
82
|
-
</Link>
|
|
83
|
-
) : (
|
|
84
|
-
<div className="flex-1" />
|
|
85
|
-
)}
|
|
86
|
-
{chapter.nextChapter ? (
|
|
87
|
-
<Link
|
|
88
|
-
href={`/books/${book.slug}/${chapter.nextChapter.id}`}
|
|
89
|
-
className="flex-1 group flex items-center justify-end gap-3 py-4 px-5 rounded-xl bg-muted/5 hover:bg-muted/10 no-underline transition-colors text-right"
|
|
90
|
-
>
|
|
91
|
-
<div className="min-w-0">
|
|
92
|
-
<span className="block text-[10px] font-sans font-bold uppercase tracking-widest text-muted mb-1">{t('next')}</span>
|
|
93
|
-
<span className="block text-sm font-medium text-foreground/80 group-hover:text-foreground truncate transition-colors">
|
|
94
|
-
{chapter.nextChapter.title}
|
|
95
|
-
</span>
|
|
96
|
-
</div>
|
|
97
|
-
<svg className="w-5 h-5 flex-shrink-0 text-muted group-hover:text-accent transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
98
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
|
99
|
-
</svg>
|
|
100
|
-
</Link>
|
|
101
|
-
) : (
|
|
102
|
-
<div className="flex-1" />
|
|
103
|
-
)}
|
|
104
|
-
</nav>
|
|
68
|
+
<div className="mt-16 pt-8 border-t border-muted/10">
|
|
69
|
+
<PrevNextNav
|
|
70
|
+
prev={chapter.prevChapter ? { href: getBookChapterUrl(book.slug, chapter.prevChapter.id), title: chapter.prevChapter.title } : null}
|
|
71
|
+
next={chapter.nextChapter ? { href: getBookChapterUrl(book.slug, chapter.nextChapter.id), title: chapter.nextChapter.title } : null}
|
|
72
|
+
size="lg"
|
|
73
|
+
/>
|
|
74
|
+
</div>
|
|
105
75
|
</article>
|
|
106
76
|
</div>
|
|
107
77
|
</div>
|
|
@@ -14,6 +14,7 @@ import AuthorCard from '@/components/AuthorCard';
|
|
|
14
14
|
import ShareBar from '@/components/ShareBar';
|
|
15
15
|
import { siteConfig } from '../../site.config';
|
|
16
16
|
import { t } from '@/lib/i18n';
|
|
17
|
+
import { getPostUrl } from '@/lib/urls';
|
|
17
18
|
|
|
18
19
|
interface PostLayoutProps {
|
|
19
20
|
post: PostData;
|
|
@@ -30,7 +31,7 @@ export default function PostLayout({ post, relatedPosts, seriesPosts, seriesTitl
|
|
|
30
31
|
const showToc = siteConfig.posts?.toc !== false && post.toc !== false && post.headings && post.headings.length > 0;
|
|
31
32
|
const hasSeries = !!(post.series && seriesPosts && seriesPosts.length > 0);
|
|
32
33
|
const showSidebar = showToc || hasSeries;
|
|
33
|
-
const postUrl = `${siteConfig.baseUrl}
|
|
34
|
+
const postUrl = `${siteConfig.baseUrl}${getPostUrl(post)}`;
|
|
34
35
|
|
|
35
36
|
return (
|
|
36
37
|
<div className="layout-container">
|
|
@@ -52,7 +53,7 @@ export default function PostLayout({ post, relatedPosts, seriesPosts, seriesTitl
|
|
|
52
53
|
/>
|
|
53
54
|
)}
|
|
54
55
|
|
|
55
|
-
<article className="min-w-0 max-w-3xl mx-auto">
|
|
56
|
+
<article className="min-w-0 w-full max-w-3xl mx-auto overflow-x-hidden">
|
|
56
57
|
<header className="mb-16 border-b border-muted/10 pb-8">
|
|
57
58
|
{post.draft && (
|
|
58
59
|
<div className="mb-4">
|
|
@@ -71,26 +72,34 @@ export default function PostLayout({ post, relatedPosts, seriesPosts, seriesTitl
|
|
|
71
72
|
<span className="font-mono">{post.readingTime}</span>
|
|
72
73
|
</div>
|
|
73
74
|
|
|
74
|
-
<h1 className="text-4xl md:text-5xl font-serif font-bold text-heading leading-tight mb-
|
|
75
|
+
<h1 className="text-4xl md:text-5xl font-serif font-bold text-heading leading-tight mb-4">
|
|
75
76
|
{post.title}
|
|
76
77
|
</h1>
|
|
77
78
|
|
|
78
|
-
|
|
79
|
-
<
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
79
|
+
{post.subtitle && (
|
|
80
|
+
<p className="text-xl md:text-2xl font-serif italic text-muted leading-snug mb-6">
|
|
81
|
+
{post.subtitle}
|
|
82
|
+
</p>
|
|
83
|
+
)}
|
|
84
|
+
|
|
85
|
+
{siteConfig.posts?.authors?.showInHeader !== false && post.authors.length > 0 && (
|
|
86
|
+
<div className="flex items-center gap-2 mb-8 text-sm font-serif italic text-muted">
|
|
87
|
+
<span>{t('written_by')}</span>
|
|
88
|
+
<div className="flex items-center gap-1">
|
|
89
|
+
{post.authors.map((author, index) => (
|
|
90
|
+
<span key={author} className="flex items-center">
|
|
91
|
+
<Link
|
|
92
|
+
href={`/authors/${getAuthorSlug(author)}`}
|
|
93
|
+
className="text-foreground hover:text-accent no-underline transition-colors duration-200"
|
|
94
|
+
>
|
|
95
|
+
{author}
|
|
96
|
+
</Link>
|
|
97
|
+
{index < post.authors.length - 1 && <span className="mr-1">,</span>}
|
|
98
|
+
</span>
|
|
99
|
+
))}
|
|
100
|
+
</div>
|
|
92
101
|
</div>
|
|
93
|
-
|
|
102
|
+
)}
|
|
94
103
|
|
|
95
104
|
{post.excerpt && (
|
|
96
105
|
<p className="text-xl text-foreground font-serif italic leading-relaxed mb-8">
|
|
@@ -113,7 +122,11 @@ export default function PostLayout({ post, relatedPosts, seriesPosts, seriesTitl
|
|
|
113
122
|
</div>
|
|
114
123
|
)}
|
|
115
124
|
|
|
116
|
-
<MarkdownRenderer content={post.content} latex={post.latex} slug={post.slug} slugRegistry={slugRegistry} />
|
|
125
|
+
<MarkdownRenderer content={post.content} latex={post.latex} slug={`posts/${post.slug}`} slugRegistry={slugRegistry} />
|
|
126
|
+
|
|
127
|
+
{siteConfig.posts?.authors?.showAuthorCard !== false && (
|
|
128
|
+
<AuthorCard authors={post.authors} />
|
|
129
|
+
)}
|
|
117
130
|
|
|
118
131
|
{post.tags && post.tags.length > 0 && (
|
|
119
132
|
<div className="mt-12 pt-12 border-t border-muted/20 flex flex-wrap items-center gap-2">
|
|
@@ -124,10 +137,6 @@ export default function PostLayout({ post, relatedPosts, seriesPosts, seriesTitl
|
|
|
124
137
|
</div>
|
|
125
138
|
)}
|
|
126
139
|
|
|
127
|
-
{post.externalLinks && post.externalLinks.length > 0 && (
|
|
128
|
-
<ExternalLinks links={post.externalLinks} />
|
|
129
|
-
)}
|
|
130
|
-
|
|
131
140
|
<Backlinks backlinks={backlinks ?? []} />
|
|
132
141
|
|
|
133
142
|
<ShareBar
|
|
@@ -136,11 +145,13 @@ export default function PostLayout({ post, relatedPosts, seriesPosts, seriesTitl
|
|
|
136
145
|
className={showSidebar ? 'mt-8 lg:hidden' : 'mt-8'}
|
|
137
146
|
/>
|
|
138
147
|
|
|
139
|
-
<
|
|
148
|
+
<Comments slug={post.slug} postUrl={postUrl} />
|
|
140
149
|
|
|
141
|
-
|
|
150
|
+
{post.externalLinks && post.externalLinks.length > 0 && (
|
|
151
|
+
<ExternalLinks links={post.externalLinks} />
|
|
152
|
+
)}
|
|
142
153
|
|
|
143
|
-
<
|
|
154
|
+
<PostNavigation prev={prevPost ?? null} next={nextPost ?? null} />
|
|
144
155
|
|
|
145
156
|
<RelatedPosts posts={relatedPosts || []} />
|
|
146
157
|
</article>
|
|
@@ -36,16 +36,16 @@ export default function SimpleLayout({ post, titleKey, subtitleKey }: SimpleLayo
|
|
|
36
36
|
{localeEntries.length > 0 ? (
|
|
37
37
|
<LocaleSwitch>
|
|
38
38
|
<div data-locale={defaultLocale}>
|
|
39
|
-
<MarkdownRenderer content={post.content} latex={post.latex} slug={post.slug} />
|
|
39
|
+
<MarkdownRenderer content={post.content} latex={post.latex} slug={`posts/${post.slug}`} />
|
|
40
40
|
</div>
|
|
41
41
|
{localeEntries.map(([locale, data]) => (
|
|
42
42
|
<div key={locale} data-locale={locale} style={{ display: 'none' }}>
|
|
43
|
-
<MarkdownRenderer content={data.content} latex={post.latex} slug={post.slug} />
|
|
43
|
+
<MarkdownRenderer content={data.content} latex={post.latex} slug={`posts/${post.slug}`} />
|
|
44
44
|
</div>
|
|
45
45
|
))}
|
|
46
46
|
</LocaleSwitch>
|
|
47
47
|
) : (
|
|
48
|
-
<MarkdownRenderer content={post.content} latex={post.latex} slug={post.slug} />
|
|
48
|
+
<MarkdownRenderer content={post.content} latex={post.latex} slug={`posts/${post.slug}`} />
|
|
49
49
|
)}
|
|
50
50
|
</>
|
|
51
51
|
);
|
|
@@ -55,12 +55,12 @@ export default function SimpleLayout({ post, titleKey, subtitleKey }: SimpleLayo
|
|
|
55
55
|
{showToc ? (
|
|
56
56
|
<div className="grid grid-cols-1 lg:grid-cols-[280px_minmax(0,1fr)] gap-8 items-start">
|
|
57
57
|
<PostSidebar currentSlug={post.slug} headings={post.headings} localeHeadings={localeHeadings} />
|
|
58
|
-
<article className="min-w-0 max-w-3xl">
|
|
58
|
+
<article className="min-w-0 w-full max-w-3xl overflow-x-hidden">
|
|
59
59
|
{articleContent}
|
|
60
60
|
</article>
|
|
61
61
|
</div>
|
|
62
62
|
) : (
|
|
63
|
-
<article className="max-w-3xl mx-auto">
|
|
63
|
+
<article className="w-full max-w-3xl mx-auto overflow-x-hidden">
|
|
64
64
|
{articleContent}
|
|
65
65
|
</article>
|
|
66
66
|
)}
|