@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,10 +1,13 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect, useRef } from 'react';
3
+ import { useState, useRef } from 'react';
4
4
  import Link from 'next/link';
5
5
  import { PostData, Heading } from '@/lib/markdown';
6
+ import { getPostUrl } from '@/lib/urls';
6
7
  import { useLanguage } from './LanguageProvider';
7
- import { useScrollY } from '@/hooks/useScrollY';
8
+ import { useSidebarAutoScroll } from '@/hooks/useSidebarAutoScroll';
9
+ import { padNumber } from '@/lib/format-utils';
10
+ import TocPanel from './TocPanel';
8
11
  import ShareBar from './ShareBar';
9
12
  import { siteConfig } from '../../site.config';
10
13
 
@@ -44,59 +47,8 @@ export default function PostSidebar({ seriesSlug, seriesTitle, posts, currentSlu
44
47
  const progressIndex = hasSeries ? sortedPosts!.findIndex(p => p.slug === currentSlug) : -1;
45
48
  const currentItemRef = useRef<HTMLLIElement>(null);
46
49
  const sidebarRef = useRef<HTMLElement>(null);
47
- const [activeHeadingId, setActiveHeadingId] = useState<string>('');
48
- const [tocCollapsed, setTocCollapsed] = useState(false);
49
50
  const [seriesCollapsed, setSeriesCollapsed] = useState(false);
50
- const scrollY = useScrollY();
51
-
52
- // Derive active heading from shared scroll position
53
- useEffect(() => {
54
- if (activeHeadings.length === 0) return;
55
-
56
- const headingElements = activeHeadings
57
- .map(h => document.getElementById(h.id))
58
- .filter(Boolean) as HTMLElement[];
59
-
60
- if (headingElements.length === 0) return;
61
-
62
- const scrollPosition = scrollY + 100;
63
- let current = headingElements[0];
64
- for (const el of headingElements) {
65
- if (el.offsetTop <= scrollPosition) {
66
- current = el;
67
- } else {
68
- break;
69
- }
70
- }
71
-
72
- const rafId = requestAnimationFrame(() => {
73
- if (current) setActiveHeadingId(current.id);
74
- });
75
- return () => cancelAnimationFrame(rafId);
76
- }, [scrollY, activeHeadings]);
77
-
78
- const scrollToHeading = (e: React.MouseEvent<HTMLAnchorElement>, id: string) => {
79
- e.preventDefault();
80
- const element = document.getElementById(id);
81
- if (element) {
82
- const offset = 80;
83
- const elementPosition = element.getBoundingClientRect().top + window.scrollY;
84
- window.scrollTo({ top: elementPosition - offset, behavior: 'smooth' });
85
- history.pushState(null, '', `#${id}`);
86
- }
87
- };
88
-
89
- // Auto-scroll sidebar to current series item
90
- useEffect(() => {
91
- if (currentItemRef.current && sidebarRef.current) {
92
- const sidebar = sidebarRef.current;
93
- const item = currentItemRef.current;
94
- const itemTop = item.offsetTop;
95
- const itemHeight = item.offsetHeight;
96
- const sidebarHeight = sidebar.clientHeight;
97
- sidebar.scrollTop = itemTop - sidebarHeight / 2 + itemHeight / 2;
98
- }
99
- }, [currentSlug]);
51
+ useSidebarAutoScroll(sidebarRef, currentItemRef, currentSlug);
100
52
 
101
53
  return (
102
54
  <aside
@@ -104,57 +56,10 @@ export default function PostSidebar({ seriesSlug, seriesTitle, posts, currentSlu
104
56
  className="hidden lg:block sticky top-20 self-start w-[280px] max-h-[calc(100vh-6rem)] overflow-y-auto pr-4 scrollbar-hide hover:scrollbar-thin"
105
57
  >
106
58
  {/* TOC — always at top */}
107
- {activeHeadings.length > 0 && (
108
- <nav
109
- aria-label="Table of contents"
110
- className={`mb-6 ${hasSeries ? 'pb-4 border-b border-muted/10' : ''}`}
111
- >
112
- <div className="flex items-center justify-between mb-3">
113
- <span className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted">
114
- {t('on_this_page')}
115
- </span>
116
- <button
117
- onClick={() => setTocCollapsed(prev => !prev)}
118
- className="text-muted hover:text-foreground transition-colors"
119
- aria-label={tocCollapsed ? 'Expand table of contents' : 'Collapse table of contents'}
120
- >
121
- <svg
122
- className={`w-3.5 h-3.5 transition-transform duration-200 ${tocCollapsed ? '' : 'rotate-180'}`}
123
- fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
124
- >
125
- <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
126
- </svg>
127
- </button>
128
- </div>
129
-
130
- {!tocCollapsed && (
131
- <ul className="space-y-0.5 border-l border-muted/15 animate-slide-down">
132
- {activeHeadings.map(heading => {
133
- const isActive = heading.id === activeHeadingId;
134
- const isH3 = heading.level === 3;
135
-
136
- return (
137
- <li key={heading.id}>
138
- <a
139
- href={`#${heading.id}`}
140
- onClick={(e) => scrollToHeading(e, heading.id)}
141
- className={`block py-1 text-[13px] leading-snug no-underline transition-colors duration-200 ${
142
- isH3 ? 'pl-6' : 'pl-3'
143
- } ${
144
- isActive
145
- ? 'text-accent font-medium border-l-2 border-accent -ml-px'
146
- : 'text-foreground/70 hover:text-foreground'
147
- }`}
148
- >
149
- {heading.text}
150
- </a>
151
- </li>
152
- );
153
- })}
154
- </ul>
155
- )}
156
- </nav>
157
- )}
59
+ <TocPanel
60
+ headings={activeHeadings}
61
+ className={`mb-6 ${hasSeries ? 'pb-4 border-b border-muted/10' : ''}`}
62
+ />
158
63
 
159
64
  {/* Series section — below TOC */}
160
65
  {hasSeries && (
@@ -211,7 +116,7 @@ export default function PostSidebar({ seriesSlug, seriesTitle, posts, currentSlu
211
116
  return (
212
117
  <li key={post.slug} ref={isCurrent ? currentItemRef : undefined} className="relative">
213
118
  <Link
214
- href={`/posts/${post.slug}`}
119
+ href={getPostUrl(post)}
215
120
  className={`group flex items-start gap-3 py-2 px-2 -mx-2 rounded-lg no-underline transition-all duration-200 ${
216
121
  isCurrent ? 'bg-accent/5' : 'hover:bg-muted/5'
217
122
  }`}
@@ -224,7 +129,7 @@ export default function PostSidebar({ seriesSlug, seriesTitle, posts, currentSlu
224
129
  ? 'bg-accent/20 text-accent'
225
130
  : 'bg-muted/10 text-muted group-hover:bg-muted/20 group-hover:text-foreground'
226
131
  }`}>
227
- {String(item + 1).padStart(2, '0')}
132
+ {padNumber(item + 1)}
228
133
  </div>
229
134
  <div className="flex-1 min-w-0 pt-0.5">
230
135
  <span className={`block text-sm leading-snug transition-colors ${
@@ -0,0 +1,63 @@
1
+ 'use client';
2
+
3
+ import Link from 'next/link';
4
+ import { useLanguage } from './LanguageProvider';
5
+
6
+ interface NavItem {
7
+ href: string;
8
+ title: string;
9
+ }
10
+
11
+ interface PrevNextNavProps {
12
+ prev: NavItem | null;
13
+ next: NavItem | null;
14
+ size?: 'sm' | 'lg';
15
+ }
16
+
17
+ export default function PrevNextNav({ prev, next, size = 'sm' }: PrevNextNavProps) {
18
+ const { t } = useLanguage();
19
+ const isLg = size === 'lg';
20
+ const padding = isLg ? 'py-4 px-5' : 'py-2.5 px-3';
21
+ const rounding = isLg ? 'rounded-xl' : 'rounded-lg';
22
+ const iconSize = isLg ? 'w-5 h-5' : 'w-4 h-4';
23
+ const gap = isLg ? 'gap-3' : 'gap-2';
24
+ const titleClass = isLg ? 'text-sm font-medium text-foreground/80 group-hover:text-foreground truncate transition-colors' : 'text-sm text-foreground/80 group-hover:text-foreground truncate transition-colors';
25
+ const labelMargin = isLg ? 'mb-1' : 'mb-0.5';
26
+
27
+ return (
28
+ <div className={`flex ${isLg ? 'gap-4' : 'gap-3'}`}>
29
+ {prev ? (
30
+ <Link
31
+ href={prev.href}
32
+ className={`flex-1 flex items-center ${gap} ${padding} ${rounding} bg-muted/5 hover:bg-muted/10 no-underline transition-colors group`}
33
+ >
34
+ <svg className={`${iconSize} flex-shrink-0 text-muted group-hover:text-accent transition-colors`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
35
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
36
+ </svg>
37
+ <div className="min-w-0">
38
+ <span className={`block text-[10px] font-sans font-bold uppercase tracking-widest text-muted ${labelMargin}`}>{t('prev')}</span>
39
+ <span className={`block ${titleClass}`}>{prev.title}</span>
40
+ </div>
41
+ </Link>
42
+ ) : (
43
+ <div className="flex-1" />
44
+ )}
45
+ {next ? (
46
+ <Link
47
+ href={next.href}
48
+ className={`flex-1 flex items-center justify-end ${gap} ${padding} ${rounding} bg-muted/5 hover:bg-muted/10 no-underline transition-colors group text-right`}
49
+ >
50
+ <div className="min-w-0">
51
+ <span className={`block text-[10px] font-sans font-bold uppercase tracking-widest text-muted ${labelMargin}`}>{t('next')}</span>
52
+ <span className={`block ${titleClass}`}>{next.title}</span>
53
+ </div>
54
+ <svg className={`${iconSize} flex-shrink-0 text-muted group-hover:text-accent transition-colors`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
55
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
56
+ </svg>
57
+ </Link>
58
+ ) : (
59
+ <div className="flex-1" />
60
+ )}
61
+ </div>
62
+ );
63
+ }
@@ -19,7 +19,7 @@ export default function RecentNotesSection({ notes }: RecentNotesSectionProps) {
19
19
  if (notes.length === 0) return null;
20
20
 
21
21
  return (
22
- <section className="mt-16">
22
+ <section id="recent-flows">
23
23
  <div className="flex items-center justify-between mb-8">
24
24
  <h2 className="text-3xl font-serif font-bold text-heading">{t('recent_notes')}</h2>
25
25
  <Link
@@ -35,7 +35,7 @@ export default function RecentNotesSection({ notes }: RecentNotesSectionProps) {
35
35
 
36
36
  <div className="space-y-0">
37
37
  {notes.map(note => (
38
- <div key={note.slug} className="relative pl-6 pb-6 border-l-2 border-muted/20 last:pb-0">
38
+ <div key={note.slug} className="relative pl-6 pb-6 border-l-2 border-muted/20 last:pb-0 last:border-l-transparent">
39
39
  <div className="absolute -left-[5px] top-1.5 w-2 h-2 rounded-full bg-accent" />
40
40
  <Link href={`/flows/${note.slug}`} className="no-underline group">
41
41
  <time className="text-sm font-mono text-accent group-hover:text-accent/70 transition-colors">{note.date}</time>
@@ -1,16 +1,17 @@
1
1
  import Link from 'next/link';
2
2
  import { PostData } from '@/lib/markdown';
3
3
  import { t } from '@/lib/i18n';
4
+ import { getPostUrl } from '@/lib/urls';
4
5
 
5
6
  export default function RelatedPosts({ posts }: { posts: PostData[] }) {
6
7
  if (!posts || posts.length === 0) return null;
7
8
 
8
9
  return (
9
- <div className="mt-12 pt-12 border-t border-muted/20">
10
+ <div className="mt-12">
10
11
  <h3 className="text-2xl font-serif font-bold text-heading mb-8">{t('related_posts')}</h3>
11
12
  <div className="grid grid-cols-1 md:grid-cols-3 gap-8">
12
13
  {posts.map(post => (
13
- <Link key={post.slug} href={`/posts/${post.slug}`} className="group block no-underline">
14
+ <Link key={post.slug} href={getPostUrl(post)} className="group block no-underline">
14
15
  <article className="flex flex-col h-full">
15
16
  <div className="text-xs font-sans text-muted mb-2 flex items-center gap-2">
16
17
  <time className="font-mono">{post.date}</time>
@@ -0,0 +1,55 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { siteConfig } from '../../site.config';
5
+ import { useLanguage } from '@/components/LanguageProvider';
6
+ import { LuCopy, LuCheck } from 'react-icons/lu';
7
+
8
+ function FeedRow({ url, label }: { url: string; label?: string }) {
9
+ const { t } = useLanguage();
10
+ const [copied, setCopied] = useState(false);
11
+
12
+ const handleCopy = async () => {
13
+ try {
14
+ await navigator.clipboard.writeText(url);
15
+ setCopied(true);
16
+ setTimeout(() => setCopied(false), 2000);
17
+ } catch {
18
+ // clipboard API not available
19
+ }
20
+ };
21
+
22
+ return (
23
+ <div className="flex items-center gap-2 px-3 py-2.5 rounded-lg bg-muted/5 border border-muted/15 not-prose">
24
+ {label && <span className="text-xs font-medium text-muted/40 shrink-0 uppercase tracking-wide">{label}</span>}
25
+ <code className="text-xs font-mono text-muted/60 flex-1 truncate">{url}</code>
26
+ <button
27
+ onClick={handleCopy}
28
+ className="flex-shrink-0 flex items-center gap-1.5 text-xs text-muted/60 hover:text-accent transition-colors"
29
+ aria-label={t('copy_feed_url')}
30
+ >
31
+ {copied
32
+ ? <><LuCheck className="w-3.5 h-3.5 text-accent" /><span className="text-accent">{t('feed_url_copied')}</span></>
33
+ : <><LuCopy className="w-3.5 h-3.5" /><span>{t('copy_feed_url')}</span></>
34
+ }
35
+ </button>
36
+ </div>
37
+ );
38
+ }
39
+
40
+ export default function RssFeedWidget() {
41
+ const base = siteConfig.baseUrl.replace(/\/+$/, '');
42
+ const { format } = siteConfig.feed;
43
+ const showBoth = format === 'both';
44
+
45
+ return (
46
+ <div className="my-4 flex flex-col gap-2">
47
+ {(format === 'rss' || format === 'both') && (
48
+ <FeedRow url={`${base}/feed.xml`} label={showBoth ? 'RSS' : undefined} />
49
+ )}
50
+ {(format === 'atom' || format === 'both') && (
51
+ <FeedRow url={`${base}/feed.atom`} label={showBoth ? 'Atom' : undefined} />
52
+ )}
53
+ </div>
54
+ );
55
+ }
@@ -0,0 +1,16 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ interface SectionLabelProps {
4
+ children: ReactNode;
5
+ color?: 'muted' | 'accent';
6
+ className?: string;
7
+ as?: 'span' | 'p';
8
+ }
9
+
10
+ export default function SectionLabel({ children, color = 'muted', className = '', as: Tag = 'span' }: SectionLabelProps) {
11
+ return (
12
+ <Tag className={`text-[10px] font-sans font-bold uppercase tracking-widest ${color === 'accent' ? 'text-accent' : 'text-muted'} ${className}`.trim()}>
13
+ {children}
14
+ </Tag>
15
+ );
16
+ }
@@ -2,8 +2,8 @@
2
2
 
3
3
  import Link from 'next/link';
4
4
  import CoverImage from './CoverImage';
5
- import HorizontalScroll from './HorizontalScroll';
6
5
  import { useLanguage } from './LanguageProvider';
6
+ import { getBookUrl } from '@/lib/urls';
7
7
 
8
8
  export interface BookItem {
9
9
  slug: string;
@@ -18,27 +18,25 @@ export interface BookItem {
18
18
  interface SelectedBooksSectionProps {
19
19
  books: BookItem[];
20
20
  maxItems?: number;
21
- scrollThreshold?: number;
22
21
  }
23
22
 
24
- export default function SelectedBooksSection({ books, maxItems = 4, scrollThreshold = 2 }: SelectedBooksSectionProps) {
23
+ export default function SelectedBooksSection({ books, maxItems = 4 }: SelectedBooksSectionProps) {
25
24
  const { t } = useLanguage();
26
25
  const displayed = books.slice(0, maxItems);
27
26
 
28
27
  if (displayed.length === 0) return null;
29
28
 
30
29
  return (
31
- <section className="mb-24">
32
- <div className="flex items-center justify-between mb-12">
30
+ <section id="featured-books" className="mb-24">
31
+ <div className="flex items-center justify-between mb-8">
33
32
  <h2 className="text-3xl font-serif font-bold text-heading">{t('selected_books')}</h2>
34
33
  <Link href="/books" className="text-sm font-sans font-bold uppercase tracking-widest text-muted hover:text-accent transition-colors no-underline hover:underline">
35
34
  {t('all_books')} →
36
35
  </Link>
37
36
  </div>
38
- <HorizontalScroll itemCount={displayed.length} scrollThreshold={scrollThreshold}>
39
- <div className={`flex gap-8 ${displayed.length > scrollThreshold ? 'pb-4' : ''}`}>
37
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
40
38
  {displayed.map(book => (
41
- <Link key={book.slug} href={`/books/${book.slug}`} className={`group block no-underline ${displayed.length > scrollThreshold ? 'w-[85vw] md:w-[calc(50%-1rem)] flex-shrink-0 snap-start' : 'flex-1'}`}>
39
+ <Link key={book.slug} href={getBookUrl(book.slug)} className="group block no-underline">
42
40
  <div className="card-base h-full group flex flex-col p-0 overflow-hidden">
43
41
  <div className="relative h-48 w-full overflow-hidden bg-muted/10">
44
42
  <CoverImage
@@ -48,11 +46,11 @@ export default function SelectedBooksSection({ books, maxItems = 4, scrollThresh
48
46
  className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
49
47
  />
50
48
  </div>
51
- <div className="p-8 flex flex-col flex-1">
52
- <span className="badge-accent mb-4 inline-block">
49
+ <div className="p-6 flex flex-col flex-1">
50
+ <span className="badge-accent self-start">
53
51
  {book.chapterCount} {t('chapters_count')}
54
52
  </span>
55
- <h3 className="mb-3 font-serif text-2xl font-bold text-heading group-hover:text-accent transition-colors">
53
+ <h3 className="mb-3 font-serif text-xl font-bold text-heading group-hover:text-accent transition-colors line-clamp-2">
56
54
  {book.title}
57
55
  </h3>
58
56
  {book.authors.length > 0 && (
@@ -61,12 +59,12 @@ export default function SelectedBooksSection({ books, maxItems = 4, scrollThresh
61
59
  </p>
62
60
  )}
63
61
  {book.excerpt && (
64
- <p className="text-muted font-serif italic leading-relaxed line-clamp-3">
62
+ <p className="text-muted font-serif italic leading-relaxed line-clamp-3 text-sm mb-4">
65
63
  {book.excerpt}
66
64
  </p>
67
65
  )}
68
66
  {book.firstChapter && (
69
- <div className="mt-auto pt-6 border-t border-muted/10">
67
+ <div className="mt-auto pt-4 border-t border-muted/10">
70
68
  <span className="text-sm font-sans font-bold text-accent flex items-center gap-1.5">
71
69
  {t('start_reading')}
72
70
  <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
@@ -79,8 +77,7 @@ export default function SelectedBooksSection({ books, maxItems = 4, scrollThresh
79
77
  </div>
80
78
  </Link>
81
79
  ))}
82
- </div>
83
- </HorizontalScroll>
80
+ </div>
84
81
  </section>
85
82
  );
86
83
  }
@@ -1,6 +1,9 @@
1
1
  import Link from 'next/link';
2
2
  import { PostData } from '@/lib/markdown';
3
3
  import CoverImage from './CoverImage';
4
+ import { getPostUrl } from '@/lib/urls';
5
+ import { padNumber } from '@/lib/format-utils';
6
+ import { t } from '@/lib/i18n';
4
7
 
5
8
  interface SeriesCatalogProps {
6
9
  posts: PostData[];
@@ -18,84 +21,87 @@ export default function SeriesCatalog({ posts, startIndex = 0, totalPosts }: Ser
18
21
  <div className="space-y-6">
19
22
  {posts.map((post, index) => (
20
23
  <article key={post.slug} className="group relative">
24
+ {/* Cover link — sits above card content via z-index, tag links use z-10 to appear above it */}
21
25
  <Link
22
- href={`/posts/${post.slug}`}
23
- className="block no-underline"
24
- >
25
- <div className="flex gap-6 md:gap-8">
26
- {/* Left side: Number indicator */}
27
- <div className="hidden md:flex flex-col items-center">
28
- <div className="relative z-10 flex h-10 w-10 items-center justify-center rounded-full bg-background border-2 border-muted/20 group-hover:border-accent/50 transition-colors">
29
- <span className="text-sm font-mono font-bold text-muted group-hover:text-accent transition-colors">
30
- {String(startIndex + index + 1).padStart(2, '0')}
31
- </span>
32
- </div>
26
+ href={getPostUrl(post)}
27
+ className="absolute inset-0 z-0 rounded-2xl"
28
+ aria-label={post.title}
29
+ />
30
+
31
+ <div className="flex gap-6 md:gap-8">
32
+ {/* Left side: Number indicator */}
33
+ <div className="hidden md:flex flex-col items-center">
34
+ <div className="relative z-10 flex h-10 w-10 items-center justify-center rounded-full bg-background border-2 border-muted/20 group-hover:border-accent/50 transition-colors">
35
+ <span className="text-sm font-mono font-bold text-muted group-hover:text-accent transition-colors">
36
+ {padNumber(startIndex + index + 1)}
37
+ </span>
33
38
  </div>
39
+ </div>
34
40
 
35
- {/* Right side: Content card */}
36
- <div className="flex-1 rounded-2xl border border-muted/20 bg-muted/5 overflow-hidden transition-all duration-300 group-hover:border-accent/30 group-hover:bg-muted/10 group-hover:shadow-lg group-hover:shadow-accent/5">
37
- <div className="flex flex-col sm:flex-row">
38
- {/* Thumbnail */}
39
- <div className="relative w-full sm:w-48 h-40 sm:h-auto flex-shrink-0 overflow-hidden bg-muted/10">
40
- <CoverImage
41
- src={post.coverImage}
42
- title={post.title}
43
- slug={post.slug}
44
- className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
45
- />
46
- {/* Mobile number badge */}
47
- <div className="absolute top-3 left-3 md:hidden flex h-8 w-8 items-center justify-center rounded-full bg-background/90 backdrop-blur border border-muted/20">
48
- <span className="text-xs font-mono font-bold text-muted">
49
- {String(startIndex + index + 1).padStart(2, '0')}
50
- </span>
51
- </div>
41
+ {/* Right side: Content card */}
42
+ <div className="flex-1 rounded-2xl border border-muted/20 bg-muted/5 overflow-hidden transition-all duration-300 group-hover:border-accent/30 group-hover:bg-muted/10 group-hover:shadow-lg group-hover:shadow-accent/5">
43
+ <div className="flex flex-col sm:flex-row">
44
+ {/* Thumbnail */}
45
+ <div className="relative w-full sm:w-48 h-40 sm:h-auto flex-shrink-0 overflow-hidden bg-muted/10">
46
+ <CoverImage
47
+ src={post.coverImage}
48
+ title={post.title}
49
+ slug={post.slug}
50
+ className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
51
+ />
52
+ {/* Mobile number badge */}
53
+ <div className="absolute top-3 left-3 md:hidden flex h-8 w-8 items-center justify-center rounded-full bg-background/90 backdrop-blur border border-muted/20">
54
+ <span className="text-xs font-mono font-bold text-muted">
55
+ {padNumber(startIndex + index + 1)}
56
+ </span>
52
57
  </div>
58
+ </div>
53
59
 
54
- {/* Content */}
55
- <div className="flex-1 p-5 sm:p-6 flex flex-col">
56
- {/* Meta info */}
57
- <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-mono text-muted mb-3">
58
- <span>{post.date}</span>
59
- <span className="hidden sm:inline">•</span>
60
- <span className="text-accent/80">{post.readingTime}</span>
61
- {post.category && (
62
- <>
63
- <span className="hidden sm:inline">•</span>
64
- <span className="uppercase tracking-wider">{post.category}</span>
65
- </>
66
- )}
67
- </div>
60
+ {/* Content */}
61
+ <div className="flex-1 p-5 sm:p-6 flex flex-col">
62
+ {/* Meta info */}
63
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-mono text-muted mb-3">
64
+ <span>{post.date}</span>
65
+ <span className="hidden sm:inline">•</span>
66
+ <span className="text-accent/80">{post.readingTime}</span>
67
+ {post.category && (
68
+ <>
69
+ <span className="hidden sm:inline">•</span>
70
+ <span className="uppercase tracking-wider">{post.category}</span>
71
+ </>
72
+ )}
73
+ </div>
68
74
 
69
- {/* Title */}
70
- <h3 className="font-serif text-xl font-bold text-heading mb-2 leading-snug group-hover:text-accent transition-colors line-clamp-2">
71
- {post.title}
72
- </h3>
75
+ {/* Title */}
76
+ <h3 className="font-serif text-xl font-bold text-heading mb-2 leading-snug group-hover:text-accent transition-colors line-clamp-2">
77
+ {post.title}
78
+ </h3>
73
79
 
74
- {/* Excerpt */}
75
- {post.excerpt && (
76
- <p className="text-sm text-muted leading-relaxed line-clamp-2 mb-4">
77
- {post.excerpt}
78
- </p>
79
- )}
80
+ {/* Excerpt */}
81
+ {post.excerpt && (
82
+ <p className="text-sm text-muted leading-relaxed line-clamp-2 mb-4">
83
+ {post.excerpt}
84
+ </p>
85
+ )}
80
86
 
81
- {/* Tags */}
82
- {post.tags && post.tags.length > 0 && (
83
- <div className="mt-auto flex flex-wrap gap-2">
84
- {post.tags.slice(0, 3).map(tag => (
85
- <span
86
- key={tag}
87
- className="text-xs px-2 py-0.5 rounded-full bg-muted/10 text-muted/70"
88
- >
89
- {tag}
90
- </span>
91
- ))}
92
- </div>
93
- )}
94
- </div>
87
+ {/* Tags */}
88
+ {post.tags && post.tags.length > 0 && (
89
+ <div className="mt-auto flex flex-wrap gap-2">
90
+ {post.tags.slice(0, 3).map(tag => (
91
+ <Link
92
+ key={tag}
93
+ href={`/tags/${encodeURIComponent(tag)}`}
94
+ className="relative z-10 text-xs px-2 py-0.5 rounded-full bg-muted/10 text-muted/70 hover:bg-accent/10 hover:text-accent transition-colors no-underline"
95
+ >
96
+ {tag}
97
+ </Link>
98
+ ))}
99
+ </div>
100
+ )}
95
101
  </div>
96
102
  </div>
97
103
  </div>
98
- </Link>
104
+ </div>
99
105
  </article>
100
106
  ))}
101
107
  </div>
@@ -103,8 +109,7 @@ export default function SeriesCatalog({ posts, startIndex = 0, totalPosts }: Ser
103
109
  {/* Series progress summary */}
104
110
  <div className="mt-10 pt-8 border-t border-muted/10 text-center">
105
111
  <p className="text-sm text-muted">
106
- <span className="font-mono text-accent">{total}</span>
107
- {total === 1 ? ' article' : ' articles'} in this series
112
+ <span className="font-mono text-accent">{total}</span> {t('parts')}
108
113
  </p>
109
114
  </div>
110
115
  </div>