Add the Next.js web app and shared lesson content.

Move lessons out of the C sources into JSON so the web client can use the same material as the desktop app, and point release builds at desktop/.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
FBukovina 2026-09-17 16:06:15 +02:00
commit f7470d333c
113 changed files with 25817 additions and 9 deletions

7
web/next-env.d.ts vendored Normal file
View file

@ -0,0 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

10
web/next.config.ts Normal file
View file

@ -0,0 +1,10 @@
import type { NextConfig } from 'next';
const config: NextConfig = {
// `content/` lives outside web/, so allow tracing files from the repo root.
outputFileTracingRoot: __dirname + '/..',
// Routes are built from content ids at runtime, so they cannot be statically typed.
typedRoutes: false,
};
export default config;

1076
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

24
web/package.json Normal file
View file

@ -0,0 +1,24 @@
{
"name": "maturita-web",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"content": "node ../tools/build-content.mjs"
},
"dependencies": {
"next": "^16.3.5",
"react": "^19.1.1",
"react-dom": "^19.1.1"
},
"devDependencies": {
"@types/node": "^22.10.2",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"typescript": "^5.7.2"
}
}

View file

@ -0,0 +1,38 @@
import type { Theme } from '../../../content/schema';
/**
* Applies the stored palette before the first paint.
*
* Without this the page renders with the dark fallback from `globals.css` and
* only recolours once <AppProvider>'s effect runs, which shows as a dark flash
* for anyone using a light theme.
*/
export function ThemeScript({ themes }: { themes: Theme[] }) {
const palettes = Object.fromEntries(
themes.map((theme) => [theme.id, { dark: theme.dark, light: theme.light }])
);
const source = `
(function () {
try {
var palettes = ${JSON.stringify(palettes)};
var stored = JSON.parse(localStorage.getItem('maturita.settings') || '{}');
var theme = palettes[stored.themeId] || palettes['catppuccin'];
var mode = stored.mode === 'light' ? 'light' : 'dark';
var palette = theme[mode];
var root = document.documentElement;
for (var key in palette) {
root.style.setProperty('--' + key.replace(/[A-Z]/g, function (c) {
return '-' + c.toLowerCase();
}), palette[key]);
}
root.dataset.mode = mode;
root.style.colorScheme = mode;
if (stored.lang === 'en' || stored.lang === 'cs') root.lang = stored.lang;
} catch (e) {}
})();`;
return <script dangerouslySetInnerHTML={{ __html: source }} />;
}

View file

@ -0,0 +1,12 @@
import { notFound } from 'next/navigation';
import { getLesson } from '@/content';
import { LessonScreen } from '@/components/LessonScreen';
export default async function MluvnicePage() {
try {
return <LessonScreen lesson={await getLesson('czech', 'mluvnice')} backHref="/czech" />;
} catch {
notFound();
}
}

View file

@ -0,0 +1,28 @@
import { notFound } from 'next/navigation';
import { getLessons, getPath } from '@/content';
import { PathScreen } from '@/components/PathScreen';
/**
* The three Czech areas from `build_czech_page`: Literatura (still empty),
* Mluvnice and Maturitní četba. Each node links on rather than opening a
* lesson, so `hrefBase` is unused here.
*/
export default async function CzechPage() {
const path = await getPath('czech');
if (!path) notFound();
const lessons = await getLessons('czech');
const taskCounts = Object.fromEntries(lessons.map((l) => [l.id, l.tasks.length]));
return (
<PathScreen
path={path}
titleKey="Český jazyk a literatura"
subtitleKey="czech_sub"
backHref="/subjects"
hrefBase="/czech"
taskCounts={taskCounts}
/>
);
}

View file

@ -0,0 +1,21 @@
import { notFound } from 'next/navigation';
import { getLesson, getPath } from '@/content';
import { LessonScreen } from '@/components/LessonScreen';
export async function generateStaticParams() {
const path = await getPath('czech', 'reading');
return (path?.units ?? [])
.filter((unit) => unit.lesson !== null)
.map((unit) => ({ book: unit.lesson as string }));
}
export default async function BookPage({ params }: { params: Promise<{ book: string }> }) {
const { book } = await params;
try {
return <LessonScreen lesson={await getLesson('czech', book)} backHref="/czech/reading" />;
} catch {
notFound();
}
}

View file

@ -0,0 +1,24 @@
import { notFound } from 'next/navigation';
import { getLessons, getPath } from '@/content';
import { PathScreen } from '@/components/PathScreen';
/** The reading list: one node per book, both unlocked. */
export default async function ReadingListPage() {
const path = await getPath('czech', 'reading');
if (!path) notFound();
const lessons = await getLessons('czech');
const taskCounts = Object.fromEntries(lessons.map((l) => [l.id, l.tasks.length]));
return (
<PathScreen
path={path}
title="Maturitní četba"
subtitle="Vyberte knihu a projděte si přehled, kvíz a řazení děje."
backHref="/czech"
hrefBase="/czech/reading"
taskCounts={taskCounts}
/>
);
}

View file

@ -0,0 +1,30 @@
import { notFound } from 'next/navigation';
import { getLesson, getLessonIds } from '@/content';
import { LessonScreen } from '@/components/LessonScreen';
export async function generateStaticParams() {
const ids = await getLessonIds('deutsch');
return ids.map((lesson) => ({ lesson }));
}
export default async function DeutschLessonPage({
params,
}: {
params: Promise<{ lesson: string }>;
}) {
const { lesson: id } = await params;
try {
const lesson = await getLesson('deutsch', id);
return (
<LessonScreen
lesson={lesson}
backHref="/deutsch"
vocabHref={lesson.vocab?.length ? `/deutsch/${id}/vocab` : undefined}
/>
);
} catch {
notFound();
}
}

View file

@ -0,0 +1,142 @@
'use client';
import { useMemo, useState } from 'react';
import type { VocabSection } from '@/content';
import { useApp } from '@/app/providers';
import { TopBar } from '@/components/TopBar';
import { isCorrect } from '@/lib/answers';
interface Card {
section: string;
prompt: string;
answers: string[];
meaning?: string;
}
/**
* Vocabulary drill.
*
* Follows the desktop rule from `sub_translate`: a wrong answer goes back to the
* end of the queue, so the round only finishes once every word was translated
* correctly at least once.
*/
export function VocabTrainer({
sections,
backHref,
}: {
sections: VocabSection[];
backHref: string;
}) {
const { t } = useApp();
const cards = useMemo<Card[]>(
() =>
sections.flatMap((section) =>
section.items.map((item) => ({ section: section.header, ...item }))
),
[sections]
);
const [queue, setQueue] = useState<number[]>(() => cards.map((_, i) => i));
const [value, setValue] = useState('');
const [verdict, setVerdict] = useState<'idle' | 'ok' | 'wrong' | 'done'>('idle');
const [solvedCount, setSolvedCount] = useState(0);
/** Answer of the card just missed, kept because the queue has already rotated. */
const [missed, setMissed] = useState('');
const current = queue.length > 0 ? cards[queue[0]] : null;
const total = cards.length;
const submit = () => {
if (!current || verdict === 'done') return;
if (current.answers.some((a) => isCorrect(value, a))) {
const rest = queue.slice(1);
setSolvedCount((n) => n + 1);
setQueue(rest);
setValue('');
setVerdict(rest.length === 0 ? 'done' : 'ok');
return;
}
// Wrong: show the expected answer and requeue the card at the back.
setMissed(current.answers[0] ?? '');
setVerdict('wrong');
setQueue((q) => [...q.slice(1), q[0]]);
setValue('');
};
return (
<div className="shell">
<TopBar title="Vokabeltraining" backHref={backHref} />
<main className="page">
<div className="page__head">
<h1 className="page__title">Vokabeltraining</h1>
<p className="page__sub">{t('sub_translate')}</p>
</div>
<div className="stack">
<div className="bar" aria-hidden>
<div
className="bar__fill"
style={{ width: `${total ? (solvedCount / total) * 100 : 0}%` }}
/>
</div>
<p className="muted tabular">
{t('trans_progress').replace('%d', String(solvedCount)).replace('%d', String(total))}
</p>
{verdict === 'done' || !current ? (
<p className="feedback feedback--ok" role="status">
{t('trans_done')}
</p>
) : (
<div className="ex__item">
<span className="card__kicker">{current.section}</span>
<p className="ex__prompt" style={{ fontSize: '1.3rem' }}>
{current.prompt}
</p>
<form
onSubmit={(e) => {
e.preventDefault();
submit();
}}
>
<div className="row">
<input
className="gap"
style={{ flex: 1, minWidth: 200 }}
type="text"
value={value}
placeholder={t('your_answer')}
autoComplete="off"
autoFocus
spellCheck={false}
onChange={(e) => setValue(e.target.value)}
/>
<button className="btn" type="submit">
{t('check')}
</button>
</div>
</form>
{verdict === 'ok' && (
<p className="feedback feedback--ok" role="status">
{t('feedback_ok')}
</p>
)}
{verdict === 'wrong' && (
<p className="feedback feedback--err" role="status">
{t('trans_wrong').replace('%s', missed)}
</p>
)}
</div>
)}
</div>
</main>
</div>
);
}

View file

@ -0,0 +1,21 @@
import { notFound } from 'next/navigation';
import { getLesson, getLessonIds } from '@/content';
import { VocabTrainer } from './VocabTrainer';
export async function generateStaticParams() {
const ids = await getLessonIds('deutsch');
return ids.map((lesson) => ({ lesson }));
}
export default async function VocabPage({ params }: { params: Promise<{ lesson: string }> }) {
const { lesson: id } = await params;
try {
const lesson = await getLesson('deutsch', id);
if (!lesson.vocab?.length) notFound();
return <VocabTrainer sections={lesson.vocab} backHref={`/deutsch/${id}`} />;
} catch {
notFound();
}
}

View file

@ -0,0 +1,24 @@
import { notFound } from 'next/navigation';
import { getLessons, getPath } from '@/content';
import { PathScreen } from '@/components/PathScreen';
export default async function DeutschPathPage() {
const path = await getPath('deutsch');
if (!path) notFound();
const lessons = await getLessons('deutsch');
const taskCounts = Object.fromEntries(lessons.map((l) => [l.id, l.tasks.length]));
return (
<PathScreen
path={path}
titleKey="roadmap_title"
subtitleKey="roadmap_sub"
backHref="/subjects"
hrefBase="/deutsch"
taskCounts={taskCounts}
finish
/>
);
}

1005
web/src/app/globals.css Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,23 @@
import { notFound } from 'next/navigation';
import { getLesson, getLessonIds } from '@/content';
import { LessonScreen } from '@/components/LessonScreen';
export async function generateStaticParams() {
const ids = await getLessonIds('hardware');
return ids.map((lesson) => ({ lesson }));
}
export default async function HardwareLessonPage({
params,
}: {
params: Promise<{ lesson: string }>;
}) {
const { lesson: id } = await params;
try {
return <LessonScreen lesson={await getLesson('hardware', id)} backHref="/hardware" />;
} catch {
notFound();
}
}

View file

@ -0,0 +1,24 @@
import { notFound } from 'next/navigation';
import { getLessons, getPath } from '@/content';
import { PathScreen } from '@/components/PathScreen';
export default async function HardwarePage() {
const path = await getPath('hardware');
if (!path) notFound();
const lessons = await getLessons('hardware');
const taskCounts = Object.fromEntries(lessons.map((l) => [l.id, l.tasks.length]));
return (
<PathScreen
path={path}
// The desktop app keys subject titles by their Czech text.
titleKey="Technické vybavení"
subtitleKey="hw_sub"
backHref="/subjects"
hrefBase="/hardware"
taskCounts={taskCounts}
/>
);
}

36
web/src/app/layout.tsx Normal file
View file

@ -0,0 +1,36 @@
import type { Metadata, Viewport } from 'next';
import { getThemes, getTranslations } from '@/content';
import { AppProvider } from './providers';
import { ThemeScript } from './ThemeScript';
import './globals.css';
export const metadata: Metadata = {
title: 'maturita.c',
description:
'Vzdělávací aplikace pro přípravu k maturitě — němčina, počítačové sítě, ' +
'technické vybavení a český jazyk.',
};
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
themeColor: '#1e1e2e',
};
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const [themes, translations] = await Promise.all([getThemes(), getTranslations()]);
return (
<html lang="cs" data-mode="dark" suppressHydrationWarning>
<head>
<ThemeScript themes={themes} />
</head>
<body>
<AppProvider themes={themes} translations={translations}>
{children}
</AppProvider>
</body>
</html>
);
}

View file

@ -0,0 +1,53 @@
'use client';
import Link from 'next/link';
import { useApp } from '@/app/providers';
import { TopBar } from '@/components/TopBar';
import { LockIcon } from '@/components/icons';
/** Only year 1 has content, matching `build_net_years_page` in the desktop app. */
const YEARS = [1, 2, 3, 4];
export function NetworkYearsScreen() {
const { t } = useApp();
return (
<div className="shell">
<TopBar title={t('net_years_title')} backHref="/subjects" />
<main className="page">
<div className="page__head">
<h1 className="page__title">{t('net_years_title')}</h1>
<p className="page__sub">{t('net_years_sub')}</p>
</div>
<div className="grid">
{YEARS.map((year) => {
const label = t(`net_year${year}`);
const locked = year !== 1;
if (locked) {
return (
<div className="card card--locked" key={year} aria-disabled>
<span className="row" style={{ color: 'var(--overlay)' }}>
<LockIcon size={22} />
</span>
<h2 className="card__title">{label}</h2>
<p className="card__body">{t('net_year_locked_sub')}</p>
</div>
);
}
return (
<Link className="card card--interactive" href={`/networks/year-${year}`} key={year}>
<span className="card__kicker">{year}</span>
<h2 className="card__title">{label}</h2>
</Link>
);
})}
</div>
</main>
</div>
);
}

View file

@ -0,0 +1,6 @@
import { NetworkYearsScreen } from './NetworkYearsScreen';
/** Year picker: year 1 has all 27 lessons, years 2-4 are placeholders. */
export default function NetworkYearsPage() {
return <NetworkYearsScreen />;
}

View file

@ -0,0 +1,23 @@
import { notFound } from 'next/navigation';
import { getLesson, getLessonIds } from '@/content';
import { LessonScreen } from '@/components/LessonScreen';
export async function generateStaticParams() {
const ids = await getLessonIds('networks');
return ids.map((lesson) => ({ lesson }));
}
export default async function NetworkLessonPage({
params,
}: {
params: Promise<{ lesson: string }>;
}) {
const { lesson: id } = await params;
try {
return <LessonScreen lesson={await getLesson('networks', id)} backHref="/networks/year-1" />;
} catch {
notFound();
}
}

View file

@ -0,0 +1,23 @@
import { notFound } from 'next/navigation';
import { getLessons, getPath } from '@/content';
import { PathScreen } from '@/components/PathScreen';
export default async function NetworkYear1Page() {
const path = await getPath('networks');
if (!path) notFound();
const lessons = await getLessons('networks');
const taskCounts = Object.fromEntries(lessons.map((l) => [l.id, l.tasks.length]));
return (
<PathScreen
path={path}
titleKey="net_years_title"
subtitleKey="net_sub"
backHref="/networks"
hrefBase="/networks/year-1"
taskCounts={taskCounts}
/>
);
}

20
web/src/app/page.tsx Normal file
View file

@ -0,0 +1,20 @@
'use client';
import Link from 'next/link';
import { useApp } from './providers';
/** Welcome screen: branded greeting plus a continue button, as on the desktop. */
export default function WelcomePage() {
const { t } = useApp();
return (
<main className="welcome">
<h1 className="welcome__title">maturita.c</h1>
<p className="welcome__body">{t('welcome_body')}</p>
<Link className="btn btn--lg" href="/subjects">
{t('continue')}
</Link>
</main>
);
}

150
web/src/app/providers.tsx Normal file
View file

@ -0,0 +1,150 @@
'use client';
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react';
import type { Lang, Theme, ThemePalette } from '../../../content/schema';
import type { TranslationTable } from '@/content';
/**
* Client-side app settings: palette, dark/light mode and UI language.
*
* These mirror `progress/settings.conf` in the desktop app and persist to
* localStorage so a reload keeps the student's choices.
*/
const STORAGE_KEY = 'maturita.settings';
interface Settings {
themeId: string;
mode: 'dark' | 'light';
lang: Lang;
}
const DEFAULTS: Settings = { themeId: 'catppuccin', mode: 'dark', lang: 'cs' };
interface AppContextValue extends Settings {
themes: Theme[];
palette: ThemePalette;
setThemeId: (id: string) => void;
setMode: (mode: 'dark' | 'light') => void;
setLang: (lang: Lang) => void;
/** Translate a key, falling back to Czech and then to the key itself. */
t: (key: string) => string;
/** Like `t`, but returns undefined for unknown keys instead of the key. */
tOpt: (key: string | null | undefined) => string | undefined;
}
const AppContext = createContext<AppContextValue | null>(null);
function readStored(): Settings {
if (typeof window === 'undefined') return DEFAULTS;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return DEFAULTS;
return { ...DEFAULTS, ...(JSON.parse(raw) as Partial<Settings>) };
} catch {
return DEFAULTS;
}
}
/** Turn a palette into the CSS custom properties the stylesheet consumes. */
function paletteVars(palette: ThemePalette): Record<string, string> {
const vars: Record<string, string> = {};
for (const [key, value] of Object.entries(palette)) {
// camelCase -> kebab-case, e.g. lockedBg -> --locked-bg
const name = key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
vars[`--${name}`] = value;
}
return vars;
}
export function AppProvider({
themes,
translations,
children,
}: {
themes: Theme[];
translations: TranslationTable;
children: ReactNode;
}) {
const [settings, setSettings] = useState<Settings>(DEFAULTS);
const [hydrated, setHydrated] = useState(false);
// Read persisted settings after mount so server and client markup agree.
useEffect(() => {
setSettings(readStored());
setHydrated(true);
}, []);
useEffect(() => {
if (!hydrated) return;
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
}, [settings, hydrated]);
const theme = useMemo(
() => themes.find((x) => x.id === settings.themeId) ?? themes[0],
[themes, settings.themeId]
);
const palette = settings.mode === 'light' ? theme.light : theme.dark;
// Apply the palette to <html> so it also covers the page background.
useEffect(() => {
const root = document.documentElement;
for (const [name, value] of Object.entries(paletteVars(palette))) {
root.style.setProperty(name, value);
}
root.dataset.mode = settings.mode;
root.style.colorScheme = settings.mode;
}, [palette, settings.mode]);
useEffect(() => {
document.documentElement.lang = settings.lang;
}, [settings.lang]);
const t = useCallback(
(key: string) => {
if (!key) return '';
if (settings.lang === 'en') {
return translations.en[key] ?? translations.cs[key] ?? key;
}
return translations.cs[key] ?? key;
},
[translations, settings.lang]
);
const tOpt = useCallback(
(key: string | null | undefined) => {
if (!key) return undefined;
const table = settings.lang === 'en' ? translations.en : translations.cs;
return table[key] ?? (settings.lang === 'en' ? translations.cs[key] : undefined);
},
[translations, settings.lang]
);
const value: AppContextValue = {
...settings,
themes,
palette,
setThemeId: (themeId) => setSettings((s) => ({ ...s, themeId })),
setMode: (mode) => setSettings((s) => ({ ...s, mode })),
setLang: (lang) => setSettings((s) => ({ ...s, lang })),
t,
tOpt,
};
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}
export function useApp(): AppContextValue {
const ctx = useContext(AppContext);
if (!ctx) throw new Error('useApp must be used inside <AppProvider>');
return ctx;
}

View file

@ -0,0 +1,74 @@
'use client';
import { useApp } from '@/app/providers';
import { TopBar } from '@/components/TopBar';
/** Mode, palette and language, mirroring the desktop settings panel. */
export default function SettingsPage() {
const { t, themes, themeId, mode, lang, setThemeId, setMode, setLang } = useApp();
return (
<div className="shell">
<TopBar title={t('settings_title')} backHref="/subjects" showTools={false} />
<main className="page">
<div className="page__head">
<h1 className="page__title">{t('settings_title')}</h1>
</div>
<div className="settings">
<section className="settings__group">
<span className="settings__label">{t('mode')}</span>
<div className="segmented" role="group" aria-label={t('mode')}>
<button aria-pressed={mode === 'dark'} onClick={() => setMode('dark')}>
{t('mode_dark')}
</button>
<button aria-pressed={mode === 'light'} onClick={() => setMode('light')}>
{t('mode_light')}
</button>
</div>
</section>
<section className="settings__group">
<span className="settings__label">{t('theme')}</span>
<div className="swatches">
{themes.map((theme) => {
const palette = mode === 'light' ? theme.light : theme.dark;
return (
<button
className="swatch"
key={theme.id}
aria-pressed={theme.id === themeId}
onClick={() => setThemeId(theme.id)}
>
<span className="swatch__dots">
{/* Keyed by position: some palettes reuse the same hex twice. */}
{[palette.accent, palette.accent3, palette.success, palette.warning].map(
(color, i) => (
<span className="swatch__dot" key={i} style={{ background: color }} />
)
)}
</span>
<span className="swatch__name">{theme.name}</span>
</button>
);
})}
</div>
</section>
<section className="settings__group">
<span className="settings__label">{t('language')}</span>
<div className="segmented" role="group" aria-label={t('language')}>
<button aria-pressed={lang === 'cs'} onClick={() => setLang('cs')}>
Čeština
</button>
<button aria-pressed={lang === 'en'} onClick={() => setLang('en')}>
English
</button>
</div>
</section>
</div>
</main>
</div>
);
}

View file

@ -0,0 +1,155 @@
'use client';
import { useApp } from '@/app/providers';
import { TopBar } from '@/components/TopBar';
import { useProgress } from '@/lib/progress';
export interface SubjectStats {
id: string;
key: string;
lessons: {
id: string;
number: number;
titleKey: string | null;
title: string | null;
taskCount: number;
}[];
}
/**
* Progress overview: totals at the top, then a bar per subject and a per-lesson
* breakdown, following the layout of the desktop statistics page.
*/
export function StatsScreen({
tracked,
locked,
}: {
tracked: SubjectStats[];
locked: { id: string; key: string }[];
}) {
const { t, tOpt } = useApp();
const { doneCount, ready } = useProgress();
const totals = tracked.reduce(
(acc, subject) => {
const total = subject.lessons.reduce((a, l) => a + l.taskCount, 0);
const done = subject.lessons.reduce((a, l) => a + doneCount(subject.id, l.id), 0);
const finished = subject.lessons.filter(
(l) => l.taskCount > 0 && doneCount(subject.id, l.id) >= l.taskCount
).length;
return {
done: acc.done + done,
total: acc.total + total,
finishedUnits: acc.finishedUnits + finished,
openUnits: acc.openUnits + subject.lessons.length,
};
},
{ done: 0, total: 0, finishedUnits: 0, openUnits: 0 }
);
const percent = totals.total ? Math.round((totals.done / totals.total) * 100) : 0;
return (
<div className="shell">
<TopBar title={t('stats_title')} backHref="/subjects" showTools={false} />
<main className="page">
<div className="page__head">
<h1 className="page__title">{t('stats_title')}</h1>
<p className="page__sub">{t('stats_sub')}</p>
</div>
<div className="summary">
<Cell label={t('stats_ex_label')} value={ready ? `${totals.done} / ${totals.total}` : '—'} />
<Cell label={t('stats_pct_label')} value={ready ? `${percent} %` : '—'} />
<Cell
label={t('stats_units_label')}
value={ready ? `${totals.finishedUnits} / ${totals.openUnits}` : '—'}
/>
</div>
<h2 className="settings__label">{t('stats_section')}</h2>
{tracked.map((subject) => {
const total = subject.lessons.reduce((a, l) => a + l.taskCount, 0);
const done = subject.lessons.reduce((a, l) => a + doneCount(subject.id, l.id), 0);
return (
<div key={subject.id}>
<Row
name={t(subject.key)}
count={total ? `${done} / ${total}` : t('stats_locked')}
ratio={total ? done / total : 0}
/>
{/* Per-lesson detail, only where a lesson has tasks. */}
<div style={{ paddingLeft: 16 }}>
{subject.lessons
.filter((lesson) => lesson.taskCount > 0)
.map((lesson) => {
const lessonDone = doneCount(subject.id, lesson.id);
return (
<Row
key={lesson.id}
name={`${lesson.number}. ${
tOpt(lesson.titleKey) ?? lesson.title ?? lesson.id
}`}
count={`${lessonDone} / ${lesson.taskCount}`}
ratio={lessonDone / lesson.taskCount}
small
/>
);
})}
</div>
</div>
);
})}
{locked.map((subject) => (
<Row key={subject.id} name={t(subject.key)} count={t('stats_locked')} ratio={0} dim />
))}
</main>
</div>
);
}
const Cell = ({ label, value }: { label: string; value: string }) => (
<div className="summary__cell">
<div className="summary__value tabular">{value}</div>
<div className="summary__label">{label}</div>
</div>
);
function Row({
name,
count,
ratio,
small,
dim,
}: {
name: string;
count: string;
ratio: number;
small?: boolean;
dim?: boolean;
}) {
const complete = ratio >= 1;
return (
<div className="stat-row" style={{ opacity: dim ? 0.5 : 1 }}>
<div className="stat-row__head">
<span className="stat-row__name" style={small ? { fontSize: '0.9rem' } : undefined}>
{name}
</span>
<span className="stat-row__count">{count}</span>
</div>
<div className="bar">
<div
className={`bar__fill${complete ? ' bar__fill--done' : ''}`}
style={{ width: `${Math.min(100, ratio * 100)}%` }}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,29 @@
import { getLessons, getSubjects } from '@/content';
import { StatsScreen, type SubjectStats } from './StatsScreen';
export default async function StatsPage() {
const subjects = await getSubjects();
const tracked: SubjectStats[] = await Promise.all(
subjects
.filter((s) => s.unlocked)
.map(async (subject) => {
const lessons = await getLessons(subject.id);
return {
id: subject.id,
key: subject.key,
lessons: lessons.map((l) => ({
id: l.id,
number: l.number,
titleKey: l.titleKey ?? null,
title: l.title?.cs ?? null,
taskCount: l.tasks.length,
})),
};
})
);
const locked = subjects.filter((s) => !s.unlocked).map((s) => ({ id: s.id, key: s.key }));
return <StatsScreen tracked={tracked} locked={locked} />;
}

View file

@ -0,0 +1,58 @@
'use client';
import Link from 'next/link';
import type { Subject } from '@/content';
import { useApp } from '@/app/providers';
import { TopBar } from '@/components/TopBar';
import { LockIcon, SUBJECT_ICONS } from '@/components/icons';
/** Subject picker. Locked subjects are dimmed placeholders, as on the desktop. */
export function SubjectsScreen({ subjects }: { subjects: Subject[] }) {
const { t } = useApp();
return (
<div className="shell">
<TopBar title={t('subjects_title')} backHref="/" />
<main className="page">
<div className="page__head">
<h1 className="page__title">{t('subjects_title')}</h1>
</div>
<div className="grid">
{subjects.map((subject) => (
<SubjectCard key={subject.id} subject={subject} label={t(subject.key)} />
))}
</div>
</main>
</div>
);
}
function SubjectCard({ subject, label }: { subject: Subject; label: string }) {
const Icon = subject.icon ? SUBJECT_ICONS[subject.icon] : null;
const inner = (
<>
<span className="row" style={{ color: 'var(--accent2)' }}>
{Icon ? <Icon /> : <LockIcon size={24} />}
</span>
<h2 className="card__title">{label}</h2>
</>
);
if (!subject.unlocked || !subject.route) {
return (
<div className="card card--locked" aria-disabled>
{inner}
</div>
);
}
return (
<Link className="card card--interactive" href={subject.route}>
{inner}
</Link>
);
}

View file

@ -0,0 +1,6 @@
import { getSubjects } from '@/content';
import { SubjectsScreen } from './SubjectsScreen';
export default async function SubjectsPage() {
return <SubjectsScreen subjects={await getSubjects()} />;
}

View file

@ -0,0 +1,127 @@
'use client';
import { useState } from 'react';
import type { Lesson } from '@/content';
import { useApp } from '@/app/providers';
import { useProgress } from '@/lib/progress';
import { PathMap, type PathNode } from './PathMap';
import { Slides } from './Slides';
import { TopBar } from './TopBar';
import { TaskRenderer } from './exercises/TaskRenderer';
/**
* A lesson: optional theory slides, a map of exercise bubbles and the exercise
* itself. All three live on one route and swap in place, which keeps the
* student's progress state without a round trip.
*/
export function LessonScreen({
lesson,
backHref,
vocabHref,
}: {
lesson: Lesson;
backHref: string;
vocabHref?: string;
}) {
const { t, tOpt } = useApp();
const { isDone, markDone, ready } = useProgress();
const hasSlides = lesson.slides.length > 0;
const [view, setView] = useState<'slides' | 'map'>(hasSlides ? 'slides' : 'map');
const [taskNumber, setTaskNumber] = useState<number | null>(null);
const title = tOpt(lesson.titleKey) ?? lesson.title?.cs ?? lesson.id;
const subtitle = tOpt(lesson.subtitleKey) ?? lesson.subtitle?.cs;
const task = taskNumber === null ? null : lesson.tasks.find((x) => x.number === taskNumber);
/* --- exercise view --- */
if (task) {
const heading = task.heading ?? task.title;
return (
<div className="shell">
<TopBar title={heading} />
<main className="page">
<div className="page__head">
<h1 className="page__title">{heading}</h1>
</div>
<TaskRenderer
key={task.number}
task={task}
subtitle={tOpt(task.subtitleKey) ?? task.subtitle}
onSolved={() => markDone(lesson.subject, lesson.id, task.number)}
/>
<div className="actions" style={{ marginTop: 28 }}>
<button className="btn btn--ghost" onClick={() => setTaskNumber(null)}>
{t('back')}
</button>
</div>
</main>
</div>
);
}
/* --- theory view --- */
if (view === 'slides') {
return (
<div className="shell">
<TopBar title={title} backHref={backHref} />
<main className="page">
<div className="page__head">
<h1 className="page__title">{title}</h1>
{subtitle && <p className="page__sub">{subtitle}</p>}
</div>
<Slides slides={lesson.slides} onFinish={() => setView('map')} />
</main>
</div>
);
}
/* --- exercise map --- */
const nodes: PathNode[] = lesson.tasks.map((x) => ({
number: x.number,
label: x.title,
locked: false,
done: ready && isDone(lesson.subject, lesson.id, x.number),
}));
return (
<div className="shell">
<TopBar title={title} backHref={backHref} />
<main className="page">
<div className="page__head">
<h1 className="page__title">{title}</h1>
{subtitle && <p className="page__sub">{subtitle}</p>}
</div>
{/* A single-task lesson (every network quiz) needs no bubble map. */}
{lesson.tasks.length === 1 ? (
<div className="stack">
<button className="btn" onClick={() => setTaskNumber(lesson.tasks[0].number)}>
{lesson.tasks[0].title}
</button>
</div>
) : (
<PathMap nodes={nodes} onSelect={(node) => setTaskNumber(node.number)} />
)}
<div className="actions" style={{ marginTop: 32 }}>
{hasSlides && (
<button className="btn btn--ghost" onClick={() => setView('slides')}>
{t('net_slide_prev')}
</button>
)}
{vocabHref && (
<a className="btn btn--ghost" href={vocabHref}>
Vokabeltraining
</a>
)}
</div>
</main>
</div>
);
}

View file

@ -0,0 +1,151 @@
'use client';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { CheckIcon, LockIcon } from './icons';
export interface PathNode {
number: number;
label: string;
/** Destination for nodes that navigate; omit when using `onSelect`. */
href?: string | null;
locked: boolean;
done: boolean;
}
/**
* Serpentine learning path.
*
* The desktop version measures the window and folds the rail into rows; here
* the node count per row is derived from the viewport width and rows alternate
* direction in CSS, so the shape survives a resize without measuring anything.
*/
export function PathMap({
nodes,
finish,
onSelect,
}: {
nodes: PathNode[];
finish?: boolean;
/** Used instead of `href` when a node selects something on the same page. */
onSelect?: (node: PathNode) => void;
}) {
const perRow = useNodesPerRow();
const rows: PathNode[][] = [];
for (let i = 0; i < nodes.length; i += perRow) {
rows.push(nodes.slice(i, i + perRow));
}
return (
<div className="path">
{rows.map((row, rowIndex) => (
<div className="path__row" key={rowIndex}>
{row.map((node, i) => (
<Fragmentish key={node.number}>
{i > 0 && <Rail done={row[i - 1].done} />}
<PathNodeButton node={node} onSelect={onSelect} />
</Fragmentish>
))}
{/* Keep the last row left-aligned instead of stretching its nodes. */}
{row.length < perRow && <span className="spacer" />}
</div>
))}
{finish && (
<div className="path__row">
<span className="node node--finish" aria-label="Finish">
<span className="node__number">★</span>
</span>
<span className="spacer" />
</div>
)}
</div>
);
}
const Rail = ({ done }: { done: boolean }) => (
<span className={`path__rail${done ? ' path__rail--done' : ''}`} aria-hidden />
);
/** Avoids importing Fragment just to group a rail with its node. */
function Fragmentish({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
function PathNodeButton({
node,
onSelect,
}: {
node: PathNode;
onSelect?: (node: PathNode) => void;
}) {
const state = node.locked ? 'locked' : node.done ? 'done' : 'current';
const className = `node node--${state}`;
const inner = (
<>
{node.done ? (
<CheckIcon />
) : node.locked ? (
<LockIcon />
) : (
<span className="node__number">{node.number}</span>
)}
<span className="node__label">{node.label}</span>
</>
);
if (node.locked) {
return (
<span className={className} aria-disabled title={node.label}>
{inner}
</span>
);
}
if (onSelect) {
return (
<button className={className} title={node.label} onClick={() => onSelect(node)}>
{inner}
</button>
);
}
if (!node.href) {
return (
<span className={className} aria-disabled title={node.label}>
{inner}
</span>
);
}
return (
<Link className={className} href={node.href} title={node.label}>
{inner}
</Link>
);
}
/** Node count per serpentine row, chosen to keep labels legible on phones. */
function useNodesPerRow(): number {
const [perRow, setPerRow] = useState(4);
useEffect(() => {
const update = () => {
const w = window.innerWidth;
if (w < 420) setPerRow(2);
else if (w < 620) setPerRow(3);
else if (w < 860) setPerRow(4);
else setPerRow(5);
};
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, []);
return perRow;
}

View file

@ -0,0 +1,71 @@
'use client';
import type { Path } from '@/content';
import { useApp } from '@/app/providers';
import { useProgress } from '@/lib/progress';
import { PathMap, type PathNode } from './PathMap';
import { TopBar } from './TopBar';
export interface PathScreenProps {
path: Path;
/** i18n key for the heading; falls back to `title` when it has no key. */
titleKey?: string;
title?: string;
subtitleKey?: string;
subtitle?: string;
backHref: string;
/** Base URL that each unit slug is appended to. */
hrefBase: string;
/** Tasks per lesson, used to decide whether a unit counts as finished. */
taskCounts: Record<string, number>;
finish?: boolean;
}
/** Renders a subject's serpentine unit path with live completion state. */
export function PathScreen({
path,
titleKey,
title,
subtitleKey,
subtitle,
backHref,
hrefBase,
taskCounts,
finish,
}: PathScreenProps) {
const { tOpt } = useApp();
const { doneCount, ready } = useProgress();
const nodes: PathNode[] = path.units.map((unit) => {
const total = unit.lesson ? taskCounts[unit.lesson] ?? 0 : 0;
const done = unit.lesson ? doneCount(path.subject, unit.lesson) : 0;
return {
number: unit.number,
label: tOpt(unit.titleKey) ?? unit.title?.cs ?? String(unit.number),
// A node either opens a lesson under `hrefBase` or links on to its own path.
href: unit.lesson ? `${hrefBase}/${unit.lesson}` : unit.route ?? null,
locked: !unit.unlocked,
// Only claim completion once progress has loaded, to avoid a flash of green.
done: ready && total > 0 && done >= total,
};
});
const heading = tOpt(titleKey) ?? title ?? '';
const sub = tOpt(subtitleKey) ?? subtitle;
return (
<div className="shell">
<TopBar title={heading} backHref={backHref} />
<main className="page">
<div className="page__head">
<h1 className="page__title">{heading}</h1>
{sub && <p className="page__sub">{sub}</p>}
</div>
<PathMap nodes={nodes} finish={finish} />
</main>
</div>
);
}

View file

@ -0,0 +1,61 @@
'use client';
import { useState } from 'react';
import type { Slide } from '@/content';
import { useApp } from '@/app/providers';
/** Theory note-cards with prev/next navigation, as in `build_net_unit_page`. */
export function Slides({ slides, onFinish }: { slides: Slide[]; onFinish: () => void }) {
const { t } = useApp();
const [index, setIndex] = useState(0);
if (slides.length === 0) return null;
const slide = slides[index];
const last = index === slides.length - 1;
return (
<div className="stack">
<article className="slide">
<span className="slide__kicker">{slide.kicker}</span>
<h2 className="slide__title">{slide.title}</h2>
{slide.tip && <p className="slide__tip">{slide.tip}</p>}
<ul className="slide__lines">
{slide.lines.map((line, i) => (
<li key={i}>{line}</li>
))}
</ul>
</article>
<div className="slides__nav">
<button
className="btn btn--ghost"
disabled={index === 0}
onClick={() => setIndex((i) => i - 1)}
>
{t('net_slide_prev')}
</button>
<div className="dots" role="tablist" aria-label={slide.title}>
{slides.map((s, i) => (
<button
key={i}
role="tab"
aria-selected={i === index}
// Only the active dot carries aria-current; `false` still reads as current.
aria-current={i === index ? 'true' : undefined}
aria-label={s.title}
onClick={() => setIndex(i)}
/>
))}
</div>
<button className="btn" onClick={() => (last ? onFinish() : setIndex((i) => i + 1))}>
{last ? t('net_slide_start') : t('net_slide_next')}
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,52 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useApp } from '@/app/providers';
import { BackIcon, ChartIcon, GearIcon } from './icons';
/**
* Header shared by every screen: a circular back button, the page title and the
* statistics / settings shortcuts, mirroring the desktop app's top bar.
*/
export function TopBar({
title,
backHref,
showTools = true,
}: {
title: string;
/** Explicit destination; falls back to browser history when omitted. */
backHref?: string;
showTools?: boolean;
}) {
const router = useRouter();
const { t } = useApp();
return (
<header className="topbar">
{backHref ? (
<Link className="icon-btn" href={backHref} aria-label={t('back')}>
<BackIcon />
</Link>
) : (
<button className="icon-btn" onClick={() => router.back()} aria-label={t('back')}>
<BackIcon />
</button>
)}
<span className="topbar__title">{title}</span>
{showTools && (
<>
<Link className="icon-btn" href="/stats" aria-label={t('stats_title')}>
<ChartIcon />
</Link>
<Link className="icon-btn" href="/settings" aria-label={t('settings_title')}>
<GearIcon />
</Link>
</>
)}
</header>
);
}

View file

@ -0,0 +1,117 @@
'use client';
import { useMemo, useState } from 'react';
import type { AssemblyExercise } from '@/content';
import { TaskShell, type CheckState, type TaskFrame } from './TaskShell';
/**
* Build a sentence by clicking scrambled words in the right order.
*
* The desktop app uses drag and drop; clicking is used here so the exercise
* works the same on a phone. Clicking a placed word sends it back to the bank.
*/
export function AssemblyTask({
items,
onSolved,
...frame
}: TaskFrame & {
items: AssemblyExercise[];
onSolved: () => void;
}) {
// Scramble once per mount so re-renders do not reshuffle mid-exercise.
const banks = useMemo(() => items.map((item) => shuffle(item.words)), [items]);
const [placed, setPlaced] = useState<number[][]>(() => items.map(() => []));
const [state, setState] = useState<CheckState>('idle');
const [checked, setChecked] = useState(false);
const [revealed, setRevealed] = useState(false);
const rowCorrect = (i: number) => {
const built = placed[i].map((bankIndex) => banks[i][bankIndex]);
return built.length === items[i].words.length && built.every((w, j) => w === items[i].words[j]);
};
const check = () => {
setChecked(true);
const allCorrect = items.every((_, i) => rowCorrect(i));
setState(allCorrect ? 'ok' : 'wrong');
if (allCorrect) onSolved();
};
const place = (item: number, bankIndex: number) => {
setPlaced((prev) => prev.map((row, i) => (i === item ? [...row, bankIndex] : row)));
setChecked(false);
setState('idle');
};
const remove = (item: number, position: number) => {
setPlaced((prev) =>
prev.map((row, i) => (i === item ? row.filter((_, p) => p !== position) : row))
);
setChecked(false);
setState('idle');
};
return (
<TaskShell
{...frame}
state={state}
onCheck={check}
onReveal={() => setRevealed(true)}
revealed={revealed}
>
{items.map((item, i) => {
const status = checked ? (rowCorrect(i) ? ' slot--ok' : ' slot--wrong') : '';
return (
<div className="ex__item" key={i}>
{item.prompt && (
<p className="ex__prompt">
<span className="ex__num">{i + 1}</span>
{item.prompt}
</p>
)}
<div className={`slot${status}`}>
{placed[i].length === 0 && (
<span className="slot__placeholder">…</span>
)}
{placed[i].map((bankIndex, position) => (
<button className="chip" key={position} onClick={() => remove(i, position)}>
{banks[i][bankIndex]}
</button>
))}
</div>
<div className="bank">
{banks[i].map((word, bankIndex) => (
<button
className={`chip${placed[i].includes(bankIndex) ? ' chip--used' : ''}`}
key={bankIndex}
disabled={placed[i].includes(bankIndex)}
onClick={() => place(i, bankIndex)}
>
{word}
</button>
))}
</div>
{item.meaning && <p className="ex__meaning">{item.meaning}</p>}
{revealed && <p className="ex__model">{item.words.join(' ')}</p>}
</div>
);
})}
</TaskShell>
);
}
/** Fisher-Yates, matching `shuffle_indices` in the desktop app. */
function shuffle<T>(input: readonly T[]): T[] {
const out = [...input];
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[out[i], out[j]] = [out[j], out[i]];
}
return out;
}

View file

@ -0,0 +1,117 @@
'use client';
import { useMemo, useState } from 'react';
import type { AssignExercise } from '@/content';
import { useApp } from '@/app/providers';
import { TaskShell, type CheckState, type TaskFrame } from './TaskShell';
/**
* Sort cards into named groups.
*
* Mirrors the desktop interaction: select a group, then click cards to move
* them in; clicking a card inside a group returns it to the pool.
*/
export function AssignTask({
exercise,
onSolved,
...frame
}: TaskFrame & {
exercise: AssignExercise;
onSolved: () => void;
}) {
const { t } = useApp();
const order = useMemo(() => shuffle(exercise.items.map((_, i) => i)), [exercise]);
/** Group index per card, or null while the card is still in the pool. */
const [assigned, setAssigned] = useState<(number | null)[]>(() => exercise.items.map(() => null));
const [activeGroup, setActiveGroup] = useState(0);
const [state, setState] = useState<CheckState>('idle');
const [checked, setChecked] = useState(false);
const check = () => {
setChecked(true);
const allCorrect = exercise.items.every((item, i) => assigned[i] === item.group);
setState(allCorrect ? 'ok' : 'wrong');
if (allCorrect) onSolved();
};
const move = (card: number, group: number | null) => {
setAssigned((prev) => prev.map((g, i) => (i === card ? group : g)));
setChecked(false);
setState('idle');
};
const cardLabel = (i: number) => {
const item = exercise.items[i];
return `${item.emoji ? `${item.emoji} ` : ''}${item.label}`;
};
const cardStatus = (i: number) => {
if (!checked || assigned[i] === null) return '';
return assigned[i] === exercise.items[i].group ? ' gap--ok' : ' gap--wrong';
};
return (
<TaskShell {...frame} state={state} onCheck={check}>
<div className="ex__item">
<p className="ex__prompt">{t('assign_hint')}</p>
<div className="segmented" role="group">
{exercise.groups.map((group, gi) => (
<button key={gi} aria-pressed={activeGroup === gi} onClick={() => setActiveGroup(gi)}>
{group}
</button>
))}
</div>
<div className="bank">
{order
.filter((i) => assigned[i] === null)
.map((i) => (
<button className="chip" key={i} onClick={() => move(i, activeGroup)}>
{cardLabel(i)}
</button>
))}
</div>
<div className="groups">
{exercise.groups.map((group, gi) => (
<div className="group" key={gi}>
<span className="group__title">{group}</span>
{order
.filter((i) => assigned[i] === gi)
.map((i) => (
<button className={`chip${cardStatus(i)}`} key={i} onClick={() => move(i, null)}>
{cardLabel(i)}
</button>
))}
</div>
))}
</div>
{checked && (
<ul className="ex__meaning" style={{ listStyle: 'none', padding: 0 }}>
{exercise.items.map(
(item, i) =>
item.meaning && (
<li key={i}>
{item.label} — {item.meaning}
</li>
)
)}
</ul>
)}
</div>
</TaskShell>
);
}
function shuffle<T>(input: readonly T[]): T[] {
const out = [...input];
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[out[i], out[j]] = [out[j], out[i]];
}
return out;
}

View file

@ -0,0 +1,73 @@
'use client';
import { useState } from 'react';
import type { ChoiceExercise } from '@/content';
import { TaskShell, type CheckState, type TaskFrame } from './TaskShell';
/** Multiple choice, one correct option per question. */
export function ChoiceTask({
items,
onSolved,
...frame
}: TaskFrame & {
items: ChoiceExercise[];
onSolved: () => void;
}) {
const [picked, setPicked] = useState<(number | null)[]>(() => items.map(() => null));
const [state, setState] = useState<CheckState>('idle');
const [checked, setChecked] = useState(false);
const check = () => {
setChecked(true);
const allCorrect = items.every((q, i) => picked[i] === q.correct);
setState(allCorrect ? 'ok' : 'wrong');
if (allCorrect) onSolved();
};
const pick = (question: number, option: number) => {
setPicked((prev) => prev.map((v, i) => (i === question ? option : v)));
// Editing an answer clears the previous verdict so the colours stay honest.
setChecked(false);
setState('idle');
};
return (
<TaskShell {...frame} state={state} onCheck={check}>
{items.map((q, qi) => (
<div className="ex__item" key={qi}>
<p className="ex__prompt">
<span className="ex__num">{qi + 1}</span>
{q.prompt}
</p>
<div className="opts">
{q.options.map((option, oi) => {
const selected = picked[qi] === oi;
let modifier = '';
if (checked && selected) modifier = oi === q.correct ? ' opt--ok' : ' opt--wrong';
// After a wrong guess, also point at the right answer.
if (checked && !selected && oi === q.correct && picked[qi] !== null) {
modifier = ' opt--ok';
}
return (
<button
className={`opt${modifier}`}
key={oi}
aria-pressed={selected}
onClick={() => pick(qi, oi)}
>
{option}
</button>
);
})}
</div>
{q.hint && checked && <p className="ex__meaning">{q.hint}</p>}
{q.meaning && checked && <p className="ex__meaning">{q.meaning}</p>}
</div>
))}
</TaskShell>
);
}

View file

@ -0,0 +1,96 @@
'use client';
import { useState } from 'react';
import type { DialogueExercise } from '@/content';
import { isCorrect, primaryAnswer } from '@/lib/answers';
import { TaskShell, type CheckState, type TaskFrame } from './TaskShell';
/** Short dialogues with one missing word per line, picked from a shared pool. */
export function DialogueTask({
items,
pool,
onSolved,
...frame
}: TaskFrame & {
items: DialogueExercise[];
pool?: string[];
onSolved: () => void;
}) {
const [values, setValues] = useState<string[][]>(() => items.map((d) => d.rows.map(() => '')));
const [state, setState] = useState<CheckState>('idle');
const [checked, setChecked] = useState(false);
const [revealed, setRevealed] = useState(false);
const check = () => {
setChecked(true);
const allCorrect = items.every((dialogue, d) =>
dialogue.rows.every((row, r) => isCorrect(values[d][r] ?? '', row.answer))
);
setState(allCorrect ? 'ok' : 'wrong');
if (allCorrect) onSolved();
};
const set = (dialogue: number, row: number, value: string) => {
setValues((prev) =>
prev.map((rows, d) => (d === dialogue ? rows.map((v, r) => (r === row ? value : v)) : rows))
);
setChecked(false);
setState('idle');
};
return (
<TaskShell
{...frame}
state={state}
onCheck={check}
onReveal={() => setRevealed(true)}
revealed={revealed}
>
{items.map((dialogue, d) => (
<div className="ex__item" key={d}>
<p className="ex__prompt">{dialogue.title}</p>
{dialogue.rows.map((row, r) => {
const status = checked
? isCorrect(values[d][r] ?? '', row.answer)
? 'ok'
: 'wrong'
: undefined;
return (
<div key={r} style={{ marginBottom: 10 }}>
<div className="sentence">
{row.speaker && <span className="speaker">{row.speaker}</span>}
{row.before && <span>{row.before}</span>}
<select
className={`gap${status ? ` gap--${status}` : ''}`}
value={values[d][r] ?? ''}
onChange={(e) => set(d, r, e.target.value)}
>
<option value="">—</option>
{(pool ?? []).map((word) => (
<option key={word} value={word}>
{word}
</option>
))}
</select>
{row.after && <span>{row.after}</span>}
</div>
{row.meaning && <p className="ex__meaning">{row.meaning}</p>}
{revealed && (
<p className="ex__model">
{`${row.before}${primaryAnswer(row.answer)}${row.after}`.trim()}
</p>
)}
</div>
);
})}
</div>
))}
</TaskShell>
);
}

View file

@ -0,0 +1,84 @@
'use client';
import { useState } from 'react';
import type { FieldsExercise } from '@/content';
import { isCorrect } from '@/lib/answers';
import { TaskShell, type CheckState, type TaskFrame } from './TaskShell';
/**
* A prompt answered through several labelled fields.
*
* The Czech grammar exercises use this for questions with more than one part —
* "determine the gender, number and case" fills three fields from one sentence.
*/
export function FieldsTask({
items,
onSolved,
...frame
}: TaskFrame & {
items: FieldsExercise[];
onSolved: () => void;
}) {
const [values, setValues] = useState<string[][]>(() =>
items.map((item) => item.fields.map(() => ''))
);
const [state, setState] = useState<CheckState>('idle');
const [checked, setChecked] = useState(false);
const [revealed, setRevealed] = useState(false);
const ok = (i: number, f: number) =>
items[i].fields[f].answers.some((a) => isCorrect(values[i][f] ?? '', a));
const check = () => {
setChecked(true);
const allCorrect = items.every((item, i) => item.fields.every((_, f) => ok(i, f)));
setState(allCorrect ? 'ok' : 'wrong');
if (allCorrect) onSolved();
};
const set = (item: number, field: number, value: string) => {
setValues((prev) =>
prev.map((row, i) => (i === item ? row.map((v, f) => (f === field ? value : v)) : row))
);
setChecked(false);
setState('idle');
};
return (
<TaskShell
{...frame}
state={state}
onCheck={check}
onReveal={() => setRevealed(true)}
revealed={revealed}
>
{items.map((item, i) => (
<div className="ex__item" key={i}>
<p className="ex__prompt">
<span className="ex__num">{i + 1}</span>
{item.prompt}
</p>
<div className="fields">
{item.fields.map((field, f) => (
<label className="fields__field" key={f}>
<span className="fields__label">{field.label}</span>
<input
className={`gap${checked ? (ok(i, f) ? ' gap--ok' : ' gap--wrong') : ''}`}
type="text"
value={values[i][f] ?? ''}
autoComplete="off"
spellCheck={false}
onChange={(e) => set(i, f, e.target.value)}
/>
</label>
))}
</div>
{revealed && item.solution && <p className="ex__model">{item.solution}</p>}
</div>
))}
</TaskShell>
);
}

View file

@ -0,0 +1,212 @@
'use client';
import { useState } from 'react';
import type { FillExercise } from '@/content';
import { useApp } from '@/app/providers';
import { isCorrect, primaryAnswer } from '@/lib/answers';
import { TaskShell, type CheckState, type TaskFrame } from './TaskShell';
/**
* Sentences with gaps.
*
* Every gap variant in the desktop app collapses to this component: a dropdown
* fed from the task's word pool, or a free text field when there is no pool.
*/
export function FillTask({
items,
pool,
altPool,
onSolved,
...frame
}: TaskFrame & {
items: FillExercise[];
pool?: string[];
altPool?: string[];
onSolved: () => void;
}) {
const { t } = useApp();
const [values, setValues] = useState<string[][]>(() => items.map((it) => it.answers.map(() => '')));
const [state, setState] = useState<CheckState>('idle');
const [checked, setChecked] = useState(false);
const [revealed, setRevealed] = useState(false);
const check = () => {
setChecked(true);
const allCorrect = items.every((item, i) =>
item.answers.every((answer, g) => isCorrect(values[i][g] ?? '', answer))
);
setState(allCorrect ? 'ok' : 'wrong');
if (allCorrect) onSolved();
};
const set = (item: number, gap: number, value: string) => {
setValues((prev) =>
prev.map((row, i) => (i === item ? row.map((v, g) => (g === gap ? value : v)) : row))
);
setChecked(false);
setState('idle');
};
return (
<TaskShell
{...frame}
state={state}
onCheck={check}
onReveal={() => setRevealed(true)}
revealed={revealed}
>
{items.map((item, i) => (
<div className="ex__item" key={i}>
{item.heading && <h2 className="ex__group">{item.heading}</h2>}
<div className={`sentence${item.letters ? ' sentence--letters' : ''}`}>
{item.label && <span className="ex__num">{item.label}</span>}
{item.emoji && <span className="sentence__emoji">{item.emoji}</span>}
{item.clue && <span className="sentence__clue">{item.clue}</span>}
{chunk(item).map((group, g) =>
group === SPACE ? (
<span key={g}> </span>
) : (
<span className="sentence__chunk" key={g}>
{group.map((node, n) =>
typeof node === 'string' ? (
<span key={n}>{node}</span>
) : (
<Gap
key={n}
value={values[i][node.gap] ?? ''}
onChange={(v) => set(i, node.gap, v)}
pool={
item.input === 'select'
? item.gapPool?.[node.gap] === 'alt'
? altPool
: pool
: undefined
}
placeholder={item.letters ? '' : t('your_answer')}
letters={item.letters}
status={
checked
? isCorrect(values[i][node.gap] ?? '', item.answers[node.gap])
? 'ok'
: 'wrong'
: undefined
}
/>
)
)}
</span>
)
)}
</div>
{item.meaning && <p className="ex__meaning">{item.meaning}</p>}
{item.glosses?.some(Boolean) && (
<p className="ex__gloss">{item.glosses.filter(Boolean).join(' · ')}</p>
)}
{revealed && (
<p className="ex__model">
{buildSolution(item)}
</p>
)}
</div>
))}
</TaskShell>
);
}
const SPACE = Symbol('space');
type GapRef = { gap: number };
type Chunk = (string | GapRef)[] | typeof SPACE;
/**
* Split a sentence into groups that must not be broken across lines.
*
* A letter gap sits inside a word, so wrapping between the gap and the letters
* around it would tear the word in half ("Öster[re]" / "ich."). Splitting only
* at real spaces keeps each word — gaps included — on one line.
*/
function chunk(item: FillExercise): Chunk[] {
const chunks: Chunk[] = [];
let current: (string | GapRef)[] = [];
const flush = () => {
if (current.length) chunks.push(current);
current = [];
};
item.segments.forEach((segment, s) => {
for (const part of segment.split(/(\s+)/)) {
if (!part) continue;
if (/^\s+$/.test(part)) {
flush();
chunks.push(SPACE);
} else {
current.push(part);
}
}
// One gap follows every segment except the last.
if (s < item.answers.length) current.push({ gap: s });
});
flush();
return chunks;
}
function Gap({
value,
onChange,
pool,
placeholder,
letters,
status,
}: {
value: string;
onChange: (value: string) => void;
pool?: string[];
placeholder: string;
letters?: boolean;
status?: 'ok' | 'wrong';
}) {
const className =
`gap${letters ? ' gap--letters' : ''}${status ? ` gap--${status}` : ''}`;
if (pool?.length) {
return (
<select className={className} value={value} onChange={(e) => onChange(e.target.value)}>
<option value="">—</option>
{pool.map((word) => (
<option key={word} value={word}>
{word}
</option>
))}
</select>
);
}
return (
<input
className={className}
type="text"
value={value}
placeholder={placeholder}
autoComplete="off"
spellCheck={false}
onChange={(e) => onChange(e.target.value)}
/>
);
}
/** Reassemble the sentence with its correct answers filled in. */
function buildSolution(item: FillExercise): string {
return item.segments
.map((segment, s) => segment + (s < item.answers.length ? primaryAnswer(item.answers[s]) : ''))
.join('')
.replace(/\s+/g, ' ')
.trim();
}

View file

@ -0,0 +1,81 @@
'use client';
import { useState } from 'react';
import type { FreeExercise } from '@/content';
import { useApp } from '@/app/providers';
import { TaskShell, type CheckState, type TaskFrame } from './TaskShell';
/**
* Open questions with a model answer.
*
* There is no single right string, so writing something for every question
* counts as done and reveals the samples to compare against — the same rule the
* desktop app applies.
*/
export function FreeTask({
items,
onSolved,
...frame
}: TaskFrame & {
items: FreeExercise[];
onSolved: () => void;
}) {
const { t, lang } = useApp();
const [values, setValues] = useState<string[]>(() => items.map(() => ''));
const [state, setState] = useState<CheckState>('idle');
const [revealed, setRevealed] = useState(false);
const check = () => {
const answered = values.every((v) => v.trim().length > 0);
setState(answered ? 'ok' : 'wrong');
if (answered) {
setRevealed(true);
onSolved();
}
};
return (
<TaskShell {...frame} state={state} onCheck={check}>
{items.map((item, i) => (
<div className="ex__item" key={i}>
<p className="ex__prompt">
<span className="ex__num">{i + 1}</span>
{item.question}
</p>
{item.questionCs && <p className="ex__meaning">{t(item.questionCs)}</p>}
<input
className="gap"
style={{ width: '100%', minWidth: 0 }}
type="text"
value={values[i]}
placeholder={t('your_answer')}
autoComplete="off"
onChange={(e) => {
const value = e.target.value;
setValues((prev) => prev.map((v, j) => (j === i ? value : v)));
setState('idle');
}}
/>
{revealed && (
<p className="ex__model">
{item.sample}
{item.sampleCs && (
<>
{' — '}
<span className="muted">{t(item.sampleCs)}</span>
</>
)}
</p>
)}
</div>
))}
{/* The umlaut tip only matters while typing German. */}
{lang === 'cs' && <p className="ex__meaning">{t('hint_umlauts')}</p>}
</TaskShell>
);
}

View file

@ -0,0 +1,104 @@
'use client';
import { useState } from 'react';
import type { HangmanExercise } from '@/content';
import { useApp } from '@/app/providers';
import type { TaskFrame } from './TaskShell';
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÜ'.split('');
const MAX_MISSES = 7;
/** Guess the profession letter by letter; a Czech hint is always visible. */
export function HangmanTask({
items,
subtitle,
onSolved,
}: TaskFrame & {
items: HangmanExercise[];
onSolved: () => void;
}) {
const { t } = useApp();
const [index, setIndex] = useState(0);
const [guessed, setGuessed] = useState<string[]>([]);
const current = items[index];
const letters = current.word.toUpperCase().split('');
const misses = guessed.filter((g) => !letters.includes(g)).length;
const solved = letters.every((l) => !/\p{L}/u.test(l) || guessed.includes(l));
const lost = misses >= MAX_MISSES;
const last = index === items.length - 1;
const guess = (letter: string) => {
if (solved || lost || guessed.includes(letter)) return;
setGuessed((prev) => [...prev, letter]);
};
const next = () => {
if (last) {
onSolved();
return;
}
setIndex((i) => i + 1);
setGuessed([]);
};
return (
<div className="stack">
{subtitle && <p className="page__sub">{subtitle}</p>}
<div className="ex__item">
<p className="ex__prompt">
<span className="ex__num">{index + 1}</span>
{t('hm_hint')}: {current.hint}
</p>
<p
className="sentence"
style={{ fontSize: '1.7rem', fontWeight: 700, letterSpacing: '0.16em' }}
>
{letters.map((letter, i) => (
<span key={i}>
{!/\p{L}/u.test(letter) ? letter : guessed.includes(letter) ? letter : '_'}
</span>
))}
</p>
<p className="muted tabular">
{misses} / {MAX_MISSES}
</p>
<div className="bank">
{ALPHABET.map((letter) => {
const used = guessed.includes(letter);
const hit = used && letters.includes(letter);
return (
<button
className={`chip${used ? (hit ? ' gap--ok' : ' gap--wrong') : ''}`}
key={letter}
disabled={used || solved || lost}
onClick={() => guess(letter)}
>
{letter}
</button>
);
})}
</div>
{(solved || lost) && (
<>
<p className={`feedback feedback--${solved ? 'ok' : 'err'}`} role="status">
{solved ? t('feedback_ok') : current.word}
</p>
<div className="actions">
<button className="btn" onClick={next}>
{last ? t('check') : t('net_slide_next')}
</button>
</div>
</>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,64 @@
'use client';
import { useState } from 'react';
import type { ProfileExercise } from '@/content';
import { useApp } from '@/app/providers';
import { TaskShell, type CheckState, type TaskFrame } from './TaskShell';
/**
* "Steckbrief" profile questions.
*
* Like the free-answer exercise there is no single right string, so answering
* every line counts as done and reveals the model sentences.
*/
export function ProfileTask({
items,
onSolved,
...frame
}: TaskFrame & {
items: ProfileExercise[];
onSolved: () => void;
}) {
const { t } = useApp();
const [values, setValues] = useState<string[]>(() => items.map(() => ''));
const [state, setState] = useState<CheckState>('idle');
const [revealed, setRevealed] = useState(false);
const check = () => {
const answered = values.every((v) => v.trim().length > 0);
setState(answered ? 'ok' : 'wrong');
if (answered) {
setRevealed(true);
onSolved();
}
};
return (
<TaskShell {...frame} state={state} onCheck={check}>
{items.map((item, i) => (
<div className="ex__item" key={i}>
<div className="sentence">
<span className="ex__num">{i + 1}</span>
<span>{item.stem}</span>
<input
className="gap"
style={{ minWidth: 200 }}
type="text"
value={values[i]}
placeholder={t('your_answer')}
autoComplete="off"
onChange={(e) => {
const value = e.target.value;
setValues((prev) => prev.map((v, j) => (j === i ? value : v)));
setState('idle');
}}
/>
</div>
{revealed && <p className="ex__model">{item.sample}</p>}
</div>
))}
</TaskShell>
);
}

View file

@ -0,0 +1,71 @@
'use client';
import { useState } from 'react';
import { useApp } from '@/app/providers';
import type { RevealExercise } from '@/content';
import { TaskShell, type CheckState, type TaskFrame } from './TaskShell';
/**
* Self-assessed exercise: write your answer, then compare it with the model.
*
* The desktop app labels the button "Zobrazit řešení" for these, because there
* is nothing meaningful to check automatically — morpheme analysis, punctuation
* and rewriting direct speech all have too many valid phrasings.
*/
export function RevealTask({
items,
onSolved,
...frame
}: TaskFrame & {
items: RevealExercise[];
onSolved: () => void;
}) {
const { t } = useApp();
const [values, setValues] = useState<string[]>(() => items.map(() => ''));
const [state, setState] = useState<CheckState>('idle');
const [revealed, setRevealed] = useState(false);
const reveal = () => {
setRevealed(true);
// Completion here means "you attempted every item", as on the desktop.
const attempted = values.every((v) => v.trim().length > 0);
setState(attempted ? 'ok' : 'wrong');
if (attempted) onSolved();
};
return (
<TaskShell
{...frame}
state={state}
checkLabel="net_solution"
onCheck={reveal}
>
{items.map((item, i) => (
<div className="ex__item" key={i}>
<p className="ex__prompt">
<span className="ex__num">{i + 1}</span>
{item.prompt}
</p>
<input
className="gap"
style={{ width: '100%', minWidth: 0 }}
type="text"
value={values[i]}
placeholder={t('your_answer')}
autoComplete="off"
spellCheck={false}
onChange={(e) => {
const value = e.target.value;
setValues((prev) => prev.map((v, j) => (j === i ? value : v)));
setState('idle');
}}
/>
{revealed && <p className="ex__model">{item.solution}</p>}
</div>
))}
</TaskShell>
);
}

View file

@ -0,0 +1,146 @@
'use client';
import { useState } from 'react';
import type { SubnetExercise } from '@/content';
import { useApp } from '@/app/providers';
import { TaskShell, type CheckState, type TaskFrame } from './TaskShell';
/** Columns of the VLSM worksheet, matching the desktop table headings. */
const FIELDS = [
{ key: 'network', cs: 'Síťová adresa', en: 'Network address' },
{ key: 'broadcast', cs: 'Broadcast', en: 'Broadcast' },
{ key: 'firstHost', cs: 'První uzel', en: 'First host' },
{ key: 'lastHost', cs: 'Poslední uzel', en: 'Last host' },
] as const;
type Entry = { prefix: string; network: string; broadcast: string; firstHost: string; lastHost: string };
const emptyEntry = (): Entry => ({
prefix: '',
network: '',
broadcast: '',
firstHost: '',
lastHost: '',
});
/**
* VLSM subnetting: read the scenario, then fill in prefix, network address,
* broadcast and host range for each of the four subnets.
*/
export function SubnetTask({
items,
onSolved,
...frame
}: TaskFrame & {
items: SubnetExercise[];
onSolved: () => void;
}) {
const { lang } = useApp();
const subnetWord = lang === 'en' ? 'Subnet' : 'Podsíť';
const [entries, setEntries] = useState<Entry[][]>(() =>
items.map((item) => (item.blocks ?? []).map(emptyEntry))
);
const [state, setState] = useState<CheckState>('idle');
const [checked, setChecked] = useState(false);
const [revealed, setRevealed] = useState(false);
const cellOk = (taskIndex: number, blockIndex: number, field: keyof Entry) => {
const block = items[taskIndex].blocks?.[blockIndex];
if (!block) return true;
const typed = entries[taskIndex][blockIndex][field].trim();
const expected = field === 'prefix' ? String(block.prefix) : block[field];
return typed.replace(/^\//, '') === String(expected);
};
const check = () => {
setChecked(true);
const allCorrect = items.every((item, ti) =>
// Scenarios without a valid allocation are informational only.
!item.blocks ||
item.blocks.every((_, bi) =>
(['prefix', ...FIELDS.map((f) => f.key)] as (keyof Entry)[]).every((f) =>
cellOk(ti, bi, f)
)
)
);
setState(allCorrect ? 'ok' : 'wrong');
if (allCorrect) onSolved();
};
const set = (ti: number, bi: number, field: keyof Entry, value: string) => {
setEntries((prev) =>
prev.map((blocks, i) =>
i === ti ? blocks.map((b, j) => (j === bi ? { ...b, [field]: value } : b)) : blocks
)
);
setChecked(false);
setState('idle');
};
return (
<TaskShell
{...frame}
state={state}
onCheck={check}
onReveal={() => setRevealed(true)}
revealed={revealed}
>
{items.map((item, ti) => (
<div className="ex__item" key={ti}>
<p className="ex__prompt" style={{ whiteSpace: 'pre-wrap' }}>
{item.scenario}
</p>
{item.blocks ? (
<div className="stack" style={{ gap: 12 }}>
{item.blocks.map((_, bi) => (
<div key={bi} className="stack" style={{ gap: 6 }}>
<span className="card__kicker">
{/* Subnets are labelled A-D in the scenario text. */}
{`${subnetWord} ${String.fromCharCode(65 + bi)}`}
</span>
<div className="row">
<label className="row" style={{ gap: 6 }}>
<span className="muted">Prefix /</span>
<input
className={`gap${checked ? (cellOk(ti, bi, 'prefix') ? ' gap--ok' : ' gap--wrong') : ''}`}
style={{ minWidth: 70 }}
inputMode="numeric"
value={entries[ti][bi].prefix}
onChange={(e) => set(ti, bi, 'prefix', e.target.value)}
/>
</label>
{FIELDS.map((field) => (
<label className="row" style={{ gap: 6 }} key={field.key}>
<span className="muted">{field[lang]}</span>
<input
className={`gap${checked ? (cellOk(ti, bi, field.key) ? ' gap--ok' : ' gap--wrong') : ''}`}
style={{ minWidth: 140 }}
autoComplete="off"
spellCheck={false}
value={entries[ti][bi][field.key]}
onChange={(e) => set(ti, bi, field.key, e.target.value)}
/>
</label>
))}
</div>
</div>
))}
</div>
) : (
<p className="ex__meaning">{item.solution}</p>
)}
{revealed && item.blocks && (
<p className="ex__model" style={{ whiteSpace: 'pre-wrap' }}>
{item.solution}
</p>
)}
</div>
))}
</TaskShell>
);
}

View file

@ -0,0 +1,102 @@
'use client';
import { useState } from 'react';
import type { TableExercise } from '@/content';
import { isCorrect } from '@/lib/answers';
import { TaskShell, type CheckState, type TaskFrame } from './TaskShell';
/** Editable grid such as the possessive-pronoun table; some cells come prefilled. */
export function TableTask({
exercise,
onSolved,
...frame
}: TaskFrame & {
exercise: TableExercise;
onSolved: () => void;
}) {
const [values, setValues] = useState<string[][]>(() =>
exercise.answers.map((row) => row.map((cell) => (cell.given ? cell.value : '')))
);
const [state, setState] = useState<CheckState>('idle');
const [checked, setChecked] = useState(false);
const [revealed, setRevealed] = useState(false);
const cellOk = (r: number, c: number) =>
isCorrect(values[r][c] ?? '', exercise.answers[r][c].value);
const check = () => {
setChecked(true);
const allCorrect = exercise.answers.every((row, r) => row.every((_, c) => cellOk(r, c)));
setState(allCorrect ? 'ok' : 'wrong');
if (allCorrect) onSolved();
};
return (
<TaskShell
{...frame}
state={state}
onCheck={check}
onReveal={() => setRevealed(true)}
revealed={revealed}
>
<div className="ex__item" style={{ overflowX: 'auto' }}>
<table style={{ borderCollapse: 'collapse', minWidth: '100%' }}>
<thead>
<tr>
<th />
{exercise.columns.map((column) => (
<th key={column} className="card__kicker" style={{ padding: '6px 10px' }}>
{column}
</th>
))}
</tr>
</thead>
<tbody>
{exercise.rows.map((rowLabel, r) => (
<tr key={rowLabel}>
<th
scope="row"
style={{ padding: '6px 10px', textAlign: 'left', fontWeight: 620 }}
>
{rowLabel}
</th>
{exercise.columns.map((_, c) => {
const cell = exercise.answers[r][c];
return (
<td key={c} style={{ padding: 4 }}>
<input
className={`gap${checked ? (cellOk(r, c) ? ' gap--ok' : ' gap--wrong') : ''}`}
style={{ minWidth: 110 }}
value={values[r][c]}
readOnly={cell.given}
autoComplete="off"
spellCheck={false}
onChange={(e) => {
const value = e.target.value;
setValues((prev) =>
prev.map((row, i) =>
i === r ? row.map((v, j) => (j === c ? value : v)) : row
)
);
setChecked(false);
setState('idle');
}}
/>
</td>
);
})}
</tr>
))}
</tbody>
</table>
{revealed && (
<p className="ex__model">
{exercise.answers.map((row) => row.map((cell) => cell.value).join(' · ')).join(' | ')}
</p>
)}
</div>
</TaskShell>
);
}

View file

@ -0,0 +1,99 @@
'use client';
import type { Task } from '@/content';
import { AssemblyTask } from './AssemblyTask';
import { AssignTask } from './AssignTask';
import { ChoiceTask } from './ChoiceTask';
import { DialogueTask } from './DialogueTask';
import { FieldsTask } from './FieldsTask';
import { FillTask } from './FillTask';
import { FreeTask } from './FreeTask';
import { HangmanTask } from './HangmanTask';
import { ProfileTask } from './ProfileTask';
import { RevealTask } from './RevealTask';
import { SubnetTask } from './SubnetTask';
import { TableTask } from './TableTask';
import { TypedTask } from './TypedTask';
/**
* Picks the component for a task based on the `kind` of its items.
*
* Every task in the content holds items of a single kind, so the first item
* decides the renderer.
*/
export function TaskRenderer({
task,
subtitle,
onSolved,
}: {
task: Task;
subtitle?: string;
onSolved: () => void;
}) {
const first = task.items[0];
if (!first) return null;
const shared = {
subtitle,
example: task.example,
note: task.note,
onSolved,
};
switch (first.kind) {
case 'choice':
return <ChoiceTask items={task.items.filter(is('choice'))} {...shared} />;
case 'fill':
return (
<FillTask
items={task.items.filter(is('fill'))}
pool={task.pool}
altPool={task.altPool}
{...shared}
/>
);
case 'dialogue':
return (
<DialogueTask items={task.items.filter(is('dialogue'))} pool={task.pool} {...shared} />
);
case 'assembly':
return <AssemblyTask items={task.items.filter(is('assembly'))} {...shared} />;
case 'assign':
// Group sorting is always a single exercise per task.
return <AssignTask exercise={first} {...shared} />;
case 'free':
return <FreeTask items={task.items.filter(is('free'))} {...shared} />;
case 'typed':
return <TypedTask items={task.items.filter(is('typed'))} {...shared} />;
case 'fields':
return <FieldsTask items={task.items.filter(is('fields'))} {...shared} />;
case 'reveal':
return <RevealTask items={task.items.filter(is('reveal'))} {...shared} />;
case 'hangman':
return <HangmanTask items={task.items.filter(is('hangman'))} {...shared} />;
case 'profile':
return <ProfileTask items={task.items.filter(is('profile'))} {...shared} />;
case 'table':
return <TableTask exercise={first} {...shared} />;
case 'subnet':
return <SubnetTask items={task.items.filter(is('subnet'))} {...shared} />;
}
}
/** Narrowing filter, so each component receives only its own item type. */
function is<K extends Task['items'][number]['kind']>(kind: K) {
return (item: Task['items'][number]): item is Extract<Task['items'][number], { kind: K }> =>
item.kind === kind;
}

View file

@ -0,0 +1,69 @@
'use client';
import { useApp } from '@/app/providers';
export type CheckState = 'idle' | 'ok' | 'wrong';
/** The instruction lines every exercise takes from its task. */
export interface TaskFrame {
subtitle?: string;
/** Worked example shown above the items. */
example?: string;
/** Extra guidance under the instruction, e.g. a word bank. */
note?: string;
}
/**
* Frame shared by every exercise: the instruction, the task body, a check
* button and the pass/fail feedback line.
*/
export function TaskShell({
subtitle,
example,
note,
state,
checkLabel,
onCheck,
onReveal,
revealed,
children,
}: TaskFrame & {
state: CheckState;
/** i18n key overriding the default "check" button label. */
checkLabel?: string;
onCheck: () => void;
/** Provided by exercises that can show a model solution. */
onReveal?: () => void;
revealed?: boolean;
children: React.ReactNode;
}) {
const { t } = useApp();
return (
<div className="stack">
{subtitle && <p className="page__sub">{subtitle}</p>}
{note && <p className="ex__note">{note}</p>}
{example && <p className="ex__example">{example}</p>}
<div className="ex">{children}</div>
<div className="actions">
<button className="btn" onClick={onCheck}>
{t(checkLabel ?? 'check')}
</button>
{onReveal && (
<button className="btn btn--ghost" onClick={onReveal} disabled={revealed}>
{t('net_solution')}
</button>
)}
</div>
{state !== 'idle' && (
<p className={`feedback feedback--${state === 'ok' ? 'ok' : 'err'}`} role="status">
{t(state === 'ok' ? 'feedback_ok' : 'feedback_retry_short')}
</p>
)}
</div>
);
}

View file

@ -0,0 +1,76 @@
'use client';
import { useState } from 'react';
import type { TypedExercise } from '@/content';
import { useApp } from '@/app/providers';
import { containsAnswer, isCorrect } from '@/lib/answers';
import { TaskShell, type CheckState, type TaskFrame } from './TaskShell';
/** Type the answer; any of the accepted spellings counts. */
export function TypedTask({
items,
onSolved,
...frame
}: TaskFrame & {
items: TypedExercise[];
onSolved: () => void;
}) {
const { t } = useApp();
const [values, setValues] = useState<string[]>(() => items.map(() => ''));
const [state, setState] = useState<CheckState>('idle');
const [checked, setChecked] = useState(false);
const [revealed, setRevealed] = useState(false);
const ok = (i: number) => {
const item = items[i];
return item.substring
? containsAnswer(values[i], item.answers)
: item.answers.some((a) => isCorrect(values[i], a));
};
const check = () => {
setChecked(true);
const allCorrect = items.every((_, i) => ok(i));
setState(allCorrect ? 'ok' : 'wrong');
if (allCorrect) onSolved();
};
return (
<TaskShell
{...frame}
state={state}
onCheck={check}
onReveal={() => setRevealed(true)}
revealed={revealed}
>
{items.map((item, i) => (
<div className="ex__item" key={i}>
<p className="ex__prompt">
<span className="ex__num">{i + 1}</span>
{item.prompt}
</p>
<input
className={`gap${checked ? (ok(i) ? ' gap--ok' : ' gap--wrong') : ''}`}
style={{ width: '100%', minWidth: 0 }}
type="text"
value={values[i]}
placeholder={t('your_answer')}
autoComplete="off"
spellCheck={false}
onChange={(e) => {
const value = e.target.value;
setValues((prev) => prev.map((v, j) => (j === i ? value : v)));
setChecked(false);
setState('idle');
}}
/>
{item.meaning && <p className="ex__meaning">{item.meaning}</p>}
{revealed && <p className="ex__model">{item.model ?? item.answers[0]}</p>}
</div>
))}
</TaskShell>
);
}

View file

@ -0,0 +1,88 @@
/** Inline SVG counterparts to the Cairo-drawn icons in `desktop/icons.c`. */
const base = {
width: 20,
height: 20,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 2,
strokeLinecap: 'round' as const,
strokeLinejoin: 'round' as const,
'aria-hidden': true,
};
export const BackIcon = () => (
<svg {...base}>
<path d="M15 18l-6-6 6-6" />
</svg>
);
export const LockIcon = ({ size = 18 }: { size?: number }) => (
<svg {...base} width={size} height={size}>
<rect x="4" y="10" width="16" height="11" rx="2.5" />
<path d="M8 10V7a4 4 0 0 1 8 0v3" />
</svg>
);
export const CheckIcon = ({ size = 22 }: { size?: number }) => (
<svg {...base} width={size} height={size} strokeWidth={2.6}>
<path d="M4.5 12.5l5 5L19.5 7" />
</svg>
);
export const GearIcon = () => (
<svg {...base}>
<circle cx="12" cy="12" r="3.2" />
<path d="M12 2.6v2.6M12 18.8v2.6M4.2 12H1.6M22.4 12h-2.6M6.5 6.5L4.7 4.7M19.3 19.3l-1.8-1.8M17.5 6.5l1.8-1.8M4.7 19.3l1.8-1.8" />
</svg>
);
export const ChartIcon = () => (
<svg {...base}>
<path d="M4 20V10M10 20V4M16 20v-7M22 20H2" />
</svg>
);
export const WifiIcon = ({ size = 26 }: { size?: number }) => (
<svg {...base} width={size} height={size}>
<path d="M2.5 8.5a15 15 0 0 1 19 0M6 12.5a10 10 0 0 1 12 0M9.5 16.5a5 5 0 0 1 5 0" />
<circle cx="12" cy="20" r="1" fill="currentColor" stroke="none" />
</svg>
);
export const ChipIcon = ({ size = 26 }: { size?: number }) => (
<svg {...base} width={size} height={size}>
<rect x="7" y="7" width="10" height="10" rx="1.5" />
<path d="M10 3v4M14 3v4M10 17v4M14 17v4M3 10h4M3 14h4M17 10h4M17 14h4" />
</svg>
);
export const BookIcon = ({ size = 26 }: { size?: number }) => (
<svg {...base} width={size} height={size}>
<path d="M4 4.5A1.5 1.5 0 0 1 5.5 3H10a2 2 0 0 1 2 2v15a2 2 0 0 0-2-2H4z" />
<path d="M20 4.5A1.5 1.5 0 0 0 18.5 3H14a2 2 0 0 0-2 2v15a2 2 0 0 1 2-2h6z" />
</svg>
);
/** German flag as three stacked bands, matching the desktop subject bubble. */
export const FlagDeIcon = ({ size = 30 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 24 24" aria-hidden>
<clipPath id="flag-de-clip">
<circle cx="12" cy="12" r="11" />
</clipPath>
<g clipPath="url(#flag-de-clip)">
<rect x="0" y="1" width="24" height="7.34" fill="#000" />
<rect x="0" y="8.34" width="24" height="7.33" fill="#dd0000" />
<rect x="0" y="15.67" width="24" height="7.33" fill="#ffce00" />
</g>
<circle cx="12" cy="12" r="11" fill="none" stroke="currentColor" strokeWidth="1.4" />
</svg>
);
export const SUBJECT_ICONS = {
'flag-de': FlagDeIcon,
wifi: WifiIcon,
chip: ChipIcon,
book: BookIcon,
} as const;

84
web/src/content/index.ts Normal file
View file

@ -0,0 +1,84 @@
import { readFile, readdir } from 'node:fs/promises';
import { join } from 'node:path';
import type {
Lesson,
Path,
Subject,
Theme,
} from '../../../content/schema';
/**
* Server-side access to the shared `content/` folder.
*
* Everything is read at build time and cached per process, so pages can stay
* static. Lesson JSON is the same data the desktop app compiles in, exported by
* `tools/build-content.mjs`.
*/
const CONTENT_DIR = join(process.cwd(), '..', 'content');
const cache = new Map<string, unknown>();
async function readJson<T>(relPath: string): Promise<T> {
const hit = cache.get(relPath);
if (hit) return hit as T;
const raw = await readFile(join(CONTENT_DIR, relPath), 'utf8');
const parsed = JSON.parse(raw) as T;
cache.set(relPath, parsed);
return parsed;
}
export interface TranslationTable {
cs: Record<string, string>;
en: Record<string, string>;
}
/** Merged UI + lesson translations, so callers need only one lookup table. */
export async function getTranslations(): Promise<TranslationTable> {
const [ui, content] = await Promise.all([
readJson<TranslationTable>('i18n/ui.json'),
readJson<TranslationTable>('i18n/content.json'),
]);
return {
cs: { ...content.cs, ...ui.cs },
en: { ...content.en, ...ui.en },
};
}
export const getThemes = () => readJson<Theme[]>('themes.json');
export const getSubjects = () => readJson<Subject[]>('subjects.json');
export const getPaths = () => readJson<Path[]>('paths.json');
/**
* The main path for a subject, or one of its branches.
*
* Czech has two: the top-level area picker and the reading list hanging off it.
*/
export async function getPath(subject: string, branch?: string): Promise<Path | undefined> {
const paths = await getPaths();
return paths.find((p) => p.subject === subject && p.branch === branch);
}
export function getLesson(subject: string, id: string): Promise<Lesson> {
return readJson<Lesson>(`lessons/${subject}/${id}.json`);
}
/** Lesson ids for a subject, in path order. */
export async function getLessonIds(subject: string): Promise<string[]> {
try {
const files = await readdir(join(CONTENT_DIR, 'lessons', subject));
return files.filter((f) => f.endsWith('.json')).map((f) => f.replace(/\.json$/, '')).sort();
} catch {
return [];
}
}
export async function getLessons(subject: string): Promise<Lesson[]> {
const ids = await getLessonIds(subject);
return Promise.all(ids.map((id) => getLesson(subject, id)));
}
export type * from '../../../content/schema';

58
web/src/lib/answers.ts Normal file
View file

@ -0,0 +1,58 @@
/**
* Answer comparison, ported from `normalize_answer` in `desktop/util.c`.
*
* The rules matter for fairness, so they follow the desktop app exactly:
* German umlauts get their two-letter keyboard fallbacks, accents are dropped
* so Czech diacritics are optional, punctuation is ignored and whitespace is
* collapsed.
*/
const GERMAN_FALLBACKS: Record<string, string> = {
ä: 'ae',
ö: 'oe',
ü: 'ue',
ß: 'ss',
};
export function normalizeAnswer(input: string): string {
const lowered = (input ?? '').toLowerCase();
// German letters first, so ä does not decompose to a bare "a".
let pre = '';
for (const ch of lowered) pre += GERMAN_FALLBACKS[ch] ?? ch;
// Strip combining marks, keep alphanumerics, collapse runs of whitespace.
const decomposed = pre.normalize('NFD').replace(/\p{M}+/gu, '');
return decomposed
.replace(/[^\p{L}\p{N}\s]+/gu, '')
.replace(/\s+/g, ' ')
.trim();
}
/** Accepted answers are pipe-separated in the content, e.g. `bin|heiße`. */
export const splitAccepted = (answer: string): string[] =>
answer.split('|').map((a) => a.trim()).filter(Boolean);
/** True when `input` matches any accepted spelling of `answer`. */
export function isCorrect(input: string, answer: string): boolean {
const normalized = normalizeAnswer(input);
if (!normalized) return false;
return splitAccepted(answer).some((a) => normalizeAnswer(a) === normalized);
}
/** The spelling shown when revealing a solution. */
export const primaryAnswer = (answer: string): string => splitAccepted(answer)[0] ?? answer;
/**
* True when `input` contains any of the required keywords.
*
* The Unit 3 "describe the picture" exercises accept whole free-form sentences
* as long as the key noun appears, so they compare on containment rather than
* on equality.
*/
export function containsAnswer(input: string, answers: string[]): boolean {
const normalized = normalizeAnswer(input);
if (!normalized) return false;
return answers.some((a) => normalized.includes(normalizeAnswer(a)));
}

82
web/src/lib/progress.ts Normal file
View file

@ -0,0 +1,82 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
/**
* Exercise completion, stored in localStorage.
*
* This is the browser equivalent of the desktop app's `progress/*.conf` files:
* one boolean per task, keyed by subject and lesson.
*/
const STORAGE_KEY = 'maturita.progress';
/** `{ [subject]: { [lessonId]: taskNumber[] } }` */
export type ProgressMap = Record<string, Record<string, number[]>>;
function read(): ProgressMap {
if (typeof window === 'undefined') return {};
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as ProgressMap) : {};
} catch {
return {};
}
}
/** Notifies every hook instance in the tab, which `storage` events do not. */
const CHANGE_EVENT = 'maturita.progress.change';
function write(next: ProgressMap) {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
window.dispatchEvent(new Event(CHANGE_EVENT));
}
export function useProgress() {
const [progress, setProgress] = useState<ProgressMap>({});
const [ready, setReady] = useState(false);
useEffect(() => {
setProgress(read());
setReady(true);
const sync = () => setProgress(read());
window.addEventListener(CHANGE_EVENT, sync);
window.addEventListener('storage', sync);
return () => {
window.removeEventListener(CHANGE_EVENT, sync);
window.removeEventListener('storage', sync);
};
}, []);
const markDone = useCallback((subject: string, lesson: string, task: number) => {
const next = read();
const lessons = (next[subject] ??= {});
const done = (lessons[lesson] ??= []);
if (!done.includes(task)) {
done.push(task);
done.sort((a, b) => a - b);
write(next);
}
}, []);
const reset = useCallback(() => write({}), []);
const isDone = useCallback(
(subject: string, lesson: string, task: number) =>
progress[subject]?.[lesson]?.includes(task) ?? false,
[progress]
);
const doneCount = useCallback(
(subject: string, lesson?: string) => {
const lessons = progress[subject];
if (!lessons) return 0;
if (lesson) return lessons[lesson]?.length ?? 0;
return Object.values(lessons).reduce((a, tasks) => a + tasks.length, 0);
},
[progress]
);
return { progress, ready, markDone, isDone, reset, doneCount };
}

44
web/tsconfig.json Normal file
View file

@ -0,0 +1,44 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"dom",
"dom.iterable",
"ES2022"
],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
],
"@content/*": [
"../content/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}