@hutusi/amytis 1.9.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.entire/settings.json +4 -0
- package/AGENTS.md +13 -7
- package/CHANGELOG.md +41 -0
- package/CLAUDE.md +30 -2
- package/README.md +84 -58
- package/README.zh.md +195 -0
- package/TODO.md +6 -4
- package/bun.lock +30 -4
- package/content/books/sample-book/images/digital-garden.svg +25 -0
- package/content/books/sample-book/index.mdx +1 -1
- package/content/books/sample-book/introduction.mdx +2 -0
- package/content/posts/2026-01-21-kitchen-sink/index.mdx +26 -1
- package/content/posts/legacy-markdown.md +0 -2
- package/content/posts/markdown-features.mdx +1 -1
- package/content/posts/multilingual-test.mdx +0 -1
- package/content/posts/syntax-highlighting-showcase.mdx +1 -0
- package/content/posts/understanding-react-hooks.mdx +1 -0
- package/content/series/ai-nexus-weekly/index.mdx +1 -1
- package/content/series/digital-garden/01-philosophy.mdx +2 -0
- package/content/series/digital-garden/index.mdx +1 -1
- package/content/series/markdown-showcase/index.mdx +1 -1
- package/content/series/markdown-showcase/syntax-highlighting.mdx +2 -0
- package/content/series/nextjs-deep-dive/01-getting-started.mdx +2 -1
- package/content/series/nextjs-deep-dive/index.mdx +1 -1
- package/content/subscribe.mdx +27 -0
- package/docs/ARCHITECTURE.md +85 -105
- package/docs/CONTRIBUTING.md +11 -3
- package/docs/DIGITAL_GARDEN.md +11 -9
- package/docs/deployment.md +31 -13
- package/eslint.config.mjs +2 -0
- package/package.json +13 -4
- package/packages/create-amytis/package.json +13 -0
- package/packages/create-amytis/src/index.test.ts +187 -0
- package/packages/create-amytis/src/index.ts +230 -0
- package/packages/create-amytis/tsconfig.json +14 -0
- package/public/images/amytis-screenshot.jpg +0 -0
- package/public/images/antelope-canyon.jpg +0 -0
- package/public/images/avatar.jpg +0 -0
- package/public/images/cappadocia.jpg +0 -0
- package/public/images/flowers.jpg +0 -0
- package/public/images/galaxy.jpg +0 -0
- package/public/images/lake.jpg +0 -0
- package/public/images/mountains.jpg +0 -0
- package/public/images/vibrant-waves.avif +0 -0
- package/public/images/wechat-qr.jpg +0 -0
- package/public/next-image-export-optimizer-hashes.json +72 -2
- package/scripts/deploy.ts +77 -0
- package/site.config.example.ts +268 -0
- package/site.config.ts +82 -10
- package/src/app/[slug]/[postSlug]/page.tsx +161 -0
- package/src/app/[slug]/page/[page]/page.tsx +202 -0
- package/src/app/[slug]/page.tsx +162 -7
- package/src/app/archive/page.tsx +5 -8
- package/src/app/books/[slug]/[chapter]/page.tsx +45 -3
- package/src/app/books/[slug]/page.tsx +16 -2
- package/src/app/books/page.tsx +12 -8
- package/src/app/feed.atom/route.ts +62 -0
- package/src/app/feed.xml/route.ts +45 -20
- package/src/app/flows/[year]/[month]/[day]/page.tsx +9 -1
- package/src/app/flows/[year]/page.tsx +1 -1
- package/src/app/flows/page/[page]/page.tsx +2 -0
- package/src/app/flows/page.tsx +4 -0
- package/src/app/globals.css +9 -3
- package/src/app/layout.tsx +22 -1
- package/src/app/notes/[slug]/page.tsx +2 -3
- package/src/app/page.tsx +86 -8
- package/src/app/posts/[slug]/page.tsx +33 -7
- package/src/app/posts/page/[page]/page.tsx +9 -5
- package/src/app/posts/page.tsx +13 -7
- package/src/app/series/[slug]/page/[page]/page.tsx +8 -25
- package/src/app/series/[slug]/page.tsx +21 -19
- package/src/app/series/page.tsx +14 -28
- package/src/app/sitemap.ts +2 -1
- package/src/app/tags/[tag]/page.tsx +1 -1
- package/src/app/tags/page.tsx +1 -1
- package/src/components/Analytics.tsx +34 -36
- package/src/components/AuthorCard.tsx +56 -14
- package/src/components/BookMobileNav.tsx +8 -34
- package/src/components/BookSidebar.tsx +17 -107
- package/src/components/CodeBlock.test.tsx +19 -0
- package/src/components/CodeBlock.tsx +20 -16
- package/src/components/Comments.tsx +4 -3
- package/src/components/CoverImage.tsx +11 -3
- package/src/components/CuratedSeriesSection.tsx +17 -15
- package/src/components/ExternalLinks.tsx +1 -1
- package/src/components/FeaturedStoriesSection.tsx +124 -58
- package/src/components/FlowCalendarSidebar.tsx +10 -7
- package/src/components/FlowContent.tsx +33 -5
- package/src/components/Footer.tsx +28 -6
- package/src/components/Hero.tsx +44 -92
- package/src/components/InlineBookToc.tsx +56 -0
- package/src/components/KnowledgeGraph.tsx +26 -0
- package/src/components/LanguageProvider.tsx +7 -2
- package/src/components/LanguageSwitch.tsx +1 -1
- package/src/components/LatestWritingSection.tsx +12 -22
- package/src/components/MarkdownRenderer.test.tsx +41 -0
- package/src/components/MarkdownRenderer.tsx +43 -8
- package/src/components/Navbar.tsx +24 -15
- package/src/components/NoteSidebar.tsx +6 -76
- package/src/components/Pagination.tsx +2 -2
- package/src/components/PostCard.tsx +2 -1
- package/src/components/PostList.tsx +81 -62
- package/src/components/PostNavigation.tsx +8 -7
- package/src/components/PostSidebar.tsx +12 -107
- package/src/components/PrevNextNav.tsx +63 -0
- package/src/components/RecentNotesSection.tsx +2 -2
- package/src/components/RelatedPosts.tsx +3 -2
- package/src/components/RssFeedWidget.tsx +55 -0
- package/src/components/SectionLabel.tsx +16 -0
- package/src/components/SelectedBooksSection.tsx +12 -15
- package/src/components/SeriesCatalog.tsx +74 -69
- package/src/components/SeriesList.tsx +11 -36
- package/src/components/SeriesSidebar.tsx +4 -2
- package/src/components/TagContentTabs.tsx +17 -12
- package/src/components/TagPageHeader.tsx +12 -2
- package/src/components/TagSidebar.tsx +3 -2
- package/src/components/TagsIndexClient.tsx +1 -1
- package/src/components/TocPanel.tsx +67 -0
- package/src/hooks/useActiveHeading.ts +32 -0
- package/src/hooks/useSidebarAutoScroll.ts +19 -0
- package/src/i18n/translations.ts +26 -4
- package/src/layouts/BookLayout.tsx +10 -40
- package/src/layouts/PostLayout.tsx +37 -26
- package/src/layouts/SimpleLayout.tsx +5 -5
- package/src/lib/feed-utils.ts +68 -0
- package/src/lib/format-utils.test.ts +27 -0
- package/src/lib/format-utils.ts +3 -0
- package/src/lib/image-utils.ts +12 -0
- package/src/lib/json-ld.ts +236 -0
- package/src/lib/markdown.ts +62 -7
- package/src/lib/rehype-image-metadata.test.ts +124 -0
- package/src/lib/rehype-image-metadata.ts +21 -5
- package/src/lib/scroll-utils.ts +11 -0
- package/src/lib/shuffle.ts +21 -0
- package/src/lib/urls.ts +51 -0
- package/tests/e2e/navigation.test.ts +15 -1
- package/tests/integration/books.test.ts +61 -0
- package/tests/integration/feed-utils.test.ts +145 -0
- package/tests/integration/series.test.ts +9 -0
- package/tests/unit/static-params.test.ts +59 -2
- package/src/app/subscribe/page.tsx +0 -17
- package/src/components/SubscribePage.tsx +0 -298
- /package/public/{screenshot.png → images/screenshot.png} +0 -0
|
@@ -1,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
|
|
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
|
-
|
|
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={
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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 {
|
|
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 &&
|
|
52
|
-
<
|
|
53
|
-
|
|
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
|
|
43
|
-
return pages.filter((
|
|
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={
|
|
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
|
-
|
|
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={
|
|
30
|
-
className="
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
74
|
+
</span>
|
|
48
75
|
)}
|
|
49
76
|
</div>
|
|
50
77
|
|
|
51
|
-
{/*
|
|
52
|
-
<
|
|
53
|
-
{
|
|
54
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
</
|
|
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={
|
|
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
|
-
)
|
|
35
|
+
)}
|
|
35
36
|
|
|
36
|
-
{next
|
|
37
|
+
{next && (
|
|
37
38
|
<Link
|
|
38
|
-
href={
|
|
39
|
-
className=
|
|
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
|
-
)
|
|
53
|
+
)}
|
|
53
54
|
</nav>
|
|
54
55
|
);
|
|
55
56
|
}
|