@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
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState, useEffect, useRef
|
|
3
|
+
import { useState, useEffect, useRef } from 'react';
|
|
4
4
|
import Link from 'next/link';
|
|
5
5
|
import { BookTocItem, BookChapterEntry, Heading } from '@/lib/markdown';
|
|
6
6
|
import { useLanguage } from './LanguageProvider';
|
|
7
|
+
import { useSidebarAutoScroll } from '@/hooks/useSidebarAutoScroll';
|
|
8
|
+
import InlineBookToc from './InlineBookToc';
|
|
9
|
+
import { getBookChapterUrl } from '@/lib/urls';
|
|
7
10
|
|
|
8
11
|
interface BookSidebarProps {
|
|
9
12
|
bookSlug: string;
|
|
@@ -17,10 +20,8 @@ interface BookSidebarProps {
|
|
|
17
20
|
export default function BookSidebar({ bookSlug, bookTitle, toc, chapters, currentChapter, headings = [] }: BookSidebarProps) {
|
|
18
21
|
const { t } = useLanguage();
|
|
19
22
|
const currentIndex = chapters.findIndex(ch => ch.id === currentChapter);
|
|
20
|
-
const [headingsCollapsed, setHeadingsCollapsed] = useState(false);
|
|
21
23
|
const currentItemRef = useRef<HTMLLIElement>(null);
|
|
22
24
|
const sidebarRef = useRef<HTMLElement>(null);
|
|
23
|
-
const [activeHeadingId, setActiveHeadingId] = useState<string>('');
|
|
24
25
|
|
|
25
26
|
// Track which parts are collapsed
|
|
26
27
|
const [collapsedParts, setCollapsedParts] = useState<Record<string, boolean>>(() => {
|
|
@@ -38,60 +39,16 @@ export default function BookSidebar({ bookSlug, bookTitle, toc, chapters, curren
|
|
|
38
39
|
setCollapsedParts(prev => ({ ...prev, [part]: !prev[part] }));
|
|
39
40
|
};
|
|
40
41
|
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const scrollPosition = window.scrollY + 100;
|
|
52
|
-
let current = headingElements[0];
|
|
53
|
-
for (const el of headingElements) {
|
|
54
|
-
if (el.offsetTop <= scrollPosition) {
|
|
55
|
-
current = el;
|
|
56
|
-
} else {
|
|
57
|
-
break;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
if (current) {
|
|
62
|
-
setActiveHeadingId(current.id);
|
|
63
|
-
}
|
|
64
|
-
}, [headings]);
|
|
65
|
-
|
|
66
|
-
useEffect(() => {
|
|
67
|
-
if (headings.length === 0) return;
|
|
68
|
-
handleScroll();
|
|
69
|
-
window.addEventListener('scroll', handleScroll, { passive: true });
|
|
70
|
-
return () => window.removeEventListener('scroll', handleScroll);
|
|
71
|
-
}, [handleScroll, headings.length]);
|
|
72
|
-
|
|
73
|
-
// Smooth scroll to heading
|
|
74
|
-
const scrollToHeading = (e: React.MouseEvent<HTMLAnchorElement>, id: string) => {
|
|
75
|
-
e.preventDefault();
|
|
76
|
-
const element = document.getElementById(id);
|
|
77
|
-
if (element) {
|
|
78
|
-
const offset = 80;
|
|
79
|
-
const elementPosition = element.getBoundingClientRect().top + window.scrollY;
|
|
80
|
-
window.scrollTo({ top: elementPosition - offset, behavior: 'smooth' });
|
|
81
|
-
history.pushState(null, '', `#${id}`);
|
|
82
|
-
}
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
useEffect(() => {
|
|
86
|
-
if (currentItemRef.current && sidebarRef.current) {
|
|
87
|
-
const sidebar = sidebarRef.current;
|
|
88
|
-
const item = currentItemRef.current;
|
|
89
|
-
const itemTop = item.offsetTop;
|
|
90
|
-
const itemHeight = item.offsetHeight;
|
|
91
|
-
const sidebarHeight = sidebar.clientHeight;
|
|
92
|
-
sidebar.scrollTop = itemTop - sidebarHeight / 2 + itemHeight / 2;
|
|
93
|
-
}
|
|
94
|
-
}, [currentChapter]);
|
|
42
|
+
// Re-run auto-scroll when the chapter changes AND when its part becomes visible.
|
|
43
|
+
// Without the visibility check, navigating into a collapsed part would trigger
|
|
44
|
+
// the expansion effect but the chapter DOM element wouldn't exist yet, so scroll
|
|
45
|
+
// would silently do nothing.
|
|
46
|
+
const isCurrentChapterVisible = toc.some(item =>
|
|
47
|
+
'part' in item
|
|
48
|
+
? !collapsedParts[item.part] && item.chapters.some(ch => ch.id === currentChapter)
|
|
49
|
+
: item.id === currentChapter
|
|
50
|
+
);
|
|
51
|
+
useSidebarAutoScroll(sidebarRef, currentItemRef, `${currentChapter}:${isCurrentChapterVisible}`);
|
|
95
52
|
|
|
96
53
|
// Expand part containing current chapter when it changes
|
|
97
54
|
useEffect(() => {
|
|
@@ -102,53 +59,6 @@ export default function BookSidebar({ bookSlug, bookTitle, toc, chapters, curren
|
|
|
102
59
|
}
|
|
103
60
|
}, [currentChapter, toc]);
|
|
104
61
|
|
|
105
|
-
// Render the inline headings sub-list for the current chapter
|
|
106
|
-
const renderHeadings = () => {
|
|
107
|
-
if (headings.length === 0) return null;
|
|
108
|
-
|
|
109
|
-
return (
|
|
110
|
-
<div className="mt-1.5 mb-1 ml-3">
|
|
111
|
-
<button
|
|
112
|
-
onClick={() => setHeadingsCollapsed(prev => !prev)}
|
|
113
|
-
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"
|
|
114
|
-
>
|
|
115
|
-
<svg
|
|
116
|
-
className={`w-3 h-3 flex-shrink-0 transition-transform duration-200 ${headingsCollapsed ? '' : 'rotate-180'}`}
|
|
117
|
-
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
|
|
118
|
-
>
|
|
119
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
|
120
|
-
</svg>
|
|
121
|
-
{t('on_this_page')}
|
|
122
|
-
</button>
|
|
123
|
-
{!headingsCollapsed && (
|
|
124
|
-
<ul className="space-y-0.5 border-l border-muted/15 animate-slide-down">
|
|
125
|
-
{headings.map(heading => {
|
|
126
|
-
const isActive = heading.id === activeHeadingId;
|
|
127
|
-
const isH3 = heading.level === 3;
|
|
128
|
-
|
|
129
|
-
return (
|
|
130
|
-
<li key={heading.id}>
|
|
131
|
-
<a
|
|
132
|
-
href={`#${heading.id}`}
|
|
133
|
-
onClick={(e) => scrollToHeading(e, heading.id)}
|
|
134
|
-
className={`block py-1 text-[13px] leading-snug no-underline transition-colors duration-200 ${
|
|
135
|
-
isH3 ? 'pl-6' : 'pl-3'
|
|
136
|
-
} ${
|
|
137
|
-
isActive
|
|
138
|
-
? 'text-accent font-medium border-l-2 border-accent -ml-px'
|
|
139
|
-
: 'text-foreground/70 hover:text-foreground'
|
|
140
|
-
}`}
|
|
141
|
-
>
|
|
142
|
-
{heading.text}
|
|
143
|
-
</a>
|
|
144
|
-
</li>
|
|
145
|
-
);
|
|
146
|
-
})}
|
|
147
|
-
</ul>
|
|
148
|
-
)}
|
|
149
|
-
</div>
|
|
150
|
-
);
|
|
151
|
-
};
|
|
152
62
|
|
|
153
63
|
// Pre-calculate chapter global indices to avoid reassignment during render
|
|
154
64
|
const chapterIndices = new Map<string, number>();
|
|
@@ -172,7 +82,7 @@ export default function BookSidebar({ bookSlug, bookTitle, toc, chapters, curren
|
|
|
172
82
|
return (
|
|
173
83
|
<li key={ch.id} ref={isCurrent ? currentItemRef : undefined}>
|
|
174
84
|
<Link
|
|
175
|
-
href={
|
|
85
|
+
href={getBookChapterUrl(bookSlug, ch.id)}
|
|
176
86
|
className={`block py-2 px-3 rounded-lg text-sm no-underline transition-all duration-200 ${
|
|
177
87
|
isCurrent
|
|
178
88
|
? 'bg-accent/10 text-accent font-semibold border-l-2 border-accent'
|
|
@@ -184,7 +94,7 @@ export default function BookSidebar({ bookSlug, bookTitle, toc, chapters, curren
|
|
|
184
94
|
>
|
|
185
95
|
{ch.title}
|
|
186
96
|
</Link>
|
|
187
|
-
{isCurrent &&
|
|
97
|
+
{isCurrent && <InlineBookToc headings={headings} />}
|
|
188
98
|
</li>
|
|
189
99
|
);
|
|
190
100
|
};
|
|
@@ -253,7 +163,7 @@ export default function BookSidebar({ bookSlug, bookTitle, toc, chapters, curren
|
|
|
253
163
|
{/* Footer */}
|
|
254
164
|
<div className="mt-6 pt-4 border-t border-muted/10">
|
|
255
165
|
<Link
|
|
256
|
-
href=
|
|
166
|
+
href="/books"
|
|
257
167
|
className="text-xs font-sans text-muted hover:text-accent transition-colors no-underline flex items-center gap-1"
|
|
258
168
|
>
|
|
259
169
|
{t('all_books')}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import CodeBlock from "./CodeBlock";
|
|
4
|
+
|
|
5
|
+
describe("CodeBlock", () => {
|
|
6
|
+
test("keeps code scrolling inside its own container", () => {
|
|
7
|
+
const html = renderToStaticMarkup(
|
|
8
|
+
<CodeBlock language="typescript">
|
|
9
|
+
{"const veryLongLine = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';"}
|
|
10
|
+
</CodeBlock>
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
expect(html).toContain("relative my-6 w-full min-w-0 max-w-full");
|
|
14
|
+
expect(html).toContain("overflow-x-auto");
|
|
15
|
+
expect(html).toContain("overflow-y-hidden");
|
|
16
|
+
expect(html).toContain("max-width:100%");
|
|
17
|
+
expect(html).toContain("box-sizing:border-box");
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -67,7 +67,7 @@ export default function CodeBlock({ language, children, ...props }: CodeBlockPro
|
|
|
67
67
|
};
|
|
68
68
|
|
|
69
69
|
return (
|
|
70
|
-
<div className="relative my-6 rounded-lg border border-muted/20 bg-background/50 overflow-hidden shadow-sm">
|
|
70
|
+
<div className="relative my-6 w-full min-w-0 max-w-full rounded-lg border border-muted/20 bg-background/50 overflow-hidden shadow-sm">
|
|
71
71
|
<div className="flex items-center justify-between px-4 py-2 border-b border-muted/10 bg-muted/5">
|
|
72
72
|
<span className="text-xs font-mono text-muted uppercase tracking-wider">
|
|
73
73
|
{language || 'text'}
|
|
@@ -90,21 +90,25 @@ export default function CodeBlock({ language, children, ...props }: CodeBlockPro
|
|
|
90
90
|
)}
|
|
91
91
|
</button>
|
|
92
92
|
</div>
|
|
93
|
-
<
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
93
|
+
<div className="w-full min-w-0 max-w-full overflow-x-auto overflow-y-hidden">
|
|
94
|
+
<SyntaxHighlighter
|
|
95
|
+
language={language || 'text'}
|
|
96
|
+
PreTag="div"
|
|
97
|
+
useInlineStyles={false}
|
|
98
|
+
customStyle={{
|
|
99
|
+
margin: 0,
|
|
100
|
+
padding: '1.5rem',
|
|
101
|
+
background: 'transparent',
|
|
102
|
+
fontSize: '0.9rem',
|
|
103
|
+
lineHeight: '1.6',
|
|
104
|
+
maxWidth: '100%',
|
|
105
|
+
boxSizing: 'border-box',
|
|
106
|
+
}}
|
|
107
|
+
{...props}
|
|
108
|
+
>
|
|
109
|
+
{children}
|
|
110
|
+
</SyntaxHighlighter>
|
|
111
|
+
</div>
|
|
108
112
|
</div>
|
|
109
113
|
);
|
|
110
114
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import Giscus from '@giscus/react';
|
|
4
4
|
import { siteConfig } from '../../site.config';
|
|
5
5
|
import { useTheme } from 'next-themes';
|
|
6
|
+
import { getPostsBasePath } from '@/lib/urls';
|
|
6
7
|
|
|
7
8
|
// Maps site locale codes to Giscus-supported language codes.
|
|
8
9
|
// Full list: https://github.com/giscus/giscus/tree/main/locales
|
|
@@ -19,7 +20,7 @@ const GISCUS_LANG: Record<string, string> = {
|
|
|
19
20
|
ru: 'ru',
|
|
20
21
|
};
|
|
21
22
|
|
|
22
|
-
export default function Comments({ slug }: { slug: string }) {
|
|
23
|
+
export default function Comments({ slug, postUrl }: { slug: string; postUrl?: string }) {
|
|
23
24
|
const { provider, giscus, disqus } = siteConfig.comments;
|
|
24
25
|
const { theme, systemTheme } = useTheme();
|
|
25
26
|
|
|
@@ -56,8 +57,8 @@ export default function Comments({ slug }: { slug: string }) {
|
|
|
56
57
|
dangerouslySetInnerHTML={{
|
|
57
58
|
__html: `
|
|
58
59
|
var disqus_config = function () {
|
|
59
|
-
this.page.url =
|
|
60
|
-
this.page.identifier =
|
|
60
|
+
this.page.url = ${JSON.stringify(postUrl ?? `${siteConfig.baseUrl.replace(/\/+$/, '')}/${getPostsBasePath()}/${slug}`)};
|
|
61
|
+
this.page.identifier = ${JSON.stringify(slug)};
|
|
61
62
|
};
|
|
62
63
|
(function() {
|
|
63
64
|
var d = document, s = d.createElement('script');
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import ExportedImage from 'next-image-export-optimizer';
|
|
3
|
+
import { siteConfig } from '../../site.config';
|
|
4
|
+
import { getCdnImageUrl } from '@/lib/image-utils';
|
|
3
5
|
|
|
4
6
|
// Each palette defines a gradient background and text color for light/dark modes
|
|
5
7
|
const palettes = [
|
|
@@ -81,13 +83,19 @@ export default function CoverImage({ title, slug, src, className = "h-full w-ful
|
|
|
81
83
|
);
|
|
82
84
|
}
|
|
83
85
|
|
|
86
|
+
const cdnBaseUrl = siteConfig.images?.cdnBaseUrl ?? '';
|
|
87
|
+
const imageSrc = getCdnImageUrl(src!, cdnBaseUrl);
|
|
88
|
+
const isCdn = cdnBaseUrl && imageSrc !== src;
|
|
89
|
+
const isDev = process.env.NODE_ENV === 'development';
|
|
90
|
+
|
|
84
91
|
return (
|
|
85
|
-
<ExportedImage
|
|
86
|
-
src={
|
|
87
|
-
alt={title}
|
|
92
|
+
<ExportedImage
|
|
93
|
+
src={imageSrc}
|
|
94
|
+
alt={title}
|
|
88
95
|
className={className}
|
|
89
96
|
fill
|
|
90
97
|
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
|
98
|
+
unoptimized={isDev || !!isCdn}
|
|
91
99
|
/>
|
|
92
100
|
);
|
|
93
101
|
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState, useCallback
|
|
3
|
+
import { useState, useCallback } from 'react';
|
|
4
4
|
import Link from 'next/link';
|
|
5
5
|
import HorizontalScroll from './HorizontalScroll';
|
|
6
6
|
import CoverImage from './CoverImage';
|
|
7
7
|
import { useLanguage } from './LanguageProvider';
|
|
8
|
-
import { shuffle } from '@/lib/shuffle';
|
|
8
|
+
import { shuffle, shuffleSeeded } from '@/lib/shuffle';
|
|
9
|
+
import { getPostUrl } from '@/lib/urls';
|
|
9
10
|
|
|
10
11
|
export interface SeriesItem {
|
|
11
12
|
name: string;
|
|
@@ -25,11 +26,12 @@ interface CuratedSeriesSectionProps {
|
|
|
25
26
|
|
|
26
27
|
export default function CuratedSeriesSection({ allSeries, maxItems, scrollThreshold }: CuratedSeriesSectionProps) {
|
|
27
28
|
const { t } = useLanguage();
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
// Use a daily seed so SSR and client hydration agree on the initial order,
|
|
30
|
+
// preventing a visible reshuffle flash on page load.
|
|
31
|
+
const [displayed, setDisplayed] = useState(() => {
|
|
32
|
+
const dailySeed = Math.floor(Date.now() / 86400000);
|
|
33
|
+
return shuffleSeeded(allSeries, dailySeed).slice(0, maxItems);
|
|
34
|
+
});
|
|
33
35
|
|
|
34
36
|
const handleShuffle = useCallback(() => {
|
|
35
37
|
setDisplayed(shuffle(allSeries).slice(0, maxItems));
|
|
@@ -38,16 +40,16 @@ export default function CuratedSeriesSection({ allSeries, maxItems, scrollThresh
|
|
|
38
40
|
if (allSeries.length === 0) return null;
|
|
39
41
|
|
|
40
42
|
return (
|
|
41
|
-
<section className="mb-24">
|
|
42
|
-
<div className="flex items-center justify-between mb-
|
|
43
|
+
<section id="featured-series" className="mb-24">
|
|
44
|
+
<div className="flex items-center justify-between mb-8">
|
|
43
45
|
<h2 className="text-3xl font-serif font-bold text-heading">{t('curated_series')}</h2>
|
|
44
46
|
<div className="flex items-center gap-4">
|
|
45
47
|
{allSeries.length > maxItems && (
|
|
46
48
|
<button
|
|
47
49
|
onClick={handleShuffle}
|
|
48
50
|
className="text-sm text-muted hover:text-accent transition-colors focus:outline-none"
|
|
49
|
-
aria-label=
|
|
50
|
-
title=
|
|
51
|
+
aria-label={t('shuffle_series')}
|
|
52
|
+
title={t('shuffle_series')}
|
|
51
53
|
>
|
|
52
54
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
|
53
55
|
<path strokeLinecap="round" strokeLinejoin="round" d="M16 3h5v5M4 20L21 3M21 16v5h-5M15 15l6 6M4 4l5 5" />
|
|
@@ -70,10 +72,10 @@ export default function CuratedSeriesSection({ allSeries, maxItems, scrollThresh
|
|
|
70
72
|
className={`card-base group flex flex-col p-0 overflow-hidden snap-start ${
|
|
71
73
|
displayed.length > scrollThreshold
|
|
72
74
|
? 'w-[85vw] md:w-[calc(50%-1rem)] flex-shrink-0'
|
|
73
|
-
: 'flex-1'
|
|
75
|
+
: 'flex-1 md:max-w-[calc(50%-1rem)]'
|
|
74
76
|
}`}
|
|
75
77
|
>
|
|
76
|
-
<Link href={series.url} className="relative h-
|
|
78
|
+
<Link href={series.url} className="relative h-44 w-full overflow-hidden bg-muted/10 block focus:outline-none focus:ring-2 focus:ring-accent/50 focus:ring-inset">
|
|
77
79
|
<CoverImage
|
|
78
80
|
src={series.coverImage}
|
|
79
81
|
title={series.title}
|
|
@@ -82,7 +84,7 @@ export default function CuratedSeriesSection({ allSeries, maxItems, scrollThresh
|
|
|
82
84
|
/>
|
|
83
85
|
<div className="absolute inset-0 bg-black/10 group-hover:bg-transparent transition-colors duration-500" />
|
|
84
86
|
</Link>
|
|
85
|
-
<div className="p-
|
|
87
|
+
<div className="p-6 flex flex-col flex-1 relative z-10">
|
|
86
88
|
<div className="mb-4">
|
|
87
89
|
<span className="badge-accent">
|
|
88
90
|
{series.postCount} {t('parts')}
|
|
@@ -101,7 +103,7 @@ export default function CuratedSeriesSection({ allSeries, maxItems, scrollThresh
|
|
|
101
103
|
{series.topPosts.map((p, idx) => (
|
|
102
104
|
<Link
|
|
103
105
|
key={p.slug}
|
|
104
|
-
href={
|
|
106
|
+
href={getPostUrl({ slug: p.slug, series: series.name })}
|
|
105
107
|
className="flex items-center gap-3 group/link no-underline"
|
|
106
108
|
>
|
|
107
109
|
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-muted/10 text-[10px] flex items-center justify-center font-mono text-muted group-hover/link:bg-accent/10 group-hover/link:text-accent transition-colors">
|
|
@@ -14,7 +14,7 @@ export default function ExternalLinks({ links }: ExternalLinksProps) {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
return (
|
|
17
|
-
<div className="mt-
|
|
17
|
+
<div className="mt-8">
|
|
18
18
|
<h3 className="text-sm font-sans font-semibold uppercase tracking-widest text-muted mb-4">
|
|
19
19
|
{t('discuss_post')}
|
|
20
20
|
</h3>
|
|
@@ -1,52 +1,88 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState, useCallback
|
|
3
|
+
import { useState, useCallback } from 'react';
|
|
4
4
|
import Link from 'next/link';
|
|
5
|
-
import HorizontalScroll from './HorizontalScroll';
|
|
6
5
|
import CoverImage from './CoverImage';
|
|
7
6
|
import { useLanguage } from './LanguageProvider';
|
|
8
|
-
import { shuffle } from '@/lib/shuffle';
|
|
7
|
+
import { shuffle, shuffleSeeded } from '@/lib/shuffle';
|
|
8
|
+
import { getPostUrl } from '@/lib/urls';
|
|
9
9
|
|
|
10
10
|
export interface FeaturedPost {
|
|
11
11
|
slug: string;
|
|
12
12
|
title: string;
|
|
13
|
+
subtitle?: string;
|
|
13
14
|
excerpt: string;
|
|
14
15
|
date: string;
|
|
15
16
|
category: string;
|
|
16
17
|
readingTime: string;
|
|
17
18
|
coverImage?: string;
|
|
19
|
+
series?: string;
|
|
20
|
+
pinned?: boolean;
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
interface FeaturedStoriesSectionProps {
|
|
21
24
|
allFeatured: FeaturedPost[];
|
|
22
25
|
maxItems: number;
|
|
23
|
-
scrollThreshold: number;
|
|
24
26
|
}
|
|
25
27
|
|
|
26
|
-
|
|
28
|
+
function buildDisplayed(allFeatured: FeaturedPost[], maxItems: number, shuffledNonPinned: FeaturedPost[]): FeaturedPost[] {
|
|
29
|
+
const pinned = allFeatured.filter(p => p.pinned);
|
|
30
|
+
const nonPinned = allFeatured.filter(p => !p.pinned);
|
|
31
|
+
|
|
32
|
+
const hero = pinned[0] ?? nonPinned[0];
|
|
33
|
+
if (!hero) return [];
|
|
34
|
+
|
|
35
|
+
const maxSecondaries = maxItems - 1;
|
|
36
|
+
const fixedSecondaries = pinned.slice(1, maxSecondaries + 1); // cap to available secondary slots
|
|
37
|
+
const shuffleSlots = Math.max(0, maxSecondaries - fixedSecondaries.length);
|
|
38
|
+
|
|
39
|
+
// Non-pinned pool excludes the hero if the hero is non-pinned
|
|
40
|
+
const heroIsNonPinned = !hero.pinned;
|
|
41
|
+
const shufflePool = heroIsNonPinned ? nonPinned.filter(p => p.slug !== hero.slug) : nonPinned;
|
|
42
|
+
const shuffledSlice = shuffledNonPinned.filter(p => shufflePool.some(q => q.slug === p.slug)).slice(0, shuffleSlots);
|
|
43
|
+
|
|
44
|
+
return [hero, ...fixedSecondaries, ...shuffledSlice];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default function FeaturedStoriesSection({ allFeatured, maxItems }: FeaturedStoriesSectionProps) {
|
|
27
48
|
const { t } = useLanguage();
|
|
28
|
-
const [displayed, setDisplayed] = useState(() => allFeatured.slice(0, maxItems));
|
|
29
49
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
50
|
+
const nonPinned = allFeatured.filter(p => !p.pinned);
|
|
51
|
+
|
|
52
|
+
// Use a daily seed so SSR and client hydration agree on the initial order,
|
|
53
|
+
// preventing a visible reshuffle flash on page load.
|
|
54
|
+
const [shuffledNonPinned, setShuffledNonPinned] = useState<FeaturedPost[]>(() => {
|
|
55
|
+
const dailySeed = Math.floor(Date.now() / 86400000);
|
|
56
|
+
return shuffleSeeded(nonPinned, dailySeed);
|
|
57
|
+
});
|
|
33
58
|
|
|
34
59
|
const handleShuffle = useCallback(() => {
|
|
35
|
-
|
|
36
|
-
|
|
60
|
+
setShuffledNonPinned(shuffle(nonPinned));
|
|
61
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
62
|
+
}, [allFeatured]);
|
|
37
63
|
|
|
38
|
-
|
|
64
|
+
const displayed = buildDisplayed(allFeatured, maxItems, shuffledNonPinned);
|
|
65
|
+
|
|
66
|
+
if (displayed.length === 0) return null;
|
|
67
|
+
|
|
68
|
+
// Show shuffle button only when there are more non-pinned posts than available shuffle slots
|
|
69
|
+
const pinned = allFeatured.filter(p => p.pinned);
|
|
70
|
+
const fixedCount = 1 + Math.min(pinned.slice(1).length, maxItems - 1);
|
|
71
|
+
const shuffleSlots = Math.max(0, maxItems - fixedCount);
|
|
72
|
+
const canShuffle = nonPinned.length > shuffleSlots + (pinned.length === 0 ? 1 : 0);
|
|
73
|
+
|
|
74
|
+
const [hero, ...secondary] = displayed;
|
|
39
75
|
|
|
40
76
|
return (
|
|
41
|
-
<section className="mb-24">
|
|
42
|
-
<div className="flex items-center justify-between mb-
|
|
77
|
+
<section id="featured-posts" className="mb-24">
|
|
78
|
+
<div className="flex items-center justify-between mb-8">
|
|
43
79
|
<h2 className="text-3xl font-serif font-bold text-heading">{t('featured_articles')}</h2>
|
|
44
|
-
{
|
|
80
|
+
{canShuffle && (
|
|
45
81
|
<button
|
|
46
82
|
onClick={handleShuffle}
|
|
47
83
|
className="text-sm text-muted hover:text-accent transition-colors focus:outline-none"
|
|
48
|
-
aria-label=
|
|
49
|
-
title=
|
|
84
|
+
aria-label={t('shuffle_posts')}
|
|
85
|
+
title={t('shuffle_posts')}
|
|
50
86
|
>
|
|
51
87
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
|
52
88
|
<path strokeLinecap="round" strokeLinejoin="round" d="M16 3h5v5M4 20L21 3M21 16v5h-5M15 15l6 6M4 4l5 5" />
|
|
@@ -54,53 +90,83 @@ export default function FeaturedStoriesSection({ allFeatured, maxItems, scrollTh
|
|
|
54
90
|
</button>
|
|
55
91
|
)}
|
|
56
92
|
</div>
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
93
|
+
|
|
94
|
+
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 lg:gap-8">
|
|
95
|
+
{/* Hero card — full image with obi (belly band) text overlay */}
|
|
96
|
+
<div className={secondary.length > 0 ? 'lg:col-span-7' : 'lg:col-span-12'}>
|
|
97
|
+
<Link href={getPostUrl(hero)} className={`group block no-underline${secondary.length > 0 ? ' h-full' : ''}`}>
|
|
98
|
+
<div className={`relative overflow-hidden rounded-2xl bg-muted/10 ${secondary.length > 0 ? 'aspect-[16/9] lg:aspect-auto lg:h-full' : 'aspect-[16/9]'}`}>
|
|
99
|
+
<CoverImage
|
|
100
|
+
src={hero.coverImage}
|
|
101
|
+
title={hero.title}
|
|
102
|
+
slug={hero.slug}
|
|
103
|
+
className="h-full w-full object-cover transition-transform duration-700 group-hover:scale-105"
|
|
104
|
+
/>
|
|
105
|
+
{/* Gradient overlay */}
|
|
106
|
+
<div className="absolute inset-0 bg-gradient-to-t from-black/65 via-black/20 to-transparent" />
|
|
107
|
+
{/* Obi text band */}
|
|
108
|
+
<div className="absolute bottom-0 left-0 right-0 p-6 md:p-8">
|
|
109
|
+
<div className="flex items-center gap-2 text-xs font-mono text-white/60 mb-3">
|
|
110
|
+
<span className="text-accent uppercase tracking-wider">{hero.category}</span>
|
|
111
|
+
<span>·</span>
|
|
112
|
+
<span>{hero.readingTime}</span>
|
|
113
|
+
<span>·</span>
|
|
114
|
+
<span>{hero.date}</span>
|
|
115
|
+
</div>
|
|
116
|
+
<h3 className="text-2xl md:text-3xl font-serif font-bold text-white mb-3 leading-snug group-hover:text-accent/90 transition-colors line-clamp-2">
|
|
117
|
+
{hero.title}
|
|
118
|
+
</h3>
|
|
119
|
+
{(hero.subtitle || hero.excerpt) && (
|
|
120
|
+
<p className="text-white/65 text-sm leading-relaxed line-clamp-1">
|
|
121
|
+
{hero.subtitle || hero.excerpt}
|
|
122
|
+
</p>
|
|
123
|
+
)}
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
</Link>
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
{/* Secondary cards — box style with flush right image */}
|
|
130
|
+
{secondary.length > 0 && (
|
|
131
|
+
<div className="lg:col-span-5 flex flex-col gap-4">
|
|
132
|
+
{secondary.map(post => (
|
|
133
|
+
<Link
|
|
134
|
+
key={post.slug}
|
|
135
|
+
href={getPostUrl(post)}
|
|
136
|
+
className="group flex no-underline rounded-2xl border border-muted/20 bg-muted/5 overflow-hidden hover:border-accent/30 hover:bg-muted/10 hover:shadow-lg hover:shadow-accent/5 transition-all duration-300 h-32"
|
|
137
|
+
>
|
|
138
|
+
{/* Text content */}
|
|
139
|
+
<div className="flex-1 p-4 flex flex-col min-w-0">
|
|
140
|
+
<div className="flex items-center gap-2 text-xs font-mono text-muted mb-2">
|
|
141
|
+
<span className="text-accent uppercase tracking-wider truncate max-w-[5rem]">{post.category}</span>
|
|
142
|
+
<span className="shrink-0">·</span>
|
|
143
|
+
<span className="shrink-0">{post.readingTime}</span>
|
|
144
|
+
<span className="shrink-0">·</span>
|
|
145
|
+
<span className="shrink-0">{post.date}</span>
|
|
146
|
+
</div>
|
|
147
|
+
<h4 className="font-serif font-bold text-heading group-hover:text-accent transition-colors line-clamp-2 text-base leading-snug">
|
|
148
|
+
{post.title}
|
|
149
|
+
</h4>
|
|
150
|
+
{(post.subtitle || post.excerpt) && (
|
|
151
|
+
<p className="text-xs text-muted leading-relaxed line-clamp-1 mt-1">
|
|
152
|
+
{post.subtitle || post.excerpt}
|
|
153
|
+
</p>
|
|
154
|
+
)}
|
|
155
|
+
</div>
|
|
156
|
+
{/* Cover image — flush to right edge, full card height */}
|
|
157
|
+
<div className="relative w-32 flex-shrink-0 overflow-hidden bg-muted/10">
|
|
73
158
|
<CoverImage
|
|
74
159
|
src={post.coverImage}
|
|
75
160
|
title={post.title}
|
|
76
161
|
slug={post.slug}
|
|
77
|
-
className="h-full w-full object-cover transition-transform duration-
|
|
162
|
+
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
|
78
163
|
/>
|
|
79
|
-
<div className="absolute inset-0 bg-black/10 group-hover:bg-transparent transition-colors duration-500" />
|
|
80
|
-
</Link>
|
|
81
|
-
<div className={`${displayed.length > scrollThreshold ? 'lg:col-span-5' : 'md:col-span-5'} flex flex-col justify-center`}>
|
|
82
|
-
<div className="flex items-center gap-3 text-xs font-mono text-muted mb-6">
|
|
83
|
-
<span className="text-accent uppercase tracking-wider">{post.category}</span>
|
|
84
|
-
<span>•</span>
|
|
85
|
-
<span>{post.readingTime}</span>
|
|
86
|
-
</div>
|
|
87
|
-
<h3 className="text-2xl md:text-3xl font-serif font-bold text-heading mb-4 leading-snug group-hover:text-accent transition-colors line-clamp-2">
|
|
88
|
-
<Link href={`/posts/${post.slug}`} className="no-underline focus:outline-none focus:text-accent">
|
|
89
|
-
{post.title}
|
|
90
|
-
</Link>
|
|
91
|
-
</h3>
|
|
92
|
-
<p className="text-muted text-base leading-relaxed mb-6 line-clamp-2">
|
|
93
|
-
{post.excerpt}
|
|
94
|
-
</p>
|
|
95
|
-
<div className="flex items-center gap-4 text-xs font-mono text-muted/80">
|
|
96
|
-
<span>{post.date}</span>
|
|
97
|
-
</div>
|
|
98
164
|
</div>
|
|
99
|
-
</
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
</
|
|
165
|
+
</Link>
|
|
166
|
+
))}
|
|
167
|
+
</div>
|
|
168
|
+
)}
|
|
169
|
+
</div>
|
|
104
170
|
</section>
|
|
105
171
|
);
|
|
106
172
|
}
|