mirror of
https://github.com/pepaskopec-blip/Graduately.git
synced 2026-10-27 23:24:34 +00:00
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:
parent
4708c69d9c
commit
f7470d333c
113 changed files with 25817 additions and 9 deletions
7
web/next-env.d.ts
vendored
Normal file
7
web/next-env.d.ts
vendored
Normal 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
10
web/next.config.ts
Normal 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
1076
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
24
web/package.json
Normal file
24
web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
38
web/src/app/ThemeScript.tsx
Normal file
38
web/src/app/ThemeScript.tsx
Normal 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 }} />;
|
||||
}
|
||||
12
web/src/app/czech/mluvnice/page.tsx
Normal file
12
web/src/app/czech/mluvnice/page.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
28
web/src/app/czech/page.tsx
Normal file
28
web/src/app/czech/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
21
web/src/app/czech/reading/[book]/page.tsx
Normal file
21
web/src/app/czech/reading/[book]/page.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
24
web/src/app/czech/reading/page.tsx
Normal file
24
web/src/app/czech/reading/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
30
web/src/app/deutsch/[lesson]/page.tsx
Normal file
30
web/src/app/deutsch/[lesson]/page.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
142
web/src/app/deutsch/[lesson]/vocab/VocabTrainer.tsx
Normal file
142
web/src/app/deutsch/[lesson]/vocab/VocabTrainer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
web/src/app/deutsch/[lesson]/vocab/page.tsx
Normal file
21
web/src/app/deutsch/[lesson]/vocab/page.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
24
web/src/app/deutsch/page.tsx
Normal file
24
web/src/app/deutsch/page.tsx
Normal 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
1005
web/src/app/globals.css
Normal file
File diff suppressed because it is too large
Load diff
23
web/src/app/hardware/[lesson]/page.tsx
Normal file
23
web/src/app/hardware/[lesson]/page.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
24
web/src/app/hardware/page.tsx
Normal file
24
web/src/app/hardware/page.tsx
Normal 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
36
web/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
web/src/app/networks/NetworkYearsScreen.tsx
Normal file
53
web/src/app/networks/NetworkYearsScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
web/src/app/networks/page.tsx
Normal file
6
web/src/app/networks/page.tsx
Normal 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 />;
|
||||
}
|
||||
23
web/src/app/networks/year-1/[lesson]/page.tsx
Normal file
23
web/src/app/networks/year-1/[lesson]/page.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
23
web/src/app/networks/year-1/page.tsx
Normal file
23
web/src/app/networks/year-1/page.tsx
Normal 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
20
web/src/app/page.tsx
Normal 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
150
web/src/app/providers.tsx
Normal 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;
|
||||
}
|
||||
74
web/src/app/settings/page.tsx
Normal file
74
web/src/app/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
155
web/src/app/stats/StatsScreen.tsx
Normal file
155
web/src/app/stats/StatsScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
web/src/app/stats/page.tsx
Normal file
29
web/src/app/stats/page.tsx
Normal 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} />;
|
||||
}
|
||||
58
web/src/app/subjects/SubjectsScreen.tsx
Normal file
58
web/src/app/subjects/SubjectsScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
web/src/app/subjects/page.tsx
Normal file
6
web/src/app/subjects/page.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { getSubjects } from '@/content';
|
||||
import { SubjectsScreen } from './SubjectsScreen';
|
||||
|
||||
export default async function SubjectsPage() {
|
||||
return <SubjectsScreen subjects={await getSubjects()} />;
|
||||
}
|
||||
127
web/src/components/LessonScreen.tsx
Normal file
127
web/src/components/LessonScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
151
web/src/components/PathMap.tsx
Normal file
151
web/src/components/PathMap.tsx
Normal 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;
|
||||
}
|
||||
71
web/src/components/PathScreen.tsx
Normal file
71
web/src/components/PathScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
web/src/components/Slides.tsx
Normal file
61
web/src/components/Slides.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
web/src/components/TopBar.tsx
Normal file
52
web/src/components/TopBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
117
web/src/components/exercises/AssemblyTask.tsx
Normal file
117
web/src/components/exercises/AssemblyTask.tsx
Normal 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;
|
||||
}
|
||||
117
web/src/components/exercises/AssignTask.tsx
Normal file
117
web/src/components/exercises/AssignTask.tsx
Normal 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;
|
||||
}
|
||||
73
web/src/components/exercises/ChoiceTask.tsx
Normal file
73
web/src/components/exercises/ChoiceTask.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
96
web/src/components/exercises/DialogueTask.tsx
Normal file
96
web/src/components/exercises/DialogueTask.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
web/src/components/exercises/FieldsTask.tsx
Normal file
84
web/src/components/exercises/FieldsTask.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
212
web/src/components/exercises/FillTask.tsx
Normal file
212
web/src/components/exercises/FillTask.tsx
Normal 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();
|
||||
}
|
||||
81
web/src/components/exercises/FreeTask.tsx
Normal file
81
web/src/components/exercises/FreeTask.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
web/src/components/exercises/HangmanTask.tsx
Normal file
104
web/src/components/exercises/HangmanTask.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
web/src/components/exercises/ProfileTask.tsx
Normal file
64
web/src/components/exercises/ProfileTask.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
web/src/components/exercises/RevealTask.tsx
Normal file
71
web/src/components/exercises/RevealTask.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
146
web/src/components/exercises/SubnetTask.tsx
Normal file
146
web/src/components/exercises/SubnetTask.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
102
web/src/components/exercises/TableTask.tsx
Normal file
102
web/src/components/exercises/TableTask.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
99
web/src/components/exercises/TaskRenderer.tsx
Normal file
99
web/src/components/exercises/TaskRenderer.tsx
Normal 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;
|
||||
}
|
||||
69
web/src/components/exercises/TaskShell.tsx
Normal file
69
web/src/components/exercises/TaskShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
web/src/components/exercises/TypedTask.tsx
Normal file
76
web/src/components/exercises/TypedTask.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
web/src/components/icons.tsx
Normal file
88
web/src/components/icons.tsx
Normal 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
84
web/src/content/index.ts
Normal 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
58
web/src/lib/answers.ts
Normal 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
82
web/src/lib/progress.ts
Normal 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
44
web/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue