@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,12 +1,14 @@
1
1
  import ReactMarkdown, { Components, ExtraProps } from 'react-markdown';
2
2
  import Mermaid from '@/components/Mermaid';
3
3
  import CodeBlock from '@/components/CodeBlock';
4
+ import RssFeedWidget from '@/components/RssFeedWidget';
4
5
  import remarkGfm from 'remark-gfm';
5
6
  import rehypeRaw from 'rehype-raw';
6
7
  import remarkMath from 'remark-math';
7
8
  import rehypeKatex from 'rehype-katex';
8
9
  import rehypeSlug from 'rehype-slug';
9
10
  import rehypeImageMetadata from '@/lib/rehype-image-metadata';
11
+ import { siteConfig } from '../../site.config';
10
12
  import remarkWikilinks from '@/lib/remark-wikilinks';
11
13
  import ExportedImage from 'next-image-export-optimizer';
12
14
  import { PluggableList } from 'unified';
@@ -21,7 +23,8 @@ interface MarkdownRendererProps {
21
23
 
22
24
  export default function MarkdownRenderer({ content, latex = false, slug, slugRegistry }: MarkdownRendererProps) {
23
25
  const remarkPlugins: PluggableList = [remarkGfm];
24
- const rehypePlugins: PluggableList = [rehypeRaw, rehypeSlug, [rehypeImageMetadata, { slug }]];
26
+ const cdnBaseUrl = siteConfig.images?.cdnBaseUrl ?? '';
27
+ const rehypePlugins: PluggableList = [rehypeRaw, rehypeSlug, [rehypeImageMetadata, { slug, cdnBaseUrl }]];
25
28
 
26
29
  if (slugRegistry && slugRegistry.size > 0) {
27
30
  remarkPlugins.push([remarkWikilinks, { slugRegistry }]);
@@ -61,7 +64,7 @@ export default function MarkdownRenderer({ content, latex = false, slug, slugReg
61
64
  );
62
65
  },
63
66
  // Render 'pre' as a 'div' to allow block-level children
64
- pre: ({ children }) => <div className="not-prose">{children}</div>,
67
+ pre: ({ children }) => <div className="not-prose w-full min-w-0 max-w-full">{children}</div>,
65
68
  // Style links individually to avoid hover-all issue
66
69
  a: (props) => {
67
70
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -99,6 +102,31 @@ export default function MarkdownRenderer({ content, latex = false, slug, slugReg
99
102
  </code>
100
103
  );
101
104
  },
105
+ // Ensure video elements are responsive and always show controls
106
+ video: (props: React.ClassAttributes<HTMLVideoElement> & React.VideoHTMLAttributes<HTMLVideoElement> & ExtraProps) => {
107
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
108
+ const { node: _node, width: _w, height: _h, className: _cls, ...rest } = props;
109
+ return (
110
+ <video
111
+ {...rest}
112
+ controls
113
+ className="max-w-full w-full h-auto rounded-lg my-4"
114
+ />
115
+ );
116
+ },
117
+ // Wrap iframes in a 16:9 responsive container (covers YouTube, Vimeo, Bilibili, etc.)
118
+ iframe: (props: React.ClassAttributes<HTMLIFrameElement> & React.IframeHTMLAttributes<HTMLIFrameElement> & ExtraProps) => {
119
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
120
+ const { node: _node, width: _w, height: _h, className: _cls, ...rest } = props;
121
+ return (
122
+ <div className="relative w-full my-6 rounded-lg overflow-hidden aspect-video">
123
+ <iframe
124
+ {...rest}
125
+ className="absolute inset-0 w-full h-full border-0"
126
+ />
127
+ </div>
128
+ );
129
+ },
102
130
  // Ensure images are responsive and styled, using optimized image if dimensions exist
103
131
  // In development mode, use unoptimized images since WebP versions don't exist yet
104
132
  img: (props: React.ClassAttributes<HTMLImageElement> & React.ImgHTMLAttributes<HTMLImageElement> & ExtraProps) => {
@@ -106,16 +134,19 @@ export default function MarkdownRenderer({ content, latex = false, slug, slugReg
106
134
  const { src, alt, width, height, node: _node, ...rest } = props;
107
135
  const isDev = process.env.NODE_ENV === 'development';
108
136
  const imageSrc = src as string;
109
-
110
- if (width && height) {
137
+ const isExternal = imageSrc?.startsWith('http') || imageSrc?.startsWith('//');
138
+
139
+ if (!isExternal) {
111
140
  return (
112
141
  <ExportedImage
113
142
  src={imageSrc || ''}
114
143
  alt={alt || ''}
115
- width={Number(width)}
116
- height={Number(height)}
144
+ width={width ? Number(width) : 1200}
145
+ height={height ? Number(height) : 900}
117
146
  className="max-w-full h-auto rounded-lg my-4"
147
+ sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 70vw"
118
148
  unoptimized={isDev}
149
+ style={(!width || !height) ? { width: '100%', height: 'auto' } : undefined}
119
150
  />
120
151
  );
121
152
  }
@@ -124,8 +155,12 @@ export default function MarkdownRenderer({ content, latex = false, slug, slugReg
124
155
  },
125
156
  };
126
157
 
158
+ // Merge custom HTML elements not in the Components type (e.g. web components used in MDX)
159
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
160
+ const allComponents = { ...components, 'rss-feed': () => <RssFeedWidget /> } as any;
161
+
127
162
  return (
128
- <div className="prose prose-lg max-w-none text-foreground
163
+ <div className="prose prose-lg max-w-none min-w-0 overflow-x-hidden text-foreground
129
164
  prose-headings:font-serif prose-headings:text-heading
130
165
  prose-p:text-foreground prose-p:leading-loose
131
166
  prose-strong:text-heading prose-strong:font-semibold
@@ -137,7 +172,7 @@ export default function MarkdownRenderer({ content, latex = false, slug, slugReg
137
172
  <ReactMarkdown
138
173
  remarkPlugins={remarkPlugins}
139
174
  rehypePlugins={rehypePlugins}
140
- components={components}
175
+ components={allComponents}
141
176
  >
142
177
  {content}
143
178
  </ReactMarkdown>
@@ -101,21 +101,30 @@ export default function Navbar({ seriesList = [], booksList = [] }: NavbarProps)
101
101
  href="/"
102
102
  className="flex items-center gap-3 text-xl font-serif font-bold text-heading hover:text-accent transition-colors duration-200"
103
103
  >
104
- <svg
105
- viewBox="0 0 32 32"
106
- className="w-8 h-8 text-accent"
107
- fill="none"
108
- stroke="currentColor"
109
- strokeWidth="2"
110
- strokeLinecap="round"
111
- strokeLinejoin="round"
112
- aria-hidden="true"
113
- >
114
- <path d="M16 4 L7 28" />
115
- <path d="M16 4 L25 28" />
116
- <path d="M11.5 18 H 20.5" />
117
- <path d="M20.5 18 Q 26 14 26 8 Q 23 12 20.5 18" fill="currentColor" stroke="none" />
118
- </svg>
104
+ {siteConfig.logo?.src ? (
105
+ // eslint-disable-next-line @next/next/no-img-element
106
+ <img
107
+ src={siteConfig.logo.src}
108
+ alt=""
109
+ className="h-8 w-auto"
110
+ />
111
+ ) : (
112
+ <svg
113
+ viewBox="0 0 32 32"
114
+ className="w-8 h-8 text-accent"
115
+ fill="none"
116
+ stroke="currentColor"
117
+ strokeWidth="2"
118
+ strokeLinecap="round"
119
+ strokeLinejoin="round"
120
+ aria-hidden="true"
121
+ >
122
+ <path d="M16 4 L7 28" />
123
+ <path d="M16 4 L25 28" />
124
+ <path d="M11.5 18 H 20.5" />
125
+ <path d="M20.5 18 Q 26 14 26 8 Q 23 12 20.5 18" fill="currentColor" stroke="none" />
126
+ </svg>
127
+ )}
119
128
  <span>{resolveLocaleValue(siteConfig.title, language)}</span>
120
129
  </Link>
121
130
 
@@ -1,10 +1,10 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect, type ReactNode } from 'react';
3
+ import type { ReactNode } from 'react';
4
4
  import Link from 'next/link';
5
- import { useScrollY } from '@/hooks/useScrollY';
6
5
  import type { BacklinkSource, Heading } from '@/lib/markdown';
7
6
  import { useLanguage } from './LanguageProvider';
7
+ import TocPanel from './TocPanel';
8
8
 
9
9
  interface NoteSidebarProps {
10
10
  headings: Heading[];
@@ -15,86 +15,16 @@ interface NoteSidebarProps {
15
15
 
16
16
  export default function NoteSidebar({ headings, showToc, backlinks, breadcrumb }: NoteSidebarProps) {
17
17
  const { t } = useLanguage();
18
- const scrollY = useScrollY();
19
- const [activeHeadingId, setActiveHeadingId] = useState('');
20
- const [tocCollapsed, setTocCollapsed] = useState(false);
21
-
22
- useEffect(() => {
23
- if (!showToc || headings.length === 0) return;
24
- const elements = headings
25
- .map(h => document.getElementById(h.id))
26
- .filter(Boolean) as HTMLElement[];
27
- if (!elements.length) return;
28
- const scrollPosition = scrollY + 100;
29
- let current = elements[0];
30
- for (const el of elements) {
31
- if (el.offsetTop <= scrollPosition) current = el;
32
- else break;
33
- }
34
- const rafId = requestAnimationFrame(() => { if (current) setActiveHeadingId(current.id); });
35
- return () => cancelAnimationFrame(rafId);
36
- }, [scrollY, headings, showToc]);
37
-
38
- const scrollToHeading = (e: React.MouseEvent<HTMLAnchorElement>, id: string) => {
39
- e.preventDefault();
40
- const el = document.getElementById(id);
41
- if (el) {
42
- window.scrollTo({ top: el.getBoundingClientRect().top + window.scrollY - 80, behavior: 'smooth' });
43
- history.pushState(null, '', `#${id}`);
44
- }
45
- };
46
18
 
47
19
  return (
48
20
  <aside 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">
49
21
  {breadcrumb && <div className="mb-4">{breadcrumb}</div>}
50
22
  {/* TOC */}
51
- {showToc && headings.length > 0 && (
52
- <nav
53
- aria-label="Table of contents"
23
+ {showToc && (
24
+ <TocPanel
25
+ headings={headings}
54
26
  className={`mb-6 ${backlinks.length > 0 ? 'pb-6 border-b border-muted/10' : ''}`}
55
- >
56
- <div className="flex items-center justify-between mb-3">
57
- <span className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted">
58
- {t('on_this_page')}
59
- </span>
60
- <button
61
- onClick={() => setTocCollapsed(p => !p)}
62
- className="text-muted hover:text-foreground transition-colors"
63
- aria-label={tocCollapsed ? 'Expand' : 'Collapse'}
64
- >
65
- <svg
66
- className={`w-3.5 h-3.5 transition-transform duration-200 ${tocCollapsed ? '' : 'rotate-180'}`}
67
- fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
68
- >
69
- <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
70
- </svg>
71
- </button>
72
- </div>
73
- {!tocCollapsed && (
74
- <ul className="space-y-0.5 border-l border-muted/15 animate-slide-down">
75
- {headings.map(h => {
76
- const isActive = h.id === activeHeadingId;
77
- return (
78
- <li key={h.id}>
79
- <a
80
- href={`#${h.id}`}
81
- onClick={e => scrollToHeading(e, h.id)}
82
- className={`block py-1 text-[13px] leading-snug no-underline transition-colors duration-200 ${
83
- h.level === 3 ? 'pl-6' : 'pl-3'
84
- } ${
85
- isActive
86
- ? 'text-accent font-medium border-l-2 border-accent -ml-px'
87
- : 'text-foreground/70 hover:text-foreground'
88
- }`}
89
- >
90
- {h.text}
91
- </a>
92
- </li>
93
- );
94
- })}
95
- </ul>
96
- )}
97
- </nav>
27
+ />
98
28
  )}
99
29
 
100
30
  {/* Backlinks */}
@@ -39,8 +39,8 @@ export default function Pagination({ currentPage, totalPages, basePath }: Pagina
39
39
  pages.push('...');
40
40
  }
41
41
  }
42
- // Filter duplicates (like multiple ...)
43
- return pages.filter((item, index) => pages.indexOf(item) === index);
42
+ // Filter consecutive duplicate ellipses
43
+ return pages.filter((p, i) => p !== '...' || pages[i - 1] !== '...');
44
44
  };
45
45
 
46
46
  const pages = getPages();
@@ -1,10 +1,11 @@
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';
4
5
 
5
6
  export default function PostCard({ post }: { post: PostData }) {
6
7
  return (
7
- <Link href={`/posts/${post.slug}`} className="group block h-full no-underline">
8
+ <Link href={getPostUrl(post)} className="group block h-full no-underline">
8
9
  <div className="flex flex-col h-full overflow-hidden rounded-xl border border-muted/20 bg-background transition-all duration-300 hover:border-accent/40 hover:shadow-lg hover:shadow-accent/5">
9
10
  <div className="relative h-32 w-full overflow-hidden bg-muted/10">
10
11
  <CoverImage
@@ -1,22 +1,26 @@
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 { t } from '@/lib/i18n';
4
6
 
5
7
  interface PostListProps {
6
8
  posts: PostData[];
7
9
  showExcerpt?: boolean;
8
10
  showTags?: boolean;
11
+ excerptLines?: 1 | 2;
9
12
  }
10
13
 
11
14
  export default function PostList({
12
15
  posts,
13
16
  showExcerpt = true,
14
17
  showTags = true,
18
+ excerptLines = 2,
15
19
  }: PostListProps) {
16
20
  if (posts.length === 0) {
17
21
  return (
18
22
  <div className="text-center py-12 text-muted">
19
- No posts found.
23
+ {t('no_posts')}
20
24
  </div>
21
25
  );
22
26
  }
@@ -25,78 +29,93 @@ export default function PostList({
25
29
  <div className="space-y-6">
26
30
  {posts.map((post) => (
27
31
  <article key={post.slug} className="group relative">
32
+ {/* Cover link — sits above card content via z-index, tag/series links use z-10 to appear above it */}
28
33
  <Link
29
- href={`/posts/${post.slug}`}
30
- className="block no-underline"
31
- >
32
- {/* Content card */}
33
- <div className="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">
34
- <div className="flex flex-col sm:flex-row">
35
- {/* Thumbnail */}
36
- <div className="relative w-full sm:w-48 h-40 sm:h-auto flex-shrink-0 overflow-hidden bg-muted/10">
37
- <CoverImage
38
- src={post.coverImage}
39
- title={post.title}
40
- slug={post.slug}
41
- className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
42
- />
43
- {/* Draft badge on mobile */}
34
+ href={getPostUrl(post)}
35
+ className="absolute inset-0 z-0 rounded-2xl"
36
+ aria-label={post.title}
37
+ />
38
+
39
+ {/* Content card */}
40
+ <div className="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">
41
+ <div className="flex flex-col sm:flex-row">
42
+ {/* Thumbnail */}
43
+ <div className="relative w-full sm:w-48 h-40 sm:h-auto flex-shrink-0 overflow-hidden bg-muted/10">
44
+ <CoverImage
45
+ src={post.coverImage}
46
+ title={post.title}
47
+ slug={post.slug}
48
+ className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
49
+ />
50
+ {/* Draft badge on mobile */}
51
+ {post.draft && (
52
+ <div className="absolute top-3 left-3 text-[10px] font-bold text-red-500 bg-red-100 dark:bg-red-900/30 px-2 py-0.5 rounded tracking-wider">
53
+ DRAFT
54
+ </div>
55
+ )}
56
+ </div>
57
+
58
+ {/* Content */}
59
+ <div className="flex-1 p-5 sm:p-6 flex flex-col overflow-hidden">
60
+ {/* Meta info */}
61
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-mono text-muted mb-3">
62
+ {post.category && (
63
+ <>
64
+ <span className="text-accent uppercase tracking-wider">{post.category}</span>
65
+ <span className="hidden sm:inline">•</span>
66
+ </>
67
+ )}
68
+ <span>{post.readingTime}</span>
69
+ <span className="hidden sm:inline">•</span>
70
+ <span>{post.date}</span>
44
71
  {post.draft && (
45
- <div className="absolute top-3 left-3 text-[10px] font-bold text-red-500 bg-red-100 dark:bg-red-900/30 px-2 py-0.5 rounded tracking-wider">
72
+ <span className="hidden sm:inline text-[10px] font-bold text-red-500 bg-red-100 dark:bg-red-900/30 px-1.5 py-0.5 rounded tracking-wider">
46
73
  DRAFT
47
- </div>
74
+ </span>
48
75
  )}
49
76
  </div>
50
77
 
51
- {/* Content */}
52
- <div className="flex-1 p-5 sm:p-6 flex flex-col">
53
- {/* Meta info */}
54
- <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-mono text-muted mb-3">
55
- <span>{post.date}</span>
56
- <span className="hidden sm:inline">•</span>
57
- <span className="text-accent/80">{post.readingTime}</span>
58
- {post.category && (
59
- <>
60
- <span className="hidden sm:inline">•</span>
61
- <span className="uppercase tracking-wider">{post.category}</span>
62
- </>
63
- )}
64
- {post.draft && (
65
- <span className="hidden sm:inline text-[10px] font-bold text-red-500 bg-red-100 dark:bg-red-900/30 px-1.5 py-0.5 rounded tracking-wider">
66
- DRAFT
67
- </span>
68
- )}
69
- </div>
78
+ {/* Title */}
79
+ <h3 className="font-serif text-xl font-bold text-heading mb-2 leading-snug group-hover:text-accent transition-colors line-clamp-2">
80
+ {post.title}
81
+ </h3>
70
82
 
71
- {/* Title */}
72
- <h3 className="font-serif text-xl font-bold text-heading mb-2 leading-snug group-hover:text-accent transition-colors line-clamp-2">
73
- {post.title}
74
- </h3>
83
+ {/* Series indicator */}
84
+ {post.series && post.seriesTitle && (
85
+ <p className="text-xs text-muted mb-2">
86
+ <Link
87
+ href={`/series/${post.series}`}
88
+ className="relative z-10 hover:text-accent transition-colors no-underline"
89
+ >
90
+ {t('series')}: {post.seriesTitle}
91
+ </Link>
92
+ </p>
93
+ )}
75
94
 
76
- {/* Excerpt */}
77
- {showExcerpt && post.excerpt && (
78
- <p className="text-sm text-muted leading-relaxed line-clamp-2 mb-4">
79
- {post.excerpt}
80
- </p>
81
- )}
95
+ {/* Excerpt */}
96
+ {showExcerpt && (post.subtitle || post.excerpt) && (
97
+ <p className={`text-sm text-muted leading-relaxed ${excerptLines === 1 ? 'line-clamp-1' : 'line-clamp-2 mb-4'}`}>
98
+ {post.subtitle || post.excerpt}
99
+ </p>
100
+ )}
82
101
 
83
- {/* Tags */}
84
- {showTags && post.tags && post.tags.length > 0 && (
85
- <div className="mt-auto flex flex-wrap gap-2">
86
- {post.tags.slice(0, 3).map(tag => (
87
- <span
88
- key={tag}
89
- className="text-xs px-2 py-0.5 rounded-full bg-muted/10 text-muted/70"
90
- >
91
- {tag}
92
- </span>
93
- ))}
94
- </div>
95
- )}
96
- </div>
102
+ {/* Tags */}
103
+ {showTags && post.tags && post.tags.length > 0 && (
104
+ <div className="mt-auto flex flex-wrap gap-2">
105
+ {post.tags.slice(0, 3).map(tag => (
106
+ <Link
107
+ key={tag}
108
+ href={`/tags/${encodeURIComponent(tag)}`}
109
+ 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"
110
+ >
111
+ {tag}
112
+ </Link>
113
+ ))}
114
+ </div>
115
+ )}
97
116
  </div>
98
117
  </div>
99
- </Link>
118
+ </div>
100
119
  </article>
101
120
  ))}
102
121
  </div>
@@ -1,6 +1,7 @@
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
  interface PostNavigationProps {
6
7
  prev: PostData | null;
@@ -15,9 +16,9 @@ export default function PostNavigation({ prev, next }: PostNavigationProps) {
15
16
  className="mt-12 pt-12 border-t border-muted/20 grid grid-cols-1 sm:grid-cols-2 gap-3"
16
17
  aria-label={t('post_navigation')}
17
18
  >
18
- {prev ? (
19
+ {prev && (
19
20
  <Link
20
- href={`/posts/${prev.slug}`}
21
+ href={getPostUrl(prev)}
21
22
  className="group flex flex-col gap-1.5 p-4 rounded-xl border border-muted/15 hover:border-accent/30 hover:bg-accent/5 transition-all no-underline"
22
23
  >
23
24
  <span className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted flex items-center gap-1.5">
@@ -31,12 +32,12 @@ export default function PostNavigation({ prev, next }: PostNavigationProps) {
31
32
  </span>
32
33
  <span className="text-xs font-mono text-muted/60">{prev.date}</span>
33
34
  </Link>
34
- ) : <div />}
35
+ )}
35
36
 
36
- {next ? (
37
+ {next && (
37
38
  <Link
38
- href={`/posts/${next.slug}`}
39
- className="group flex flex-col gap-1.5 p-4 rounded-xl border border-muted/15 hover:border-accent/30 hover:bg-accent/5 transition-all no-underline sm:items-end sm:text-right"
39
+ href={getPostUrl(next)}
40
+ className={`group flex flex-col gap-1.5 p-4 rounded-xl border border-muted/15 hover:border-accent/30 hover:bg-accent/5 transition-all no-underline sm:items-end sm:text-right${!prev ? ' sm:col-start-2' : ''}`}
40
41
  >
41
42
  <span className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted flex items-center gap-1.5">
42
43
  {t('next')}
@@ -49,7 +50,7 @@ export default function PostNavigation({ prev, next }: PostNavigationProps) {
49
50
  </span>
50
51
  <span className="text-xs font-mono text-muted/60">{next.date}</span>
51
52
  </Link>
52
- ) : <div />}
53
+ )}
53
54
  </nav>
54
55
  );
55
56
  }