@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.
Files changed (143) hide show
  1. package/.entire/settings.json +4 -0
  2. package/AGENTS.md +13 -7
  3. package/CHANGELOG.md +41 -0
  4. package/CLAUDE.md +30 -2
  5. package/README.md +84 -58
  6. package/README.zh.md +195 -0
  7. package/TODO.md +6 -4
  8. package/bun.lock +30 -4
  9. package/content/books/sample-book/images/digital-garden.svg +25 -0
  10. package/content/books/sample-book/index.mdx +1 -1
  11. package/content/books/sample-book/introduction.mdx +2 -0
  12. package/content/posts/2026-01-21-kitchen-sink/index.mdx +26 -1
  13. package/content/posts/legacy-markdown.md +0 -2
  14. package/content/posts/markdown-features.mdx +1 -1
  15. package/content/posts/multilingual-test.mdx +0 -1
  16. package/content/posts/syntax-highlighting-showcase.mdx +1 -0
  17. package/content/posts/understanding-react-hooks.mdx +1 -0
  18. package/content/series/ai-nexus-weekly/index.mdx +1 -1
  19. package/content/series/digital-garden/01-philosophy.mdx +2 -0
  20. package/content/series/digital-garden/index.mdx +1 -1
  21. package/content/series/markdown-showcase/index.mdx +1 -1
  22. package/content/series/markdown-showcase/syntax-highlighting.mdx +2 -0
  23. package/content/series/nextjs-deep-dive/01-getting-started.mdx +2 -1
  24. package/content/series/nextjs-deep-dive/index.mdx +1 -1
  25. package/content/subscribe.mdx +27 -0
  26. package/docs/ARCHITECTURE.md +85 -105
  27. package/docs/CONTRIBUTING.md +11 -3
  28. package/docs/DIGITAL_GARDEN.md +11 -9
  29. package/docs/deployment.md +31 -13
  30. package/eslint.config.mjs +2 -0
  31. package/package.json +13 -4
  32. package/packages/create-amytis/package.json +13 -0
  33. package/packages/create-amytis/src/index.test.ts +187 -0
  34. package/packages/create-amytis/src/index.ts +230 -0
  35. package/packages/create-amytis/tsconfig.json +14 -0
  36. package/public/images/amytis-screenshot.jpg +0 -0
  37. package/public/images/antelope-canyon.jpg +0 -0
  38. package/public/images/avatar.jpg +0 -0
  39. package/public/images/cappadocia.jpg +0 -0
  40. package/public/images/flowers.jpg +0 -0
  41. package/public/images/galaxy.jpg +0 -0
  42. package/public/images/lake.jpg +0 -0
  43. package/public/images/mountains.jpg +0 -0
  44. package/public/images/vibrant-waves.avif +0 -0
  45. package/public/images/wechat-qr.jpg +0 -0
  46. package/public/next-image-export-optimizer-hashes.json +72 -2
  47. package/scripts/deploy.ts +77 -0
  48. package/site.config.example.ts +268 -0
  49. package/site.config.ts +82 -10
  50. package/src/app/[slug]/[postSlug]/page.tsx +161 -0
  51. package/src/app/[slug]/page/[page]/page.tsx +202 -0
  52. package/src/app/[slug]/page.tsx +162 -7
  53. package/src/app/archive/page.tsx +5 -8
  54. package/src/app/books/[slug]/[chapter]/page.tsx +45 -3
  55. package/src/app/books/[slug]/page.tsx +16 -2
  56. package/src/app/books/page.tsx +12 -8
  57. package/src/app/feed.atom/route.ts +62 -0
  58. package/src/app/feed.xml/route.ts +45 -20
  59. package/src/app/flows/[year]/[month]/[day]/page.tsx +9 -1
  60. package/src/app/flows/[year]/page.tsx +1 -1
  61. package/src/app/flows/page/[page]/page.tsx +2 -0
  62. package/src/app/flows/page.tsx +4 -0
  63. package/src/app/globals.css +9 -3
  64. package/src/app/layout.tsx +22 -1
  65. package/src/app/notes/[slug]/page.tsx +2 -3
  66. package/src/app/page.tsx +86 -8
  67. package/src/app/posts/[slug]/page.tsx +33 -7
  68. package/src/app/posts/page/[page]/page.tsx +9 -5
  69. package/src/app/posts/page.tsx +13 -7
  70. package/src/app/series/[slug]/page/[page]/page.tsx +8 -25
  71. package/src/app/series/[slug]/page.tsx +21 -19
  72. package/src/app/series/page.tsx +14 -28
  73. package/src/app/sitemap.ts +2 -1
  74. package/src/app/tags/[tag]/page.tsx +1 -1
  75. package/src/app/tags/page.tsx +1 -1
  76. package/src/components/Analytics.tsx +34 -36
  77. package/src/components/AuthorCard.tsx +56 -14
  78. package/src/components/BookMobileNav.tsx +8 -34
  79. package/src/components/BookSidebar.tsx +17 -107
  80. package/src/components/CodeBlock.test.tsx +19 -0
  81. package/src/components/CodeBlock.tsx +20 -16
  82. package/src/components/Comments.tsx +4 -3
  83. package/src/components/CoverImage.tsx +11 -3
  84. package/src/components/CuratedSeriesSection.tsx +17 -15
  85. package/src/components/ExternalLinks.tsx +1 -1
  86. package/src/components/FeaturedStoriesSection.tsx +124 -58
  87. package/src/components/FlowCalendarSidebar.tsx +10 -7
  88. package/src/components/FlowContent.tsx +33 -5
  89. package/src/components/Footer.tsx +28 -6
  90. package/src/components/Hero.tsx +44 -92
  91. package/src/components/InlineBookToc.tsx +56 -0
  92. package/src/components/KnowledgeGraph.tsx +26 -0
  93. package/src/components/LanguageProvider.tsx +7 -2
  94. package/src/components/LanguageSwitch.tsx +1 -1
  95. package/src/components/LatestWritingSection.tsx +12 -22
  96. package/src/components/MarkdownRenderer.test.tsx +41 -0
  97. package/src/components/MarkdownRenderer.tsx +43 -8
  98. package/src/components/Navbar.tsx +24 -15
  99. package/src/components/NoteSidebar.tsx +6 -76
  100. package/src/components/Pagination.tsx +2 -2
  101. package/src/components/PostCard.tsx +2 -1
  102. package/src/components/PostList.tsx +81 -62
  103. package/src/components/PostNavigation.tsx +8 -7
  104. package/src/components/PostSidebar.tsx +12 -107
  105. package/src/components/PrevNextNav.tsx +63 -0
  106. package/src/components/RecentNotesSection.tsx +2 -2
  107. package/src/components/RelatedPosts.tsx +3 -2
  108. package/src/components/RssFeedWidget.tsx +55 -0
  109. package/src/components/SectionLabel.tsx +16 -0
  110. package/src/components/SelectedBooksSection.tsx +12 -15
  111. package/src/components/SeriesCatalog.tsx +74 -69
  112. package/src/components/SeriesList.tsx +11 -36
  113. package/src/components/SeriesSidebar.tsx +4 -2
  114. package/src/components/TagContentTabs.tsx +17 -12
  115. package/src/components/TagPageHeader.tsx +12 -2
  116. package/src/components/TagSidebar.tsx +3 -2
  117. package/src/components/TagsIndexClient.tsx +1 -1
  118. package/src/components/TocPanel.tsx +67 -0
  119. package/src/hooks/useActiveHeading.ts +32 -0
  120. package/src/hooks/useSidebarAutoScroll.ts +19 -0
  121. package/src/i18n/translations.ts +26 -4
  122. package/src/layouts/BookLayout.tsx +10 -40
  123. package/src/layouts/PostLayout.tsx +37 -26
  124. package/src/layouts/SimpleLayout.tsx +5 -5
  125. package/src/lib/feed-utils.ts +68 -0
  126. package/src/lib/format-utils.test.ts +27 -0
  127. package/src/lib/format-utils.ts +3 -0
  128. package/src/lib/image-utils.ts +12 -0
  129. package/src/lib/json-ld.ts +236 -0
  130. package/src/lib/markdown.ts +62 -7
  131. package/src/lib/rehype-image-metadata.test.ts +124 -0
  132. package/src/lib/rehype-image-metadata.ts +21 -5
  133. package/src/lib/scroll-utils.ts +11 -0
  134. package/src/lib/shuffle.ts +21 -0
  135. package/src/lib/urls.ts +51 -0
  136. package/tests/e2e/navigation.test.ts +15 -1
  137. package/tests/integration/books.test.ts +61 -0
  138. package/tests/integration/feed-utils.test.ts +145 -0
  139. package/tests/integration/series.test.ts +9 -0
  140. package/tests/unit/static-params.test.ts +59 -2
  141. package/src/app/subscribe/page.tsx +0 -17
  142. package/src/components/SubscribePage.tsx +0 -298
  143. /package/public/{screenshot.png → images/screenshot.png} +0 -0
@@ -1,9 +1,12 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect, useRef, useCallback } from 'react';
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
- // Scroll tracking for page headings
42
- const handleScroll = useCallback(() => {
43
- if (headings.length === 0) return;
44
-
45
- const headingElements = headings
46
- .map(h => document.getElementById(h.id))
47
- .filter(Boolean) as HTMLElement[];
48
-
49
- if (headingElements.length === 0) return;
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={`/books/${bookSlug}/${ch.id}`}
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 && renderHeadings()}
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={`/books/${bookSlug}`}
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
- <SyntaxHighlighter
94
- language={language || 'text'}
95
- PreTag="div"
96
- useInlineStyles={false}
97
- customStyle={{
98
- margin: 0,
99
- padding: '1.5rem',
100
- background: 'transparent',
101
- fontSize: '0.9rem',
102
- lineHeight: '1.6',
103
- }}
104
- {...props}
105
- >
106
- {children}
107
- </SyntaxHighlighter>
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 = '${siteConfig.baseUrl}/posts/${slug}';
60
- this.page.identifier = '${slug}';
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={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, useEffect } from 'react';
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
- const [displayed, setDisplayed] = useState(() => allSeries.slice(0, maxItems));
29
-
30
- useEffect(() => {
31
- setDisplayed(shuffle(allSeries).slice(0, maxItems));
32
- }, [allSeries, maxItems]);
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-12">
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="Shuffle series"
50
- title="Show different series"
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-56 w-full overflow-hidden bg-muted/10 block focus:outline-none focus:ring-2 focus:ring-accent/50 focus:ring-inset">
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-8 flex flex-col flex-1 relative z-10">
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={`/posts/${p.slug}`}
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-12 pt-12 border-t border-muted/20">
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, useEffect } from 'react';
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
- export default function FeaturedStoriesSection({ allFeatured, maxItems, scrollThreshold }: FeaturedStoriesSectionProps) {
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
- useEffect(() => {
31
- setDisplayed(shuffle(allFeatured).slice(0, maxItems));
32
- }, [allFeatured, maxItems]);
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
- setDisplayed(shuffle(allFeatured).slice(0, maxItems));
36
- }, [allFeatured, maxItems]);
60
+ setShuffledNonPinned(shuffle(nonPinned));
61
+ // eslint-disable-next-line react-hooks/exhaustive-deps
62
+ }, [allFeatured]);
37
63
 
38
- if (allFeatured.length === 0) return null;
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-12">
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
- {allFeatured.length > maxItems && (
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="Shuffle featured stories"
49
- title="Show different stories"
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
- <HorizontalScroll
58
- itemCount={displayed.length}
59
- scrollThreshold={scrollThreshold}
60
- >
61
- <div className={`flex gap-8 ${displayed.length > scrollThreshold ? 'pb-4' : 'flex-col'}`}>
62
- {displayed.map(post => (
63
- <div
64
- key={post.slug}
65
- className={`group snap-start ${
66
- displayed.length > scrollThreshold
67
- ? 'w-[90vw] md:w-[70vw] lg:w-[60vw] flex-shrink-0'
68
- : 'w-full'
69
- }`}
70
- >
71
- <div className={`grid grid-cols-1 ${displayed.length > scrollThreshold ? 'md:grid-cols-1 lg:grid-cols-12' : 'md:grid-cols-12'} gap-8 items-center`}>
72
- <Link href={`/posts/${post.slug}`} className={`${displayed.length > scrollThreshold ? 'lg:col-span-7' : 'md:col-span-7'} relative aspect-[16/9] overflow-hidden rounded-2xl bg-muted/10 block focus:outline-none focus:ring-2 focus:ring-accent/50 focus:ring-offset-2 focus:ring-offset-background`}>
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-700 group-hover:scale-105"
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
- </div>
100
- </div>
101
- ))}
102
- </div>
103
- </HorizontalScroll>
165
+ </Link>
166
+ ))}
167
+ </div>
168
+ )}
169
+ </div>
104
170
  </section>
105
171
  );
106
172
  }