@hutusi/amytis 1.16.0 → 1.17.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/.claude/rules/immersive-reading.md +21 -0
- package/.claude/rules/rst.md +13 -0
- package/CHANGELOG.md +16 -0
- package/CLAUDE.md +10 -11
- package/docs/ARCHITECTURE.md +81 -0
- package/docs/DIGITAL_GARDEN.md +1 -1
- package/docs/guides/importing-vuepress-books.md +95 -36
- package/package.json +1 -1
- package/scripts/sync-vuepress-book.ts +277 -66
- package/site.config.example.ts +3 -3
- package/site.config.ts +3 -3
- package/src/app/[slug]/layout.tsx +30 -0
- package/src/app/books/[slug]/layout.tsx +24 -0
- package/src/app/books/[slug]/page.tsx +18 -2
- package/src/app/globals.css +67 -0
- package/src/app/page.tsx +6 -0
- package/src/app/posts/layout.tsx +20 -0
- package/src/app/series/[slug]/page.tsx +33 -9
- package/src/components/BookReadingShell.tsx +145 -0
- package/src/components/BookSidebar.tsx +0 -0
- package/src/components/CuratedSeriesSection.tsx +28 -10
- package/src/components/FeaturedStoriesSection.tsx +41 -20
- package/src/components/Footer.tsx +1 -1
- package/src/components/ImmersiveReader.tsx +130 -0
- package/src/components/ImmersiveReaderTopBar.tsx +106 -0
- package/src/components/ImmersiveReadingFlagHandler.tsx +40 -0
- package/src/components/ImmersiveReadingPrefsPopover.tsx +249 -0
- package/src/components/ImmersiveReadingProvider.tsx +168 -0
- package/src/components/ImmersiveSeriesSidebar.tsx +143 -0
- package/src/components/ImmersiveToggleButton.tsx +45 -0
- package/src/components/MarkdownRenderer.tsx +31 -0
- package/src/components/Navbar.tsx +3 -1
- package/src/components/PostReadingShell.tsx +68 -0
- package/src/components/ReadingProgressBar.tsx +1 -1
- package/src/components/SelectedBooksSection.tsx +27 -8
- package/src/hooks/useActiveHeading.ts +35 -13
- package/src/hooks/useSidebarAutoScroll.ts +31 -7
- package/src/i18n/translations.ts +42 -0
- package/src/layouts/BookLayout.tsx +46 -89
- package/src/layouts/PostLayout.tsx +154 -115
- package/src/lib/immersive-reading-prefs.ts +104 -0
- package/src/lib/markdown.ts +18 -11
- package/src/lib/scroll-utils.ts +44 -6
- package/src/lib/shuffle.ts +15 -1
- package/src/lib/sort.ts +15 -0
- package/src/lib/urls.ts +5 -0
- package/tests/integration/book-index-cta.test.ts +87 -0
- package/tests/integration/series-index-cta.test.ts +88 -0
- package/tests/integration/sync-vuepress-book.test.ts +205 -2
- package/tests/unit/immersive-reading-prefs.test.ts +144 -0
- package/vercel.json +7 -0
package/src/app/globals.css
CHANGED
|
@@ -996,3 +996,70 @@ html.dark .rst-rendered aside.admonition-danger {
|
|
|
996
996
|
font-weight: 600;
|
|
997
997
|
color: var(--heading);
|
|
998
998
|
}
|
|
999
|
+
|
|
1000
|
+
/* Immersive reading mode.
|
|
1001
|
+
* Toggled by ImmersiveReadingProvider via the html element's data-immersive
|
|
1002
|
+
* attribute. The fullscreen ImmersiveReader overlay also covers the site
|
|
1003
|
+
* chrome, but these rules are kept as defense-in-depth (and reset the
|
|
1004
|
+
* navbar offset so the scroll padding doesn't peek through the overlay).
|
|
1005
|
+
* Do not strip the data-site-nav / data-site-footer / data-reading-progress
|
|
1006
|
+
* hooks.
|
|
1007
|
+
*
|
|
1008
|
+
* IMPORTANT: every attribute selector below uses an explicit ="true" value,
|
|
1009
|
+
* and no rule starts with `html[...]`. Tailwind v4 / Lightning CSS silently
|
|
1010
|
+
* dead-code-eliminates rules that start with `html[...]` and bare attribute
|
|
1011
|
+
* selectors like `[data-site-nav]` — they end up missing from the compiled
|
|
1012
|
+
* CSS bundle even though they're written here in source. The surviving
|
|
1013
|
+
* patterns are bare-attribute-with-value (e.g. `[data-palette="blue"]`,
|
|
1014
|
+
* `[data-line-numbers="true"]`) and class-prefixed compounds (e.g.
|
|
1015
|
+
* `.dark[data-palette="blue"]`). React serialises a bare JSX
|
|
1016
|
+
* `data-site-nav` prop to the HTML `data-site-nav="true"`, so writing
|
|
1017
|
+
* `="true"` here costs nothing on the component side. */
|
|
1018
|
+
[data-immersive="true"] [data-site-nav="true"],
|
|
1019
|
+
[data-immersive="true"] [data-site-footer="true"],
|
|
1020
|
+
[data-immersive="true"] [data-reading-progress="true"] {
|
|
1021
|
+
display: none;
|
|
1022
|
+
}
|
|
1023
|
+
[data-immersive="true"] #main-content {
|
|
1024
|
+
padding-top: 0;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/* Font-size in the reader overlay is driven by a CSS var set inline on the
|
|
1028
|
+
* overlay container. A var (not stacked text-* classes) sidesteps fighting
|
|
1029
|
+
* the prose-lg specificity. */
|
|
1030
|
+
[data-reader-overlay="true"] .prose {
|
|
1031
|
+
font-size: var(--reading-font-size, 1.125rem);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/* Reading-theme overrides — scoped to the overlay so they compose with the
|
|
1035
|
+
* site's light/dark theme without leaking outside. 'auto' inherits the site
|
|
1036
|
+
* theme (no override needed); 'light' / 'dark' / 'sepia' set their own
|
|
1037
|
+
* variables. The overlay also adds Tailwind's `.dark` class on its container
|
|
1038
|
+
* when readingTheme === 'dark' so `dark:prose-invert` activates regardless of
|
|
1039
|
+
* the site theme. Shiki code blocks keep their own theme on purpose. */
|
|
1040
|
+
[data-reader-overlay="true"][data-reading-theme="light"] {
|
|
1041
|
+
--background: #fafaf9;
|
|
1042
|
+
--foreground: #44403c;
|
|
1043
|
+
--heading: #1c1917;
|
|
1044
|
+
--muted: #78716c;
|
|
1045
|
+
}
|
|
1046
|
+
[data-reader-overlay="true"][data-reading-theme="dark"] {
|
|
1047
|
+
--background: #292524;
|
|
1048
|
+
--foreground: #f5f5f4;
|
|
1049
|
+
--heading: #fafaf9;
|
|
1050
|
+
--muted: #a8a29e;
|
|
1051
|
+
}
|
|
1052
|
+
[data-reader-overlay="true"][data-reading-theme="sepia"] {
|
|
1053
|
+
--background: #f4ecd8;
|
|
1054
|
+
--foreground: #5b4636;
|
|
1055
|
+
--heading: #3b2f24;
|
|
1056
|
+
--muted: #8a7a66;
|
|
1057
|
+
}
|
|
1058
|
+
[data-reader-overlay="true"][data-reading-theme="sepia"] .prose h1,
|
|
1059
|
+
[data-reader-overlay="true"][data-reading-theme="sepia"] .prose h2,
|
|
1060
|
+
[data-reader-overlay="true"][data-reading-theme="sepia"] .prose h3,
|
|
1061
|
+
[data-reader-overlay="true"][data-reading-theme="sepia"] .prose h4,
|
|
1062
|
+
[data-reader-overlay="true"][data-reading-theme="sepia"] .prose h5,
|
|
1063
|
+
[data-reader-overlay="true"][data-reading-theme="sepia"] .prose h6 {
|
|
1064
|
+
color: var(--heading);
|
|
1065
|
+
}
|
package/src/app/page.tsx
CHANGED
|
@@ -34,6 +34,7 @@ type HomepageSection = {
|
|
|
34
34
|
enabled?: boolean;
|
|
35
35
|
weight: number;
|
|
36
36
|
maxItems?: number;
|
|
37
|
+
order?: 'shuffle' | 'date-desc' | 'date-asc';
|
|
37
38
|
};
|
|
38
39
|
|
|
39
40
|
export default function Home() {
|
|
@@ -76,6 +77,7 @@ export default function Home() {
|
|
|
76
77
|
url: `/series/${slug}`,
|
|
77
78
|
postCount: seriesPosts.length,
|
|
78
79
|
topPosts: seriesPosts.slice(0, 3).map(p => ({ slug: p.slug, title: p.title })),
|
|
80
|
+
date: seriesData?.date ?? seriesPosts[0]?.date ?? '',
|
|
79
81
|
};
|
|
80
82
|
})
|
|
81
83
|
: [];
|
|
@@ -89,6 +91,7 @@ export default function Home() {
|
|
|
89
91
|
authors: b.authors,
|
|
90
92
|
chapterCount: b.chapters.length,
|
|
91
93
|
firstChapter: b.chapters[0]?.id,
|
|
94
|
+
date: b.date,
|
|
92
95
|
}))
|
|
93
96
|
: [];
|
|
94
97
|
|
|
@@ -133,6 +136,7 @@ export default function Home() {
|
|
|
133
136
|
key="featured-series"
|
|
134
137
|
allSeries={seriesItems}
|
|
135
138
|
maxItems={section.maxItems ?? 6}
|
|
139
|
+
order={section.order ?? 'shuffle'}
|
|
136
140
|
/>
|
|
137
141
|
);
|
|
138
142
|
case 'featured-books':
|
|
@@ -142,6 +146,7 @@ export default function Home() {
|
|
|
142
146
|
key="featured-books"
|
|
143
147
|
books={bookItems}
|
|
144
148
|
maxItems={section.maxItems ?? 4}
|
|
149
|
+
order={section.order ?? 'shuffle'}
|
|
145
150
|
/>
|
|
146
151
|
);
|
|
147
152
|
case 'featured-posts':
|
|
@@ -151,6 +156,7 @@ export default function Home() {
|
|
|
151
156
|
key="featured-posts"
|
|
152
157
|
allFeatured={featuredItems}
|
|
153
158
|
maxItems={section.maxItems ?? 4}
|
|
159
|
+
order={section.order ?? 'shuffle'}
|
|
154
160
|
/>
|
|
155
161
|
);
|
|
156
162
|
case 'latest-posts':
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Suspense, type ReactNode } from 'react';
|
|
2
|
+
import { ImmersiveReadingProvider } from '@/components/ImmersiveReadingProvider';
|
|
3
|
+
import ImmersiveReadingFlagHandler from '@/components/ImmersiveReadingFlagHandler';
|
|
4
|
+
|
|
5
|
+
// Mirror of `src/app/[slug]/layout.tsx` for the default-path post URL
|
|
6
|
+
// (`/posts/<slug>`). Series posts can live under either URL pattern
|
|
7
|
+
// depending on the `series.autoPaths` setting in site.config.ts — having the
|
|
8
|
+
// provider mounted at both layout boundaries means immersive state persists
|
|
9
|
+
// across in-series navigation regardless of which pattern the user's site
|
|
10
|
+
// uses. Same Suspense isolation around the flag handler.
|
|
11
|
+
export default function PostsLayout({ children }: { children: ReactNode }) {
|
|
12
|
+
return (
|
|
13
|
+
<ImmersiveReadingProvider>
|
|
14
|
+
<Suspense fallback={null}>
|
|
15
|
+
<ImmersiveReadingFlagHandler />
|
|
16
|
+
</Suspense>
|
|
17
|
+
{children}
|
|
18
|
+
</ImmersiveReadingProvider>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -156,16 +156,40 @@ export default async function SeriesPage({ params }: { params: Promise<{ slug: s
|
|
|
156
156
|
const firstPost = (seriesData?.sort === 'date-asc' || seriesData?.sort === 'manual' || isCollection)
|
|
157
157
|
? allPosts[0]
|
|
158
158
|
: allPosts[allPosts.length - 1];
|
|
159
|
+
const primaryHref = isCollection ? getPostUrlInCollection(firstPost, slug) : getPostUrl(firstPost);
|
|
160
|
+
// primaryHref already carries `?collection=<slug>` for collection
|
|
161
|
+
// contexts (see getPostUrlInCollection), so naïvely appending
|
|
162
|
+
// `?immersive=1` would produce an invalid double-`?` URL and the
|
|
163
|
+
// flag handler would never fire. Use the right separator.
|
|
164
|
+
const immersiveHref = `${primaryHref}${primaryHref.includes('?') ? '&' : '?'}immersive=1`;
|
|
159
165
|
return (
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
166
|
+
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
|
|
167
|
+
<Link
|
|
168
|
+
href={primaryHref}
|
|
169
|
+
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full bg-accent text-white text-sm font-medium hover:bg-accent/90 transition-colors no-underline"
|
|
170
|
+
>
|
|
171
|
+
{t('start_reading')}
|
|
172
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
173
|
+
<polyline points="9 18 15 12 9 6" />
|
|
174
|
+
</svg>
|
|
175
|
+
</Link>
|
|
176
|
+
{/* Secondary CTA — opens the first post of the series in immersive mode.
|
|
177
|
+
The `?immersive=1` query param is read by ImmersiveReadingProvider
|
|
178
|
+
on mount, which calls enter() then strips the flag from the URL
|
|
179
|
+
so back-navigation doesn't re-trigger it. */}
|
|
180
|
+
<Link
|
|
181
|
+
href={immersiveHref}
|
|
182
|
+
className="inline-flex items-center gap-2 px-5 py-2.5 border border-muted/30 text-foreground/80 hover:text-accent hover:border-accent/50 rounded-full text-sm font-medium no-underline transition-colors"
|
|
183
|
+
>
|
|
184
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
185
|
+
<path d="M3 7V5a2 2 0 0 1 2-2h2" />
|
|
186
|
+
<path d="M17 3h2a2 2 0 0 1 2 2v2" />
|
|
187
|
+
<path d="M21 17v2a2 2 0 0 1-2 2h-2" />
|
|
188
|
+
<path d="M7 21H5a2 2 0 0 1-2-2v-2" />
|
|
189
|
+
</svg>
|
|
190
|
+
{t('immersive_reading')}
|
|
191
|
+
</Link>
|
|
192
|
+
</div>
|
|
169
193
|
);
|
|
170
194
|
})()}
|
|
171
195
|
{authors.length > 0 && (
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
import BookSidebar from '@/components/BookSidebar';
|
|
5
|
+
import BookMobileNav from '@/components/BookMobileNav';
|
|
6
|
+
import PrevNextNav from '@/components/PrevNextNav';
|
|
7
|
+
import ReadingProgressBar from '@/components/ReadingProgressBar';
|
|
8
|
+
import Comments from '@/components/Comments';
|
|
9
|
+
import { useLanguage } from '@/components/LanguageProvider';
|
|
10
|
+
import { useImmersiveReading } from '@/components/ImmersiveReadingProvider';
|
|
11
|
+
import ImmersiveReader from '@/components/ImmersiveReader';
|
|
12
|
+
import ImmersiveToggleButton from '@/components/ImmersiveToggleButton';
|
|
13
|
+
import type { BookTocItem, BookChapterEntry, Heading } from '@/lib/markdown';
|
|
14
|
+
import { getBookUrl } from '@/lib/urls';
|
|
15
|
+
|
|
16
|
+
interface BookReadingShellProps {
|
|
17
|
+
book: {
|
|
18
|
+
slug: string;
|
|
19
|
+
title: string;
|
|
20
|
+
toc: BookTocItem[];
|
|
21
|
+
chapters: BookChapterEntry[];
|
|
22
|
+
showChapterExcerpt: boolean;
|
|
23
|
+
};
|
|
24
|
+
chapter: {
|
|
25
|
+
slug: string;
|
|
26
|
+
title: string;
|
|
27
|
+
wordCount: number;
|
|
28
|
+
readingMinutes: number;
|
|
29
|
+
excerpt?: string;
|
|
30
|
+
headings: Heading[];
|
|
31
|
+
};
|
|
32
|
+
prev: { href: string; title: string } | null;
|
|
33
|
+
next: { href: string; title: string } | null;
|
|
34
|
+
comments: { slug: string; postUrl: string } | null;
|
|
35
|
+
children: ReactNode;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default function BookReadingShell({
|
|
39
|
+
book,
|
|
40
|
+
chapter,
|
|
41
|
+
prev,
|
|
42
|
+
next,
|
|
43
|
+
comments,
|
|
44
|
+
children,
|
|
45
|
+
}: BookReadingShellProps) {
|
|
46
|
+
const { t } = useLanguage();
|
|
47
|
+
const { enabled } = useImmersiveReading();
|
|
48
|
+
|
|
49
|
+
const chapterHeader = (
|
|
50
|
+
<header className="mb-12 pb-8 border-b border-muted/10">
|
|
51
|
+
<div className="flex items-center gap-3 text-xs font-sans text-muted mb-4">
|
|
52
|
+
<span className="uppercase tracking-widest font-semibold text-accent">
|
|
53
|
+
{t('chapter')}
|
|
54
|
+
</span>
|
|
55
|
+
<span className="w-1 h-1 rounded-full bg-muted/30" />
|
|
56
|
+
<span className="font-mono">
|
|
57
|
+
{chapter.wordCount.toLocaleString()} {t('words')}
|
|
58
|
+
</span>
|
|
59
|
+
<span className="w-1 h-1 rounded-full bg-muted/30" />
|
|
60
|
+
<span className="font-mono text-muted/70">
|
|
61
|
+
{chapter.readingMinutes} {t('reading_time')}
|
|
62
|
+
</span>
|
|
63
|
+
{/* ImmersiveToggleButton hides itself when enabled — no outer wrap
|
|
64
|
+
needed beyond the layout positioning. */}
|
|
65
|
+
<span className="ml-auto">
|
|
66
|
+
<ImmersiveToggleButton />
|
|
67
|
+
</span>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<h1 className="text-3xl md:text-4xl font-serif font-bold text-heading leading-tight mb-4">
|
|
71
|
+
{chapter.title}
|
|
72
|
+
</h1>
|
|
73
|
+
|
|
74
|
+
{book.showChapterExcerpt && chapter.excerpt && (
|
|
75
|
+
<p className="text-lg text-muted font-serif italic leading-relaxed">
|
|
76
|
+
{chapter.excerpt}
|
|
77
|
+
</p>
|
|
78
|
+
)}
|
|
79
|
+
</header>
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const prevNext = (
|
|
83
|
+
<div className="mt-16 pt-8 border-t border-muted/10">
|
|
84
|
+
<PrevNextNav prev={prev} next={next} size="lg" />
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
if (enabled) {
|
|
89
|
+
return (
|
|
90
|
+
<ImmersiveReader
|
|
91
|
+
rootHref={getBookUrl(book.slug)}
|
|
92
|
+
rootTitle={book.title}
|
|
93
|
+
currentTitle={chapter.title}
|
|
94
|
+
sidebar={
|
|
95
|
+
<BookSidebar
|
|
96
|
+
mode="fill"
|
|
97
|
+
bookSlug={book.slug}
|
|
98
|
+
bookTitle={book.title}
|
|
99
|
+
toc={book.toc}
|
|
100
|
+
chapters={book.chapters}
|
|
101
|
+
currentChapter={chapter.slug}
|
|
102
|
+
headings={chapter.headings}
|
|
103
|
+
/>
|
|
104
|
+
}
|
|
105
|
+
>
|
|
106
|
+
{chapterHeader}
|
|
107
|
+
{children}
|
|
108
|
+
{prevNext}
|
|
109
|
+
</ImmersiveReader>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<div className="layout-container">
|
|
115
|
+
<ReadingProgressBar />
|
|
116
|
+
<div className="grid grid-cols-1 lg:grid-cols-[280px_minmax(0,1fr)] gap-8 items-start">
|
|
117
|
+
<BookSidebar
|
|
118
|
+
bookSlug={book.slug}
|
|
119
|
+
bookTitle={book.title}
|
|
120
|
+
toc={book.toc}
|
|
121
|
+
chapters={book.chapters}
|
|
122
|
+
currentChapter={chapter.slug}
|
|
123
|
+
headings={chapter.headings}
|
|
124
|
+
/>
|
|
125
|
+
|
|
126
|
+
<article className="min-w-0 w-full max-w-3xl mx-auto overflow-x-hidden">
|
|
127
|
+
<div className="lg:hidden mb-8">
|
|
128
|
+
<BookMobileNav
|
|
129
|
+
bookSlug={book.slug}
|
|
130
|
+
bookTitle={book.title}
|
|
131
|
+
toc={book.toc}
|
|
132
|
+
chapters={book.chapters}
|
|
133
|
+
currentChapter={chapter.slug}
|
|
134
|
+
/>
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
{chapterHeader}
|
|
138
|
+
{children}
|
|
139
|
+
{comments && <Comments slug={comments.slug} postUrl={comments.postUrl} />}
|
|
140
|
+
{prevNext}
|
|
141
|
+
</article>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
Binary file
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState, useCallback } from 'react';
|
|
3
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
4
4
|
import Link from 'next/link';
|
|
5
5
|
import HorizontalScroll from './HorizontalScroll';
|
|
6
6
|
import CoverImage from './CoverImage';
|
|
7
7
|
import { useLanguage } from './LanguageProvider';
|
|
8
|
-
import { shuffle
|
|
8
|
+
import { shuffle } from '@/lib/shuffle';
|
|
9
|
+
import { byDateAsc, byDateDesc } from '@/lib/sort';
|
|
9
10
|
import { getPostUrl, getSeriesListUrl } from '@/lib/urls';
|
|
10
11
|
|
|
11
12
|
export interface SeriesItem {
|
|
@@ -16,21 +17,38 @@ export interface SeriesItem {
|
|
|
16
17
|
url: string;
|
|
17
18
|
postCount: number;
|
|
18
19
|
topPosts: { slug: string; title: string }[];
|
|
20
|
+
date: string;
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
type SeriesOrder = 'shuffle' | 'date-desc' | 'date-asc';
|
|
24
|
+
|
|
21
25
|
interface CuratedSeriesSectionProps {
|
|
22
26
|
allSeries: SeriesItem[];
|
|
23
27
|
maxItems: number;
|
|
28
|
+
order?: SeriesOrder;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function canonicalOrder(series: SeriesItem[], order: SeriesOrder): SeriesItem[] {
|
|
32
|
+
if (order === 'date-desc') return [...series].sort(byDateDesc);
|
|
33
|
+
if (order === 'date-asc') return [...series].sort(byDateAsc);
|
|
34
|
+
return series;
|
|
24
35
|
}
|
|
25
36
|
|
|
26
|
-
export default function CuratedSeriesSection({ allSeries, maxItems }: CuratedSeriesSectionProps) {
|
|
37
|
+
export default function CuratedSeriesSection({ allSeries, maxItems, order = 'shuffle' }: CuratedSeriesSectionProps) {
|
|
27
38
|
const { t } = useLanguage();
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
39
|
+
// SSR renders the canonical input order so server and client agree on first paint.
|
|
40
|
+
// For 'shuffle', the post-mount useEffect swaps to a fresh random permutation,
|
|
41
|
+
// so every reload re-rolls without any hydration mismatch.
|
|
42
|
+
const [displayed, setDisplayed] = useState(() => canonicalOrder(allSeries, order).slice(0, maxItems));
|
|
43
|
+
|
|
44
|
+
// Shuffle on mount so every reload re-rolls. SSR's canonical render is stable; the
|
|
45
|
+
// post-hydration swap is the intentional client-only behaviour, not a sync issue.
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (order === 'shuffle') {
|
|
48
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
49
|
+
setDisplayed(shuffle(allSeries).slice(0, maxItems));
|
|
50
|
+
}
|
|
51
|
+
}, [allSeries, maxItems, order]);
|
|
34
52
|
|
|
35
53
|
const handleShuffle = useCallback(() => {
|
|
36
54
|
setDisplayed(shuffle(allSeries).slice(0, maxItems));
|
|
@@ -43,7 +61,7 @@ export default function CuratedSeriesSection({ allSeries, maxItems }: CuratedSer
|
|
|
43
61
|
<div className="flex items-center justify-between mb-8">
|
|
44
62
|
<h2 className="text-2xl sm:text-3xl font-serif font-bold text-heading">{t('curated_series')}</h2>
|
|
45
63
|
<div className="flex items-center gap-4">
|
|
46
|
-
{allSeries.length > maxItems && (
|
|
64
|
+
{order === 'shuffle' && allSeries.length > maxItems && (
|
|
47
65
|
<button
|
|
48
66
|
onClick={handleShuffle}
|
|
49
67
|
className="rounded-sm text-sm text-muted transition-colors hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 focus-visible:ring-offset-2"
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState, useCallback } from 'react';
|
|
3
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
4
4
|
import Link from 'next/link';
|
|
5
5
|
import CoverImage from './CoverImage';
|
|
6
6
|
import { useLanguage } from './LanguageProvider';
|
|
7
|
-
import { shuffle
|
|
7
|
+
import { shuffle } from '@/lib/shuffle';
|
|
8
|
+
import { byDateAsc, byDateDesc } from '@/lib/sort';
|
|
8
9
|
import { getPostUrl } from '@/lib/urls';
|
|
9
10
|
|
|
10
11
|
export interface FeaturedPost {
|
|
@@ -20,56 +21,76 @@ export interface FeaturedPost {
|
|
|
20
21
|
pinned?: boolean;
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
type PostOrder = 'shuffle' | 'date-desc' | 'date-asc';
|
|
25
|
+
|
|
23
26
|
interface FeaturedStoriesSectionProps {
|
|
24
27
|
allFeatured: FeaturedPost[];
|
|
25
28
|
maxItems: number;
|
|
29
|
+
order?: PostOrder;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function canonicalOrder(posts: FeaturedPost[], order: PostOrder): FeaturedPost[] {
|
|
33
|
+
if (order === 'date-desc') return [...posts].sort(byDateDesc);
|
|
34
|
+
if (order === 'date-asc') return [...posts].sort(byDateAsc);
|
|
35
|
+
return posts;
|
|
26
36
|
}
|
|
27
37
|
|
|
28
|
-
function buildDisplayed(allFeatured: FeaturedPost[], maxItems: number,
|
|
38
|
+
function buildDisplayed(allFeatured: FeaturedPost[], maxItems: number, orderedNonPinned: FeaturedPost[]): FeaturedPost[] {
|
|
29
39
|
const pinned = allFeatured.filter(p => p.pinned);
|
|
30
|
-
const nonPinned = allFeatured.filter(p => !p.pinned);
|
|
31
40
|
|
|
32
|
-
const hero = pinned[0] ??
|
|
41
|
+
const hero = pinned[0] ?? orderedNonPinned[0];
|
|
33
42
|
if (!hero) return [];
|
|
34
43
|
|
|
35
44
|
const maxSecondaries = maxItems - 1;
|
|
36
45
|
const fixedSecondaries = pinned.slice(1, maxSecondaries + 1); // cap to available secondary slots
|
|
37
|
-
const
|
|
46
|
+
const fillSlots = Math.max(0, maxSecondaries - fixedSecondaries.length);
|
|
38
47
|
|
|
39
48
|
// Non-pinned pool excludes the hero if the hero is non-pinned
|
|
40
49
|
const heroIsNonPinned = !hero.pinned;
|
|
41
|
-
const
|
|
42
|
-
const
|
|
50
|
+
const fillPool = heroIsNonPinned ? orderedNonPinned.filter(p => p.slug !== hero.slug) : orderedNonPinned;
|
|
51
|
+
const fillSlice = fillPool.slice(0, fillSlots);
|
|
43
52
|
|
|
44
|
-
return [hero, ...fixedSecondaries, ...
|
|
53
|
+
return [hero, ...fixedSecondaries, ...fillSlice];
|
|
45
54
|
}
|
|
46
55
|
|
|
47
|
-
export default function FeaturedStoriesSection({ allFeatured, maxItems }: FeaturedStoriesSectionProps) {
|
|
56
|
+
export default function FeaturedStoriesSection({ allFeatured, maxItems, order = 'shuffle' }: FeaturedStoriesSectionProps) {
|
|
48
57
|
const { t } = useLanguage();
|
|
49
58
|
|
|
50
59
|
const nonPinned = allFeatured.filter(p => !p.pinned);
|
|
51
60
|
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
61
|
+
// SSR renders the canonical input order so server and client agree on first paint.
|
|
62
|
+
// For 'shuffle', the post-mount useEffect swaps to a fresh random permutation,
|
|
63
|
+
// so every reload re-rolls without any hydration mismatch.
|
|
64
|
+
const [orderedNonPinned, setOrderedNonPinned] = useState<FeaturedPost[]>(() => canonicalOrder(nonPinned, order));
|
|
65
|
+
|
|
66
|
+
// Shuffle on mount so every reload re-rolls. SSR's canonical render is stable; the
|
|
67
|
+
// post-hydration swap is the intentional client-only behaviour, not a sync issue.
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (order === 'shuffle') {
|
|
70
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
71
|
+
setOrderedNonPinned(shuffle(nonPinned));
|
|
72
|
+
}
|
|
73
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
74
|
+
}, [allFeatured, order]);
|
|
58
75
|
|
|
59
76
|
const handleShuffle = useCallback(() => {
|
|
60
|
-
|
|
77
|
+
setOrderedNonPinned(shuffle(nonPinned));
|
|
61
78
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
62
79
|
}, [allFeatured]);
|
|
63
80
|
|
|
64
|
-
const displayed = buildDisplayed(allFeatured, maxItems,
|
|
81
|
+
const displayed = buildDisplayed(allFeatured, maxItems, orderedNonPinned);
|
|
65
82
|
|
|
66
83
|
if (displayed.length === 0) return null;
|
|
67
84
|
|
|
68
|
-
// Show shuffle button only when there
|
|
85
|
+
// Show shuffle button only when shuffling AND there's at least one non-pinned slot
|
|
86
|
+
// AND there are more non-pinned posts than available slots
|
|
69
87
|
const pinned = allFeatured.filter(p => p.pinned);
|
|
70
88
|
const fixedCount = 1 + Math.min(pinned.slice(1).length, maxItems - 1);
|
|
71
89
|
const shuffleSlots = Math.max(0, maxItems - fixedCount);
|
|
72
|
-
const canShuffle =
|
|
90
|
+
const canShuffle =
|
|
91
|
+
order === 'shuffle'
|
|
92
|
+
&& shuffleSlots > 0
|
|
93
|
+
&& nonPinned.length > shuffleSlots + (pinned.length === 0 ? 1 : 0);
|
|
73
94
|
|
|
74
95
|
const [hero, ...secondary] = displayed;
|
|
75
96
|
|
|
@@ -11,7 +11,7 @@ export default function Footer() {
|
|
|
11
11
|
const { t, language } = useLanguage();
|
|
12
12
|
|
|
13
13
|
return (
|
|
14
|
-
<footer className="bg-muted/5 border-t border-muted/10 mt-auto select-none">
|
|
14
|
+
<footer data-site-footer className="bg-muted/5 border-t border-muted/10 mt-auto select-none">
|
|
15
15
|
<div className="max-w-6xl mx-auto px-6 py-10 lg:py-16">
|
|
16
16
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8 lg:gap-12 mb-10 lg:mb-12">
|
|
17
17
|
{/* Brand */}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
|
4
|
+
import ImmersiveReaderTopBar from '@/components/ImmersiveReaderTopBar';
|
|
5
|
+
import {
|
|
6
|
+
useImmersiveReading,
|
|
7
|
+
type ReadingColumnWidth,
|
|
8
|
+
type ReadingFontSize,
|
|
9
|
+
} from '@/components/ImmersiveReadingProvider';
|
|
10
|
+
|
|
11
|
+
const FONT_SIZE_REM: Record<ReadingFontSize, string> = {
|
|
12
|
+
s: '1rem',
|
|
13
|
+
m: '1.125rem',
|
|
14
|
+
l: '1.25rem',
|
|
15
|
+
xl: '1.5rem',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const COLUMN_WIDTH_CLASS: Record<ReadingColumnWidth, string> = {
|
|
19
|
+
narrow: 'max-w-2xl',
|
|
20
|
+
medium: 'max-w-3xl',
|
|
21
|
+
wide: 'max-w-4xl',
|
|
22
|
+
full: 'max-w-none',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const COLUMN_PADDING_CLASS: Record<ReadingColumnWidth, string> = {
|
|
26
|
+
narrow: 'px-6 sm:px-8',
|
|
27
|
+
medium: 'px-6 sm:px-8',
|
|
28
|
+
wide: 'px-6 sm:px-8',
|
|
29
|
+
full: 'px-6 sm:px-10',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
interface ImmersiveReaderProps {
|
|
33
|
+
/** Breadcrumb root link — book detail page for books, series index for series. */
|
|
34
|
+
rootHref: string;
|
|
35
|
+
/** Left side of the top-bar breadcrumb (book title or series title). */
|
|
36
|
+
rootTitle: string;
|
|
37
|
+
/** Right side of the breadcrumb (chapter or post title). */
|
|
38
|
+
currentTitle: string;
|
|
39
|
+
/** Pre-rendered sidebar element. Caller passes `<BookSidebar mode="fill" ...>`
|
|
40
|
+
* or `<SeriesList mode="fill" ...>` so the overlay stays content-type-agnostic. */
|
|
41
|
+
sidebar: ReactNode;
|
|
42
|
+
children: ReactNode;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export default function ImmersiveReader({
|
|
46
|
+
rootHref,
|
|
47
|
+
rootTitle,
|
|
48
|
+
currentTitle,
|
|
49
|
+
sidebar,
|
|
50
|
+
children,
|
|
51
|
+
}: ImmersiveReaderProps) {
|
|
52
|
+
const { fontSize, readingTheme, columnWidth, sidebarOpen } = useImmersiveReading();
|
|
53
|
+
|
|
54
|
+
const mainRef = useRef<HTMLElement>(null);
|
|
55
|
+
const [progress, setProgress] = useState(0);
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
const main = mainRef.current;
|
|
59
|
+
if (!main) return;
|
|
60
|
+
let rafId = 0;
|
|
61
|
+
const compute = () => {
|
|
62
|
+
const scrollable = main.scrollHeight - main.clientHeight;
|
|
63
|
+
const pct =
|
|
64
|
+
scrollable > 0
|
|
65
|
+
? Math.min(100, Math.max(0, (main.scrollTop / scrollable) * 100))
|
|
66
|
+
: 0;
|
|
67
|
+
setProgress(pct);
|
|
68
|
+
};
|
|
69
|
+
const onScroll = () => {
|
|
70
|
+
cancelAnimationFrame(rafId);
|
|
71
|
+
rafId = requestAnimationFrame(compute);
|
|
72
|
+
};
|
|
73
|
+
compute();
|
|
74
|
+
main.addEventListener('scroll', onScroll, { passive: true });
|
|
75
|
+
return () => {
|
|
76
|
+
cancelAnimationFrame(rafId);
|
|
77
|
+
main.removeEventListener('scroll', onScroll);
|
|
78
|
+
};
|
|
79
|
+
}, []);
|
|
80
|
+
|
|
81
|
+
const overlayStyle: CSSProperties = {
|
|
82
|
+
['--reading-font-size' as keyof CSSProperties]: FONT_SIZE_REM[fontSize],
|
|
83
|
+
} as CSSProperties;
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<div
|
|
87
|
+
data-reader-overlay
|
|
88
|
+
data-reading-theme={readingTheme}
|
|
89
|
+
style={overlayStyle}
|
|
90
|
+
// `dark` is added when readingTheme === 'dark' so Tailwind's `dark:`
|
|
91
|
+
// variants (notably `dark:prose-invert` in MarkdownRenderer) activate
|
|
92
|
+
// inside the overlay even when the site itself is in light mode.
|
|
93
|
+
className={`fixed inset-0 z-40 flex flex-col bg-background text-foreground ${
|
|
94
|
+
readingTheme === 'dark' ? 'dark' : ''
|
|
95
|
+
}`}
|
|
96
|
+
role="dialog"
|
|
97
|
+
aria-modal="true"
|
|
98
|
+
aria-label={rootTitle}
|
|
99
|
+
>
|
|
100
|
+
<ImmersiveReaderTopBar
|
|
101
|
+
rootHref={rootHref}
|
|
102
|
+
rootTitle={rootTitle}
|
|
103
|
+
currentTitle={currentTitle}
|
|
104
|
+
/>
|
|
105
|
+
|
|
106
|
+
{progress > 0 && (
|
|
107
|
+
<div className="h-0.5 w-full bg-muted/10 shrink-0">
|
|
108
|
+
<div
|
|
109
|
+
className="h-full bg-accent/70 transition-[width] duration-150 ease-out"
|
|
110
|
+
style={{ width: `${progress}%` }}
|
|
111
|
+
/>
|
|
112
|
+
</div>
|
|
113
|
+
)}
|
|
114
|
+
|
|
115
|
+
<div className="flex-1 min-h-0 flex">
|
|
116
|
+
{sidebarOpen && (
|
|
117
|
+
<aside className="w-[280px] shrink-0 border-r border-muted/15 bg-background/60">
|
|
118
|
+
{sidebar}
|
|
119
|
+
</aside>
|
|
120
|
+
)}
|
|
121
|
+
|
|
122
|
+
<main ref={mainRef} className="flex-1 min-w-0 overflow-y-auto">
|
|
123
|
+
<article className={`${COLUMN_WIDTH_CLASS[columnWidth]} mx-auto ${COLUMN_PADDING_CLASS[columnWidth]} py-10`}>
|
|
124
|
+
{children}
|
|
125
|
+
</article>
|
|
126
|
+
</main>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|