@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
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useRef } from 'react';
|
|
4
|
+
import { useSearchParams } from 'next/navigation';
|
|
5
|
+
import Link from 'next/link';
|
|
6
|
+
import type { PostData, CollectionContext, Heading } from '@/lib/markdown';
|
|
7
|
+
import { useLanguage } from './LanguageProvider';
|
|
8
|
+
import { useImmersiveReading } from './ImmersiveReadingProvider';
|
|
9
|
+
import { useSidebarAutoScroll } from '@/hooks/useSidebarAutoScroll';
|
|
10
|
+
import InlineBookToc from './InlineBookToc';
|
|
11
|
+
import { getPostUrl, getPostUrlInCollection, getSeriesListUrl, getSeriesUrl } from '@/lib/urls';
|
|
12
|
+
|
|
13
|
+
// Dedicated TOC sidebar for the immersive reader on a series post. Visually
|
|
14
|
+
// mirrors BookSidebar's `mode="fill"` shape (clean numbered list, left-
|
|
15
|
+
// border accent on the current item, inlined headings under the current
|
|
16
|
+
// post, footer pointing at the listing page) rather than the page-mode
|
|
17
|
+
// SeriesList card — see PR #95 review feedback.
|
|
18
|
+
|
|
19
|
+
interface ImmersiveSeriesSidebarProps {
|
|
20
|
+
seriesSlug: string;
|
|
21
|
+
seriesTitle: string;
|
|
22
|
+
posts: PostData[];
|
|
23
|
+
/** When the post is in a collection, the sidebar can render in that
|
|
24
|
+
* collection's scope by appending `?collection=<slug>` to the URL. Same
|
|
25
|
+
* resolution logic as SeriesList. */
|
|
26
|
+
collectionContexts?: CollectionContext[];
|
|
27
|
+
currentSlug: string;
|
|
28
|
+
/** h2/h3 headings for the current post — rendered as an inline TOC under
|
|
29
|
+
* the current post's row via the shared InlineBookToc component. */
|
|
30
|
+
headings?: Heading[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export default function ImmersiveSeriesSidebar({
|
|
34
|
+
seriesSlug,
|
|
35
|
+
seriesTitle,
|
|
36
|
+
posts,
|
|
37
|
+
collectionContexts,
|
|
38
|
+
currentSlug,
|
|
39
|
+
headings = [],
|
|
40
|
+
}: ImmersiveSeriesSidebarProps) {
|
|
41
|
+
const { t } = useLanguage();
|
|
42
|
+
const { enabled: immersiveEnabled } = useImmersiveReading();
|
|
43
|
+
const searchParams = useSearchParams();
|
|
44
|
+
const collectionParam = searchParams.get('collection');
|
|
45
|
+
const activeCollection = collectionParam
|
|
46
|
+
? (collectionContexts ?? []).find(c => c.slug === collectionParam) ?? null
|
|
47
|
+
: null;
|
|
48
|
+
|
|
49
|
+
const effectiveSlug = activeCollection?.slug ?? seriesSlug;
|
|
50
|
+
const effectiveTitle = activeCollection?.title ?? seriesTitle;
|
|
51
|
+
const effectivePosts = activeCollection?.posts ?? posts;
|
|
52
|
+
const isCollectionContext = !!activeCollection;
|
|
53
|
+
|
|
54
|
+
// Collections mix posts from different layout segments (`/posts/[slug]` vs
|
|
55
|
+
// `/[series]/[slug]`). When clicking across that boundary, the
|
|
56
|
+
// ImmersiveReadingProvider remounts with `enabled=false` and the overlay
|
|
57
|
+
// closes. Appending `?immersive=1` lets the destination layout's
|
|
58
|
+
// ImmersiveReadingFlagHandler re-enter the reader and strip the flag.
|
|
59
|
+
const postHref = (post: PostData) => {
|
|
60
|
+
const base = activeCollection
|
|
61
|
+
? getPostUrlInCollection(post, activeCollection.slug)
|
|
62
|
+
: getPostUrl(post);
|
|
63
|
+
if (!immersiveEnabled) return base;
|
|
64
|
+
const sep = base.includes('?') ? '&' : '?';
|
|
65
|
+
return `${base}${sep}immersive=1`;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const currentIndex = effectivePosts.findIndex(p => p.slug === currentSlug);
|
|
69
|
+
const currentItemRef = useRef<HTMLLIElement>(null);
|
|
70
|
+
const sidebarRef = useRef<HTMLElement>(null);
|
|
71
|
+
useSidebarAutoScroll(sidebarRef, currentItemRef, currentSlug);
|
|
72
|
+
|
|
73
|
+
if (effectivePosts.length === 0) return null;
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<aside
|
|
77
|
+
ref={sidebarRef}
|
|
78
|
+
className="block w-full h-full overflow-y-auto px-4 py-6 scrollbar-hide hover:scrollbar-thin"
|
|
79
|
+
>
|
|
80
|
+
{/* Header — series / collection label + post count + title link */}
|
|
81
|
+
<div className="mb-6 pb-4 border-b border-muted/10">
|
|
82
|
+
<div className="flex items-center justify-between mb-2">
|
|
83
|
+
<span className="text-[10px] font-sans font-bold uppercase tracking-widest text-accent">
|
|
84
|
+
{isCollectionContext ? t('collection') : t('series')}
|
|
85
|
+
</span>
|
|
86
|
+
<span className="text-xs font-mono text-muted whitespace-nowrap">
|
|
87
|
+
{currentIndex + 1}/{effectivePosts.length}
|
|
88
|
+
</span>
|
|
89
|
+
</div>
|
|
90
|
+
<Link href={getSeriesUrl(effectiveSlug)} className="group block no-underline">
|
|
91
|
+
<h3 className="font-serif font-bold text-heading text-lg leading-snug group-hover:text-accent transition-colors">
|
|
92
|
+
{effectiveTitle}
|
|
93
|
+
</h3>
|
|
94
|
+
</Link>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
{/* Posts list — flat, current with left-border + accent (same treatment
|
|
98
|
+
as BookSidebar's chapter link). Past posts dimmed less than future
|
|
99
|
+
posts, also matching BookSidebar. */}
|
|
100
|
+
<nav aria-label={isCollectionContext ? t('collection') : t('series')}>
|
|
101
|
+
<ul className="space-y-1">
|
|
102
|
+
{effectivePosts.map((post, index) => {
|
|
103
|
+
const isCurrent = post.slug === currentSlug;
|
|
104
|
+
const isPast = index < currentIndex;
|
|
105
|
+
return (
|
|
106
|
+
<li key={post.slug} ref={isCurrent ? currentItemRef : undefined}>
|
|
107
|
+
<Link
|
|
108
|
+
href={postHref(post)}
|
|
109
|
+
className={`block py-2 px-3 rounded-lg text-sm no-underline transition-all duration-200 ${
|
|
110
|
+
isCurrent
|
|
111
|
+
? 'bg-accent/10 text-accent font-semibold border-l-2 border-accent'
|
|
112
|
+
: isPast
|
|
113
|
+
? 'text-foreground/70 hover:text-foreground hover:bg-muted/5'
|
|
114
|
+
: 'text-muted hover:text-foreground hover:bg-muted/5'
|
|
115
|
+
}`}
|
|
116
|
+
aria-current={isCurrent ? 'page' : undefined}
|
|
117
|
+
>
|
|
118
|
+
{post.title}
|
|
119
|
+
</Link>
|
|
120
|
+
{isCurrent && <InlineBookToc headings={headings} />}
|
|
121
|
+
</li>
|
|
122
|
+
);
|
|
123
|
+
})}
|
|
124
|
+
</ul>
|
|
125
|
+
</nav>
|
|
126
|
+
|
|
127
|
+
{/* Footer — points at the series listing (not back to the current
|
|
128
|
+
series detail, which the header already links to). Matches
|
|
129
|
+
BookSidebar's "All Books" footer pattern. */}
|
|
130
|
+
<div className="mt-6 pt-4 border-t border-muted/10">
|
|
131
|
+
<Link
|
|
132
|
+
href={getSeriesListUrl()}
|
|
133
|
+
className="text-xs font-sans text-muted hover:text-accent transition-colors no-underline flex items-center gap-1"
|
|
134
|
+
>
|
|
135
|
+
{t('all_series')}
|
|
136
|
+
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
137
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
|
138
|
+
</svg>
|
|
139
|
+
</Link>
|
|
140
|
+
</div>
|
|
141
|
+
</aside>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useImmersiveReading } from '@/components/ImmersiveReadingProvider';
|
|
4
|
+
import { useLanguage } from '@/components/LanguageProvider';
|
|
5
|
+
|
|
6
|
+
export default function ImmersiveToggleButton() {
|
|
7
|
+
const { enabled, toggle } = useImmersiveReading();
|
|
8
|
+
const { t } = useLanguage();
|
|
9
|
+
// The button is the "enter" affordance; in immersive mode the top bar's
|
|
10
|
+
// exit (✕) is the only way out, so the inline button hides to avoid
|
|
11
|
+
// duplicating the exit and reading "Exit reading mode" next to it. Owning
|
|
12
|
+
// the visibility here means callers (PostLayout's article header, etc.)
|
|
13
|
+
// don't need to gate it with `{!enabled && ...}` separately.
|
|
14
|
+
if (enabled) return null;
|
|
15
|
+
const label = t('immersive_reading');
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<button
|
|
19
|
+
type="button"
|
|
20
|
+
onClick={toggle}
|
|
21
|
+
title={label}
|
|
22
|
+
aria-label={label}
|
|
23
|
+
className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-sans text-muted hover:text-accent hover:bg-muted/10 transition-colors border border-transparent hover:border-muted/20 select-none"
|
|
24
|
+
>
|
|
25
|
+
<svg
|
|
26
|
+
width="14"
|
|
27
|
+
height="14"
|
|
28
|
+
viewBox="0 0 24 24"
|
|
29
|
+
fill="none"
|
|
30
|
+
stroke="currentColor"
|
|
31
|
+
strokeWidth="2"
|
|
32
|
+
strokeLinecap="round"
|
|
33
|
+
strokeLinejoin="round"
|
|
34
|
+
aria-hidden="true"
|
|
35
|
+
>
|
|
36
|
+
<path d="M3 7V5a2 2 0 0 1 2-2h2" />
|
|
37
|
+
<path d="M17 3h2a2 2 0 0 1 2 2v2" />
|
|
38
|
+
<path d="M21 17v2a2 2 0 0 1-2 2h-2" />
|
|
39
|
+
<path d="M7 21H5a2 2 0 0 1-2-2v-2" />
|
|
40
|
+
<path d="M8 12h8" />
|
|
41
|
+
</svg>
|
|
42
|
+
<span className="hidden sm:inline">{label}</span>
|
|
43
|
+
</button>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -31,6 +31,22 @@ import { parseFenceMeta } from '@/lib/shiki';
|
|
|
31
31
|
import { isExternalUrl } from '@/lib/urls';
|
|
32
32
|
|
|
33
33
|
|
|
34
|
+
// Flatten an arbitrary React children tree to its text content. Used by the
|
|
35
|
+
// raw-HTML <mermaid> handler below — react-markdown hands us the mermaid
|
|
36
|
+
// source as a tree of text nodes (possibly nested through whitespace-only
|
|
37
|
+
// wrappers) and the Mermaid component expects a single string.
|
|
38
|
+
function flattenChildrenToText(node: React.ReactNode): string {
|
|
39
|
+
if (node == null || typeof node === 'boolean') return '';
|
|
40
|
+
if (typeof node === 'string') return node;
|
|
41
|
+
if (typeof node === 'number') return String(node);
|
|
42
|
+
if (Array.isArray(node)) return node.map(flattenChildrenToText).join('');
|
|
43
|
+
if (React.isValidElement(node)) {
|
|
44
|
+
const children = (node.props as { children?: React.ReactNode }).children;
|
|
45
|
+
return flattenChildrenToText(children);
|
|
46
|
+
}
|
|
47
|
+
return '';
|
|
48
|
+
}
|
|
49
|
+
|
|
34
50
|
interface MarkdownRendererProps {
|
|
35
51
|
content: string;
|
|
36
52
|
latex?: boolean;
|
|
@@ -280,6 +296,21 @@ export default function MarkdownRenderer({ content, latex = false, slug, slugReg
|
|
|
280
296
|
globaltoc: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
|
281
297
|
homehero: () => null,
|
|
282
298
|
chatdemo: () => null,
|
|
299
|
+
// <mermaid>...graph syntax...</mermaid> is the VuePress inline form. We
|
|
300
|
+
// already handle ```mermaid fenced blocks via the `code` renderer above;
|
|
301
|
+
// route the raw-HTML form to the same Mermaid component by flattening
|
|
302
|
+
// the children to a string.
|
|
303
|
+
mermaid: ({ children }: { children?: React.ReactNode }) => (
|
|
304
|
+
<Mermaid chart={flattenChildrenToText(children).trim()} />
|
|
305
|
+
),
|
|
306
|
+
// <GitHubWrapper>...</GitHubWrapper> wraps GitHub project links / cards
|
|
307
|
+
// in the fenix VuePress book. Pass children through unchanged — they're
|
|
308
|
+
// usually a paragraph or an <a>/<img> the author wants to display.
|
|
309
|
+
githubwrapper: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
|
310
|
+
// <words type='span' chapter='/' /> is a VuePress word-count widget that
|
|
311
|
+
// we can't reproduce without the upstream counter. Render nothing — the
|
|
312
|
+
// surrounding prose ("全文合计 X 字") degrades to "全文合计 字".
|
|
313
|
+
words: () => null,
|
|
283
314
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
284
315
|
} as any;
|
|
285
316
|
|
|
@@ -91,7 +91,9 @@ export default function Navbar({ seriesList = [], booksList = [] }: NavbarProps)
|
|
|
91
91
|
}, [isMenuOpen]);
|
|
92
92
|
|
|
93
93
|
return (
|
|
94
|
-
<nav
|
|
94
|
+
<nav
|
|
95
|
+
data-site-nav
|
|
96
|
+
className={`fixed top-0 left-0 w-full z-50 border-b transition-all duration-300 select-none ${
|
|
95
97
|
isScrolled
|
|
96
98
|
? 'border-muted/10 bg-background/90 backdrop-blur-md shadow-sm'
|
|
97
99
|
: 'border-transparent bg-transparent'
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
import { useImmersiveReading } from '@/components/ImmersiveReadingProvider';
|
|
5
|
+
import ImmersiveReader from '@/components/ImmersiveReader';
|
|
6
|
+
import ImmersiveSeriesSidebar from '@/components/ImmersiveSeriesSidebar';
|
|
7
|
+
import { getSeriesUrl } from '@/lib/urls';
|
|
8
|
+
import type { CollectionContext, Heading, PostData } from '@/lib/markdown';
|
|
9
|
+
|
|
10
|
+
interface PostReadingShellProps {
|
|
11
|
+
post: { slug: string; title: string; series?: string; headings?: Heading[] };
|
|
12
|
+
seriesSlug?: string;
|
|
13
|
+
seriesTitle?: string;
|
|
14
|
+
seriesPosts?: PostData[];
|
|
15
|
+
collectionContexts?: CollectionContext[];
|
|
16
|
+
/** Slim article subtree to render inside the overlay (header + body + nav).
|
|
17
|
+
* Pre-built in PostLayout so the heavy MarkdownRenderer/RstRenderer is the
|
|
18
|
+
* same ReactElement reference as in `children` — only one of the two ever
|
|
19
|
+
* mounts, so the body renders exactly once. */
|
|
20
|
+
overlayArticle: ReactNode;
|
|
21
|
+
/** Full normal-mode layout subtree (sidebar + article + comments + nav +
|
|
22
|
+
* related etc.). Rendered when immersive is off OR the post isn't in a
|
|
23
|
+
* series. */
|
|
24
|
+
children: ReactNode;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Post-side analog of BookReadingShell. Branches on the immersive `enabled`
|
|
29
|
+
* flag AND whether the post belongs to a series — without a series there's no
|
|
30
|
+
* meaningful TOC for the overlay sidebar, so the toggle is a no-op and we
|
|
31
|
+
* just render the normal layout.
|
|
32
|
+
*/
|
|
33
|
+
export default function PostReadingShell({
|
|
34
|
+
post,
|
|
35
|
+
seriesSlug,
|
|
36
|
+
seriesTitle,
|
|
37
|
+
seriesPosts,
|
|
38
|
+
collectionContexts,
|
|
39
|
+
overlayArticle,
|
|
40
|
+
children,
|
|
41
|
+
}: PostReadingShellProps) {
|
|
42
|
+
const { enabled } = useImmersiveReading();
|
|
43
|
+
const inSeries = !!(seriesSlug && seriesPosts && seriesPosts.length > 0);
|
|
44
|
+
|
|
45
|
+
if (!enabled || !inSeries) {
|
|
46
|
+
return <>{children}</>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<ImmersiveReader
|
|
51
|
+
rootHref={getSeriesUrl(seriesSlug)}
|
|
52
|
+
rootTitle={seriesTitle ?? seriesSlug}
|
|
53
|
+
currentTitle={post.title}
|
|
54
|
+
sidebar={
|
|
55
|
+
<ImmersiveSeriesSidebar
|
|
56
|
+
seriesSlug={seriesSlug}
|
|
57
|
+
seriesTitle={seriesTitle ?? seriesSlug}
|
|
58
|
+
posts={seriesPosts}
|
|
59
|
+
collectionContexts={collectionContexts}
|
|
60
|
+
currentSlug={post.slug}
|
|
61
|
+
headings={post.headings}
|
|
62
|
+
/>
|
|
63
|
+
}
|
|
64
|
+
>
|
|
65
|
+
{overlayArticle}
|
|
66
|
+
</ImmersiveReader>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -12,7 +12,7 @@ export default function ReadingProgressBar() {
|
|
|
12
12
|
if (progress <= 0) return null;
|
|
13
13
|
|
|
14
14
|
return (
|
|
15
|
-
<div className="fixed top-16 left-0 w-full h-0.5 z-50 bg-muted/10">
|
|
15
|
+
<div data-reading-progress className="fixed top-16 left-0 w-full h-0.5 z-50 bg-muted/10">
|
|
16
16
|
<div
|
|
17
17
|
className="h-full bg-accent/70 transition-[width] duration-150 ease-out"
|
|
18
18
|
style={{ width: `${progress}%` }}
|
|
@@ -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 CoverImage from './CoverImage';
|
|
6
6
|
import HorizontalScroll from './HorizontalScroll';
|
|
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 { getBooksListUrl, getBookUrl, getBookChapterUrl } from '@/lib/urls';
|
|
10
11
|
|
|
11
12
|
export interface BookItem {
|
|
@@ -16,19 +17,37 @@ export interface BookItem {
|
|
|
16
17
|
authors: string[];
|
|
17
18
|
chapterCount: number;
|
|
18
19
|
firstChapter?: string;
|
|
20
|
+
date: string;
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
type BookOrder = 'shuffle' | 'date-desc' | 'date-asc';
|
|
24
|
+
|
|
21
25
|
interface SelectedBooksSectionProps {
|
|
22
26
|
books: BookItem[];
|
|
23
27
|
maxItems?: number;
|
|
28
|
+
order?: BookOrder;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function canonicalOrder(books: BookItem[], order: BookOrder): BookItem[] {
|
|
32
|
+
if (order === 'date-desc') return [...books].sort(byDateDesc);
|
|
33
|
+
if (order === 'date-asc') return [...books].sort(byDateAsc);
|
|
34
|
+
// For 'shuffle': SSR-stable canonical order (input is already date-desc from getAllBooks).
|
|
35
|
+
// The post-mount useEffect swaps to a random permutation on the client.
|
|
36
|
+
return books;
|
|
24
37
|
}
|
|
25
38
|
|
|
26
|
-
export default function SelectedBooksSection({ books, maxItems = 4 }: SelectedBooksSectionProps) {
|
|
39
|
+
export default function SelectedBooksSection({ books, maxItems = 4, order = 'shuffle' }: SelectedBooksSectionProps) {
|
|
27
40
|
const { t } = useLanguage();
|
|
28
|
-
const [displayed, setDisplayed] = useState(() =>
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
41
|
+
const [displayed, setDisplayed] = useState(() => canonicalOrder(books, order).slice(0, maxItems));
|
|
42
|
+
|
|
43
|
+
// Shuffle on mount so every reload re-rolls. SSR's canonical render is stable; the
|
|
44
|
+
// post-hydration swap is the intentional client-only behaviour, not a sync issue.
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (order === 'shuffle') {
|
|
47
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
48
|
+
setDisplayed(shuffle(books).slice(0, maxItems));
|
|
49
|
+
}
|
|
50
|
+
}, [books, maxItems, order]);
|
|
32
51
|
|
|
33
52
|
const handleShuffle = useCallback(() => {
|
|
34
53
|
setDisplayed(shuffle(books).slice(0, maxItems));
|
|
@@ -41,7 +60,7 @@ export default function SelectedBooksSection({ books, maxItems = 4 }: SelectedBo
|
|
|
41
60
|
<div className="flex items-center justify-between mb-8">
|
|
42
61
|
<h2 className="text-2xl sm:text-3xl font-serif font-bold text-heading">{t('selected_books')}</h2>
|
|
43
62
|
<div className="flex items-center gap-4">
|
|
44
|
-
{books.length > maxItems && (
|
|
63
|
+
{order === 'shuffle' && books.length > maxItems && (
|
|
45
64
|
<button
|
|
46
65
|
onClick={handleShuffle}
|
|
47
66
|
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"
|
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect } from 'react';
|
|
4
4
|
import type { Heading } from '@/lib/markdown';
|
|
5
|
-
import {
|
|
5
|
+
import { getScrollableAncestor } from '@/lib/scroll-utils';
|
|
6
|
+
|
|
7
|
+
const ACTIVATION_LINE_PX = 100;
|
|
6
8
|
|
|
7
9
|
export function useActiveHeading(headings: Heading[], enabled = true): string {
|
|
8
10
|
const [activeId, setActiveId] = useState('');
|
|
9
|
-
const scrollY = useScrollY();
|
|
10
11
|
|
|
11
12
|
useEffect(() => {
|
|
12
13
|
if (!enabled || headings.length === 0) return;
|
|
@@ -14,19 +15,40 @@ export function useActiveHeading(headings: Heading[], enabled = true): string {
|
|
|
14
15
|
const elements = headings
|
|
15
16
|
.map(h => document.getElementById(h.id))
|
|
16
17
|
.filter(Boolean) as HTMLElement[];
|
|
17
|
-
|
|
18
18
|
if (elements.length === 0) return;
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
20
|
+
// In immersive reading mode the chapter scrolls inside the overlay's
|
|
21
|
+
// <main>, not the window, so subscribe to whichever ancestor is doing
|
|
22
|
+
// the scrolling. `getScrollableAncestor` returns null for normal pages.
|
|
23
|
+
const container = getScrollableAncestor(elements[0]);
|
|
24
|
+
const target: HTMLElement | Window = container ?? window;
|
|
25
|
+
|
|
26
|
+
let rafId = 0;
|
|
27
|
+
const compute = () => {
|
|
28
|
+
const containerTop = container
|
|
29
|
+
? container.getBoundingClientRect().top
|
|
30
|
+
: 0;
|
|
31
|
+
let current = elements[0];
|
|
32
|
+
for (const el of elements) {
|
|
33
|
+
const top = el.getBoundingClientRect().top - containerTop;
|
|
34
|
+
if (top <= ACTIVATION_LINE_PX) current = el;
|
|
35
|
+
else break;
|
|
36
|
+
}
|
|
37
|
+
setActiveId(current.id);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const onScroll = () => {
|
|
41
|
+
cancelAnimationFrame(rafId);
|
|
42
|
+
rafId = requestAnimationFrame(compute);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
compute();
|
|
46
|
+
target.addEventListener('scroll', onScroll, { passive: true });
|
|
47
|
+
return () => {
|
|
48
|
+
cancelAnimationFrame(rafId);
|
|
49
|
+
target.removeEventListener('scroll', onScroll);
|
|
50
|
+
};
|
|
51
|
+
}, [enabled, headings]);
|
|
30
52
|
|
|
31
53
|
return activeId;
|
|
32
54
|
}
|
|
@@ -1,18 +1,42 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useEffect, type RefObject } from 'react';
|
|
3
|
+
import { useEffect, useRef, type RefObject } from 'react';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Scrolls the current sidebar item into view when `dep` changes.
|
|
7
|
+
*
|
|
8
|
+
* - **First run** (sidebar just mounted — e.g. the reader landed on a page
|
|
9
|
+
* mid-TOC): centres the current item so there's context above and below.
|
|
10
|
+
* - **Subsequent runs** (the reader clicked another sidebar link to
|
|
11
|
+
* navigate): only scrolls if the new current item is out of view, and
|
|
12
|
+
* only enough to bring it on-screen. Crucially, if the target was
|
|
13
|
+
* already visible, the sidebar's scroll position does **not** change —
|
|
14
|
+
* clicking a chapter right in front of you no longer makes the sidebar
|
|
15
|
+
* jump.
|
|
16
|
+
*
|
|
17
|
+
* The previous implementation always hard-centred via `scrollTop = ...`
|
|
18
|
+
* regardless of visibility, which on long book TOCs visibly snapped the
|
|
19
|
+
* sidebar back toward the top whenever the new current chapter was in
|
|
20
|
+
* the upper half. `scrollIntoView({ block: 'nearest' })` handles the
|
|
21
|
+
* "skip if already visible" case natively. The `sidebarRef` first
|
|
22
|
+
* parameter is kept for API stability (all callers still pass it); the
|
|
23
|
+
* new implementation doesn't need it because `scrollIntoView` walks up
|
|
24
|
+
* to the nearest scrollable ancestor on its own.
|
|
25
|
+
*/
|
|
5
26
|
export function useSidebarAutoScroll(
|
|
6
|
-
|
|
27
|
+
_sidebarRef: RefObject<HTMLElement | null>,
|
|
7
28
|
itemRef: RefObject<HTMLElement | null>,
|
|
8
29
|
dep: unknown,
|
|
9
30
|
): void {
|
|
31
|
+
const hasRunRef = useRef(false);
|
|
10
32
|
useEffect(() => {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
33
|
+
const item = itemRef.current;
|
|
34
|
+
if (!item) return;
|
|
35
|
+
item.scrollIntoView({
|
|
36
|
+
block: hasRunRef.current ? 'nearest' : 'center',
|
|
37
|
+
inline: 'nearest',
|
|
38
|
+
});
|
|
39
|
+
hasRunRef.current = true;
|
|
16
40
|
// refs are stable; only re-run when dep changes
|
|
17
41
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
18
42
|
}, [dep]);
|
package/src/i18n/translations.ts
CHANGED
|
@@ -145,6 +145,27 @@ export const translations = {
|
|
|
145
145
|
archive_description: "A complete chronological archive of all articles.",
|
|
146
146
|
tags_description: "Explore topics spanning all articles and flow notes.",
|
|
147
147
|
posts_description: "Browse all articles.",
|
|
148
|
+
immersive_reading: "Immersive reading",
|
|
149
|
+
exit_reading_mode: "Exit reading mode",
|
|
150
|
+
reading_preferences: "Reading preferences",
|
|
151
|
+
font_size: "Font size",
|
|
152
|
+
reading_theme: "Theme",
|
|
153
|
+
column_width: "Width",
|
|
154
|
+
theme_auto: "Auto",
|
|
155
|
+
theme_light: "Light",
|
|
156
|
+
theme_sepia: "Sepia",
|
|
157
|
+
theme_dark: "Dark",
|
|
158
|
+
size_small: "S",
|
|
159
|
+
size_medium: "M",
|
|
160
|
+
size_large: "L",
|
|
161
|
+
size_xl: "XL",
|
|
162
|
+
width_narrow: "Narrow",
|
|
163
|
+
width_medium: "Medium",
|
|
164
|
+
width_wide: "Wide",
|
|
165
|
+
width_full: "Full",
|
|
166
|
+
collapse_sidebar: "Collapse sidebar",
|
|
167
|
+
expand_sidebar: "Expand sidebar",
|
|
168
|
+
reset_to_defaults: "Reset to defaults",
|
|
148
169
|
},
|
|
149
170
|
zh: {
|
|
150
171
|
home: "首页",
|
|
@@ -292,6 +313,27 @@ export const translations = {
|
|
|
292
313
|
archive_description: "全部文章的时间轴归档。",
|
|
293
314
|
tags_description: "浏览全部文章与随笔的主题标签。",
|
|
294
315
|
posts_description: "浏览全部文章。",
|
|
316
|
+
immersive_reading: "沉浸式阅读",
|
|
317
|
+
exit_reading_mode: "退出阅读模式",
|
|
318
|
+
reading_preferences: "阅读设置",
|
|
319
|
+
font_size: "字号",
|
|
320
|
+
reading_theme: "主题",
|
|
321
|
+
column_width: "宽度",
|
|
322
|
+
theme_auto: "自动",
|
|
323
|
+
theme_light: "浅色",
|
|
324
|
+
theme_sepia: "护眼",
|
|
325
|
+
theme_dark: "深色",
|
|
326
|
+
size_small: "小",
|
|
327
|
+
size_medium: "中",
|
|
328
|
+
size_large: "大",
|
|
329
|
+
size_xl: "特大",
|
|
330
|
+
width_narrow: "窄",
|
|
331
|
+
width_medium: "中",
|
|
332
|
+
width_wide: "宽",
|
|
333
|
+
width_full: "全宽",
|
|
334
|
+
collapse_sidebar: "收起目录",
|
|
335
|
+
expand_sidebar: "展开目录",
|
|
336
|
+
reset_to_defaults: "恢复默认",
|
|
295
337
|
},
|
|
296
338
|
};
|
|
297
339
|
|