@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,481 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
4
|
+
import Link from 'next/link';
|
|
5
|
+
import { useGate } from '@/components/GateProvider';
|
|
6
|
+
import { useProgressMode } from '@/components/ProgressModeProvider';
|
|
7
|
+
import { ProgressBar } from '@/components/ProgressBar';
|
|
8
|
+
import { GitHubIcon, HomeIcon } from '@/components/Sidebar/icons';
|
|
9
|
+
import type { Course, FlatLessonEntry } from '@/lib/course';
|
|
10
|
+
import { formatDurationShort, parseDurationMin } from '@/lib/format';
|
|
11
|
+
import { applyGatePainting } from '@/lib/gate-mark-script';
|
|
12
|
+
import type { Lang } from '@/lib/lang';
|
|
13
|
+
import { isCompleted, lessonKey, markCompletedAndAdvance } from '@/lib/progress';
|
|
14
|
+
import { useLang, useT } from '@/lib/use-i18n';
|
|
15
|
+
import { LockIcon } from './LockIcon';
|
|
16
|
+
import styles from './ProgramDrawer.module.css';
|
|
17
|
+
|
|
18
|
+
// useLayoutEffect on the client, no-op on the server — gate marking touches
|
|
19
|
+
// the DOM and only matters in the browser, but unconditional useLayoutEffect
|
|
20
|
+
// would warn during SSR.
|
|
21
|
+
const useIsomorphicLayoutEffect =
|
|
22
|
+
typeof window === 'undefined' ? useEffect : useLayoutEffect;
|
|
23
|
+
|
|
24
|
+
type ProgramDrawerProps = {
|
|
25
|
+
course: Course;
|
|
26
|
+
currentModuleId?: string;
|
|
27
|
+
currentSlug?: string;
|
|
28
|
+
/* Mobile-only "current lesson" card surfaces these three together — they
|
|
29
|
+
mirror the design's `dln-current` block (eyebrow + title + meta). Kept
|
|
30
|
+
optional because home/module-index pages have no active lesson. */
|
|
31
|
+
currentLessonTitle?: string;
|
|
32
|
+
currentLessonIndex?: number;
|
|
33
|
+
currentModuleTitle?: string;
|
|
34
|
+
isOpen: boolean;
|
|
35
|
+
onClose: () => void;
|
|
36
|
+
/* Mobile-only overlay extras. On desktop the breadcrumb header and the
|
|
37
|
+
bottom sidebar carry these affordances; on mobile both are gone, so the
|
|
38
|
+
drawer becomes the single command surface and renders them inline. */
|
|
39
|
+
prev?: FlatLessonEntry | null;
|
|
40
|
+
next?: FlatLessonEntry | null;
|
|
41
|
+
repoUrl?: string;
|
|
42
|
+
/* `lang` is also available from the i18n provider via useLang(), but the
|
|
43
|
+
prop wins so the drawer paints the right links during SSR before the
|
|
44
|
+
provider initialises on the client. */
|
|
45
|
+
lang?: Lang;
|
|
46
|
+
totalLessons?: number;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export function ProgramDrawer({
|
|
50
|
+
course,
|
|
51
|
+
currentModuleId,
|
|
52
|
+
currentSlug,
|
|
53
|
+
currentLessonTitle,
|
|
54
|
+
currentLessonIndex,
|
|
55
|
+
currentModuleTitle,
|
|
56
|
+
isOpen,
|
|
57
|
+
onClose,
|
|
58
|
+
prev,
|
|
59
|
+
next,
|
|
60
|
+
repoUrl,
|
|
61
|
+
lang: langProp,
|
|
62
|
+
totalLessons,
|
|
63
|
+
}: ProgramDrawerProps) {
|
|
64
|
+
const gate = useGate();
|
|
65
|
+
const { disabled } = useProgressMode();
|
|
66
|
+
const t = useT();
|
|
67
|
+
const langCtx = useLang();
|
|
68
|
+
const lang = langProp ?? langCtx;
|
|
69
|
+
// Use the shared progress map from GateProvider (single source of truth) so
|
|
70
|
+
// the drawer agrees with checkmark state elsewhere on the page.
|
|
71
|
+
const progress = gate.hydrated ? gate.progress : null;
|
|
72
|
+
|
|
73
|
+
// Default expanded set:
|
|
74
|
+
// • the module containing the active lesson, if any (so the user lands
|
|
75
|
+
// on their own context),
|
|
76
|
+
// • otherwise the first two modules — enough to communicate the
|
|
77
|
+
// accordion shape without the drawer becoming a wall of text.
|
|
78
|
+
const initialExpanded = useMemo(() => {
|
|
79
|
+
const map: Record<string, boolean> = {};
|
|
80
|
+
course.modules.forEach((m, i) => {
|
|
81
|
+
map[m.id] = currentModuleId ? m.id === currentModuleId : i < 2;
|
|
82
|
+
});
|
|
83
|
+
return map;
|
|
84
|
+
}, [course, currentModuleId]);
|
|
85
|
+
|
|
86
|
+
const [expanded, setExpanded] = useState<Record<string, boolean>>(initialExpanded);
|
|
87
|
+
const asideRef = useRef<HTMLElement | null>(null);
|
|
88
|
+
|
|
89
|
+
// Toggle `inert` on the drawer aside instead of relying on per-element
|
|
90
|
+
// tabindex. The gate-mark script reaches into [data-lesson-key] elements
|
|
91
|
+
// and strips `tabindex` on unlocked items so they regain default focus
|
|
92
|
+
// behavior in HomePage / ModulePage / MDX cross-lesson links — which is
|
|
93
|
+
// correct everywhere except inside a closed offscreen drawer. `inert`
|
|
94
|
+
// removes the entire subtree from the focus order regardless of what
|
|
95
|
+
// individual `tabindex` attributes say, so the two concerns no longer
|
|
96
|
+
// fight each other. useLayoutEffect runs before paint so a freshly
|
|
97
|
+
// closed drawer never leaks focus between render and effect.
|
|
98
|
+
useIsomorphicLayoutEffect(() => {
|
|
99
|
+
const aside = asideRef.current;
|
|
100
|
+
if (!aside) return;
|
|
101
|
+
if (isOpen) {
|
|
102
|
+
aside.removeAttribute('inert');
|
|
103
|
+
} else {
|
|
104
|
+
aside.setAttribute('inert', '');
|
|
105
|
+
}
|
|
106
|
+
}, [isOpen]);
|
|
107
|
+
|
|
108
|
+
// Re-seed expansion when the active module changes — opening the drawer
|
|
109
|
+
// from a different lesson should snap to that module.
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
setExpanded(initialExpanded);
|
|
112
|
+
}, [initialExpanded]);
|
|
113
|
+
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
if (!isOpen) return;
|
|
116
|
+
function handleKey(event: KeyboardEvent) {
|
|
117
|
+
if (event.key === 'Escape') onClose();
|
|
118
|
+
}
|
|
119
|
+
window.addEventListener('keydown', handleKey);
|
|
120
|
+
return () => window.removeEventListener('keydown', handleKey);
|
|
121
|
+
}, [isOpen, onClose]);
|
|
122
|
+
|
|
123
|
+
// Lock the page behind the drawer while it's open (matches the referenced
|
|
124
|
+
// prototype's body.style.overflow = 'hidden' behavior).
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (!isOpen) return;
|
|
127
|
+
const prev = document.body.style.overflow;
|
|
128
|
+
document.body.style.overflow = 'hidden';
|
|
129
|
+
return () => {
|
|
130
|
+
document.body.style.overflow = prev;
|
|
131
|
+
};
|
|
132
|
+
}, [isOpen]);
|
|
133
|
+
|
|
134
|
+
const toggle = (id: string) =>
|
|
135
|
+
setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
|
|
136
|
+
|
|
137
|
+
// Drawer-rendered lesson rows are added to the DOM after the initial
|
|
138
|
+
// gate-mark inline script ran, so re-apply marking whenever the expanded
|
|
139
|
+
// set or progress changes. useLayoutEffect runs before paint, so locked
|
|
140
|
+
// rows never flash as "open".
|
|
141
|
+
//
|
|
142
|
+
// Free-reading mode: skip painting entirely. With progress cleared the
|
|
143
|
+
// resolved furthest index is -1, so applyGatePainting would re-lock every
|
|
144
|
+
// row past index 0 (and the row onClick then blocks navigation), defeating
|
|
145
|
+
// the feature. The freshly rendered rows carry no data-locked, so leaving
|
|
146
|
+
// them untouched keeps the whole course reachable — mirrors the early-exit
|
|
147
|
+
// in GateProvider's sync effect.
|
|
148
|
+
useIsomorphicLayoutEffect(() => {
|
|
149
|
+
if (!gate.hydrated || disabled) return;
|
|
150
|
+
applyGatePainting(course, gate.furthestIndex, gate.basePath, lang);
|
|
151
|
+
}, [gate.hydrated, gate.furthestIndex, gate.basePath, course, expanded, isOpen, lang, disabled]);
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<>
|
|
155
|
+
<div
|
|
156
|
+
className={styles.overlay}
|
|
157
|
+
data-open={isOpen ? 'true' : 'false'}
|
|
158
|
+
onClick={onClose}
|
|
159
|
+
aria-hidden="true"
|
|
160
|
+
/>
|
|
161
|
+
<aside
|
|
162
|
+
ref={asideRef}
|
|
163
|
+
className={styles.drawer}
|
|
164
|
+
data-open={isOpen ? 'true' : 'false'}
|
|
165
|
+
aria-label={t.programCourse}
|
|
166
|
+
aria-hidden={!isOpen}
|
|
167
|
+
role="dialog"
|
|
168
|
+
aria-modal="true"
|
|
169
|
+
// SSR-side `inert` so the closed drawer is non-focusable before
|
|
170
|
+
// hydration runs the layout effect above. The effect keeps it in
|
|
171
|
+
// sync on subsequent open/close transitions.
|
|
172
|
+
{...((isOpen ? {} : { inert: '' }) as Record<string, string>)}
|
|
173
|
+
>
|
|
174
|
+
<header className={styles.header}>
|
|
175
|
+
<div>
|
|
176
|
+
<div className={styles.eyebrow}>/ contents</div>
|
|
177
|
+
<h2 className={styles.title}>{t.programCourse}</h2>
|
|
178
|
+
</div>
|
|
179
|
+
<button
|
|
180
|
+
type="button"
|
|
181
|
+
className={styles.close}
|
|
182
|
+
onClick={onClose}
|
|
183
|
+
aria-label={t.close}
|
|
184
|
+
tabIndex={isOpen ? 0 : -1}
|
|
185
|
+
>
|
|
186
|
+
<CloseIcon />
|
|
187
|
+
</button>
|
|
188
|
+
</header>
|
|
189
|
+
|
|
190
|
+
{/* Mobile-only context strip. Desktop suppresses these blocks via CSS
|
|
191
|
+
because the Sidebar (home / GitHub) and Header (progress / prev /
|
|
192
|
+
next) already carry the same affordances. */}
|
|
193
|
+
{totalLessons ? (
|
|
194
|
+
<div className={styles.contextProgress}>
|
|
195
|
+
<ProgressBar total={totalLessons} lang={lang} />
|
|
196
|
+
</div>
|
|
197
|
+
) : null}
|
|
198
|
+
|
|
199
|
+
{currentModuleId && currentSlug && (prev || next || currentLessonTitle) ? (
|
|
200
|
+
<div className={styles.contextNav} aria-label={t.lessonNavLabel}>
|
|
201
|
+
{currentLessonTitle ? (
|
|
202
|
+
<div className={styles.currentCard}>
|
|
203
|
+
<div className={styles.currentEyebrow}>{t.currentLessonEyebrow}</div>
|
|
204
|
+
<div className={styles.currentTitle}>{currentLessonTitle}</div>
|
|
205
|
+
{typeof currentLessonIndex === 'number' ? (
|
|
206
|
+
<div className={styles.currentMeta}>
|
|
207
|
+
{t.currentLessonNumberPrefix}{' '}
|
|
208
|
+
{String(currentLessonIndex).padStart(2, '0')}
|
|
209
|
+
{currentModuleTitle ? ` · ${currentModuleTitle}` : ''}
|
|
210
|
+
</div>
|
|
211
|
+
) : null}
|
|
212
|
+
</div>
|
|
213
|
+
) : null}
|
|
214
|
+
<div className={styles.navRow}>
|
|
215
|
+
{prev ? (
|
|
216
|
+
<Link
|
|
217
|
+
href={`/${lang}/${prev.moduleId}/${prev.lesson.slug}`}
|
|
218
|
+
className={`${styles.navCard} ${styles.navPrev}`}
|
|
219
|
+
onClick={onClose}
|
|
220
|
+
tabIndex={isOpen ? 0 : -1}
|
|
221
|
+
aria-label={t.prevLessonAria}
|
|
222
|
+
>
|
|
223
|
+
<span className={styles.navChevron} aria-hidden="true">
|
|
224
|
+
<ChevronLeftIcon />
|
|
225
|
+
</span>
|
|
226
|
+
<span className={styles.navMeta}>
|
|
227
|
+
<span className={styles.navLabel}>{t.prevLessonShort}</span>
|
|
228
|
+
<span className={styles.navTitle}>{prev.lesson.title}</span>
|
|
229
|
+
</span>
|
|
230
|
+
</Link>
|
|
231
|
+
) : (
|
|
232
|
+
<span
|
|
233
|
+
className={`${styles.navCard} ${styles.navPrev} ${styles.navDisabled}`}
|
|
234
|
+
aria-hidden="true"
|
|
235
|
+
>
|
|
236
|
+
<span className={styles.navChevron}>
|
|
237
|
+
<ChevronLeftIcon />
|
|
238
|
+
</span>
|
|
239
|
+
<span className={styles.navMeta}>
|
|
240
|
+
<span className={styles.navLabel}>{t.firstLessonTitle}</span>
|
|
241
|
+
</span>
|
|
242
|
+
</span>
|
|
243
|
+
)}
|
|
244
|
+
{next ? (
|
|
245
|
+
<Link
|
|
246
|
+
href={`/${lang}/${next.moduleId}/${next.lesson.slug}`}
|
|
247
|
+
className={`${styles.navCard} ${styles.navNext}`}
|
|
248
|
+
onClick={() => {
|
|
249
|
+
// Free-reading mode: the card stays visible and navigates,
|
|
250
|
+
// but must never record progress (mirrors the guard in
|
|
251
|
+
// HeaderLessonNav / LessonNav — this is the mobile drawer's
|
|
252
|
+
// equivalent next-lesson write site).
|
|
253
|
+
if (!disabled) {
|
|
254
|
+
markCompletedAndAdvance(
|
|
255
|
+
gate.course,
|
|
256
|
+
lessonKey(currentModuleId, currentSlug),
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
onClose();
|
|
260
|
+
}}
|
|
261
|
+
tabIndex={isOpen ? 0 : -1}
|
|
262
|
+
aria-label={t.nextLessonAria}
|
|
263
|
+
>
|
|
264
|
+
<span className={styles.navMeta}>
|
|
265
|
+
<span className={styles.navLabel}>{t.nextLessonShort}</span>
|
|
266
|
+
<span className={styles.navTitle}>{next.lesson.title}</span>
|
|
267
|
+
</span>
|
|
268
|
+
<span className={styles.navChevron} aria-hidden="true">
|
|
269
|
+
<ChevronRightIcon />
|
|
270
|
+
</span>
|
|
271
|
+
</Link>
|
|
272
|
+
) : (
|
|
273
|
+
<span
|
|
274
|
+
className={`${styles.navCard} ${styles.navNext} ${styles.navDisabled}`}
|
|
275
|
+
aria-hidden="true"
|
|
276
|
+
>
|
|
277
|
+
<span className={styles.navMeta}>
|
|
278
|
+
<span className={styles.navLabel}>{t.lastLessonTitle}</span>
|
|
279
|
+
</span>
|
|
280
|
+
<span className={styles.navChevron}>
|
|
281
|
+
<ChevronRightIcon />
|
|
282
|
+
</span>
|
|
283
|
+
</span>
|
|
284
|
+
)}
|
|
285
|
+
</div>
|
|
286
|
+
</div>
|
|
287
|
+
) : null}
|
|
288
|
+
|
|
289
|
+
<nav className={styles.body} aria-label={t.moduleListLabel}>
|
|
290
|
+
<ol className={styles.modules}>
|
|
291
|
+
{course.modules.map((mod, mIndex) => {
|
|
292
|
+
const total = mod.lessons.length;
|
|
293
|
+
const doneCount =
|
|
294
|
+
progress === null
|
|
295
|
+
? 0
|
|
296
|
+
: mod.lessons.filter((l) =>
|
|
297
|
+
isCompleted(progress, lessonKey(mod.id, l.slug)),
|
|
298
|
+
).length;
|
|
299
|
+
const isComplete = doneCount === total && total > 0;
|
|
300
|
+
const isOpenModule = !!expanded[mod.id];
|
|
301
|
+
return (
|
|
302
|
+
<li key={mod.id} className={styles.module}>
|
|
303
|
+
<button
|
|
304
|
+
type="button"
|
|
305
|
+
className={styles.moduleHead}
|
|
306
|
+
onClick={() => toggle(mod.id)}
|
|
307
|
+
aria-expanded={isOpenModule}
|
|
308
|
+
tabIndex={isOpen ? 0 : -1}
|
|
309
|
+
>
|
|
310
|
+
<span className={styles.moduleNum}>
|
|
311
|
+
{String(mIndex + 1).padStart(2, '0')}
|
|
312
|
+
</span>
|
|
313
|
+
<span className={styles.moduleTitle}>{mod.title}</span>
|
|
314
|
+
<span
|
|
315
|
+
className={styles.moduleBadge}
|
|
316
|
+
data-complete={isComplete ? 'true' : 'false'}
|
|
317
|
+
// Free-reading mode hides this done/total counter via the
|
|
318
|
+
// global `data-progress-disabled` CSS rule. The hook is a
|
|
319
|
+
// stable data attribute (not the hashed CSS-module class),
|
|
320
|
+
// matching how every other progress widget is hidden.
|
|
321
|
+
data-module-badge
|
|
322
|
+
>
|
|
323
|
+
{doneCount}/{total}
|
|
324
|
+
</span>
|
|
325
|
+
<span className={styles.moduleChevron} aria-hidden="true">
|
|
326
|
+
{isOpenModule ? '−' : '+'}
|
|
327
|
+
</span>
|
|
328
|
+
</button>
|
|
329
|
+
{isOpenModule && (
|
|
330
|
+
<ol className={styles.lessons}>
|
|
331
|
+
{mod.lessons.map((lesson, lIndex) => {
|
|
332
|
+
const key = lessonKey(mod.id, lesson.slug);
|
|
333
|
+
const done =
|
|
334
|
+
progress !== null && isCompleted(progress, key);
|
|
335
|
+
const isCurrent =
|
|
336
|
+
mod.id === currentModuleId && lesson.slug === currentSlug;
|
|
337
|
+
const durMin = parseDurationMin(lesson.duration);
|
|
338
|
+
return (
|
|
339
|
+
<li key={lesson.slug} className={styles.lesson}>
|
|
340
|
+
<Link
|
|
341
|
+
href={`/${lang}/${mod.id}/${lesson.slug}`}
|
|
342
|
+
className={styles.lessonLink}
|
|
343
|
+
aria-current={isCurrent ? 'page' : undefined}
|
|
344
|
+
data-completed={done ? 'true' : undefined}
|
|
345
|
+
data-current={isCurrent ? 'true' : undefined}
|
|
346
|
+
data-lesson-key={key}
|
|
347
|
+
onClick={(e) => {
|
|
348
|
+
if (
|
|
349
|
+
e.currentTarget.getAttribute('data-locked') === 'true'
|
|
350
|
+
) {
|
|
351
|
+
e.preventDefault();
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
onClose();
|
|
355
|
+
}}
|
|
356
|
+
tabIndex={isOpen ? 0 : -1}
|
|
357
|
+
title={t.lessonLockTitle}
|
|
358
|
+
// Why: the gate-mark inline script (runs before
|
|
359
|
+
// hydration) strips `tabindex` from unlocked
|
|
360
|
+
// rows so they pick up the default focus order
|
|
361
|
+
// when the drawer opens. Hydration would
|
|
362
|
+
// otherwise warn that the SSR attribute (-1)
|
|
363
|
+
// disagrees with the post-script DOM.
|
|
364
|
+
suppressHydrationWarning
|
|
365
|
+
>
|
|
366
|
+
<span className={styles.lessonNum}>
|
|
367
|
+
{String(lIndex + 1).padStart(2, '0')}
|
|
368
|
+
</span>
|
|
369
|
+
<span className={styles.lessonTitle}>
|
|
370
|
+
{lesson.title}
|
|
371
|
+
</span>
|
|
372
|
+
<span className={styles.lessonMeta} aria-hidden="true">
|
|
373
|
+
<span className={styles.metaOpen}>
|
|
374
|
+
{done ? (
|
|
375
|
+
<span className={styles.lessonCheck}>✓</span>
|
|
376
|
+
) : (
|
|
377
|
+
formatDurationShort(durMin, lang)
|
|
378
|
+
)}
|
|
379
|
+
</span>
|
|
380
|
+
<span className={styles.metaLocked}>
|
|
381
|
+
<LockIcon />
|
|
382
|
+
</span>
|
|
383
|
+
</span>
|
|
384
|
+
</Link>
|
|
385
|
+
</li>
|
|
386
|
+
);
|
|
387
|
+
})}
|
|
388
|
+
</ol>
|
|
389
|
+
)}
|
|
390
|
+
</li>
|
|
391
|
+
);
|
|
392
|
+
})}
|
|
393
|
+
</ol>
|
|
394
|
+
</nav>
|
|
395
|
+
|
|
396
|
+
{/* Mobile-only secondary actions. Same hide-on-desktop rule as the
|
|
397
|
+
context strip above. */}
|
|
398
|
+
{repoUrl ? (
|
|
399
|
+
<div className={styles.contextFooter}>
|
|
400
|
+
<Link
|
|
401
|
+
href={`/${lang}`}
|
|
402
|
+
className={styles.footerLink}
|
|
403
|
+
onClick={onClose}
|
|
404
|
+
tabIndex={isOpen ? 0 : -1}
|
|
405
|
+
>
|
|
406
|
+
<HomeIcon />
|
|
407
|
+
<span>{t.home}</span>
|
|
408
|
+
</Link>
|
|
409
|
+
<a
|
|
410
|
+
className={styles.footerLink}
|
|
411
|
+
href={repoUrl}
|
|
412
|
+
target="_blank"
|
|
413
|
+
rel="noreferrer noopener"
|
|
414
|
+
tabIndex={isOpen ? 0 : -1}
|
|
415
|
+
>
|
|
416
|
+
<GitHubIcon />
|
|
417
|
+
<span>{t.githubRepo}</span>
|
|
418
|
+
</a>
|
|
419
|
+
</div>
|
|
420
|
+
) : null}
|
|
421
|
+
</aside>
|
|
422
|
+
</>
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function CloseIcon() {
|
|
427
|
+
return (
|
|
428
|
+
<svg
|
|
429
|
+
width="16"
|
|
430
|
+
height="16"
|
|
431
|
+
viewBox="0 0 24 24"
|
|
432
|
+
fill="none"
|
|
433
|
+
stroke="currentColor"
|
|
434
|
+
strokeWidth="1.8"
|
|
435
|
+
strokeLinecap="round"
|
|
436
|
+
strokeLinejoin="round"
|
|
437
|
+
aria-hidden="true"
|
|
438
|
+
focusable="false"
|
|
439
|
+
>
|
|
440
|
+
<path d="M6 6l12 12M18 6 6 18" />
|
|
441
|
+
</svg>
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function ChevronLeftIcon() {
|
|
446
|
+
return (
|
|
447
|
+
<svg
|
|
448
|
+
width="14"
|
|
449
|
+
height="14"
|
|
450
|
+
viewBox="0 0 24 24"
|
|
451
|
+
fill="none"
|
|
452
|
+
stroke="currentColor"
|
|
453
|
+
strokeWidth="2"
|
|
454
|
+
strokeLinecap="round"
|
|
455
|
+
strokeLinejoin="round"
|
|
456
|
+
aria-hidden="true"
|
|
457
|
+
focusable="false"
|
|
458
|
+
>
|
|
459
|
+
<path d="M15 6l-6 6 6 6" />
|
|
460
|
+
</svg>
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function ChevronRightIcon() {
|
|
465
|
+
return (
|
|
466
|
+
<svg
|
|
467
|
+
width="14"
|
|
468
|
+
height="14"
|
|
469
|
+
viewBox="0 0 24 24"
|
|
470
|
+
fill="none"
|
|
471
|
+
stroke="currentColor"
|
|
472
|
+
strokeWidth="2"
|
|
473
|
+
strokeLinecap="round"
|
|
474
|
+
strokeLinejoin="round"
|
|
475
|
+
aria-hidden="true"
|
|
476
|
+
focusable="false"
|
|
477
|
+
>
|
|
478
|
+
<path d="M9 6l6 6-6 6" />
|
|
479
|
+
</svg>
|
|
480
|
+
);
|
|
481
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ProgramDrawer } from './ProgramDrawer';
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
.bar {
|
|
2
|
+
display: flex;
|
|
3
|
+
align-items: center;
|
|
4
|
+
gap: var(--space-3);
|
|
5
|
+
flex: 0 0 auto;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.label {
|
|
9
|
+
font-family: var(--font-mono);
|
|
10
|
+
font-size: var(--font-size-xs);
|
|
11
|
+
color: var(--content-secondary);
|
|
12
|
+
letter-spacing: 0.02em;
|
|
13
|
+
white-space: nowrap;
|
|
14
|
+
min-width: 14ch;
|
|
15
|
+
text-align: right;
|
|
16
|
+
font-variant-numeric: tabular-nums;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.track {
|
|
20
|
+
display: block;
|
|
21
|
+
width: 96px;
|
|
22
|
+
height: 6px;
|
|
23
|
+
background-color: var(--bg-muted);
|
|
24
|
+
border-radius: var(--radius-pill);
|
|
25
|
+
overflow: hidden;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.fill {
|
|
29
|
+
display: block;
|
|
30
|
+
height: 100%;
|
|
31
|
+
background-color: var(--accent-main);
|
|
32
|
+
border-radius: inherit;
|
|
33
|
+
transition: width 200ms ease;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@media (max-width: 1023px) {
|
|
37
|
+
.track {
|
|
38
|
+
width: 64px;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@media (max-width: 767px) {
|
|
43
|
+
.track {
|
|
44
|
+
display: none;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { getDict } from '@/lib/i18n';
|
|
2
|
+
import type { Lang } from '@/lib/lang';
|
|
3
|
+
import styles from './ProgressBar.module.css';
|
|
4
|
+
|
|
5
|
+
type ProgressBarProps = {
|
|
6
|
+
total: number;
|
|
7
|
+
lang: Lang;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Global course progress bar in the header. Pure server-rendered shape — the
|
|
12
|
+
* gate-paint inline script reads localStorage before first paint and rewrites
|
|
13
|
+
* count / percent / bar-width / ARIA attributes directly via the
|
|
14
|
+
* `data-progress-*` slots. No React state, no useEffect, no hydration flash.
|
|
15
|
+
*/
|
|
16
|
+
export function ProgressBar({ total, lang }: ProgressBarProps) {
|
|
17
|
+
const t = getDict(lang);
|
|
18
|
+
return (
|
|
19
|
+
<div
|
|
20
|
+
className={styles.bar}
|
|
21
|
+
role="progressbar"
|
|
22
|
+
aria-label={t.progressBarAriaLabel}
|
|
23
|
+
aria-valuemin={0}
|
|
24
|
+
aria-valuemax={total}
|
|
25
|
+
aria-valuenow={0}
|
|
26
|
+
aria-valuetext={`0 ${t.progressAriaConnector} ${total} (0%)`}
|
|
27
|
+
data-progress-scope="global"
|
|
28
|
+
data-progress-state="not-started"
|
|
29
|
+
suppressHydrationWarning
|
|
30
|
+
>
|
|
31
|
+
<span className={styles.label}>
|
|
32
|
+
<span data-progress-count suppressHydrationWarning>0</span> / {total} (
|
|
33
|
+
<span data-progress-pct suppressHydrationWarning>0</span>%)
|
|
34
|
+
</span>
|
|
35
|
+
<span className={styles.track} aria-hidden="true">
|
|
36
|
+
<span
|
|
37
|
+
className={styles.fill}
|
|
38
|
+
data-progress-bar
|
|
39
|
+
style={{ width: '0%' }}
|
|
40
|
+
suppressHydrationWarning
|
|
41
|
+
/>
|
|
42
|
+
</span>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ProgressBar } from './ProgressBar';
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
useCallback,
|
|
6
|
+
useContext,
|
|
7
|
+
useEffect,
|
|
8
|
+
useState,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from 'react';
|
|
11
|
+
import {
|
|
12
|
+
applyProgressDisabled,
|
|
13
|
+
PROGRESS_DISABLED_ATTR,
|
|
14
|
+
PROGRESS_DISABLED_STORAGE_KEY,
|
|
15
|
+
readProgressDisabled,
|
|
16
|
+
writeProgressDisabled,
|
|
17
|
+
} from '@/lib/progress-mode';
|
|
18
|
+
import { clearProgress } from '@/lib/progress';
|
|
19
|
+
|
|
20
|
+
type ProgressModeContextValue = {
|
|
21
|
+
disabled: boolean;
|
|
22
|
+
setDisabled: (value: boolean) => void;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const ProgressModeContext = createContext<ProgressModeContextValue | null>(null);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Reads the current flag from the <html> attribute set synchronously by the
|
|
29
|
+
* inline init script. Unlike ThemeProvider's lazy default-then-sync approach we
|
|
30
|
+
* seed straight from the attribute so a previously-locked lesson opened in
|
|
31
|
+
* free-reading mode never flashes its locked interstitial during hydration.
|
|
32
|
+
*/
|
|
33
|
+
function readFromHtml(): boolean {
|
|
34
|
+
if (typeof document === 'undefined') return false;
|
|
35
|
+
return document.documentElement.getAttribute(PROGRESS_DISABLED_ATTR) === 'true';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function ProgressModeProvider({ children }: { children: ReactNode }) {
|
|
39
|
+
const [disabled, setState] = useState<boolean>(readFromHtml);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
// Re-sync once mounted in case SSR seeded `false` before the attribute was
|
|
43
|
+
// observable (e.g. lazy hydration); keeps state aligned with <html>.
|
|
44
|
+
setState(readFromHtml());
|
|
45
|
+
}, []);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
function handleStorage(event: StorageEvent) {
|
|
49
|
+
if (event.key !== PROGRESS_DISABLED_STORAGE_KEY) return;
|
|
50
|
+
// Cross-tab sync only: mirror the flag and the attribute. We deliberately
|
|
51
|
+
// do NOT call clearProgress here — the reset already happened in the tab
|
|
52
|
+
// that flipped the toggle, so re-running it would double-clear.
|
|
53
|
+
const next = readProgressDisabled();
|
|
54
|
+
applyProgressDisabled(next);
|
|
55
|
+
setState(next);
|
|
56
|
+
}
|
|
57
|
+
window.addEventListener('storage', handleStorage);
|
|
58
|
+
return () => window.removeEventListener('storage', handleStorage);
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
const setDisabled = useCallback((value: boolean) => {
|
|
62
|
+
// Order matters: flip `disabled` (attribute + storage + state) BEFORE
|
|
63
|
+
// clearing progress. clearProgress dispatches PROGRESS_CHANGE_EVENT, which
|
|
64
|
+
// GateProvider handles via refresh(); by then `disabled` is already true so
|
|
65
|
+
// the effect takes the cleanup branch instead of repainting (no flash).
|
|
66
|
+
applyProgressDisabled(value);
|
|
67
|
+
writeProgressDisabled(value);
|
|
68
|
+
setState(value);
|
|
69
|
+
if (value) {
|
|
70
|
+
clearProgress();
|
|
71
|
+
}
|
|
72
|
+
}, []);
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<ProgressModeContext.Provider value={{ disabled, setDisabled }}>
|
|
76
|
+
{children}
|
|
77
|
+
</ProgressModeContext.Provider>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function useProgressMode(): ProgressModeContextValue {
|
|
82
|
+
const ctx = useContext(ProgressModeContext);
|
|
83
|
+
if (!ctx) {
|
|
84
|
+
throw new Error('useProgressMode must be used inside <ProgressModeProvider>');
|
|
85
|
+
}
|
|
86
|
+
return ctx;
|
|
87
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ProgressModeProvider, useProgressMode } from './ProgressModeProvider';
|