@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
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useState, useMemo, type ReactNode } from 'react';
|
|
4
4
|
import Link from 'next/link';
|
|
5
5
|
import { useLanguage } from '@/components/LanguageProvider';
|
|
6
|
+
import { padNumber } from '@/lib/format-utils';
|
|
6
7
|
|
|
7
8
|
interface FlowCalendarSidebarProps {
|
|
8
9
|
entryDates: string[];
|
|
@@ -14,6 +15,7 @@ interface FlowCalendarSidebarProps {
|
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
const WEEKDAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
|
|
18
|
+
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
17
19
|
|
|
18
20
|
export default function FlowCalendarSidebar({ entryDates, currentDate, tags, selectedTag, onTagSelect, breadcrumb }: FlowCalendarSidebarProps) {
|
|
19
21
|
const { t } = useLanguage();
|
|
@@ -111,7 +113,7 @@ export default function FlowCalendarSidebar({ entryDates, currentDate, tags, sel
|
|
|
111
113
|
{days.map((day, i) => {
|
|
112
114
|
if (day === null) return <div key={`empty-${i}`} />;
|
|
113
115
|
|
|
114
|
-
const dateStr = `${viewYear}-${
|
|
116
|
+
const dateStr = `${viewYear}-${padNumber(viewMonth + 1)}-${padNumber(day)}`;
|
|
115
117
|
const hasEntry = entrySet.has(dateStr);
|
|
116
118
|
const isToday = dateStr === todayStr;
|
|
117
119
|
const isCurrent = dateStr === currentDate;
|
|
@@ -122,7 +124,7 @@ export default function FlowCalendarSidebar({ entryDates, currentDate, tags, sel
|
|
|
122
124
|
return (
|
|
123
125
|
<Link
|
|
124
126
|
key={dateStr}
|
|
125
|
-
href={`/flows/${viewYear}/${
|
|
127
|
+
href={`/flows/${viewYear}/${padNumber(viewMonth + 1)}/${padNumber(day)}`}
|
|
126
128
|
className={`${baseClasses} bg-accent text-white font-bold no-underline`}
|
|
127
129
|
>
|
|
128
130
|
{day}
|
|
@@ -134,7 +136,7 @@ export default function FlowCalendarSidebar({ entryDates, currentDate, tags, sel
|
|
|
134
136
|
return (
|
|
135
137
|
<Link
|
|
136
138
|
key={dateStr}
|
|
137
|
-
href={`/flows/${viewYear}/${
|
|
139
|
+
href={`/flows/${viewYear}/${padNumber(viewMonth + 1)}/${padNumber(day)}`}
|
|
138
140
|
className={`${baseClasses} text-foreground hover:bg-accent/10 font-medium no-underline ${isToday ? 'ring-1 ring-accent' : ''}`}
|
|
139
141
|
>
|
|
140
142
|
{day}
|
|
@@ -203,12 +205,12 @@ export default function FlowCalendarSidebar({ entryDates, currentDate, tags, sel
|
|
|
203
205
|
return (
|
|
204
206
|
<Link
|
|
205
207
|
key={m}
|
|
206
|
-
href={`/flows/${year}/${
|
|
208
|
+
href={`/flows/${year}/${padNumber(m)}`}
|
|
207
209
|
className={`flex items-center justify-between text-xs no-underline px-1 py-0.5 rounded hover:bg-accent/10 ${
|
|
208
210
|
isCurrentMonth ? 'text-accent font-medium' : 'text-muted'
|
|
209
211
|
}`}
|
|
210
212
|
>
|
|
211
|
-
<span>{
|
|
213
|
+
<span>{MONTH_NAMES[m - 1]}</span>
|
|
212
214
|
<span className="text-[10px]">{months[m]}</span>
|
|
213
215
|
</Link>
|
|
214
216
|
);
|
|
@@ -230,17 +232,18 @@ export default function FlowCalendarSidebar({ entryDates, currentDate, tags, sel
|
|
|
230
232
|
<div className="flex flex-wrap gap-1.5">
|
|
231
233
|
{Object.entries(tags)
|
|
232
234
|
.sort((a, b) => b[1] - a[1])
|
|
233
|
-
.map(([tag]) => (
|
|
235
|
+
.map(([tag, count]) => (
|
|
234
236
|
<button
|
|
235
237
|
key={tag}
|
|
236
238
|
onClick={() => onTagSelect?.(tag)}
|
|
237
|
-
className={`inline-flex items-center px-2 py-0.5 text-xs rounded-full border transition-colors ${
|
|
239
|
+
className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full border transition-colors ${
|
|
238
240
|
selectedTag === tag
|
|
239
241
|
? 'bg-accent text-white border-accent'
|
|
240
242
|
: 'border-muted/20 text-muted hover:border-accent hover:text-accent'
|
|
241
243
|
}`}
|
|
242
244
|
>
|
|
243
245
|
{tag}
|
|
246
|
+
<span className={`text-[10px] ${selectedTag === tag ? 'opacity-80' : 'opacity-60'}`}>{count}</span>
|
|
244
247
|
</button>
|
|
245
248
|
))}
|
|
246
249
|
</div>
|
|
@@ -16,6 +16,7 @@ interface FlowItem {
|
|
|
16
16
|
|
|
17
17
|
interface FlowContentProps {
|
|
18
18
|
flows: FlowItem[];
|
|
19
|
+
allFlows?: FlowItem[];
|
|
19
20
|
entryDates: string[];
|
|
20
21
|
tags: Record<string, number>;
|
|
21
22
|
currentDate?: string;
|
|
@@ -27,19 +28,22 @@ interface FlowContentProps {
|
|
|
27
28
|
breadcrumb?: ReactNode;
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
export default function FlowContent({ flows, entryDates, tags, currentDate, pagination, breadcrumb }: FlowContentProps) {
|
|
31
|
+
export default function FlowContent({ flows, allFlows, entryDates, tags, currentDate, pagination, breadcrumb }: FlowContentProps) {
|
|
31
32
|
const { t } = useLanguage();
|
|
32
33
|
const [selectedTag, setSelectedTag] = useState<string | null>(null);
|
|
33
34
|
|
|
34
35
|
const filteredFlows = useMemo(() => {
|
|
35
36
|
if (!selectedTag) return flows;
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
const source = allFlows ?? flows;
|
|
38
|
+
return source.filter(f => f.tags.includes(selectedTag));
|
|
39
|
+
}, [flows, allFlows, selectedTag]);
|
|
38
40
|
|
|
39
41
|
function handleTagSelect(tag: string) {
|
|
40
42
|
setSelectedTag(prev => (prev === tag ? null : tag));
|
|
41
43
|
}
|
|
42
44
|
|
|
45
|
+
const hasTags = tags && Object.keys(tags).length > 0;
|
|
46
|
+
|
|
43
47
|
return (
|
|
44
48
|
<div className="flex gap-10">
|
|
45
49
|
<FlowCalendarSidebar
|
|
@@ -52,10 +56,34 @@ export default function FlowContent({ flows, entryDates, tags, currentDate, pagi
|
|
|
52
56
|
/>
|
|
53
57
|
|
|
54
58
|
<div className="flex-1 min-w-0">
|
|
59
|
+
{/* Mobile tag filter strip */}
|
|
60
|
+
{hasTags && (
|
|
61
|
+
<div className="lg:hidden mb-4">
|
|
62
|
+
<div className="flex items-center gap-2 overflow-x-auto pb-1 scrollbar-hide">
|
|
63
|
+
{Object.entries(tags)
|
|
64
|
+
.sort((a, b) => b[1] - a[1])
|
|
65
|
+
.map(([tag, count]) => (
|
|
66
|
+
<button
|
|
67
|
+
key={tag}
|
|
68
|
+
onClick={() => handleTagSelect(tag)}
|
|
69
|
+
className={`shrink-0 inline-flex items-center gap-1 px-2.5 py-1 text-xs rounded-full border transition-colors ${
|
|
70
|
+
selectedTag === tag
|
|
71
|
+
? 'bg-accent text-white border-accent'
|
|
72
|
+
: 'border-muted/20 text-muted hover:border-accent hover:text-accent'
|
|
73
|
+
}`}
|
|
74
|
+
>
|
|
75
|
+
{tag}
|
|
76
|
+
<span className={`text-[10px] ${selectedTag === tag ? 'opacity-80' : 'opacity-60'}`}>{count}</span>
|
|
77
|
+
</button>
|
|
78
|
+
))}
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
)}
|
|
82
|
+
|
|
55
83
|
{selectedTag && (
|
|
56
84
|
<div className="flex items-center gap-2 mb-4 text-sm text-muted">
|
|
57
85
|
<span>
|
|
58
|
-
{filteredFlows.length} / {flows.length}
|
|
86
|
+
{filteredFlows.length} / {(allFlows ?? flows).length}
|
|
59
87
|
</span>
|
|
60
88
|
<button
|
|
61
89
|
onClick={() => setSelectedTag(null)}
|
|
@@ -82,7 +110,7 @@ export default function FlowContent({ flows, entryDates, tags, currentDate, pagi
|
|
|
82
110
|
</div>
|
|
83
111
|
)}
|
|
84
112
|
|
|
85
|
-
{pagination && pagination.totalPages > 1 && (
|
|
113
|
+
{!selectedTag && pagination && pagination.totalPages > 1 && (
|
|
86
114
|
<div className="mt-12">
|
|
87
115
|
<Pagination
|
|
88
116
|
currentPage={pagination.currentPage}
|
|
@@ -12,10 +12,10 @@ export default function Footer() {
|
|
|
12
12
|
|
|
13
13
|
return (
|
|
14
14
|
<footer className="bg-muted/5 border-t border-muted/10 mt-auto">
|
|
15
|
-
<div className="max-w-6xl mx-auto px-6 py-16">
|
|
16
|
-
<div className="grid grid-cols-
|
|
15
|
+
<div className="max-w-6xl mx-auto px-6 py-10 lg:py-16">
|
|
16
|
+
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8 lg:gap-12 mb-10 lg:mb-12">
|
|
17
17
|
{/* Brand */}
|
|
18
|
-
<div className="lg:col-span-2">
|
|
18
|
+
<div className="col-span-2 lg:col-span-2">
|
|
19
19
|
<Link href="/" className="flex items-center gap-2 mb-4 group no-underline">
|
|
20
20
|
<svg
|
|
21
21
|
viewBox="0 0 32 32"
|
|
@@ -89,10 +89,32 @@ export default function Footer() {
|
|
|
89
89
|
{/* Bottom Bar */}
|
|
90
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">
|
|
91
91
|
<span>{resolveLocaleValue(siteConfig.footerText, language)}</span>
|
|
92
|
-
<div className="flex items-center gap-6">
|
|
93
|
-
|
|
94
|
-
|
|
92
|
+
<div className="flex flex-wrap items-center gap-x-6 gap-y-2">
|
|
93
|
+
{siteConfig.i18n.enabled !== false && siteConfig.i18n.locales.length >= 2 && (
|
|
94
|
+
<>
|
|
95
|
+
<LanguageSwitch variant="text" />
|
|
96
|
+
<span className="opacity-20">|</span>
|
|
97
|
+
</>
|
|
98
|
+
)}
|
|
95
99
|
<Link href="/privacy" className="hover:text-foreground transition-colors no-underline">{t('privacy')}</Link>
|
|
100
|
+
{siteConfig.footer?.bottomLinks?.map((item, index) => {
|
|
101
|
+
const label = resolveLocaleValue(item.text, language);
|
|
102
|
+
const isInternal = item.url?.startsWith('/');
|
|
103
|
+
return (
|
|
104
|
+
<span key={`${index}:${item.url ?? label}`} className="flex items-center gap-x-6">
|
|
105
|
+
<span className="opacity-20">|</span>
|
|
106
|
+
{item.url ? (
|
|
107
|
+
isInternal ? (
|
|
108
|
+
<Link href={item.url} className="hover:text-foreground transition-colors no-underline">{label}</Link>
|
|
109
|
+
) : (
|
|
110
|
+
<a href={item.url} target="_blank" rel="noopener noreferrer" className="hover:text-foreground transition-colors no-underline">{label}</a>
|
|
111
|
+
)
|
|
112
|
+
) : (
|
|
113
|
+
<span>{label}</span>
|
|
114
|
+
)}
|
|
115
|
+
</span>
|
|
116
|
+
);
|
|
117
|
+
})}
|
|
96
118
|
{siteConfig.footer?.builtWith?.show && (() => {
|
|
97
119
|
const cfg = siteConfig.footer.builtWith;
|
|
98
120
|
const label = cfg.text ? resolveLocaleValue(cfg.text, language) : t('built_with');
|
package/src/components/Hero.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import Link from 'next/link';
|
|
4
4
|
import { useLanguage } from '@/components/LanguageProvider';
|
|
5
5
|
import { resolveLocaleValue } from '@/lib/i18n';
|
|
6
6
|
|
|
@@ -10,92 +10,49 @@ interface HeroProps {
|
|
|
10
10
|
tagline: LocaleValue;
|
|
11
11
|
title: LocaleValue;
|
|
12
12
|
subtitle: LocaleValue;
|
|
13
|
+
postCount?: number;
|
|
14
|
+
seriesCount?: number;
|
|
15
|
+
bookCount?: number;
|
|
16
|
+
flowCount?: number;
|
|
17
|
+
featureNames?: {
|
|
18
|
+
flow?: LocaleValue;
|
|
19
|
+
posts?: LocaleValue;
|
|
20
|
+
series?: LocaleValue;
|
|
21
|
+
books?: LocaleValue;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface StatItem {
|
|
26
|
+
href: string;
|
|
27
|
+
count: number;
|
|
28
|
+
label: string;
|
|
13
29
|
}
|
|
14
30
|
|
|
15
|
-
export default function Hero({ tagline, title, subtitle }: HeroProps) {
|
|
31
|
+
export default function Hero({ tagline, title, subtitle, postCount, seriesCount, bookCount, flowCount, featureNames }: HeroProps) {
|
|
16
32
|
const { language } = useLanguage();
|
|
17
33
|
const resolvedTagline = resolveLocaleValue(tagline, language);
|
|
18
34
|
const resolvedTitle = resolveLocaleValue(title, language);
|
|
19
35
|
const resolvedSubtitle = resolveLocaleValue(subtitle, language);
|
|
20
|
-
const [isVisible, setIsVisible] = useState<boolean | null>(null);
|
|
21
|
-
const visibleRef = useRef(false);
|
|
22
|
-
|
|
23
|
-
useEffect(() => {
|
|
24
|
-
const saved = localStorage.getItem('amytis-hero-visible');
|
|
25
|
-
const visible = saved !== 'false';
|
|
26
|
-
// Use requestAnimationFrame to avoid cascading render lint error
|
|
27
|
-
const rafId = requestAnimationFrame(() => {
|
|
28
|
-
setIsVisible(visible);
|
|
29
|
-
visibleRef.current = visible;
|
|
30
|
-
});
|
|
31
|
-
return () => cancelAnimationFrame(rafId);
|
|
32
|
-
}, []);
|
|
33
|
-
|
|
34
|
-
// Auto-collapse on scroll or navigation away
|
|
35
|
-
useEffect(() => {
|
|
36
|
-
if (!isVisible) return;
|
|
37
|
-
|
|
38
|
-
const handleScroll = () => {
|
|
39
|
-
if (window.scrollY > 400) {
|
|
40
|
-
setIsVisible(false);
|
|
41
|
-
visibleRef.current = false;
|
|
42
|
-
localStorage.setItem('amytis-hero-visible', 'false');
|
|
43
|
-
}
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
window.addEventListener('scroll', handleScroll, { passive: true });
|
|
47
|
-
return () => {
|
|
48
|
-
window.removeEventListener('scroll', handleScroll);
|
|
49
|
-
// On unmount (navigation away), hide for next visit
|
|
50
|
-
if (visibleRef.current) {
|
|
51
|
-
localStorage.setItem('amytis-hero-visible', 'false');
|
|
52
|
-
}
|
|
53
|
-
};
|
|
54
|
-
}, [isVisible]);
|
|
55
36
|
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
visibleRef.current = false;
|
|
59
|
-
localStorage.setItem('amytis-hero-visible', 'false');
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
const handleExpand = () => {
|
|
63
|
-
setIsVisible(true);
|
|
64
|
-
visibleRef.current = true;
|
|
65
|
-
localStorage.setItem('amytis-hero-visible', 'true');
|
|
66
|
-
};
|
|
37
|
+
const label = (key: keyof NonNullable<typeof featureNames>, fallback: string) =>
|
|
38
|
+
featureNames?.[key] ? resolveLocaleValue(featureNames[key]!, language) : fallback;
|
|
67
39
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
return (
|
|
75
|
-
<div className="py-10">
|
|
76
|
-
<button
|
|
77
|
-
onClick={handleExpand}
|
|
78
|
-
className="mx-auto flex items-center justify-center gap-4 group cursor-pointer"
|
|
79
|
-
>
|
|
80
|
-
<span className="h-px w-12 bg-muted/20 group-hover:bg-accent/40 transition-colors" />
|
|
81
|
-
<span className="text-xs font-sans font-bold uppercase tracking-[0.3em] text-muted/40 group-hover:text-accent/80 transition-colors">
|
|
82
|
-
{resolvedTagline}
|
|
83
|
-
</span>
|
|
84
|
-
<span className="h-px w-12 bg-muted/20 group-hover:bg-accent/40 transition-colors" />
|
|
85
|
-
</button>
|
|
86
|
-
</div>
|
|
87
|
-
);
|
|
88
|
-
}
|
|
40
|
+
const stats = [
|
|
41
|
+
flowCount != null ? { href: '#recent-flows', count: flowCount, label: label('flow', 'Flow') } : null,
|
|
42
|
+
postCount != null ? { href: '#featured-posts', count: postCount, label: label('posts', 'Posts') } : null,
|
|
43
|
+
seriesCount != null ? { href: '#featured-series', count: seriesCount, label: label('series', 'Series') } : null,
|
|
44
|
+
bookCount != null ? { href: '#featured-books', count: bookCount, label: label('books', 'Books') } : null,
|
|
45
|
+
].filter((s): s is StatItem => s !== null);
|
|
89
46
|
|
|
90
47
|
return (
|
|
91
|
-
<header className="relative py-
|
|
48
|
+
<header className="relative py-12 md:py-20 flex flex-col items-center justify-center text-center max-w-6xl mx-auto min-h-[40vh] px-6">
|
|
92
49
|
<div className="mb-8 flex items-center justify-center animate-fade-in">
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
50
|
+
<span className="h-px w-12 bg-accent/30 mr-4"></span>
|
|
51
|
+
<span className="text-xs font-sans font-bold uppercase tracking-[0.3em] text-accent/80">{resolvedTagline}</span>
|
|
52
|
+
<span className="h-px w-12 bg-accent/30 ml-4"></span>
|
|
96
53
|
</div>
|
|
97
54
|
|
|
98
|
-
<h1 className="text-4xl md:text-5xl lg:text-6xl font-serif font-medium text-heading leading-[1.1] tracking-tight mb-
|
|
55
|
+
<h1 className="text-4xl md:text-5xl lg:text-6xl font-serif font-medium text-heading leading-[1.1] tracking-tight mb-6 text-balance animate-slide-up">
|
|
99
56
|
{resolvedTitle}
|
|
100
57
|
</h1>
|
|
101
58
|
|
|
@@ -103,24 +60,19 @@ export default function Hero({ tagline, title, subtitle }: HeroProps) {
|
|
|
103
60
|
{resolvedSubtitle}
|
|
104
61
|
</p>
|
|
105
62
|
|
|
106
|
-
{
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 animate-bounce text-muted/30">
|
|
120
|
-
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
121
|
-
<path d="M7 13l5 5 5-5M7 6l5 5 5-5"/>
|
|
122
|
-
</svg>
|
|
123
|
-
</div>
|
|
63
|
+
{stats.length > 0 && (
|
|
64
|
+
<div className="mt-8 flex flex-wrap justify-center items-center gap-x-1 gap-y-2 text-sm font-mono animate-slide-up animation-delay-200">
|
|
65
|
+
{stats.map((stat, i) => (
|
|
66
|
+
<span key={stat.href} className="inline-flex items-center gap-1">
|
|
67
|
+
{i > 0 && <span className="text-muted/30 mx-2" aria-hidden>·</span>}
|
|
68
|
+
<Link href={stat.href} className="group no-underline">
|
|
69
|
+
<span className="font-semibold text-heading group-hover:text-accent transition-colors duration-200">{stat.count}</span>
|
|
70
|
+
<span className="text-muted ml-1 group-hover:text-accent/70 transition-colors duration-200">{stat.label}</span>
|
|
71
|
+
</Link>
|
|
72
|
+
</span>
|
|
73
|
+
))}
|
|
74
|
+
</div>
|
|
75
|
+
)}
|
|
124
76
|
</header>
|
|
125
77
|
);
|
|
126
78
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
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
|
+
export default function InlineBookToc({ headings }: { headings: Heading[] }) {
|
|
10
|
+
const { t } = useLanguage();
|
|
11
|
+
const [collapsed, setCollapsed] = useState(false);
|
|
12
|
+
const activeHeadingId = useActiveHeading(headings);
|
|
13
|
+
|
|
14
|
+
if (headings.length === 0) return null;
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<div className="mt-1.5 mb-1 ml-3">
|
|
18
|
+
<button
|
|
19
|
+
onClick={() => setCollapsed(prev => !prev)}
|
|
20
|
+
className="flex items-center gap-1.5 text-[11px] font-sans font-medium uppercase tracking-wider text-muted hover:text-foreground transition-colors mb-1.5 pl-3"
|
|
21
|
+
>
|
|
22
|
+
<svg
|
|
23
|
+
className={`w-3 h-3 flex-shrink-0 transition-transform duration-200 ${collapsed ? '' : 'rotate-180'}`}
|
|
24
|
+
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
|
|
25
|
+
>
|
|
26
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
|
27
|
+
</svg>
|
|
28
|
+
{t('on_this_page')}
|
|
29
|
+
</button>
|
|
30
|
+
{!collapsed && (
|
|
31
|
+
<ul className="space-y-0.5 border-l border-muted/15 animate-slide-down">
|
|
32
|
+
{headings.map(h => {
|
|
33
|
+
const isActive = h.id === activeHeadingId;
|
|
34
|
+
return (
|
|
35
|
+
<li key={h.id}>
|
|
36
|
+
<a
|
|
37
|
+
href={`#${h.id}`}
|
|
38
|
+
onClick={e => scrollToHeading(e, h.id)}
|
|
39
|
+
className={`block py-1 text-[13px] leading-snug no-underline transition-colors duration-200 ${
|
|
40
|
+
h.level === 3 ? 'pl-6' : 'pl-3'
|
|
41
|
+
} ${
|
|
42
|
+
isActive
|
|
43
|
+
? 'text-accent font-medium border-l-2 border-accent -ml-px'
|
|
44
|
+
: 'text-foreground/70 hover:text-foreground'
|
|
45
|
+
}`}
|
|
46
|
+
>
|
|
47
|
+
{h.text}
|
|
48
|
+
</a>
|
|
49
|
+
</li>
|
|
50
|
+
);
|
|
51
|
+
})}
|
|
52
|
+
</ul>
|
|
53
|
+
)}
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -217,6 +217,32 @@ export default function KnowledgeGraph() {
|
|
|
217
217
|
node.attr('transform', d => `translate(${d.x ?? 0},${d.y ?? 0})`);
|
|
218
218
|
});
|
|
219
219
|
|
|
220
|
+
// After simulation cools, fit all nodes into view
|
|
221
|
+
simulation.on('end', () => {
|
|
222
|
+
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
|
223
|
+
filteredNodes.forEach(d => {
|
|
224
|
+
const r = nodeRadius(d.connections);
|
|
225
|
+
x0 = Math.min(x0, (d.x ?? 0) - r);
|
|
226
|
+
y0 = Math.min(y0, (d.y ?? 0) - r);
|
|
227
|
+
x1 = Math.max(x1, (d.x ?? 0) + r);
|
|
228
|
+
y1 = Math.max(y1, (d.y ?? 0) + r);
|
|
229
|
+
});
|
|
230
|
+
if (!isFinite(x0) || x1 <= x0 || y1 <= y0) return;
|
|
231
|
+
|
|
232
|
+
const padding = 40;
|
|
233
|
+
const scale = Math.min(
|
|
234
|
+
(width - padding * 2) / (x1 - x0),
|
|
235
|
+
(height - padding * 2) / (y1 - y0)
|
|
236
|
+
);
|
|
237
|
+
const cx = (x0 + x1) / 2;
|
|
238
|
+
const cy = (y0 + y1) / 2;
|
|
239
|
+
const tx = width / 2 - scale * cx;
|
|
240
|
+
const ty = height / 2 - scale * cy;
|
|
241
|
+
|
|
242
|
+
svgEl.transition().duration(600)
|
|
243
|
+
.call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(scale));
|
|
244
|
+
});
|
|
245
|
+
|
|
220
246
|
return () => {
|
|
221
247
|
simulation.stop();
|
|
222
248
|
tooltip.remove();
|
|
@@ -15,15 +15,19 @@ interface LanguageContextType {
|
|
|
15
15
|
|
|
16
16
|
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
|
|
17
17
|
|
|
18
|
+
const isI18nEnabled = siteConfig.i18n.enabled !== false && siteConfig.i18n.locales.length >= 2;
|
|
19
|
+
|
|
18
20
|
export function LanguageProvider({ children }: { children: React.ReactNode }) {
|
|
19
21
|
// Always initialize with site default to match server-side rendering
|
|
20
22
|
const [language, setLanguageState] = useState<Language>(siteConfig.i18n.defaultLocale as Language);
|
|
21
|
-
|
|
23
|
+
// When i18n is disabled there is nothing to hydrate — mark as ready immediately
|
|
24
|
+
const [isHydrated, setIsHydrated] = useState(!isI18nEnabled);
|
|
22
25
|
|
|
23
26
|
useEffect(() => {
|
|
27
|
+
if (!isI18nEnabled) return;
|
|
24
28
|
// Only access localStorage and browser language after component is mounted (client-side)
|
|
25
29
|
const savedLang = localStorage.getItem('amytis-language') as Language;
|
|
26
|
-
|
|
30
|
+
|
|
27
31
|
// Use requestAnimationFrame to avoid cascading render lint error
|
|
28
32
|
const rafId = requestAnimationFrame(() => {
|
|
29
33
|
if (savedLang && translations[savedLang]) {
|
|
@@ -35,6 +39,7 @@ export function LanguageProvider({ children }: { children: React.ReactNode }) {
|
|
|
35
39
|
}, []);
|
|
36
40
|
|
|
37
41
|
const setLanguage = (lang: Language) => {
|
|
42
|
+
if (!isI18nEnabled) return;
|
|
38
43
|
setLanguageState(lang);
|
|
39
44
|
localStorage.setItem('amytis-language', lang);
|
|
40
45
|
};
|
|
@@ -25,7 +25,7 @@ export default function LanguageSwitch({ variant = 'pill' }: LanguageSwitchProps
|
|
|
25
25
|
const { language, setLanguage, isHydrated } = useLanguage();
|
|
26
26
|
const locales = siteConfig.i18n.locales;
|
|
27
27
|
|
|
28
|
-
if (locales.length < 2) return null;
|
|
28
|
+
if (siteConfig.i18n.enabled === false || locales.length < 2) return null;
|
|
29
29
|
|
|
30
30
|
// SSR placeholder — reserve space to avoid layout shift
|
|
31
31
|
if (!isHydrated) {
|
|
@@ -4,6 +4,7 @@ import Link from 'next/link';
|
|
|
4
4
|
import PostList from './PostList';
|
|
5
5
|
import { useLanguage } from './LanguageProvider';
|
|
6
6
|
import { PostData } from '@/lib/markdown';
|
|
7
|
+
import { getPostsListUrl } from '@/lib/urls';
|
|
7
8
|
|
|
8
9
|
interface LatestWritingSectionProps {
|
|
9
10
|
posts: PostData[];
|
|
@@ -14,32 +15,21 @@ export default function LatestWritingSection({ posts, totalCount }: LatestWritin
|
|
|
14
15
|
const { t, tWith } = useLanguage();
|
|
15
16
|
|
|
16
17
|
return (
|
|
17
|
-
<section>
|
|
18
|
+
<section id="latest-posts">
|
|
18
19
|
<div className="flex items-center justify-between mb-8">
|
|
19
20
|
<h2 className="text-3xl font-serif font-bold text-heading">{t('latest_writing')}</h2>
|
|
21
|
+
<Link
|
|
22
|
+
href={getPostsListUrl()}
|
|
23
|
+
className="text-sm text-muted hover:text-accent transition-colors no-underline inline-flex items-center gap-1"
|
|
24
|
+
>
|
|
25
|
+
{tWith('view_all_posts', { count: totalCount })}
|
|
26
|
+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
27
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
|
28
|
+
</svg>
|
|
29
|
+
</Link>
|
|
20
30
|
</div>
|
|
21
31
|
|
|
22
|
-
<PostList posts={posts} showTags={false} />
|
|
23
|
-
|
|
24
|
-
{totalCount > posts.length && (
|
|
25
|
-
<div className="mt-12 text-center">
|
|
26
|
-
<Link
|
|
27
|
-
href="/posts"
|
|
28
|
-
className="group inline-flex items-center gap-2 px-6 py-3 rounded-full border border-muted/20 bg-muted/5 text-sm font-medium text-muted hover:text-accent hover:border-accent/30 hover:bg-accent/5 transition-all duration-300 no-underline"
|
|
29
|
-
>
|
|
30
|
-
<span>{tWith('view_all_posts', { count: totalCount })}</span>
|
|
31
|
-
<svg
|
|
32
|
-
className="w-4 h-4 transform group-hover:translate-x-0.5 transition-transform"
|
|
33
|
-
fill="none"
|
|
34
|
-
viewBox="0 0 24 24"
|
|
35
|
-
stroke="currentColor"
|
|
36
|
-
strokeWidth={2}
|
|
37
|
-
>
|
|
38
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
|
39
|
-
</svg>
|
|
40
|
-
</Link>
|
|
41
|
-
</div>
|
|
42
|
-
)}
|
|
32
|
+
<PostList posts={posts} showTags={false} excerptLines={1} />
|
|
43
33
|
</section>
|
|
44
34
|
);
|
|
45
35
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import MarkdownRenderer from "./MarkdownRenderer";
|
|
4
|
+
|
|
5
|
+
describe("MarkdownRenderer", () => {
|
|
6
|
+
describe("image rendering", () => {
|
|
7
|
+
test("uses ExportedImage with fallback dimensions for local images without known dimensions", () => {
|
|
8
|
+
const content = "";
|
|
9
|
+
const html = renderToStaticMarkup(<MarkdownRenderer content={content} />);
|
|
10
|
+
// ExportedImage is used with 1200x900 fallback when no dimensions are known
|
|
11
|
+
expect(html).toContain('width="1200"');
|
|
12
|
+
expect(html).toContain('height="900"');
|
|
13
|
+
// style override ensures the image renders at its natural size
|
|
14
|
+
expect(html).toContain('width:100%');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("uses plain img for external images", () => {
|
|
18
|
+
const content = "";
|
|
19
|
+
const html = renderToStaticMarkup(<MarkdownRenderer content={content} />);
|
|
20
|
+
expect(html).toContain('src="https://example.com/image.jpg"');
|
|
21
|
+
// No fallback dimensions — external images are not processed by ExportedImage
|
|
22
|
+
expect(html).not.toContain('width="1200"');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("adds horizontal overflow containment while preserving code scrolling", () => {
|
|
27
|
+
const content = [
|
|
28
|
+
"## Example",
|
|
29
|
+
"",
|
|
30
|
+
"```bash",
|
|
31
|
+
"echo this-is-a-very-long-line-that-should-scroll-inside-the-code-block",
|
|
32
|
+
"```",
|
|
33
|
+
].join("\n");
|
|
34
|
+
|
|
35
|
+
const html = renderToStaticMarkup(<MarkdownRenderer content={content} />);
|
|
36
|
+
|
|
37
|
+
expect(html).toContain("overflow-x-hidden");
|
|
38
|
+
expect(html).toContain("not-prose w-full min-w-0 max-w-full");
|
|
39
|
+
expect(html).toContain("overflow-x-auto");
|
|
40
|
+
});
|
|
41
|
+
});
|