@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,295 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMemo } from 'react';
|
|
4
|
+
import Link from 'next/link';
|
|
5
|
+
import { useRouter } from 'next/navigation';
|
|
6
|
+
import { useGate } from '@/components/GateProvider';
|
|
7
|
+
import { LockIcon } from '@/components/ProgramDrawer/LockIcon';
|
|
8
|
+
import {
|
|
9
|
+
flattenLessons,
|
|
10
|
+
getTotalLessons,
|
|
11
|
+
type Course,
|
|
12
|
+
} from '@/lib/course';
|
|
13
|
+
import {
|
|
14
|
+
formatDurationHm,
|
|
15
|
+
formatLessonCount,
|
|
16
|
+
formatModuleCount,
|
|
17
|
+
parseDurationMin,
|
|
18
|
+
} from '@/lib/format';
|
|
19
|
+
import { openProgramDrawer } from '@/lib/program-drawer';
|
|
20
|
+
import { lessonKey } from '@/lib/progress';
|
|
21
|
+
import { navigateToFrontierHref } from '@/lib/frontier-link';
|
|
22
|
+
import { useLang, useT } from '@/lib/use-i18n';
|
|
23
|
+
import styles from './HomePage.module.css';
|
|
24
|
+
|
|
25
|
+
type HomePageProps = {
|
|
26
|
+
course: Course;
|
|
27
|
+
level: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function HomePage({ course, level }: HomePageProps) {
|
|
31
|
+
const totalLessons = getTotalLessons(course);
|
|
32
|
+
const router = useRouter();
|
|
33
|
+
const { basePath } = useGate();
|
|
34
|
+
const t = useT();
|
|
35
|
+
const lang = useLang();
|
|
36
|
+
// Hero headline is the most brand-specific surface; prefer course.brand.hero
|
|
37
|
+
// (already resolved to the active lang by parseCourse) and fall back to the
|
|
38
|
+
// i18n defaults so an un-branded course still renders the Kafka headline.
|
|
39
|
+
const hero = course.brand?.hero ?? {
|
|
40
|
+
lead: t.heroTitleLead,
|
|
41
|
+
accent: t.heroTitleAccent,
|
|
42
|
+
tail: t.heroTitleTail,
|
|
43
|
+
};
|
|
44
|
+
const totalDurationMin = useMemo(
|
|
45
|
+
() =>
|
|
46
|
+
course.modules.reduce(
|
|
47
|
+
(sum, mod) =>
|
|
48
|
+
sum + mod.lessons.reduce((s, l) => s + parseDurationMin(l.duration), 0),
|
|
49
|
+
0,
|
|
50
|
+
),
|
|
51
|
+
[course],
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
// SSR baseline: render the "fresh user" shape — 0% progress, CTA points at
|
|
55
|
+
// the first lesson, "Начать с первого урока" copy, module rows say "не
|
|
56
|
+
// начато". The inline gate-paint script reads localStorage before first
|
|
57
|
+
// paint and rewrites href + textContent + data-* attributes for the real
|
|
58
|
+
// state, so the user with progress doesn't see this baseline flash.
|
|
59
|
+
const firstEntry = useMemo(() => flattenLessons(course)[0] ?? null, [course]);
|
|
60
|
+
const firstHref = firstEntry
|
|
61
|
+
? `/${lang}/${firstEntry.moduleId}/${firstEntry.lesson.slug}`
|
|
62
|
+
: '#';
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div className={styles.page}>
|
|
66
|
+
<section className={styles.hero}>
|
|
67
|
+
<div>
|
|
68
|
+
<h1 className={styles.heroTitle}>
|
|
69
|
+
{hero.lead}{' '}
|
|
70
|
+
<span className={styles.heroTitleAccent}>{hero.accent}</span>{' '}
|
|
71
|
+
{hero.tail}
|
|
72
|
+
</h1>
|
|
73
|
+
<p className={styles.heroLead}>{collapseWhitespace(course.description)}</p>
|
|
74
|
+
<div
|
|
75
|
+
className={styles.ctaRow}
|
|
76
|
+
data-cta-frontier="global"
|
|
77
|
+
data-cta-state="not-started"
|
|
78
|
+
suppressHydrationWarning
|
|
79
|
+
>
|
|
80
|
+
{/* Two CTA variants: gate-paint flips data-cta-state and CSS
|
|
81
|
+
shows exactly one. Pre-paint baseline = "not-started" so the
|
|
82
|
+
SSR HTML reads the "start from the first lesson" copy. */}
|
|
83
|
+
<Link
|
|
84
|
+
href={firstHref}
|
|
85
|
+
className={`${styles.btn} ${styles.btnPrimary}`}
|
|
86
|
+
data-cta-variant="not-started"
|
|
87
|
+
>
|
|
88
|
+
{t.startFromFirst}
|
|
89
|
+
<span className={styles.btnArrow}>→</span>
|
|
90
|
+
</Link>
|
|
91
|
+
<Link
|
|
92
|
+
href={firstHref}
|
|
93
|
+
className={`${styles.btn} ${styles.btnPrimary}`}
|
|
94
|
+
data-cta-variant="in-progress"
|
|
95
|
+
data-cta-frontier-link
|
|
96
|
+
suppressHydrationWarning
|
|
97
|
+
onClick={(e) => navigateToFrontierHref(e, router, basePath)}
|
|
98
|
+
>
|
|
99
|
+
{t.continueLessonPrefix}{' '}
|
|
100
|
+
<span data-cta-frontier-num suppressHydrationWarning>
|
|
101
|
+
1
|
|
102
|
+
</span>
|
|
103
|
+
<span className={styles.btnArrow}>→</span>
|
|
104
|
+
</Link>
|
|
105
|
+
{/* "Start over" only visible in in-progress state. */}
|
|
106
|
+
<Link
|
|
107
|
+
href={firstHref}
|
|
108
|
+
className={`${styles.btn} ${styles.btnSecondary}`}
|
|
109
|
+
data-cta-variant="in-progress"
|
|
110
|
+
>
|
|
111
|
+
{t.startFromScratch}
|
|
112
|
+
</Link>
|
|
113
|
+
<button
|
|
114
|
+
type="button"
|
|
115
|
+
className={`${styles.btn} ${styles.btnGhost}`}
|
|
116
|
+
onClick={openProgramDrawer}
|
|
117
|
+
>
|
|
118
|
+
{t.programCourse}
|
|
119
|
+
</button>
|
|
120
|
+
</div>
|
|
121
|
+
{/* Frontier hint — only visible once gate-paint marks the page as
|
|
122
|
+
having progress. Lesson title is rewritten in-place. */}
|
|
123
|
+
<div
|
|
124
|
+
className={styles.nextHint}
|
|
125
|
+
data-frontier-hint
|
|
126
|
+
data-hint-state="hidden"
|
|
127
|
+
suppressHydrationWarning
|
|
128
|
+
>
|
|
129
|
+
<span className={styles.nextHintArrow}>↳</span>{' '}
|
|
130
|
+
<span
|
|
131
|
+
className={styles.nextHintLesson}
|
|
132
|
+
data-frontier-hint-lesson
|
|
133
|
+
suppressHydrationWarning
|
|
134
|
+
>
|
|
135
|
+
{firstEntry?.lesson.title ?? ''}
|
|
136
|
+
</span>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
<aside
|
|
141
|
+
className={styles.statsCard}
|
|
142
|
+
aria-label={t.progressSummary}
|
|
143
|
+
data-progress-scope="global"
|
|
144
|
+
data-progress-state="not-started"
|
|
145
|
+
suppressHydrationWarning
|
|
146
|
+
>
|
|
147
|
+
<div className={styles.statsProgress}>
|
|
148
|
+
<div className={styles.statsProgressRow}>
|
|
149
|
+
<span className={styles.statsPct}>
|
|
150
|
+
<span data-progress-pct suppressHydrationWarning>
|
|
151
|
+
0
|
|
152
|
+
</span>
|
|
153
|
+
<span className={styles.statsPctUnit}>%</span>
|
|
154
|
+
</span>
|
|
155
|
+
<span className={styles.statsOf}>
|
|
156
|
+
<span data-progress-count suppressHydrationWarning>
|
|
157
|
+
0
|
|
158
|
+
</span>{' '}
|
|
159
|
+
/ {formatLessonCount(totalLessons, lang)}
|
|
160
|
+
</span>
|
|
161
|
+
</div>
|
|
162
|
+
<div className={styles.statsBar} aria-hidden="true">
|
|
163
|
+
<span
|
|
164
|
+
className={styles.statsBarFill}
|
|
165
|
+
data-progress-bar
|
|
166
|
+
style={{ width: '0%' }}
|
|
167
|
+
suppressHydrationWarning
|
|
168
|
+
/>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
<dl className={styles.statsGrid}>
|
|
172
|
+
<div>
|
|
173
|
+
<dt className={styles.statsLabel}>{t.modulesLabel}</dt>
|
|
174
|
+
<dd className={styles.statsValue}>{course.modules.length}</dd>
|
|
175
|
+
</div>
|
|
176
|
+
<div>
|
|
177
|
+
<dt className={styles.statsLabel}>{t.lessonsLabel}</dt>
|
|
178
|
+
<dd className={styles.statsValue}>{totalLessons}</dd>
|
|
179
|
+
</div>
|
|
180
|
+
<div>
|
|
181
|
+
<dt className={styles.statsLabel}>{t.durationLabel}</dt>
|
|
182
|
+
<dd className={styles.statsValue}>{formatDurationHm(totalDurationMin, lang)}</dd>
|
|
183
|
+
</div>
|
|
184
|
+
<div>
|
|
185
|
+
<dt className={styles.statsLabel}>{t.stackLabel}</dt>
|
|
186
|
+
<dd className={styles.statsValue}>{level}</dd>
|
|
187
|
+
</div>
|
|
188
|
+
</dl>
|
|
189
|
+
</aside>
|
|
190
|
+
</section>
|
|
191
|
+
|
|
192
|
+
<header className={styles.sectionHead}>
|
|
193
|
+
<div>
|
|
194
|
+
<div className={styles.sectionEyebrow}>/ contents</div>
|
|
195
|
+
<h2 className={styles.sectionTitle}>{t.programCourse}</h2>
|
|
196
|
+
</div>
|
|
197
|
+
<div className={styles.sectionTools}>
|
|
198
|
+
{formatModuleCount(course.modules.length, lang)} ·{' '}
|
|
199
|
+
{formatLessonCount(totalLessons, lang)}
|
|
200
|
+
</div>
|
|
201
|
+
</header>
|
|
202
|
+
|
|
203
|
+
<ol className={styles.modules}>
|
|
204
|
+
{course.modules.map((mod, mi) => {
|
|
205
|
+
const total = mod.lessons.length;
|
|
206
|
+
const moduleDurationMin = mod.lessons.reduce(
|
|
207
|
+
(s, l) => s + parseDurationMin(l.duration),
|
|
208
|
+
0,
|
|
209
|
+
);
|
|
210
|
+
// Module is treated as a single gate target via its first lesson.
|
|
211
|
+
// CSV of lesson keys feeds the gate-paint script so it can paint
|
|
212
|
+
// per-module count / percent / state in this row.
|
|
213
|
+
const moduleKey = mod.lessons[0]
|
|
214
|
+
? lessonKey(mod.id, mod.lessons[0].slug)
|
|
215
|
+
: undefined;
|
|
216
|
+
const moduleKeysCsv = mod.lessons
|
|
217
|
+
.map((l) => lessonKey(mod.id, l.slug))
|
|
218
|
+
.join(',');
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<li key={mod.id} className={styles.moduleItem}>
|
|
222
|
+
<Link
|
|
223
|
+
href={`/${lang}/${mod.id}`}
|
|
224
|
+
className={styles.moduleRow}
|
|
225
|
+
data-lesson-key={moduleKey}
|
|
226
|
+
data-progress-scope="module"
|
|
227
|
+
data-progress-keys={moduleKeysCsv}
|
|
228
|
+
data-progress-state="not-started"
|
|
229
|
+
onClick={(e) => {
|
|
230
|
+
if (e.currentTarget.getAttribute('data-locked') === 'true') {
|
|
231
|
+
e.preventDefault();
|
|
232
|
+
}
|
|
233
|
+
}}
|
|
234
|
+
title={t.moduleLockTitle}
|
|
235
|
+
suppressHydrationWarning
|
|
236
|
+
>
|
|
237
|
+
<div className={styles.moduleNum}>
|
|
238
|
+
{String(mi + 1).padStart(2, '0')}
|
|
239
|
+
</div>
|
|
240
|
+
<div className={styles.moduleText}>
|
|
241
|
+
<h3 className={styles.moduleTitle}>{mod.title}</h3>
|
|
242
|
+
<p className={styles.moduleDesc}>{collapseWhitespace(mod.description)}</p>
|
|
243
|
+
</div>
|
|
244
|
+
<div className={styles.moduleProgress} data-module-progress>
|
|
245
|
+
<div className={styles.mpRow}>
|
|
246
|
+
{/* All three labels live in the DOM; CSS shows exactly
|
|
247
|
+
one based on the row's data-progress-state. Gate-paint
|
|
248
|
+
flips that attribute without re-rendering React. */}
|
|
249
|
+
<span className={styles.mpStatus}>
|
|
250
|
+
<span data-status="not-started">{t.statusNotStarted}</span>
|
|
251
|
+
<span data-status="in-progress">{t.statusInProgress}</span>
|
|
252
|
+
<span data-status="complete">{t.statusComplete}</span>
|
|
253
|
+
</span>
|
|
254
|
+
<span className={styles.mpPct}>
|
|
255
|
+
<span data-progress-count suppressHydrationWarning>
|
|
256
|
+
0
|
|
257
|
+
</span>
|
|
258
|
+
/{total}
|
|
259
|
+
</span>
|
|
260
|
+
</div>
|
|
261
|
+
<div className={styles.mpBar} aria-hidden="true">
|
|
262
|
+
<span
|
|
263
|
+
className={styles.mpFill}
|
|
264
|
+
data-progress-bar
|
|
265
|
+
style={{ width: '0%' }}
|
|
266
|
+
suppressHydrationWarning
|
|
267
|
+
/>
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
<div className={styles.moduleMeta}>
|
|
271
|
+
<span className={styles.mmLessons}>
|
|
272
|
+
{formatLessonCount(total, lang)}
|
|
273
|
+
</span>
|
|
274
|
+
<span className={styles.mmDuration}>
|
|
275
|
+
{formatDurationHm(moduleDurationMin, lang)}
|
|
276
|
+
</span>
|
|
277
|
+
</div>
|
|
278
|
+
<div className={styles.arrowCell} aria-hidden="true">
|
|
279
|
+
<span className={styles.arrowOpen}>→</span>
|
|
280
|
+
<span className={styles.arrowLocked}>
|
|
281
|
+
<LockIcon />
|
|
282
|
+
</span>
|
|
283
|
+
</div>
|
|
284
|
+
</Link>
|
|
285
|
+
</li>
|
|
286
|
+
);
|
|
287
|
+
})}
|
|
288
|
+
</ol>
|
|
289
|
+
</div>
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function collapseWhitespace(text: string): string {
|
|
294
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
295
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { HomePage } from './HomePage';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/* Locked-state visuals for cross-lesson MDX links live in globals.css —
|
|
2
|
+
they target plain attribute selectors and don't fit CSS Modules' "must
|
|
3
|
+
contain a local class" rule. This file only keeps the local class for
|
|
4
|
+
the inline lock badge that the global rule reveals. */
|
|
5
|
+
|
|
6
|
+
.lockBadge {
|
|
7
|
+
display: none;
|
|
8
|
+
align-items: center;
|
|
9
|
+
margin-left: 0.25em;
|
|
10
|
+
vertical-align: -0.15em;
|
|
11
|
+
color: currentColor;
|
|
12
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { AnchorHTMLAttributes, ReactNode } from 'react';
|
|
4
|
+
import { LockIcon } from '@/components/ProgramDrawer/LockIcon';
|
|
5
|
+
import { stripLangFromPath } from '@/lib/lang';
|
|
6
|
+
import styles from './LessonAwareLink.module.css';
|
|
7
|
+
|
|
8
|
+
type LessonAwareLinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & {
|
|
9
|
+
basePath?: string;
|
|
10
|
+
children?: ReactNode;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* MDX `<a>` replacement that participates in the gate. The rendered shape is
|
|
15
|
+
* always a plain anchor — the inline gate-mark script flips `data-locked` on
|
|
16
|
+
* any link whose `data-lesson-key` points at a still-locked lesson, and CSS
|
|
17
|
+
* dims the row + reveals the inline lock badge from there. Click handler
|
|
18
|
+
* uses the attribute (not React state) so SSR markup and post-hydration
|
|
19
|
+
* markup are identical and there's no flash.
|
|
20
|
+
*/
|
|
21
|
+
export function LessonAwareLink({
|
|
22
|
+
href,
|
|
23
|
+
children,
|
|
24
|
+
basePath = '',
|
|
25
|
+
...rest
|
|
26
|
+
}: LessonAwareLinkProps) {
|
|
27
|
+
const target = href ? matchLessonHref(href, basePath) : null;
|
|
28
|
+
const dataKey = target ? `${target.moduleId}/${target.slug}` : undefined;
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<a
|
|
32
|
+
href={href}
|
|
33
|
+
data-lesson-key={dataKey}
|
|
34
|
+
onClick={
|
|
35
|
+
dataKey
|
|
36
|
+
? (e) => {
|
|
37
|
+
if (e.currentTarget.getAttribute('data-locked') === 'true') {
|
|
38
|
+
e.preventDefault();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
: undefined
|
|
42
|
+
}
|
|
43
|
+
{...rest}
|
|
44
|
+
// Why: the inline gate-mark script (runs before hydration) stamps
|
|
45
|
+
// `data-locked` / `aria-disabled` / `tabindex` on any anchor whose
|
|
46
|
+
// `data-lesson-key` points at a still-locked lesson. React's
|
|
47
|
+
// hydration would otherwise flag those as "extra attributes from
|
|
48
|
+
// the server" because the virtual DOM doesn't include them.
|
|
49
|
+
suppressHydrationWarning
|
|
50
|
+
>
|
|
51
|
+
{children}
|
|
52
|
+
{dataKey && (
|
|
53
|
+
<span
|
|
54
|
+
className={styles.lockBadge}
|
|
55
|
+
data-lock-badge=""
|
|
56
|
+
aria-hidden="true"
|
|
57
|
+
>
|
|
58
|
+
<LockIcon />
|
|
59
|
+
</span>
|
|
60
|
+
)}
|
|
61
|
+
</a>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function matchLessonHref(
|
|
66
|
+
href: string,
|
|
67
|
+
basePath: string,
|
|
68
|
+
): { moduleId: string; slug: string } | null {
|
|
69
|
+
if (href.length === 0) return null;
|
|
70
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(href)) return null;
|
|
71
|
+
if (/^(?:mailto:|tel:|data:)/i.test(href)) return null;
|
|
72
|
+
if (href.startsWith('#')) return null;
|
|
73
|
+
|
|
74
|
+
let path = href;
|
|
75
|
+
if (basePath && path.startsWith(`${basePath}/`)) {
|
|
76
|
+
path = path.slice(basePath.length);
|
|
77
|
+
}
|
|
78
|
+
if (!path.startsWith('/')) return null;
|
|
79
|
+
|
|
80
|
+
const withoutHash = path.split('#')[0]?.split('?')[0] ?? '';
|
|
81
|
+
const { rest } = stripLangFromPath(withoutHash);
|
|
82
|
+
const segments = rest.split('/').filter(Boolean);
|
|
83
|
+
if (segments.length < 2) return null;
|
|
84
|
+
|
|
85
|
+
return { moduleId: segments[0], slug: segments[1] };
|
|
86
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { LessonAwareLink } from './LessonAwareLink';
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
.layout {
|
|
2
|
+
display: grid;
|
|
3
|
+
grid-template-columns: minmax(0, var(--layout-content-max));
|
|
4
|
+
gap: var(--space-8);
|
|
5
|
+
padding: var(--space-8) var(--space-6);
|
|
6
|
+
max-width: 1280px;
|
|
7
|
+
margin: 0 auto;
|
|
8
|
+
width: 100%;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.content {
|
|
12
|
+
min-width: 0;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.title {
|
|
16
|
+
font-size: var(--font-size-3xl);
|
|
17
|
+
line-height: var(--line-height-tight);
|
|
18
|
+
font-weight: var(--font-weight-bold);
|
|
19
|
+
margin: 0;
|
|
20
|
+
color: var(--content-primary);
|
|
21
|
+
letter-spacing: -0.01em;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@media (max-width: 1023px) {
|
|
25
|
+
.layout {
|
|
26
|
+
padding: var(--space-6) var(--space-4);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@media (max-width: 767px) {
|
|
31
|
+
.layout {
|
|
32
|
+
padding: var(--space-4);
|
|
33
|
+
gap: var(--space-4);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import styles from './LessonLayout.module.css';
|
|
3
|
+
|
|
4
|
+
type LessonLayoutProps = {
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
title?: ReactNode;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function LessonLayout({ children, title }: LessonLayoutProps) {
|
|
10
|
+
return (
|
|
11
|
+
<div className={styles.layout}>
|
|
12
|
+
<div className={styles.content}>
|
|
13
|
+
{title && <h1 className={styles.title}>{title}</h1>}
|
|
14
|
+
{children}
|
|
15
|
+
</div>
|
|
16
|
+
</div>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { LessonLayout } from './LessonLayout';
|