@hutusi/amytis 1.15.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 +42 -0
- package/CLAUDE.md +89 -219
- package/bun.lock +185 -547
- package/content/books/sample-book/index.mdx +3 -0
- package/content/posts/code-block-features-showcase.mdx +223 -0
- package/docs/ALERTS.md +112 -0
- package/docs/ARCHITECTURE.md +298 -5
- package/docs/CODE-BLOCKS.md +238 -0
- package/docs/CONTRIBUTING.md +25 -0
- package/docs/DIGITAL_GARDEN.md +1 -1
- package/docs/guides/README.md +11 -0
- package/docs/guides/importing-vuepress-books.md +237 -0
- package/eslint.config.mjs +18 -6
- package/package.json +42 -20
- package/scripts/generate-code-group-icons.ts +79 -0
- package/scripts/render-rst.py +207 -3
- package/scripts/sync-vuepress-book.ts +710 -0
- 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]/{[chapter] → [...chapter]}/page.tsx +32 -10
- package/src/app/books/[slug]/layout.tsx +24 -0
- package/src/app/books/[slug]/page.tsx +85 -34
- package/src/app/globals.css +570 -123
- package/src/app/page.tsx +7 -1
- package/src/app/posts/layout.tsx +20 -0
- package/src/app/series/[slug]/page.tsx +33 -9
- package/src/app/sitemap.ts +3 -3
- package/src/components/ArticleCopyCleaner.tsx +64 -0
- package/src/components/BookMobileNav.tsx +44 -50
- package/src/components/BookReadingShell.tsx +145 -0
- package/src/components/BookSidebar.tsx +0 -0
- package/src/components/CodeBlock.test.tsx +93 -8
- package/src/components/CodeBlock.tsx +39 -101
- package/src/components/CodeBlockToolbar.tsx +88 -0
- package/src/components/CodeGroup.tsx +81 -0
- package/src/components/CoverImage.tsx +1 -0
- package/src/components/CuratedSeriesSection.tsx +28 -10
- package/src/components/ExternalLinkIcon.tsx +15 -0
- package/src/components/FeaturedStoriesSection.tsx +44 -23
- package/src/components/Footer.tsx +1 -1
- package/src/components/GithubAlert.tsx +97 -0
- 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.test.tsx +14 -4
- package/src/components/MarkdownRenderer.tsx +175 -23
- package/src/components/Mermaid.tsx +32 -1
- package/src/components/Navbar.tsx +3 -1
- package/src/components/PostList.tsx +1 -1
- package/src/components/PostNavigation.tsx +13 -2
- package/src/components/PostReadingShell.tsx +68 -0
- package/src/components/PostSidebar.tsx +13 -2
- package/src/components/ReadingProgressBar.tsx +1 -1
- package/src/components/RstRenderer.test.tsx +15 -15
- package/src/components/RstRenderer.tsx +37 -2
- package/src/components/Search.tsx +18 -4
- package/src/components/SelectedBooksSection.tsx +27 -8
- package/src/components/SeriesCatalog.tsx +1 -1
- package/src/components/ShareBar.tsx +5 -0
- package/src/components/TocPanel.tsx +10 -2
- package/src/hooks/useActiveHeading.ts +35 -13
- package/src/hooks/useSidebarAutoScroll.ts +31 -7
- package/src/i18n/translations.ts +44 -0
- package/src/layouts/BookLayout.tsx +62 -74
- package/src/layouts/PostLayout.tsx +154 -111
- package/src/lib/code-group-icons.test.ts +78 -0
- package/src/lib/code-group-icons.ts +148 -0
- package/src/lib/immersive-reading-prefs.ts +104 -0
- package/src/lib/markdown.test.ts +56 -13
- package/src/lib/markdown.ts +217 -57
- package/src/lib/normalize-vuepress-math.ts +118 -0
- package/src/lib/rehype-fence-meta.ts +22 -0
- package/src/lib/remark-book-chapter-links.ts +106 -0
- package/src/lib/remark-code-group.ts +54 -0
- package/src/lib/remark-github-alerts.test.ts +83 -0
- package/src/lib/remark-github-alerts.ts +65 -0
- package/src/lib/remark-vuepress-containers.ts +130 -0
- package/src/lib/rst-renderer.ts +19 -7
- package/src/lib/rst.test.ts +212 -2
- package/src/lib/rst.ts +217 -13
- package/src/lib/scroll-utils.ts +44 -6
- package/src/lib/shiki-rst.ts +185 -0
- package/src/lib/shiki.test.ts +153 -0
- package/src/lib/shiki.ts +292 -0
- package/src/lib/shuffle.ts +15 -1
- package/src/lib/sort.ts +15 -0
- package/src/lib/urls.ts +62 -0
- package/src/test-utils/render.ts +23 -0
- package/tests/fixtures/sync-vuepress-book/docs/.vuepress/config.js +43 -0
- package/tests/fixtures/sync-vuepress-book/docs/intro/welcome.md +7 -0
- package/tests/fixtures/sync-vuepress-book/docs/maths/linear/assets/diagram.png +1 -0
- package/tests/fixtures/sync-vuepress-book/docs/maths/linear/matrices.md +7 -0
- package/tests/fixtures/sync-vuepress-book/docs/maths/linear/vectors.md +9 -0
- package/tests/helpers/env.ts +19 -0
- package/tests/integration/book-chapter-links.test.ts +107 -0
- package/tests/integration/book-index-cta.test.ts +87 -0
- package/tests/integration/books-nested-toc.test.ts +176 -0
- package/tests/integration/books.test.ts +3 -2
- package/tests/integration/code-block-features.test.ts +188 -0
- package/tests/integration/code-group.test.ts +183 -0
- package/tests/integration/code-notation.test.ts +97 -0
- package/tests/integration/github-alerts.test.ts +82 -0
- package/tests/integration/markdown-external-links.test.ts +103 -0
- package/tests/integration/normalize-vuepress-math.test.ts +149 -0
- package/tests/integration/reading-time-headings.test.ts +8 -6
- package/tests/integration/series-draft.test.ts +6 -13
- package/tests/integration/series-index-cta.test.ts +88 -0
- package/tests/integration/sync-vuepress-book.test.ts +443 -0
- package/tests/integration/vuepress-containers.test.ts +107 -0
- package/tests/tooling/new-post.test.ts +1 -1
- package/tests/unit/immersive-reading-prefs.test.ts +144 -0
- package/tests/unit/static-params.test.ts +32 -19
- package/vercel.json +7 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import Link from 'next/link';
|
|
4
|
+
import { useRef } from 'react';
|
|
5
|
+
import { useImmersiveReading } from '@/components/ImmersiveReadingProvider';
|
|
6
|
+
import { useLanguage } from '@/components/LanguageProvider';
|
|
7
|
+
import ImmersiveReadingPrefsPopover from '@/components/ImmersiveReadingPrefsPopover';
|
|
8
|
+
|
|
9
|
+
interface ImmersiveReaderTopBarProps {
|
|
10
|
+
/** Breadcrumb root link — caller computes the URL (book URL or series URL). */
|
|
11
|
+
rootHref: string;
|
|
12
|
+
/** Left side of the breadcrumb. */
|
|
13
|
+
rootTitle: string;
|
|
14
|
+
/** Right side of the breadcrumb. */
|
|
15
|
+
currentTitle: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default function ImmersiveReaderTopBar({
|
|
19
|
+
rootHref,
|
|
20
|
+
rootTitle,
|
|
21
|
+
currentTitle,
|
|
22
|
+
}: ImmersiveReaderTopBarProps) {
|
|
23
|
+
const { t } = useLanguage();
|
|
24
|
+
const { sidebarOpen, prefsPanelOpen, toggleSidebar, togglePrefsPanel, exit } = useImmersiveReading();
|
|
25
|
+
|
|
26
|
+
const prefsButtonRef = useRef<HTMLButtonElement>(null);
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<header
|
|
30
|
+
// `relative z-30` is load-bearing: `backdrop-blur-md` creates a stacking
|
|
31
|
+
// context on the header, which would otherwise paint at "block-in-flow"
|
|
32
|
+
// (step 3) of the overlay's stacking context — BELOW positioned
|
|
33
|
+
// descendants of <main>, e.g. code blocks (cb-root is `position: relative`).
|
|
34
|
+
// Promoting the header to a positioned descendant with z-index pushes it
|
|
35
|
+
// above those, so the Aa popover (which renders inside the header and
|
|
36
|
+
// visually overflows down into the article area) stays on top and
|
|
37
|
+
// clickable when it overlaps a code block.
|
|
38
|
+
className="relative z-30 h-12 flex items-center gap-3 px-3 border-b border-muted/15 bg-background/95 backdrop-blur-md shrink-0 select-none"
|
|
39
|
+
>
|
|
40
|
+
<button
|
|
41
|
+
type="button"
|
|
42
|
+
onClick={toggleSidebar}
|
|
43
|
+
aria-pressed={sidebarOpen}
|
|
44
|
+
aria-label={sidebarOpen ? t('collapse_sidebar') : t('expand_sidebar')}
|
|
45
|
+
title={sidebarOpen ? t('collapse_sidebar') : t('expand_sidebar')}
|
|
46
|
+
className="h-8 w-8 inline-flex items-center justify-center rounded-md text-foreground/80 hover:text-accent hover:bg-muted/10 transition-colors"
|
|
47
|
+
>
|
|
48
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
49
|
+
<line x1="3" y1="6" x2="21" y2="6" />
|
|
50
|
+
<line x1="3" y1="12" x2="21" y2="12" />
|
|
51
|
+
<line x1="3" y1="18" x2="21" y2="18" />
|
|
52
|
+
</svg>
|
|
53
|
+
</button>
|
|
54
|
+
|
|
55
|
+
<div className="min-w-0 flex-1 flex items-center gap-2 text-sm">
|
|
56
|
+
<Link
|
|
57
|
+
href={rootHref}
|
|
58
|
+
className="font-serif font-semibold text-heading hover:text-accent truncate no-underline"
|
|
59
|
+
title={rootTitle}
|
|
60
|
+
>
|
|
61
|
+
{rootTitle}
|
|
62
|
+
</Link>
|
|
63
|
+
<span className="text-muted/50 hidden sm:inline" aria-hidden="true">/</span>
|
|
64
|
+
<span className="text-muted truncate hidden sm:inline" title={currentTitle}>
|
|
65
|
+
{currentTitle}
|
|
66
|
+
</span>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<div className="relative shrink-0">
|
|
70
|
+
<button
|
|
71
|
+
ref={prefsButtonRef}
|
|
72
|
+
type="button"
|
|
73
|
+
onClick={togglePrefsPanel}
|
|
74
|
+
aria-expanded={prefsPanelOpen}
|
|
75
|
+
aria-haspopup="dialog"
|
|
76
|
+
aria-label={t('reading_preferences')}
|
|
77
|
+
title={t('reading_preferences')}
|
|
78
|
+
className={`h-8 w-9 inline-flex items-center justify-center rounded-md transition-colors ${
|
|
79
|
+
prefsPanelOpen
|
|
80
|
+
? 'bg-muted/10 text-accent'
|
|
81
|
+
: 'text-foreground/80 hover:text-accent hover:bg-muted/10'
|
|
82
|
+
}`}
|
|
83
|
+
>
|
|
84
|
+
<span aria-hidden="true" className="font-serif leading-none">
|
|
85
|
+
<span className="text-[15px] font-bold">A</span>
|
|
86
|
+
<span className="text-[10px]">a</span>
|
|
87
|
+
</span>
|
|
88
|
+
</button>
|
|
89
|
+
<ImmersiveReadingPrefsPopover toggleButtonRef={prefsButtonRef} />
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<button
|
|
93
|
+
type="button"
|
|
94
|
+
onClick={exit}
|
|
95
|
+
aria-label={t('exit_reading_mode')}
|
|
96
|
+
title={t('exit_reading_mode')}
|
|
97
|
+
className="h-8 w-8 inline-flex items-center justify-center rounded-md text-foreground/80 hover:text-accent hover:bg-muted/10 transition-colors shrink-0"
|
|
98
|
+
>
|
|
99
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
100
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
101
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
102
|
+
</svg>
|
|
103
|
+
</button>
|
|
104
|
+
</header>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
|
5
|
+
import { useImmersiveReading } from '@/components/ImmersiveReadingProvider';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Watches the URL for `?immersive=1` (set by the "Immersive reading" CTA on
|
|
9
|
+
* the book index page) and, when present, enters the reader and strips the
|
|
10
|
+
* flag so back-navigation doesn't loop it open.
|
|
11
|
+
*
|
|
12
|
+
* Lives in its own component so the `useSearchParams` bailout to client
|
|
13
|
+
* rendering is contained — the Suspense boundary in the parent layout only
|
|
14
|
+
* wraps this null-rendering handler, not the chapter content. Keeps the rest
|
|
15
|
+
* of the book sub-tree statically prerenderable.
|
|
16
|
+
*
|
|
17
|
+
* No one-shot ref guard: router.replace strips the flag, which updates
|
|
18
|
+
* useSearchParams reactively, which re-fires this effect with the flag gone
|
|
19
|
+
* (early return). A subsequent explicit visit to another `?immersive=1` URL
|
|
20
|
+
* in the same tab (e.g. browser back, or clicking the CTA again) re-triggers
|
|
21
|
+
* the entry — a stale ref would silently swallow that.
|
|
22
|
+
*/
|
|
23
|
+
export default function ImmersiveReadingFlagHandler() {
|
|
24
|
+
const searchParams = useSearchParams();
|
|
25
|
+
const router = useRouter();
|
|
26
|
+
const pathname = usePathname();
|
|
27
|
+
const { enter } = useImmersiveReading();
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (searchParams?.get('immersive') !== '1') return;
|
|
31
|
+
const activate = () => enter();
|
|
32
|
+
activate();
|
|
33
|
+
const params = new URLSearchParams(searchParams.toString());
|
|
34
|
+
params.delete('immersive');
|
|
35
|
+
const qs = params.toString();
|
|
36
|
+
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
|
|
37
|
+
}, [searchParams, pathname, router, enter]);
|
|
38
|
+
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, type ReactNode, type RefObject } from 'react';
|
|
4
|
+
import {
|
|
5
|
+
useImmersiveReading,
|
|
6
|
+
type ReadingColumnWidth,
|
|
7
|
+
type ReadingFontSize,
|
|
8
|
+
type ReadingTheme,
|
|
9
|
+
} from '@/components/ImmersiveReadingProvider';
|
|
10
|
+
import { useLanguage } from '@/components/LanguageProvider';
|
|
11
|
+
import type { TranslationKey } from '@/i18n/translations';
|
|
12
|
+
|
|
13
|
+
interface ImmersiveReadingPrefsPopoverProps {
|
|
14
|
+
/** The toggle button — its ref is excluded from outside-click closing so the
|
|
15
|
+
* button's onClick is the single source of truth for toggling. */
|
|
16
|
+
toggleButtonRef: RefObject<HTMLElement | null>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const FONT_PREVIEW_PX: Record<ReadingFontSize, number> = {
|
|
20
|
+
s: 12,
|
|
21
|
+
m: 15,
|
|
22
|
+
l: 19,
|
|
23
|
+
xl: 24,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const FONT_OPTIONS: Array<{ value: ReadingFontSize; labelKey: TranslationKey }> = [
|
|
27
|
+
{ value: 's', labelKey: 'size_small' },
|
|
28
|
+
{ value: 'm', labelKey: 'size_medium' },
|
|
29
|
+
{ value: 'l', labelKey: 'size_large' },
|
|
30
|
+
{ value: 'xl', labelKey: 'size_xl' },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const THEME_OPTIONS: Array<{ value: ReadingTheme; labelKey: TranslationKey }> = [
|
|
34
|
+
{ value: 'auto', labelKey: 'theme_auto' },
|
|
35
|
+
{ value: 'light', labelKey: 'theme_light' },
|
|
36
|
+
{ value: 'sepia', labelKey: 'theme_sepia' },
|
|
37
|
+
{ value: 'dark', labelKey: 'theme_dark' },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const WIDTH_OPTIONS: Array<{ value: ReadingColumnWidth; labelKey: TranslationKey; barWidthPct: number }> = [
|
|
41
|
+
{ value: 'narrow', labelKey: 'width_narrow', barWidthPct: 35 },
|
|
42
|
+
{ value: 'medium', labelKey: 'width_medium', barWidthPct: 55 },
|
|
43
|
+
{ value: 'wide', labelKey: 'width_wide', barWidthPct: 75 },
|
|
44
|
+
{ value: 'full', labelKey: 'width_full', barWidthPct: 100 },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
// Tailwind classes baked into the theme swatch — concrete hex / vars so the
|
|
48
|
+
// reader sees the actual colours, not an approximation.
|
|
49
|
+
const THEME_SWATCH_STYLE: Record<ReadingTheme, string> = {
|
|
50
|
+
auto: 'bg-gradient-to-br from-stone-50 from-50% to-stone-800 to-50% text-stone-700',
|
|
51
|
+
light: 'bg-stone-50 text-stone-900',
|
|
52
|
+
sepia: 'bg-[#f4ecd8] text-[#3b2f24]',
|
|
53
|
+
dark: 'bg-stone-800 text-stone-100',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function GroupHeading({ children }: { children: ReactNode }) {
|
|
57
|
+
return (
|
|
58
|
+
<div className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted/80 mb-2">
|
|
59
|
+
{children}
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function OptionButton({
|
|
65
|
+
active,
|
|
66
|
+
onClick,
|
|
67
|
+
ariaLabel,
|
|
68
|
+
title,
|
|
69
|
+
children,
|
|
70
|
+
className = '',
|
|
71
|
+
}: {
|
|
72
|
+
active: boolean;
|
|
73
|
+
onClick: () => void;
|
|
74
|
+
ariaLabel: string;
|
|
75
|
+
title: string;
|
|
76
|
+
children: ReactNode;
|
|
77
|
+
className?: string;
|
|
78
|
+
}) {
|
|
79
|
+
return (
|
|
80
|
+
<button
|
|
81
|
+
type="button"
|
|
82
|
+
onClick={onClick}
|
|
83
|
+
aria-pressed={active}
|
|
84
|
+
aria-label={ariaLabel}
|
|
85
|
+
title={title}
|
|
86
|
+
className={`relative flex items-center justify-center rounded-lg transition-all duration-150 ${
|
|
87
|
+
active
|
|
88
|
+
? 'ring-2 ring-accent ring-offset-2 ring-offset-background'
|
|
89
|
+
: 'ring-1 ring-muted/20 hover:ring-muted/40'
|
|
90
|
+
} ${className}`}
|
|
91
|
+
>
|
|
92
|
+
{children}
|
|
93
|
+
</button>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export default function ImmersiveReadingPrefsPopover({ toggleButtonRef }: ImmersiveReadingPrefsPopoverProps) {
|
|
98
|
+
const { t } = useLanguage();
|
|
99
|
+
const {
|
|
100
|
+
prefsPanelOpen,
|
|
101
|
+
fontSize,
|
|
102
|
+
readingTheme,
|
|
103
|
+
columnWidth,
|
|
104
|
+
setFontSize,
|
|
105
|
+
setReadingTheme,
|
|
106
|
+
setColumnWidth,
|
|
107
|
+
closePrefsPanel,
|
|
108
|
+
resetPrefs,
|
|
109
|
+
} = useImmersiveReading();
|
|
110
|
+
|
|
111
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
112
|
+
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (!prefsPanelOpen) return;
|
|
115
|
+
// pointerdown unifies mouse, touch, and pen — needed so taps outside the
|
|
116
|
+
// popover dismiss it on mobile too (mousedown alone doesn't fire there
|
|
117
|
+
// reliably).
|
|
118
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
119
|
+
const target = event.target as Node;
|
|
120
|
+
if (rootRef.current?.contains(target)) return;
|
|
121
|
+
if (toggleButtonRef.current?.contains(target)) return;
|
|
122
|
+
closePrefsPanel();
|
|
123
|
+
};
|
|
124
|
+
document.addEventListener('pointerdown', onPointerDown);
|
|
125
|
+
return () => document.removeEventListener('pointerdown', onPointerDown);
|
|
126
|
+
}, [prefsPanelOpen, closePrefsPanel, toggleButtonRef]);
|
|
127
|
+
|
|
128
|
+
if (!prefsPanelOpen) return null;
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<div
|
|
132
|
+
ref={rootRef}
|
|
133
|
+
role="dialog"
|
|
134
|
+
aria-label={t('reading_preferences')}
|
|
135
|
+
className="absolute top-full right-0 mt-2 z-50 min-w-[280px] bg-background/95 backdrop-blur-md border border-muted/20 rounded-xl shadow-xl p-5 animate-slide-down"
|
|
136
|
+
>
|
|
137
|
+
{/* Font Size */}
|
|
138
|
+
<div className="mb-5">
|
|
139
|
+
<GroupHeading>{t('font_size')}</GroupHeading>
|
|
140
|
+
<div className="grid grid-cols-4 gap-2">
|
|
141
|
+
{FONT_OPTIONS.map(opt => {
|
|
142
|
+
const active = opt.value === fontSize;
|
|
143
|
+
return (
|
|
144
|
+
<OptionButton
|
|
145
|
+
key={opt.value}
|
|
146
|
+
active={active}
|
|
147
|
+
onClick={() => setFontSize(opt.value)}
|
|
148
|
+
ariaLabel={`${t('font_size')}: ${t(opt.labelKey)}`}
|
|
149
|
+
title={t(opt.labelKey)}
|
|
150
|
+
className="h-12 bg-background"
|
|
151
|
+
>
|
|
152
|
+
<span
|
|
153
|
+
aria-hidden="true"
|
|
154
|
+
className="font-serif font-semibold text-foreground leading-none"
|
|
155
|
+
style={{ fontSize: `${FONT_PREVIEW_PX[opt.value]}px` }}
|
|
156
|
+
>
|
|
157
|
+
A
|
|
158
|
+
</span>
|
|
159
|
+
</OptionButton>
|
|
160
|
+
);
|
|
161
|
+
})}
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
{/* Theme */}
|
|
166
|
+
<div className="mb-5">
|
|
167
|
+
<GroupHeading>{t('reading_theme')}</GroupHeading>
|
|
168
|
+
<div className="grid grid-cols-4 gap-2">
|
|
169
|
+
{THEME_OPTIONS.map(opt => {
|
|
170
|
+
const active = opt.value === readingTheme;
|
|
171
|
+
return (
|
|
172
|
+
<OptionButton
|
|
173
|
+
key={opt.value}
|
|
174
|
+
active={active}
|
|
175
|
+
onClick={() => setReadingTheme(opt.value)}
|
|
176
|
+
ariaLabel={`${t('reading_theme')}: ${t(opt.labelKey)}`}
|
|
177
|
+
title={t(opt.labelKey)}
|
|
178
|
+
className="flex-col h-14 overflow-hidden"
|
|
179
|
+
>
|
|
180
|
+
<span
|
|
181
|
+
aria-hidden="true"
|
|
182
|
+
className={`flex-1 w-full flex items-center justify-center text-[13px] font-serif font-semibold ${THEME_SWATCH_STYLE[opt.value]}`}
|
|
183
|
+
>
|
|
184
|
+
Aa
|
|
185
|
+
</span>
|
|
186
|
+
<span className="block w-full text-[9px] text-muted py-0.5 bg-background border-t border-muted/15">
|
|
187
|
+
{t(opt.labelKey)}
|
|
188
|
+
</span>
|
|
189
|
+
</OptionButton>
|
|
190
|
+
);
|
|
191
|
+
})}
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
{/* Width */}
|
|
196
|
+
<div>
|
|
197
|
+
<GroupHeading>{t('column_width')}</GroupHeading>
|
|
198
|
+
<div className="grid grid-cols-4 gap-2">
|
|
199
|
+
{WIDTH_OPTIONS.map(opt => {
|
|
200
|
+
const active = opt.value === columnWidth;
|
|
201
|
+
return (
|
|
202
|
+
<OptionButton
|
|
203
|
+
key={opt.value}
|
|
204
|
+
active={active}
|
|
205
|
+
onClick={() => setColumnWidth(opt.value)}
|
|
206
|
+
ariaLabel={`${t('column_width')}: ${t(opt.labelKey)}`}
|
|
207
|
+
title={t(opt.labelKey)}
|
|
208
|
+
className="h-12 bg-background"
|
|
209
|
+
>
|
|
210
|
+
<span
|
|
211
|
+
aria-hidden="true"
|
|
212
|
+
className="flex flex-col items-center justify-center gap-1 w-full"
|
|
213
|
+
>
|
|
214
|
+
<span
|
|
215
|
+
className="block h-0.5 bg-foreground/70 rounded-full"
|
|
216
|
+
style={{ width: `${opt.barWidthPct}%` }}
|
|
217
|
+
/>
|
|
218
|
+
<span
|
|
219
|
+
className="block h-0.5 bg-foreground/40 rounded-full"
|
|
220
|
+
style={{ width: `${opt.barWidthPct}%` }}
|
|
221
|
+
/>
|
|
222
|
+
<span
|
|
223
|
+
className="block h-0.5 bg-foreground/40 rounded-full"
|
|
224
|
+
style={{ width: `${opt.barWidthPct}%` }}
|
|
225
|
+
/>
|
|
226
|
+
</span>
|
|
227
|
+
</OptionButton>
|
|
228
|
+
);
|
|
229
|
+
})}
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
{/* Reset to defaults — non-destructive (re-pickable), no confirmation. */}
|
|
234
|
+
<div className="mt-4 pt-3 border-t border-muted/15 flex justify-end">
|
|
235
|
+
<button
|
|
236
|
+
type="button"
|
|
237
|
+
onClick={resetPrefs}
|
|
238
|
+
className="text-xs font-sans text-muted hover:text-accent transition-colors inline-flex items-center gap-1.5"
|
|
239
|
+
>
|
|
240
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
241
|
+
<path d="M3 12a9 9 0 1 0 3-6.7" />
|
|
242
|
+
<polyline points="3 4 3 10 9 10" />
|
|
243
|
+
</svg>
|
|
244
|
+
{t('reset_to_defaults')}
|
|
245
|
+
</button>
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
|
|
4
|
+
import { DEFAULT_PREFS, readStoredPrefs, writeStoredPrefs } from '@/lib/immersive-reading-prefs';
|
|
5
|
+
|
|
6
|
+
export type ReadingFontSize = 's' | 'm' | 'l' | 'xl';
|
|
7
|
+
export type ReadingTheme = 'auto' | 'light' | 'sepia' | 'dark';
|
|
8
|
+
export type ReadingColumnWidth = 'narrow' | 'medium' | 'wide' | 'full';
|
|
9
|
+
|
|
10
|
+
interface ImmersiveReadingContextValue {
|
|
11
|
+
enabled: boolean;
|
|
12
|
+
fontSize: ReadingFontSize;
|
|
13
|
+
readingTheme: ReadingTheme;
|
|
14
|
+
columnWidth: ReadingColumnWidth;
|
|
15
|
+
sidebarOpen: boolean;
|
|
16
|
+
prefsPanelOpen: boolean;
|
|
17
|
+
toggle: () => void;
|
|
18
|
+
enter: () => void;
|
|
19
|
+
exit: () => void;
|
|
20
|
+
setFontSize: (size: ReadingFontSize) => void;
|
|
21
|
+
setReadingTheme: (theme: ReadingTheme) => void;
|
|
22
|
+
setColumnWidth: (width: ReadingColumnWidth) => void;
|
|
23
|
+
toggleSidebar: () => void;
|
|
24
|
+
setSidebarOpen: (open: boolean) => void;
|
|
25
|
+
togglePrefsPanel: () => void;
|
|
26
|
+
closePrefsPanel: () => void;
|
|
27
|
+
resetPrefs: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const ImmersiveReadingContext = createContext<ImmersiveReadingContextValue | undefined>(undefined);
|
|
31
|
+
|
|
32
|
+
export function ImmersiveReadingProvider({ children }: { children: ReactNode }) {
|
|
33
|
+
// Initial state intentionally matches DEFAULT_PREFS so SSR/CSR render
|
|
34
|
+
// identically — localStorage is read in an effect after mount.
|
|
35
|
+
const [enabled, setEnabled] = useState(false);
|
|
36
|
+
const [fontSize, setFontSize] = useState<ReadingFontSize>(DEFAULT_PREFS.fontSize);
|
|
37
|
+
const [readingTheme, setReadingTheme] = useState<ReadingTheme>(DEFAULT_PREFS.readingTheme);
|
|
38
|
+
const [columnWidth, setColumnWidth] = useState<ReadingColumnWidth>(DEFAULT_PREFS.columnWidth);
|
|
39
|
+
const [sidebarOpen, setSidebarOpen] = useState(DEFAULT_PREFS.sidebarOpen);
|
|
40
|
+
const [prefsPanelOpen, setPrefsPanelOpen] = useState(false);
|
|
41
|
+
|
|
42
|
+
const enter = useCallback(() => setEnabled(true), []);
|
|
43
|
+
const exit = useCallback(() => {
|
|
44
|
+
setEnabled(false);
|
|
45
|
+
setPrefsPanelOpen(false);
|
|
46
|
+
}, []);
|
|
47
|
+
const toggle = useCallback(() => {
|
|
48
|
+
setEnabled(prev => {
|
|
49
|
+
if (prev) setPrefsPanelOpen(false);
|
|
50
|
+
return !prev;
|
|
51
|
+
});
|
|
52
|
+
}, []);
|
|
53
|
+
const toggleSidebar = useCallback(() => setSidebarOpen(prev => !prev), []);
|
|
54
|
+
const togglePrefsPanel = useCallback(() => setPrefsPanelOpen(prev => !prev), []);
|
|
55
|
+
const closePrefsPanel = useCallback(() => setPrefsPanelOpen(false), []);
|
|
56
|
+
const resetPrefs = useCallback(() => {
|
|
57
|
+
setFontSize(DEFAULT_PREFS.fontSize);
|
|
58
|
+
setReadingTheme(DEFAULT_PREFS.readingTheme);
|
|
59
|
+
setColumnWidth(DEFAULT_PREFS.columnWidth);
|
|
60
|
+
setSidebarOpen(DEFAULT_PREFS.sidebarOpen);
|
|
61
|
+
}, []);
|
|
62
|
+
|
|
63
|
+
// Hydrate prefs from localStorage on mount, then persist on every change.
|
|
64
|
+
// The persist effect itself flips `hydratedRef` on its first run and
|
|
65
|
+
// no-ops — without this, the initial commit's persist run would race
|
|
66
|
+
// ahead of the hydration setters (state hasn't re-rendered yet, so it'd
|
|
67
|
+
// read the React defaults from the closure and clobber the stored blob
|
|
68
|
+
// with them before the next render restores the correct values).
|
|
69
|
+
const hydratedRef = useRef(false);
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const stored = readStoredPrefs();
|
|
72
|
+
const applyStored = () => {
|
|
73
|
+
setFontSize(stored.fontSize);
|
|
74
|
+
setReadingTheme(stored.readingTheme);
|
|
75
|
+
setColumnWidth(stored.columnWidth);
|
|
76
|
+
setSidebarOpen(stored.sidebarOpen);
|
|
77
|
+
};
|
|
78
|
+
applyStored();
|
|
79
|
+
}, []);
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (!hydratedRef.current) {
|
|
82
|
+
hydratedRef.current = true;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
writeStoredPrefs({ fontSize, readingTheme, columnWidth, sidebarOpen });
|
|
86
|
+
}, [fontSize, readingTheme, columnWidth, sidebarOpen]);
|
|
87
|
+
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (typeof document === 'undefined') return;
|
|
90
|
+
const root = document.documentElement;
|
|
91
|
+
if (enabled) {
|
|
92
|
+
root.dataset.immersive = 'true';
|
|
93
|
+
const previousOverflow = document.body.style.overflow;
|
|
94
|
+
document.body.style.overflow = 'hidden';
|
|
95
|
+
return () => {
|
|
96
|
+
delete root.dataset.immersive;
|
|
97
|
+
document.body.style.overflow = previousOverflow;
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
delete root.dataset.immersive;
|
|
101
|
+
}, [enabled]);
|
|
102
|
+
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (!enabled) return;
|
|
105
|
+
const onKey = (event: KeyboardEvent) => {
|
|
106
|
+
if (event.key !== 'Escape') return;
|
|
107
|
+
event.preventDefault();
|
|
108
|
+
if (prefsPanelOpen) {
|
|
109
|
+
setPrefsPanelOpen(false);
|
|
110
|
+
} else {
|
|
111
|
+
setEnabled(false);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
window.addEventListener('keydown', onKey);
|
|
115
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
116
|
+
}, [enabled, prefsPanelOpen]);
|
|
117
|
+
|
|
118
|
+
// Auto-collapse the sidebar on narrow viewports when entering immersive mode
|
|
119
|
+
// (or when resizing into the narrow range). Without this, the sidebar
|
|
120
|
+
// overlaps the article on tablets / phones. Deliberately one-directional:
|
|
121
|
+
// we never auto-open on a wide-resize, so a user who manually closed the
|
|
122
|
+
// sidebar on desktop keeps that preference instead of having it overridden.
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
if (!enabled || typeof window === 'undefined') return;
|
|
125
|
+
const mq = window.matchMedia('(max-width: 1023px)');
|
|
126
|
+
const collapseIfNarrow = (matches: boolean) => {
|
|
127
|
+
if (matches) setSidebarOpen(false);
|
|
128
|
+
};
|
|
129
|
+
collapseIfNarrow(mq.matches);
|
|
130
|
+
const onChange = (e: MediaQueryListEvent) => collapseIfNarrow(e.matches);
|
|
131
|
+
mq.addEventListener('change', onChange);
|
|
132
|
+
return () => mq.removeEventListener('change', onChange);
|
|
133
|
+
}, [enabled]);
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<ImmersiveReadingContext.Provider
|
|
137
|
+
value={{
|
|
138
|
+
enabled,
|
|
139
|
+
fontSize,
|
|
140
|
+
readingTheme,
|
|
141
|
+
columnWidth,
|
|
142
|
+
sidebarOpen,
|
|
143
|
+
prefsPanelOpen,
|
|
144
|
+
toggle,
|
|
145
|
+
enter,
|
|
146
|
+
exit,
|
|
147
|
+
setFontSize,
|
|
148
|
+
setReadingTheme,
|
|
149
|
+
setColumnWidth,
|
|
150
|
+
toggleSidebar,
|
|
151
|
+
setSidebarOpen,
|
|
152
|
+
togglePrefsPanel,
|
|
153
|
+
closePrefsPanel,
|
|
154
|
+
resetPrefs,
|
|
155
|
+
}}
|
|
156
|
+
>
|
|
157
|
+
{children}
|
|
158
|
+
</ImmersiveReadingContext.Provider>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function useImmersiveReading(): ImmersiveReadingContextValue {
|
|
163
|
+
const ctx = useContext(ImmersiveReadingContext);
|
|
164
|
+
if (!ctx) {
|
|
165
|
+
throw new Error('useImmersiveReading must be used within an ImmersiveReadingProvider');
|
|
166
|
+
}
|
|
167
|
+
return ctx;
|
|
168
|
+
}
|