@dsbasko/cookbook-engine 0.1.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.
Potentially problematic release.
This version of @dsbasko/cookbook-engine might be problematic. Click here for more details.
- package/LICENSE +21 -0
- package/README.md +232 -0
- package/assets/fonts/jetbrains-mono/JetBrainsMono-Bold.woff2 +0 -0
- package/assets/fonts/jetbrains-mono/JetBrainsMono-BoldItalic.woff2 +0 -0
- package/assets/fonts/jetbrains-mono/JetBrainsMono-Italic.woff2 +0 -0
- package/assets/fonts/jetbrains-mono/JetBrainsMono-Medium.woff2 +0 -0
- package/assets/fonts/jetbrains-mono/JetBrainsMono-Regular.woff2 +0 -0
- package/assets/fonts/jetbrains-mono/JetBrainsMono-SemiBold.woff2 +0 -0
- package/package.json +92 -0
- package/scripts/check-course-coverage.mts +32 -0
- package/scripts/fix-static-image-extensions.mjs +78 -0
- package/scripts/generate-readme-toc.mts +32 -0
- package/scripts/resolve-course-paths.mjs +28 -0
- package/scripts/sync-images.mjs +88 -0
- package/src/components/AppShell/AppShell.module.css +40 -0
- package/src/components/AppShell/AppShell.tsx +135 -0
- package/src/components/AppShell/index.ts +1 -0
- package/src/components/Callout/Callout.module.css +68 -0
- package/src/components/Callout/Callout.tsx +83 -0
- package/src/components/Callout/index.ts +1 -0
- package/src/components/CodeBlock/CodeBlock.module.css +68 -0
- package/src/components/CodeBlock/CodeBlock.tsx +65 -0
- package/src/components/CodeBlock/index.ts +1 -0
- package/src/components/GateProvider/GateProvider.tsx +207 -0
- package/src/components/GateProvider/index.ts +1 -0
- package/src/components/Header/Breadcrumbs.tsx +50 -0
- package/src/components/Header/Header.module.css +131 -0
- package/src/components/Header/Header.tsx +26 -0
- package/src/components/Header/HeaderLessonNav.tsx +118 -0
- package/src/components/Header/index.ts +1 -0
- package/src/components/HomePage/HomePage.module.css +538 -0
- package/src/components/HomePage/HomePage.tsx +295 -0
- package/src/components/HomePage/index.ts +1 -0
- package/src/components/LessonAwareLink/LessonAwareLink.module.css +12 -0
- package/src/components/LessonAwareLink/LessonAwareLink.tsx +86 -0
- package/src/components/LessonAwareLink/index.ts +1 -0
- package/src/components/LessonLayout/LessonLayout.module.css +35 -0
- package/src/components/LessonLayout/LessonLayout.tsx +18 -0
- package/src/components/LessonLayout/index.ts +1 -0
- package/src/components/LessonLockedInterstitial/LessonLockedInterstitial.module.css +367 -0
- package/src/components/LessonLockedInterstitial/LessonLockedInterstitial.tsx +256 -0
- package/src/components/LessonLockedInterstitial/index.ts +1 -0
- package/src/components/LessonNav/LessonNav.module.css +84 -0
- package/src/components/LessonNav/LessonNav.tsx +64 -0
- package/src/components/LessonNav/index.ts +1 -0
- package/src/components/LessonPageLayout/LessonPageLayout.module.css +118 -0
- package/src/components/LessonPageLayout/LessonPageLayout.tsx +46 -0
- package/src/components/LessonPageLayout/index.ts +1 -0
- package/src/components/LessonSideMeta/LessonSideMeta.module.css +68 -0
- package/src/components/LessonSideMeta/LessonSideMeta.tsx +87 -0
- package/src/components/LessonSideMeta/index.ts +1 -0
- package/src/components/ModulePage/ModulePage.module.css +693 -0
- package/src/components/ModulePage/ModulePage.tsx +301 -0
- package/src/components/ModulePage/index.ts +1 -0
- package/src/components/ProgramDrawer/LockIcon.tsx +19 -0
- package/src/components/ProgramDrawer/ProgramDrawer.module.css +563 -0
- package/src/components/ProgramDrawer/ProgramDrawer.tsx +481 -0
- package/src/components/ProgramDrawer/index.ts +1 -0
- package/src/components/ProgressBar/ProgressBar.module.css +46 -0
- package/src/components/ProgressBar/ProgressBar.tsx +45 -0
- package/src/components/ProgressBar/index.ts +1 -0
- package/src/components/ProgressModeProvider/ProgressModeProvider.tsx +87 -0
- package/src/components/ProgressModeProvider/index.ts +1 -0
- package/src/components/ReadingPrefsProvider/ReadingPrefsProvider.tsx +100 -0
- package/src/components/ReadingPrefsProvider/index.ts +1 -0
- package/src/components/ReadingProgress/ReadingProgress.module.css +19 -0
- package/src/components/ReadingProgress/ReadingProgress.tsx +53 -0
- package/src/components/ReadingProgress/index.ts +1 -0
- package/src/components/SettingsToggle/SettingsToggle.module.css +888 -0
- package/src/components/SettingsToggle/SettingsToggle.tsx +688 -0
- package/src/components/SettingsToggle/index.ts +1 -0
- package/src/components/Sidebar/Sidebar.module.css +157 -0
- package/src/components/Sidebar/Sidebar.tsx +63 -0
- package/src/components/Sidebar/icons/GitHubIcon.tsx +17 -0
- package/src/components/Sidebar/icons/HomeIcon.tsx +22 -0
- package/src/components/Sidebar/icons/LanguageIcon.tsx +24 -0
- package/src/components/Sidebar/icons/ProgramIcon.tsx +23 -0
- package/src/components/Sidebar/icons/SettingsIcon.tsx +26 -0
- package/src/components/Sidebar/icons/ThemeIcon.tsx +22 -0
- package/src/components/Sidebar/icons/index.ts +6 -0
- package/src/components/Sidebar/index.ts +1 -0
- package/src/components/ThemeProvider/ThemeProvider.tsx +68 -0
- package/src/components/ThemeProvider/index.ts +1 -0
- package/src/components/Toc/Toc.module.css +78 -0
- package/src/components/Toc/Toc.tsx +92 -0
- package/src/components/Toc/index.ts +1 -0
- package/src/components/TranslationBanner/TranslationBanner.module.css +32 -0
- package/src/components/TranslationBanner/TranslationBanner.tsx +40 -0
- package/src/components/TranslationBanner/index.ts +1 -0
- package/src/config.d.mts +12 -0
- package/src/config.mjs +110 -0
- package/src/index.ts +62 -0
- package/src/layout/lang.tsx +44 -0
- package/src/layout/root.tsx +223 -0
- package/src/lib/course-loader.ts +33 -0
- package/src/lib/course.ts +429 -0
- package/src/lib/coverage.ts +141 -0
- package/src/lib/description.ts +43 -0
- package/src/lib/extract-toc.ts +59 -0
- package/src/lib/format.ts +55 -0
- package/src/lib/frontier-link.ts +37 -0
- package/src/lib/gate-init-script.ts +40 -0
- package/src/lib/gate-mark-script.ts +324 -0
- package/src/lib/i18n.ts +474 -0
- package/src/lib/lang.ts +90 -0
- package/src/lib/lesson-gate.ts +79 -0
- package/src/lib/lesson.ts +66 -0
- package/src/lib/markdown-components.tsx +51 -0
- package/src/lib/markdown.ts +180 -0
- package/src/lib/mdx-plugins/rehype-callout.ts +80 -0
- package/src/lib/mdx-plugins/remark-lesson-images.ts +109 -0
- package/src/lib/mdx-plugins/remark-link-rewrite.ts +231 -0
- package/src/lib/paths.ts +36 -0
- package/src/lib/program-drawer.ts +8 -0
- package/src/lib/progress-mode.ts +69 -0
- package/src/lib/progress.ts +182 -0
- package/src/lib/reading-prefs.ts +127 -0
- package/src/lib/readme-toc.ts +69 -0
- package/src/lib/site-url.ts +33 -0
- package/src/lib/sitemap.ts +112 -0
- package/src/lib/slug.ts +15 -0
- package/src/lib/theme.ts +78 -0
- package/src/lib/use-i18n.ts +25 -0
- package/src/og/icon.tsx +40 -0
- package/src/og/opengraph-image.tsx +126 -0
- package/src/pages/home.tsx +66 -0
- package/src/pages/lesson.tsx +260 -0
- package/src/pages/module.tsx +80 -0
- package/src/pages/not-found-lang.tsx +51 -0
- package/src/pages/not-found-root.tsx +48 -0
- package/src/pages/root.tsx +44 -0
- package/src/seo/robots.ts +16 -0
- package/src/seo/sitemap.ts +10 -0
- package/src/styles/globals.css +139 -0
- package/src/styles/markdown.css +265 -0
- package/src/styles/reset.css +89 -0
- package/src/styles/tokens.css +270 -0
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useId, useRef, useState } from 'react';
|
|
4
|
+
import { createPortal } from 'react-dom';
|
|
5
|
+
import { usePathname, useRouter } from 'next/navigation';
|
|
6
|
+
import { GitHubIcon, SettingsIcon } from '@/components/Sidebar/icons';
|
|
7
|
+
import { useReadingPrefs } from '@/components/ReadingPrefsProvider';
|
|
8
|
+
import { useTheme } from '@/components/ThemeProvider';
|
|
9
|
+
import { useProgressMode } from '@/components/ProgressModeProvider';
|
|
10
|
+
import { getCompletedCount, getFurthestKey, getProgress } from '@/lib/progress';
|
|
11
|
+
import {
|
|
12
|
+
CODE_FONTS,
|
|
13
|
+
PROSE_FONTS,
|
|
14
|
+
type CodeFont,
|
|
15
|
+
type ProseFont,
|
|
16
|
+
type SizeStep,
|
|
17
|
+
} from '@/lib/reading-prefs';
|
|
18
|
+
import {
|
|
19
|
+
LANG_LABELS,
|
|
20
|
+
LANGS,
|
|
21
|
+
stripLangFromPath,
|
|
22
|
+
writeStoredLang,
|
|
23
|
+
type Lang,
|
|
24
|
+
} from '@/lib/lang';
|
|
25
|
+
import { THEME_PREFERENCES, type ThemePreference } from '@/lib/theme';
|
|
26
|
+
import { useT } from '@/lib/use-i18n';
|
|
27
|
+
import type { UIDict } from '@/lib/i18n';
|
|
28
|
+
import styles from './SettingsToggle.module.css';
|
|
29
|
+
|
|
30
|
+
const SETTINGS_REOPEN_KEY = 'kafka-cookbook:settings:reopen-after-lang';
|
|
31
|
+
|
|
32
|
+
const SIZE_STEPS: SizeStep[] = [0, 1, 2, 3, 4, 5];
|
|
33
|
+
const MAX_SIZE_STEP: SizeStep = 5;
|
|
34
|
+
|
|
35
|
+
const PROSE_SIZE_PX: Record<SizeStep, number> = {
|
|
36
|
+
0: 14,
|
|
37
|
+
1: 16,
|
|
38
|
+
2: 18,
|
|
39
|
+
3: 20,
|
|
40
|
+
4: 22,
|
|
41
|
+
5: 24,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const CODE_SIZE_PX: Record<SizeStep, number> = {
|
|
45
|
+
0: 14,
|
|
46
|
+
1: 16,
|
|
47
|
+
2: 18,
|
|
48
|
+
3: 20,
|
|
49
|
+
4: 22,
|
|
50
|
+
5: 24,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const PROSE_FONT_LABEL_KEYS: Record<ProseFont, keyof UIDict> = {
|
|
54
|
+
serif: 'readingPrefsFontSerif',
|
|
55
|
+
sans: 'readingPrefsFontSans',
|
|
56
|
+
slab: 'readingPrefsFontSlab',
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const CODE_FONT_LABEL_KEYS: Record<CodeFont, keyof UIDict> = {
|
|
60
|
+
jetbrains: 'readingPrefsFontJetBrains',
|
|
61
|
+
fira: 'readingPrefsFontFira',
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const THEME_LABEL_KEYS: Record<
|
|
65
|
+
ThemePreference,
|
|
66
|
+
'themeLight' | 'themeDark' | 'themePaper'
|
|
67
|
+
> = {
|
|
68
|
+
light: 'themeLight',
|
|
69
|
+
dark: 'themeDark',
|
|
70
|
+
paper: 'themePaper',
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
function stepDown(step: SizeStep): SizeStep {
|
|
74
|
+
return (step > 0 ? step - 1 : 0) as SizeStep;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function stepUp(step: SizeStep): SizeStep {
|
|
78
|
+
return (step < MAX_SIZE_STEP ? step + 1 : MAX_SIZE_STEP) as SizeStep;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function replaceLangInPath(pathname: string | null, next: Lang): string {
|
|
82
|
+
const { rest } = stripLangFromPath(pathname ?? '/');
|
|
83
|
+
if (rest === '/' || rest === '') return `/${next}/`;
|
|
84
|
+
return `/${next}${rest}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function CloseIcon() {
|
|
88
|
+
return (
|
|
89
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" aria-hidden="true">
|
|
90
|
+
<path d="M6 6l12 12M18 6 6 18" />
|
|
91
|
+
</svg>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function LightThemeIcon() {
|
|
96
|
+
return (
|
|
97
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
98
|
+
<circle cx="8" cy="8" r="2.6" />
|
|
99
|
+
<path d="M8 1.6v1.6M8 12.8v1.6M1.6 8h1.6M12.8 8h1.6M3.5 3.5l1.1 1.1M11.4 11.4l1.1 1.1M3.5 12.5l1.1-1.1M11.4 4.6l1.1-1.1" />
|
|
100
|
+
</svg>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function PaperThemeIcon() {
|
|
105
|
+
return (
|
|
106
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
107
|
+
<path d="M3.5 2.5h6.4l2.6 2.6V13a.5.5 0 0 1-.5.5h-8.5a.5.5 0 0 1-.5-.5V3a.5.5 0 0 1 .5-.5z" />
|
|
108
|
+
<path d="M9.6 2.6V5.2h2.6" />
|
|
109
|
+
<path d="M5.5 8.4h5M5.5 10.6h3.6" />
|
|
110
|
+
</svg>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function DarkThemeIcon() {
|
|
115
|
+
return (
|
|
116
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
117
|
+
<path d="M13.2 9.6A5.6 5.6 0 1 1 6.4 2.8a4.6 4.6 0 0 0 6.8 6.8z" />
|
|
118
|
+
</svg>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const THEME_ICONS: Record<ThemePreference, () => JSX.Element> = {
|
|
123
|
+
light: LightThemeIcon,
|
|
124
|
+
paper: PaperThemeIcon,
|
|
125
|
+
dark: DarkThemeIcon,
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
type SizeStepperProps = {
|
|
129
|
+
kind: 'prose' | 'code';
|
|
130
|
+
value: SizeStep;
|
|
131
|
+
pxLabel: string;
|
|
132
|
+
onStep: (next: SizeStep) => void;
|
|
133
|
+
decreaseLabel: string;
|
|
134
|
+
increaseLabel: string;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
function SizeStepper({ kind, value, pxLabel, onStep, decreaseLabel, increaseLabel }: SizeStepperProps) {
|
|
138
|
+
const isMin = value === 0;
|
|
139
|
+
const isMax = value === MAX_SIZE_STEP;
|
|
140
|
+
const percent = (value / MAX_SIZE_STEP) * 100;
|
|
141
|
+
return (
|
|
142
|
+
<div className={styles.sizeStepper}>
|
|
143
|
+
<button
|
|
144
|
+
type="button"
|
|
145
|
+
className={styles.sizeBtn}
|
|
146
|
+
aria-label={decreaseLabel}
|
|
147
|
+
disabled={isMin}
|
|
148
|
+
onClick={() => onStep(stepDown(value))}
|
|
149
|
+
data-kind={`${kind}-decrease`}
|
|
150
|
+
>
|
|
151
|
+
<span className={styles.sizeBtnGlyphSmall}>A</span>
|
|
152
|
+
</button>
|
|
153
|
+
<div className={styles.sizeTrack}>
|
|
154
|
+
<div className={styles.sizeRail} />
|
|
155
|
+
<div className={styles.sizeRailFill} style={{ width: `${percent}%` }} />
|
|
156
|
+
<div className={styles.sizeKnob} style={{ left: `${percent}%` }} />
|
|
157
|
+
<div className={styles.sizeTicks}>
|
|
158
|
+
{SIZE_STEPS.map((step) => (
|
|
159
|
+
<button
|
|
160
|
+
key={step}
|
|
161
|
+
type="button"
|
|
162
|
+
className={styles.sizeTick}
|
|
163
|
+
data-active={step === value ? 'true' : 'false'}
|
|
164
|
+
onClick={() => onStep(step)}
|
|
165
|
+
aria-label={`${kind === 'prose' ? PROSE_SIZE_PX[step] : CODE_SIZE_PX[step]}px`}
|
|
166
|
+
>
|
|
167
|
+
<span className={styles.sizeTickDot} />
|
|
168
|
+
</button>
|
|
169
|
+
))}
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
<button
|
|
173
|
+
type="button"
|
|
174
|
+
className={styles.sizeBtn}
|
|
175
|
+
aria-label={increaseLabel}
|
|
176
|
+
disabled={isMax}
|
|
177
|
+
onClick={() => onStep(stepUp(value))}
|
|
178
|
+
data-kind={`${kind}-increase`}
|
|
179
|
+
>
|
|
180
|
+
<span className={styles.sizeBtnGlyphLarge}>A</span>
|
|
181
|
+
</button>
|
|
182
|
+
<span className={styles.sizeValue} aria-live="polite" data-kind={`${kind}-value`}>
|
|
183
|
+
{pxLabel.replace(/px\s*$/i, '')}
|
|
184
|
+
<span className={styles.sizeUnit}>px</span>
|
|
185
|
+
</span>
|
|
186
|
+
</div>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
type SettingsToggleProps = {
|
|
191
|
+
/* Used by the mobile sheet's bottom "Ресурсы" card. Desktop renders no
|
|
192
|
+
GitHub affordance from inside this popover — the rail outside it does. */
|
|
193
|
+
repoUrl?: string;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
export function SettingsToggle({ repoUrl }: SettingsToggleProps = {}) {
|
|
197
|
+
const t = useT();
|
|
198
|
+
const { prefs, setProseSize, setCodeSize, setProseFont, setCodeFont } = useReadingPrefs();
|
|
199
|
+
const { preference: themePreference, setPreference: setThemePreference } = useTheme();
|
|
200
|
+
const { disabled: progressDisabled, setDisabled: setProgressDisabled } = useProgressMode();
|
|
201
|
+
const router = useRouter();
|
|
202
|
+
const pathname = usePathname();
|
|
203
|
+
const currentLang = stripLangFromPath(pathname ?? '/').lang;
|
|
204
|
+
|
|
205
|
+
const [open, setOpen] = useState(false);
|
|
206
|
+
const [confirmingReset, setConfirmingReset] = useState(false);
|
|
207
|
+
const [renderedSizes, setRenderedSizes] = useState<{ prose: string; code: string } | null>(null);
|
|
208
|
+
const [isMobile, setIsMobile] = useState(false);
|
|
209
|
+
const [mounted, setMounted] = useState(false);
|
|
210
|
+
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
211
|
+
const popoverRef = useRef<HTMLDivElement>(null);
|
|
212
|
+
const popoverId = useId();
|
|
213
|
+
|
|
214
|
+
useEffect(() => {
|
|
215
|
+
setMounted(true);
|
|
216
|
+
// Reopen after a language switch — changing [lang] segment remounts
|
|
217
|
+
// the layout (and this component), which would otherwise drop `open`.
|
|
218
|
+
try {
|
|
219
|
+
if (sessionStorage.getItem(SETTINGS_REOPEN_KEY) === '1') {
|
|
220
|
+
sessionStorage.removeItem(SETTINGS_REOPEN_KEY);
|
|
221
|
+
setOpen(true);
|
|
222
|
+
}
|
|
223
|
+
} catch {
|
|
224
|
+
/* sessionStorage unavailable; ignore. */
|
|
225
|
+
}
|
|
226
|
+
}, []);
|
|
227
|
+
|
|
228
|
+
// The popover stays mounted (toggled via `hidden`), so drop any pending
|
|
229
|
+
// free-reading reset confirmation when it closes — otherwise the destructive
|
|
230
|
+
// prompt would reappear out of context the next time settings is opened.
|
|
231
|
+
useEffect(() => {
|
|
232
|
+
if (!open) setConfirmingReset(false);
|
|
233
|
+
}, [open]);
|
|
234
|
+
|
|
235
|
+
useEffect(() => {
|
|
236
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
|
|
237
|
+
const mq = window.matchMedia('(max-width: 1023px)');
|
|
238
|
+
const sync = () => setIsMobile(mq.matches);
|
|
239
|
+
sync();
|
|
240
|
+
mq.addEventListener('change', sync);
|
|
241
|
+
return () => mq.removeEventListener('change', sync);
|
|
242
|
+
}, []);
|
|
243
|
+
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
if (!open) return;
|
|
246
|
+
function handlePointer(event: MouseEvent) {
|
|
247
|
+
const target = event.target as Node | null;
|
|
248
|
+
if (!target) return;
|
|
249
|
+
if (wrapperRef.current?.contains(target)) return;
|
|
250
|
+
if (popoverRef.current?.contains(target)) return;
|
|
251
|
+
setOpen(false);
|
|
252
|
+
}
|
|
253
|
+
function handleKey(event: KeyboardEvent) {
|
|
254
|
+
if (event.key === 'Escape') setOpen(false);
|
|
255
|
+
}
|
|
256
|
+
document.addEventListener('mousedown', handlePointer);
|
|
257
|
+
document.addEventListener('keydown', handleKey);
|
|
258
|
+
return () => {
|
|
259
|
+
document.removeEventListener('mousedown', handlePointer);
|
|
260
|
+
document.removeEventListener('keydown', handleKey);
|
|
261
|
+
};
|
|
262
|
+
}, [open]);
|
|
263
|
+
|
|
264
|
+
useEffect(() => {
|
|
265
|
+
if (typeof window === 'undefined') return;
|
|
266
|
+
function readActual() {
|
|
267
|
+
const cs = window.getComputedStyle(document.documentElement);
|
|
268
|
+
const prose = cs.getPropertyValue('--prose-font-size').trim();
|
|
269
|
+
const code = cs.getPropertyValue('--code-font-size').trim();
|
|
270
|
+
if (prose && code) setRenderedSizes({ prose, code });
|
|
271
|
+
else setRenderedSizes(null);
|
|
272
|
+
}
|
|
273
|
+
readActual();
|
|
274
|
+
if (typeof window.matchMedia !== 'function') return;
|
|
275
|
+
const mq = window.matchMedia('(max-width: 720px)');
|
|
276
|
+
mq.addEventListener('change', readActual);
|
|
277
|
+
return () => mq.removeEventListener('change', readActual);
|
|
278
|
+
}, [prefs.proseSize, prefs.codeSize]);
|
|
279
|
+
|
|
280
|
+
function handleLangSelect(next: Lang) {
|
|
281
|
+
writeStoredLang(next);
|
|
282
|
+
if (next === currentLang) return;
|
|
283
|
+
try {
|
|
284
|
+
sessionStorage.setItem(SETTINGS_REOPEN_KEY, '1');
|
|
285
|
+
} catch {
|
|
286
|
+
/* sessionStorage unavailable; popup will simply close. */
|
|
287
|
+
}
|
|
288
|
+
router.push(replaceLangInPath(pathname, next));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Free reading toggles destructively wipe progress, so enabling it from a
|
|
292
|
+
// non-empty state asks for inline confirmation first. Emptiness is detected
|
|
293
|
+
// through progress.ts helpers — no completed lessons and no furthest pointer.
|
|
294
|
+
function progressIsEmpty(): boolean {
|
|
295
|
+
return getCompletedCount(getProgress()) === 0 && getFurthestKey() === null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function handleFreeReadingToggle() {
|
|
299
|
+
if (progressDisabled) {
|
|
300
|
+
// Turning the mode off never resets anything, so no confirmation.
|
|
301
|
+
setConfirmingReset(false);
|
|
302
|
+
setProgressDisabled(false);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (progressIsEmpty()) {
|
|
306
|
+
setProgressDisabled(true);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
setConfirmingReset(true);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function confirmFreeReading() {
|
|
313
|
+
setConfirmingReset(false);
|
|
314
|
+
setProgressDisabled(true);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function cancelFreeReading() {
|
|
318
|
+
setConfirmingReset(false);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const proseSizeLabel = renderedSizes?.prose ?? `${PROSE_SIZE_PX[prefs.proseSize]}px`;
|
|
322
|
+
const codeSizeLabel = renderedSizes?.code ?? `${CODE_SIZE_PX[prefs.codeSize]}px`;
|
|
323
|
+
|
|
324
|
+
// ProgressModeProvider seeds `disabled` synchronously from the <html>
|
|
325
|
+
// attribute, so the client's first render can disagree with the server, which
|
|
326
|
+
// always emits `false`. Gate the switch's rendered state on `mounted` so the
|
|
327
|
+
// SSR markup and the initial hydration render match; the real value lands on
|
|
328
|
+
// the post-mount re-render. Without this the toggle stays visually off after a
|
|
329
|
+
// reload despite free-reading being active (React 18 keeps the server attribute
|
|
330
|
+
// on a hydration mismatch and never patches it).
|
|
331
|
+
const freeReadingActive = mounted && progressDisabled;
|
|
332
|
+
|
|
333
|
+
const overlayAndPopover = (
|
|
334
|
+
<>
|
|
335
|
+
{open && (
|
|
336
|
+
<button
|
|
337
|
+
type="button"
|
|
338
|
+
className={styles.overlay}
|
|
339
|
+
aria-label={t.close}
|
|
340
|
+
onClick={() => setOpen(false)}
|
|
341
|
+
/>
|
|
342
|
+
)}
|
|
343
|
+
<div
|
|
344
|
+
ref={popoverRef}
|
|
345
|
+
id={popoverId}
|
|
346
|
+
role="dialog"
|
|
347
|
+
aria-label={t.settingsLabel}
|
|
348
|
+
className={styles.popover}
|
|
349
|
+
data-open={open ? 'true' : 'false'}
|
|
350
|
+
hidden={!open}
|
|
351
|
+
>
|
|
352
|
+
<div className={styles.grabber} aria-hidden="true" />
|
|
353
|
+
<header className={styles.head}>
|
|
354
|
+
<div className={styles.headTitles}>
|
|
355
|
+
<div className={styles.eyebrow}>{t.settingsEyebrow}</div>
|
|
356
|
+
<h2 className={styles.title}>{t.settingsLabel}</h2>
|
|
357
|
+
</div>
|
|
358
|
+
<button
|
|
359
|
+
type="button"
|
|
360
|
+
className={styles.closeBtn}
|
|
361
|
+
onClick={() => setOpen(false)}
|
|
362
|
+
aria-label={t.close}
|
|
363
|
+
>
|
|
364
|
+
<CloseIcon />
|
|
365
|
+
</button>
|
|
366
|
+
</header>
|
|
367
|
+
|
|
368
|
+
<div className={styles.body}>
|
|
369
|
+
<div className={styles.row}>
|
|
370
|
+
<span className={styles.rowLabel}>{t.settingsThemeSection}</span>
|
|
371
|
+
<div className={styles.seg} role="radiogroup" aria-label={t.settingsThemeSection}>
|
|
372
|
+
{THEME_PREFERENCES.map((value) => {
|
|
373
|
+
const active = themePreference === value;
|
|
374
|
+
const label = t[THEME_LABEL_KEYS[value]];
|
|
375
|
+
const Icon = THEME_ICONS[value];
|
|
376
|
+
return (
|
|
377
|
+
<button
|
|
378
|
+
key={value}
|
|
379
|
+
type="button"
|
|
380
|
+
role="radio"
|
|
381
|
+
aria-checked={active}
|
|
382
|
+
aria-label={label}
|
|
383
|
+
className={`${styles.segOpt} ${styles.segTheme}`}
|
|
384
|
+
data-active={active ? 'true' : 'false'}
|
|
385
|
+
data-theme-pref={value}
|
|
386
|
+
onClick={() => setThemePreference(value)}
|
|
387
|
+
>
|
|
388
|
+
<span className={styles.segThemeIcon} aria-hidden="true">
|
|
389
|
+
<Icon />
|
|
390
|
+
</span>
|
|
391
|
+
<span>{label}</span>
|
|
392
|
+
</button>
|
|
393
|
+
);
|
|
394
|
+
})}
|
|
395
|
+
</div>
|
|
396
|
+
</div>
|
|
397
|
+
|
|
398
|
+
<div className={styles.row}>
|
|
399
|
+
<span className={styles.rowLabel}>{t.language}</span>
|
|
400
|
+
<div className={styles.seg} role="radiogroup" aria-label={t.language}>
|
|
401
|
+
{LANGS.map((value) => {
|
|
402
|
+
const active = currentLang === value;
|
|
403
|
+
const label = LANG_LABELS[value];
|
|
404
|
+
return (
|
|
405
|
+
<button
|
|
406
|
+
key={value}
|
|
407
|
+
type="button"
|
|
408
|
+
role="radio"
|
|
409
|
+
aria-checked={active}
|
|
410
|
+
aria-label={label}
|
|
411
|
+
className={`${styles.segOpt} ${styles.segLang}`}
|
|
412
|
+
data-active={active ? 'true' : 'false'}
|
|
413
|
+
data-lang-pref={value}
|
|
414
|
+
onClick={() => handleLangSelect(value)}
|
|
415
|
+
>
|
|
416
|
+
<span className={styles.segLangBadge}>{value.toUpperCase()}</span>
|
|
417
|
+
<span>{label}</span>
|
|
418
|
+
</button>
|
|
419
|
+
);
|
|
420
|
+
})}
|
|
421
|
+
</div>
|
|
422
|
+
</div>
|
|
423
|
+
|
|
424
|
+
<div className={styles.divider} />
|
|
425
|
+
|
|
426
|
+
<section className={styles.group}>
|
|
427
|
+
<h3 className={styles.groupTitle}>{t.readingPrefsProseSection}</h3>
|
|
428
|
+
<div
|
|
429
|
+
className={styles.preview}
|
|
430
|
+
style={{
|
|
431
|
+
fontFamily: 'var(--prose-font-active, var(--font-serif))',
|
|
432
|
+
fontSize: 'var(--prose-font-size, 16px)',
|
|
433
|
+
}}
|
|
434
|
+
>
|
|
435
|
+
{t.readingPrefsPreviewProse}
|
|
436
|
+
</div>
|
|
437
|
+
|
|
438
|
+
<div className={styles.row}>
|
|
439
|
+
<span className={styles.rowLabel}>{t.readingPrefsFont}</span>
|
|
440
|
+
<div
|
|
441
|
+
className={styles.seg}
|
|
442
|
+
role="radiogroup"
|
|
443
|
+
aria-label={`${t.readingPrefsProseSection} — ${t.readingPrefsFont}`}
|
|
444
|
+
>
|
|
445
|
+
{PROSE_FONTS.map((value) => {
|
|
446
|
+
const active = prefs.proseFont === value;
|
|
447
|
+
const label = t[PROSE_FONT_LABEL_KEYS[value]];
|
|
448
|
+
return (
|
|
449
|
+
<button
|
|
450
|
+
key={value}
|
|
451
|
+
type="button"
|
|
452
|
+
role="radio"
|
|
453
|
+
aria-checked={active}
|
|
454
|
+
aria-label={label}
|
|
455
|
+
className={styles.segOpt}
|
|
456
|
+
data-active={active ? 'true' : 'false'}
|
|
457
|
+
data-prose-font={value}
|
|
458
|
+
onClick={() => setProseFont(value)}
|
|
459
|
+
>
|
|
460
|
+
{label}
|
|
461
|
+
</button>
|
|
462
|
+
);
|
|
463
|
+
})}
|
|
464
|
+
</div>
|
|
465
|
+
</div>
|
|
466
|
+
|
|
467
|
+
<div className={styles.row}>
|
|
468
|
+
<span className={styles.rowLabel}>{t.readingPrefsSize}</span>
|
|
469
|
+
<div className={styles.sizeControl}>
|
|
470
|
+
<div className={styles.sizeControlDesktop}>
|
|
471
|
+
<SizeStepper
|
|
472
|
+
kind="prose"
|
|
473
|
+
value={prefs.proseSize}
|
|
474
|
+
pxLabel={proseSizeLabel}
|
|
475
|
+
onStep={setProseSize}
|
|
476
|
+
decreaseLabel={t.readingPrefsDecrease}
|
|
477
|
+
increaseLabel={t.readingPrefsIncrease}
|
|
478
|
+
/>
|
|
479
|
+
</div>
|
|
480
|
+
<div
|
|
481
|
+
className={`${styles.seg} ${styles.segSizes} ${styles.sizeControlMobile}`}
|
|
482
|
+
role="radiogroup"
|
|
483
|
+
aria-label={`${t.readingPrefsProseSection} — ${t.readingPrefsSize}`}
|
|
484
|
+
>
|
|
485
|
+
{SIZE_STEPS.map((step) => {
|
|
486
|
+
const active = prefs.proseSize === step;
|
|
487
|
+
const px = PROSE_SIZE_PX[step];
|
|
488
|
+
return (
|
|
489
|
+
<button
|
|
490
|
+
key={step}
|
|
491
|
+
type="button"
|
|
492
|
+
role="radio"
|
|
493
|
+
aria-checked={active}
|
|
494
|
+
aria-label={`${px}px`}
|
|
495
|
+
className={`${styles.segOpt} ${styles.segMono}`}
|
|
496
|
+
data-active={active ? 'true' : 'false'}
|
|
497
|
+
onClick={() => setProseSize(step)}
|
|
498
|
+
>
|
|
499
|
+
{px}
|
|
500
|
+
</button>
|
|
501
|
+
);
|
|
502
|
+
})}
|
|
503
|
+
</div>
|
|
504
|
+
</div>
|
|
505
|
+
</div>
|
|
506
|
+
</section>
|
|
507
|
+
|
|
508
|
+
<div className={styles.divider} />
|
|
509
|
+
|
|
510
|
+
<section className={styles.group}>
|
|
511
|
+
<h3 className={styles.groupTitle}>{t.readingPrefsCodeSection}</h3>
|
|
512
|
+
<div
|
|
513
|
+
className={`${styles.preview} ${styles.previewCode}`}
|
|
514
|
+
style={{
|
|
515
|
+
fontFamily: 'var(--code-font-active, var(--font-mono))',
|
|
516
|
+
fontSize: 'var(--code-font-size, 14px)',
|
|
517
|
+
}}
|
|
518
|
+
>
|
|
519
|
+
{t.readingPrefsPreviewCode}
|
|
520
|
+
</div>
|
|
521
|
+
|
|
522
|
+
<div className={styles.row}>
|
|
523
|
+
<span className={styles.rowLabel}>{t.readingPrefsFont}</span>
|
|
524
|
+
<div
|
|
525
|
+
className={styles.seg}
|
|
526
|
+
role="radiogroup"
|
|
527
|
+
aria-label={`${t.readingPrefsCodeSection} — ${t.readingPrefsFont}`}
|
|
528
|
+
>
|
|
529
|
+
{CODE_FONTS.map((value) => {
|
|
530
|
+
const active = prefs.codeFont === value;
|
|
531
|
+
const label = t[CODE_FONT_LABEL_KEYS[value]];
|
|
532
|
+
return (
|
|
533
|
+
<button
|
|
534
|
+
key={value}
|
|
535
|
+
type="button"
|
|
536
|
+
role="radio"
|
|
537
|
+
aria-checked={active}
|
|
538
|
+
aria-label={label}
|
|
539
|
+
className={`${styles.segOpt} ${styles.segMono}`}
|
|
540
|
+
data-active={active ? 'true' : 'false'}
|
|
541
|
+
data-code-font={value}
|
|
542
|
+
onClick={() => setCodeFont(value)}
|
|
543
|
+
>
|
|
544
|
+
{label}
|
|
545
|
+
</button>
|
|
546
|
+
);
|
|
547
|
+
})}
|
|
548
|
+
</div>
|
|
549
|
+
</div>
|
|
550
|
+
|
|
551
|
+
<div className={styles.row}>
|
|
552
|
+
<span className={styles.rowLabel}>{t.readingPrefsSize}</span>
|
|
553
|
+
<div className={styles.sizeControl}>
|
|
554
|
+
<div className={styles.sizeControlDesktop}>
|
|
555
|
+
<SizeStepper
|
|
556
|
+
kind="code"
|
|
557
|
+
value={prefs.codeSize}
|
|
558
|
+
pxLabel={codeSizeLabel}
|
|
559
|
+
onStep={setCodeSize}
|
|
560
|
+
decreaseLabel={t.readingPrefsDecrease}
|
|
561
|
+
increaseLabel={t.readingPrefsIncrease}
|
|
562
|
+
/>
|
|
563
|
+
</div>
|
|
564
|
+
<div
|
|
565
|
+
className={`${styles.seg} ${styles.segSizes} ${styles.sizeControlMobile}`}
|
|
566
|
+
role="radiogroup"
|
|
567
|
+
aria-label={`${t.readingPrefsCodeSection} — ${t.readingPrefsSize}`}
|
|
568
|
+
>
|
|
569
|
+
{SIZE_STEPS.map((step) => {
|
|
570
|
+
const active = prefs.codeSize === step;
|
|
571
|
+
const px = CODE_SIZE_PX[step];
|
|
572
|
+
return (
|
|
573
|
+
<button
|
|
574
|
+
key={step}
|
|
575
|
+
type="button"
|
|
576
|
+
role="radio"
|
|
577
|
+
aria-checked={active}
|
|
578
|
+
aria-label={`${px}px`}
|
|
579
|
+
className={`${styles.segOpt} ${styles.segMono}`}
|
|
580
|
+
data-active={active ? 'true' : 'false'}
|
|
581
|
+
onClick={() => setCodeSize(step)}
|
|
582
|
+
>
|
|
583
|
+
{px}
|
|
584
|
+
</button>
|
|
585
|
+
);
|
|
586
|
+
})}
|
|
587
|
+
</div>
|
|
588
|
+
</div>
|
|
589
|
+
</div>
|
|
590
|
+
</section>
|
|
591
|
+
|
|
592
|
+
<div className={styles.divider} />
|
|
593
|
+
|
|
594
|
+
<section className={styles.group} data-free-reading-section>
|
|
595
|
+
<h3 className={styles.groupTitle}>{t.freeReadingSection}</h3>
|
|
596
|
+
<div className={styles.freeReadingRow}>
|
|
597
|
+
<div className={styles.freeReadingText}>
|
|
598
|
+
<span className={styles.freeReadingTitle}>{t.freeReadingTitle}</span>
|
|
599
|
+
<span className={styles.freeReadingDesc}>{t.freeReadingDesc}</span>
|
|
600
|
+
</div>
|
|
601
|
+
<button
|
|
602
|
+
type="button"
|
|
603
|
+
role="switch"
|
|
604
|
+
aria-checked={freeReadingActive}
|
|
605
|
+
aria-label={t.freeReadingTitle}
|
|
606
|
+
className={styles.switch}
|
|
607
|
+
data-free-reading-toggle
|
|
608
|
+
data-active={freeReadingActive ? 'true' : 'false'}
|
|
609
|
+
onClick={handleFreeReadingToggle}
|
|
610
|
+
>
|
|
611
|
+
<span className={styles.switchThumb} aria-hidden="true" />
|
|
612
|
+
</button>
|
|
613
|
+
</div>
|
|
614
|
+
{confirmingReset ? (
|
|
615
|
+
<div className={styles.confirm} role="alert" data-free-reading-confirm-prompt>
|
|
616
|
+
<span className={styles.confirmText}>{t.freeReadingResetWarning}</span>
|
|
617
|
+
<div className={styles.confirmActions}>
|
|
618
|
+
<button
|
|
619
|
+
type="button"
|
|
620
|
+
className={styles.confirmBtn}
|
|
621
|
+
data-free-reading-cancel
|
|
622
|
+
onClick={cancelFreeReading}
|
|
623
|
+
>
|
|
624
|
+
{t.freeReadingResetCancel}
|
|
625
|
+
</button>
|
|
626
|
+
<button
|
|
627
|
+
type="button"
|
|
628
|
+
className={`${styles.confirmBtn} ${styles.confirmBtnDanger}`}
|
|
629
|
+
data-free-reading-confirm
|
|
630
|
+
onClick={confirmFreeReading}
|
|
631
|
+
>
|
|
632
|
+
{t.freeReadingResetConfirm}
|
|
633
|
+
</button>
|
|
634
|
+
</div>
|
|
635
|
+
</div>
|
|
636
|
+
) : null}
|
|
637
|
+
</section>
|
|
638
|
+
|
|
639
|
+
{repoUrl ? (
|
|
640
|
+
<>
|
|
641
|
+
<div className={`${styles.divider} ${styles.resourcesDivider}`} />
|
|
642
|
+
<div className={`${styles.row} ${styles.resourcesRow}`}>
|
|
643
|
+
<span className={styles.rowLabel}>{t.settingsResourcesLabel}</span>
|
|
644
|
+
<a
|
|
645
|
+
href={repoUrl}
|
|
646
|
+
target="_blank"
|
|
647
|
+
rel="noreferrer noopener"
|
|
648
|
+
className={styles.resourceLink}
|
|
649
|
+
>
|
|
650
|
+
<span className={styles.resourceIcon} aria-hidden="true">
|
|
651
|
+
<GitHubIcon />
|
|
652
|
+
</span>
|
|
653
|
+
<span className={styles.resourceText}>
|
|
654
|
+
<span className={styles.resourceTitle}>{t.githubRepo}</span>
|
|
655
|
+
<span className={styles.resourceSub}>{t.githubRepoSub}</span>
|
|
656
|
+
</span>
|
|
657
|
+
<span className={styles.resourceArrow} aria-hidden="true">
|
|
658
|
+
→
|
|
659
|
+
</span>
|
|
660
|
+
</a>
|
|
661
|
+
</div>
|
|
662
|
+
</>
|
|
663
|
+
) : null}
|
|
664
|
+
</div>
|
|
665
|
+
</div>
|
|
666
|
+
</>
|
|
667
|
+
);
|
|
668
|
+
|
|
669
|
+
return (
|
|
670
|
+
<div className={styles.wrapper} ref={wrapperRef}>
|
|
671
|
+
<button
|
|
672
|
+
type="button"
|
|
673
|
+
className={styles.trigger}
|
|
674
|
+
aria-label={t.settingsLabel}
|
|
675
|
+
title={t.settingsLabel}
|
|
676
|
+
aria-haspopup="dialog"
|
|
677
|
+
aria-expanded={open}
|
|
678
|
+
aria-controls={popoverId}
|
|
679
|
+
onClick={() => setOpen((prev) => !prev)}
|
|
680
|
+
>
|
|
681
|
+
<SettingsIcon />
|
|
682
|
+
</button>
|
|
683
|
+
{mounted && isMobile
|
|
684
|
+
? createPortal(overlayAndPopover, document.body)
|
|
685
|
+
: overlayAndPopover}
|
|
686
|
+
</div>
|
|
687
|
+
);
|
|
688
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { SettingsToggle } from './SettingsToggle';
|