@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,10 +1,13 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState,
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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={
|
|
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
|
-
{
|
|
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
|
|
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
|
|
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={
|
|
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
|
|
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-
|
|
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
|
-
<
|
|
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={
|
|
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-
|
|
52
|
-
<span className="badge-accent
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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={
|
|
23
|
-
className="
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
</
|
|
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>
|