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

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

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

View file

@ -27,6 +27,7 @@ jobs:
- name: Build AppImage
env:
VERSION: ${{ github.ref_name }}
working-directory: desktop
run: |
chmod +x scripts/bundle-linux.sh
./scripts/bundle-linux.sh
@ -35,7 +36,7 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: maturita-linux
path: dist/maturita-linux-*.AppImage
path: desktop/dist/maturita-linux-*.AppImage
windows:
name: Windows zip
@ -59,6 +60,7 @@ jobs:
mingw-w64-ucrt-x86_64-make
- name: Build and bundle
working-directory: desktop
run: |
make bundle
cd dist
@ -71,7 +73,7 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: maturita-windows
path: dist-out/maturita-windows-x64.zip
path: desktop/dist-out/maturita-windows-x64.zip
macos:
name: macOS app
@ -86,6 +88,7 @@ jobs:
- name: Build .app zip
env:
VERSION: ${{ github.ref_name }}
working-directory: desktop
run: |
chmod +x scripts/bundle-macos.sh
./scripts/bundle-macos.sh
@ -94,7 +97,7 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: maturita-macos
path: dist/maturita-macos-*.zip
path: desktop/dist/maturita-macos-*.zip
publish:
name: Publish GitHub Release

13
.gitignore vendored
View file

@ -18,6 +18,19 @@ build/
# Generated from assets/app-icon.png (Makefile / CMake POST_BUILD)
assets/icons/
desktop/assets/icons/
desktop/maturita
# ---> Node / Next.js (web/)
node_modules/
.next/
out/
.turbo/
*.tsbuildinfo
.env*.local
.vercel
# Generated by `next dev` for AI agents (same reason CLAUDE.md is ignored above)
web/AGENTS.md
# Object files
*.o

View file

@ -20,7 +20,7 @@ GTK 4 interface.
| Welcome screen | Subjects ("Předměty") | German learning roadmap |
| --------------------------------------- | ---------------------------------------- | --------------------------------------- |
| ![Welcome screen](assets/welcomescreen.png) | ![Subjects screen](assets/subjectsscreen.png) | ![German roadmap](assets/germanlection.png) |
| ![Welcome screen](desktop/assets/welcomescreen.png) | ![Subjects screen](desktop/assets/subjectsscreen.png) | ![German roadmap](desktop/assets/germanlection.png) |
### Features
@ -218,10 +218,30 @@ sudo pacman -S base-devel pkgconf
Windows – `gcc`, GTK 4 and `pkg-config` are installed in step 1 of the
Windows guide below. Visual Studio is not required.
#### Building the application
#### Running the web app
The web client needs Node.js 20 or newer and no GTK at all:
```bash
npm run web:install # installs web/ dependencies
npm run web # dev server on http://localhost:3000
npm run web:build # production build
```
It reads its lessons from `content/`, which is exported from the C sources by
`npm run content`. Themes, translations, exercises and progress tracking all
mirror the desktop app; progress is kept in the browser's localStorage instead of
`progress/*.conf`.
Coverage matches the desktop client: German units 1–3 with their vocabulary
trainers, all 27 network lessons, the three hardware lessons, the 20 Czech
grammar exercises and both reading-list books.
#### Building the desktop application
1. Clone or download this repository
2. Navigate to the project directory
2. Navigate to the `desktop/` directory — every command in this section runs
there, since the C sources and the Makefile live inside it
3. Build with the Makefile:
```bash
@ -378,6 +398,22 @@ Completed network units are saved to `progress/net.conf`; hardware units to
### Project structure
The repository holds three pieces: the GTK desktop app, a Next.js web app, and
the shared lesson content both read from.
```
desktop/ GTK 4 desktop app (C)
web/ Next.js web app (TypeScript / React)
content/ shared lesson content as JSON — see content/README.md
tools/ exports content/ from the desktop C sources
```
Run `npm run content` from the repo root to regenerate `content/` after editing
lesson data in `desktop/`. Run `npm run web` for the web app (after
`npm run web:install`) and `npm run desktop` for the GTK client.
#### desktop/
```
maturita.h shared types, macros, globals and prototypes
globals.c shared global state (window, stack, units, theme, …)
@ -494,7 +530,7 @@ moderním GTK 4 rozhraním stylovaným přes CSS.
| Úvodní obrazovka | Předměty | Německá učební cesta |
| --------------------------------------- | ---------------------------------------- | --------------------------------------- |
| ![Welcome screen](assets/welcomescreen.png) | ![Subjects screen](assets/subjectsscreen.png) | ![German roadmap](assets/germanlection.png) |
| ![Welcome screen](desktop/assets/welcomescreen.png) | ![Subjects screen](desktop/assets/subjectsscreen.png) | ![German roadmap](desktop/assets/germanlection.png) |
### Funkce
@ -680,10 +716,30 @@ sudo pacman -S base-devel pkgconf
Windows – `gcc`, GTK 4 i `pkg-config` se nainstalují v kroku 1 návodu níže.
Visual Studio není potřeba.
#### Sestavení aplikace
#### Spuštění webové aplikace
Webový klient potřebuje Node.js 20 nebo novější a žádné GTK:
```bash
npm run web:install # instalace závislostí ve web/
npm run web # vývojový server na http://localhost:3000
npm run web:build # produkční build
```
Lekce čte z `content/`, který se z C zdrojů exportuje příkazem
`npm run content`. Témata, překlady, cvičení i sledování postupu odpovídají
desktopové aplikaci; postup se ukládá do localStorage prohlížeče místo do
`progress/*.conf`.
Obsah odpovídá desktopovému klientovi: německé jednotky 1–3 včetně slovníkového
tréninku, všech 27 lekcí sítí, tři lekce technického vybavení, 20 cvičení
mluvnice a obě knihy maturitní četby.
#### Sestavení desktopové aplikace
1. Naklonujte nebo stáhněte tento repozitář
2. Přejděte do složky projektu
2. Přejděte do složky `desktop/` — všechny příkazy v této části se spouštějí
tam, protože C zdroje i Makefile jsou uvnitř
3. Sestavte pomocí Makefile:
```bash
@ -836,6 +892,22 @@ ukládají do `progress/net.conf`, hardwarové do `progress/hw.conf`.
### Struktura projektu
Repozitář obsahuje tři části: desktopovou aplikaci v GTK, webovou aplikaci
v Next.js a společný obsah lekcí, který obě čtou.
```
desktop/ desktopová aplikace GTK 4 (C)
web/ webová aplikace Next.js (TypeScript / React)
content/ společný obsah lekcí v JSON — viz content/README.md
tools/ export content/ z C zdrojů desktopové aplikace
```
Po úpravě obsahu lekcí v `desktop/` spusťte v hlavní složce repozitáře
`npm run content` a `content/` se přegeneruje. Webovou aplikaci spustíte přes
`npm run web` (po `npm run web:install`), desktopovou přes `npm run desktop`.
#### desktop/
```
maturita.h sdílené typy, makra, globální proměnné a prototypy
globals.c sdílený globální stav (okno, stack, jednotky, téma, …)

80
content/README.md Normal file
View file

@ -0,0 +1,80 @@
# content/
Shared lesson content for the whole project. One folder, one file per lesson —
instead of lesson text being buried inside each unit's C source file.
`schema.ts` is the single description of every shape here. Both the extractor in
`tools/` and the web app in `web/` type against it, so a change there shows up as
a type error on both sides.
## Layout
```
content/
schema.ts TypeScript types for everything below
themes.json 10 palettes, dark + light (24 colors each)
subjects.json the 13 subjects, their i18n keys and routes
paths.json serpentine unit paths per subject
manifest.json subject and lesson ids
i18n/
ui.json UI chrome strings, keyed by identifier
content.json lesson strings, keyed by the Czech text
lessons/
deutsch/unit-01.json … unit-03.json
networks/unit-01.json … unit-27.json
hardware/unit-01.json … unit-03.json
czech/mluvnice.json, orwell-1984.json, fuks-spalovac-mrtvol.json
```
`paths.json` holds one entry per path. A subject usually has exactly one, but
Czech has two: the top-level area picker and the `reading` branch listing the
books, distinguished by the `branch` field.
## Lesson shape
A lesson holds theory `slides` and a list of `tasks`. Each task is one bubble on
the unit map and contains `items` of a single `kind`:
| kind | what it is |
| ---- | ---------- |
| `choice` | multiple choice, `correct` indexes `options` |
| `fill` | sentence split into `segments` with a gap between each; `input` picks dropdown or text |
| `dialogue` | dialogue lines with one missing word each |
| `assembly` | build a sentence from scrambled words |
| `assign` | sort labelled cards into named groups |
| `free` | open question with a model answer |
| `typed` | type the answer, `answers` lists accepted spellings |
| `fields` | one prompt, several labelled fields (Czech grammatical categories) |
| `reveal` | self-assessed: write an answer, then compare it with the model |
| `hangman` | guess the word letter by letter |
| `profile` | "Steckbrief" line with a model answer |
| `table` | editable grid, some cells prefilled |
| `subnet` | VLSM subnetting worksheet |
`fill` absorbs every gap variant in the desktop app. A few flags steer it:
`letters` marks gaps holding missing letters inside a word rather than whole
words, `gapPool` picks the task's `pool` or `altPool` per gap (the "Ordne zu"
question-word and answer columns), and `heading` starts a labelled group of rows.
Two conventions carried over from the desktop app:
- **Pipe-separated answers.** `"bin|heiße"` means either spelling is accepted.
- **Lenient comparison.** Answers are matched after lowercasing, expanding
`ä ö ü ß` to `ae oe ue ss`, dropping accents and ignoring punctuation, so Czech
diacritics and German umlauts are optional. See `web/src/lib/answers.ts`.
Titles come either as a literal `title` or as a `titleKey` to look up in the
i18n tables, because German unit names are identical in both languages while
network lesson names are translated.
## Regenerating
The C sources in `desktop/` are still where content is authored, so this folder
is a generated one-way export:
```bash
npm run content # from the repo root, or: node tools/build-content.mjs
```
The extractor parses the C initializers directly and throws on any shape it does
not recognise, so a bad export fails loudly rather than emitting empty lessons.

1248
content/i18n/content.json Normal file

File diff suppressed because it is too large Load diff

541
content/i18n/ui.json Normal file
View file

@ -0,0 +1,541 @@
{
"cs": {
"back": "Zpět",
"settings": "Nastavení",
"settings_title": "Nastavení",
"mode": "REŽIM",
"mode_dark": "Tmavý",
"mode_light": "Světlý",
"theme": "TÉMA",
"language": "JAZYK",
"welcome_body": "maturita.C je vzdělávací program pro studenty středních škol a gymnázií na přípravu k maturitě.\n\nProgram je napsaný v Céčku studentama ze SSŠVT!",
"continue": "Pokračuj",
"roadmap_title": "Učební plán",
"roadmap_sub": "Vyberte jednotku na cestě a začněte procvičovat.",
"unit1_sub": "Vyberte cvičení a dokončete je.",
"check": "Zkontrolovat",
"finish": "Dokončit",
"show_sample": "Ukázat vzor",
"your_answer": "Vaše odpověď…",
"feedback_ok": "Výborně!",
"feedback_retry": "Ještě to není správně. Zkuste to znovu.",
"feedback_retry_short": "Ještě to není správně.",
"feedback_sentences": "Některé věty nejsou správně. Zkuste to znovu.",
"tip_ss": "Písmeno „ß“ se dá na klávesnici zaměnit za „ss“!",
"hint_umlauts": "Na klávesnici nemáte německé znaky? Můžete psát ä jako ae, ö jako oe, ü jako ue a ß jako ss.",
"sample_fmt": "Otázka: %s\nVzor: %s (česky: %s)",
"sub_dialog": "Doplňte chybějící slova v dialogu.",
"sub_assembly": "Přetáhněte slova do správného pořadí.",
"sub_choice_num": "Vyberte správnou číslovku.",
"sub_free": "Odpovězte vlastními slovy, poté klikněte na Dokončit.",
"sub_zahlen": "Vyberte správné slovo pro dané číslo.",
"sub_wieviel": "Spočítejte předměty a vyberte počet.",
"sub_reihe": "Doplňte chybějící číslovku.",
"sub_verb": "Vyberte správný tvar slovesa.",
"sub_wer": "Vyberte správné tázací slovo.",
"sub_bild": "Vyberte správný tvar slovesa podle obrázku.",
"sub_gruss": "Roztřiďte pozdravy na pozdravy a rozloučení.",
"sub_land": "Přiřaďte symboly ke správné zemi.",
"sub_translate": "Přeložte slovo. Špatná odpověď se vrátí na konec.",
"trans_wrong": "Špatně. Správně: %s",
"trans_incomplete": "Nepřesné – něco chybí.",
"trans_progress": "Hotovo: %d / %d",
"trans_done": "Hotovo! Zvládl(a) jsi všechna slova.",
"assign_hint": "Vyberte skupinu a klikněte na kartu. Kliknutím na kartu ve skupině ji vrátíte zpět.",
"unit2_sub": "Vyberte cvičení a dokončete je.",
"Bei uns zu Hause": "Bei uns zu Hause",
"unit3_sub": "Vyberte cvičení a dokončete je.",
"sub3_pair": "Přiřaďte každému slovu jeho protějšek.",
"sub3_poss": "Doplňte přivlastňovací zájmena (1. pád).",
"sub3_haustier": "Doplňte ein/kein ve správném tvaru.",
"sub3_buchst": "Doplňte chybějící písmena.",
"sub3_marco": "Doplňte slova z nabídky.",
"sub3_sort": "Roztřiďte slova do dvou skupin.",
"sub3_tabelle": "Doplňte přivlastňovací zájmena do tabulky.",
"sub3_akk": "Doplňte přivlastňovací zájmena (4. pád).",
"sub3_nicht": "Vyberte kein/keine/keinen/nicht.",
"sub3_satz": "Sestavte správné věty.",
"sub3_sehen": "Napište větu „Ich sehe…“.",
"sub3_wem": "Komu to patří? Doplňte zájmeno.",
"sub3_gibt": "Doplňte „Es gibt“ + ein/eine/einen.",
"sub3_saetze": "Doplňte věty podle vzoru.",
"sub3_wochen": "Doplňte chybějící písmena.",
"wordbank": "Nabídka slov:",
"sample_line": "Příklad: %s",
"sub_verben": "Doplňte sloveso ve správném tvaru.",
"sub_ausin": "Vyberte správnou předložku: aus nebo in.",
"sub_sprich": "Doplňte sloveso „sprechen“.",
"sub_nation": "Doplňte národnost – použijte slova z nabídky.",
"sub_woher": "Odkud lidé pocházejí? Napište zemi.",
"sub_laender": "Doplňte chybějící písmena v názvech zemí.",
"sub_verb2": "Vyberte správný tvar slovesa.",
"sub_euro": "Napište částku slovy.",
"sub_wortsuchen": "Najděte skryté slovo a napište ho.",
"sub_ordne": "Přiřaďte správné tázací slovo a odpověď.",
"sub_luecke": "Doplňte chybějící slova do textu.",
"sub_verbinde": "Spojte každé podstatné jméno se správným slovesem.",
"sub_zahlpaar": "Spojte čísla s německými slovy.",
"sub_steckbrief": "Doplňte údaje o sobě.",
"sub_berufe": "Hádejte povolání dřív, než bude oběšenec hotový.",
"sub_bistdu": "Odpovězte podle vlajky – jakou máte národnost?",
"hm_progress": "Slovo %d / %d",
"hm_hint": "Nápověda",
"hm_wrong": "Slovo je uhodnuto!",
"hm_fail": "Oběšenec! Slovo bylo „%s“.",
"hm_retry": "Zkusit znovu",
"hm_done": "Výborně! Uhodli jste všechna povolání.",
"hm_guess": "Hádejte písmeno",
"stats": "Statistiky",
"stats_title": "Statistiky",
"stats_sub": "Přehled vašeho pokroku.",
"stats_ex_label": "Cvičení",
"stats_pct_label": "Hotovo",
"stats_units_label": "Jednotky",
"stats_section": "Předměty",
"stats_locked": "Zamčeno",
"stats_complete": "Hotovo",
"stats_ex_fmt": "%d / %d",
"stats_pct_fmt": "%d %%",
"stats_units_fmt": "%d / %d",
"subjects_title": "Předměty",
"Český jazyk a literatura": "Český jazyk a literatura",
"Občanská nauka": "Občanská nauka",
"English": "English",
"Deutsch": "Deutsch",
"Matematika": "Matematika",
"Fyzika": "Fyzika",
"Základy Přírodopisných věd": "Základy Přírodopisných věd",
"Technická grafika": "Technická grafika",
"Prezentační grafika": "Prezentační grafika",
"Správa počítačových sítí": "Správa počítačových sítí",
"Programování": "Programování",
"Technické vybavení": "Technické vybavení",
"Programové vybavení": "Programové vybavení",
"net_sub": "Vyberte lekci na cestě a začněte procvičovat.",
"net_years_title": "Správa počítačových sítí",
"net_years_sub": "Vyberte ročník a začněte procvičovat.",
"net_year1": "1. ročník",
"net_year2": "2. ročník",
"net_year3": "3. ročník",
"net_year4": "4. ročník",
"net_year_locked_sub": "Zatím bez obsahu",
"net_unit1": "Výpočet IP adres",
"net_unit1_sub": "4 snímky s postupem a pak cvičení.",
"net_ex_title": "Cvičení: výpočet IP adres",
"net_unit2": "Základní pojmy a rozdělení sítí",
"net_unit2_sub": "5 snímků s pojmy a pak kvíz.",
"net_ex2_title": "Kvíz: základní pojmy sítí",
"net_unit3": "Sítě Klient–Server a Cloud",
"net_unit3_sub": "4 snímky o serverech a cloudu a pak kvíz.",
"net_ex3_title": "Kvíz: klient–server a cloud",
"net_unit4": "Přenos dat a jeho zabezpečení",
"net_unit4_sub": "4 snímky o přenosu a zabezpečení a pak kvíz.",
"net_ex4_title": "Kvíz: přenos dat a zabezpečení",
"net_unit5": "Signály a modulace",
"net_unit5_sub": "4 snímky o signálech a modulaci a pak kvíz.",
"net_ex5_title": "Kvíz: signály a modulace",
"net_unit6": "Cesta, kanál a okruh",
"net_unit6_sub": "4 snímky o cestě, kanálu a okruhu a pak kvíz.",
"net_ex6_title": "Kvíz: cesta, kanál a okruh",
"net_unit7": "Multiplex a směry přenosu",
"net_unit7_sub": "4 snímky o multiplexu a směrech a pak kvíz.",
"net_ex7_title": "Kvíz: multiplex a směry přenosu",
"net_unit8": "Přepojování",
"net_unit8_sub": "4 snímky o přepojování a pak kvíz.",
"net_ex8_title": "Kvíz: přepojování",
"net_unit9": "Vrstevnaté modely a protokoly",
"net_unit9_sub": "4 snímky o vrstvách a protokolech a pak kvíz.",
"net_ex9_title": "Kvíz: vrstevnaté modely a protokoly",
"net_unit10": "Model ISO/OSI a TCP/IP",
"net_unit10_sub": "3 snímky o ISO/OSI a TCP/IP a pak kvíz.",
"net_ex10_title": "Kvíz: ISO/OSI a TCP/IP",
"net_unit11": "Podrobný rozbor 7 vrstev ISO/OSI",
"net_unit11_sub": "8 snímků o jednotlivých vrstvách a pak kvíz.",
"net_ex11_title": "Kvíz: 7 vrstev ISO/OSI",
"net_unit12": "Základy architektury TCP/IP",
"net_unit12_sub": "6 snímků o TCP/IP a protokolech a pak kvíz.",
"net_ex12_title": "Kvíz: architektura TCP/IP",
"net_unit13": "Topologie sítě",
"net_unit13_sub": "4 snímky o sběrnici, hvězdě a kruhu a pak kvíz.",
"net_ex13_title": "Kvíz: topologie sítě",
"net_unit14": "Optika",
"net_unit14_sub": "5 snímků o optických vláknech a pak kvíz.",
"net_ex14_title": "Kvíz: optika",
"net_unit15": "Ochrana a montáž optiky",
"net_unit15_sub": "3 snímky o ochraně a montáži a pak kvíz.",
"net_ex15_title": "Kvíz: ochrana a montáž optiky",
"net_unit16": "Metody přístupu",
"net_unit16_sub": "4 snímky o kolizích a CSMA a pak kvíz.",
"net_ex16_title": "Kvíz: metody přístupu",
"net_unit17": "Aktivní prvky",
"net_unit17_sub": "7 snímků o hubu, switchi a routeru a pak kvíz.",
"net_ex17_title": "Kvíz: aktivní prvky",
"net_unit18": "Switch (podrobně)",
"net_unit18_sub": "5 snímků o CAM a přepínání a pak kvíz.",
"net_ex18_title": "Kvíz: switch podrobně",
"net_unit19": "Směrování (Routing)",
"net_unit19_sub": "7 snímků o směrovacích algoritmech a pak kvíz.",
"net_ex19_title": "Kvíz: směrování",
"net_unit20": "Směrovač a brána",
"net_unit20_sub": "7 snímků o routeru a gateway a pak kvíz.",
"net_ex20_title": "Kvíz: směrovač a brána",
"net_unit21": "Adresy a rámce v ethernetu",
"net_unit21_sub": "8 snímků o MAC a typech rámců a pak kvíz.",
"net_ex21_title": "Kvíz: ethernet adresy a rámce",
"net_unit22": "Paket IP",
"net_unit22_sub": "8 snímků o IP datagramu a hlavičce a pak kvíz.",
"net_ex22_title": "Kvíz: paket IP",
"net_unit23": "ICMP",
"net_unit23_sub": "5 snímků o ICMP zprávách a traceroute a pak kvíz.",
"net_ex23_title": "Kvíz: ICMP",
"net_unit24": "ARP",
"net_unit24_sub": "5 snímků o ARP/RARP a cache a pak kvíz.",
"net_ex24_title": "Kvíz: ARP",
"net_unit25": "TCP a UDP",
"net_unit25_sub": "6 snímků o transportní vrstvě a pak kvíz.",
"net_ex25_title": "Kvíz: TCP a UDP",
"net_unit26": "Aplikační protokoly",
"net_unit26_sub": "7 snímků o portech a službách a pak kvíz.",
"net_ex26_title": "Kvíz: Aplikační protokoly",
"net_unit27": "Bezdrátové sítě",
"net_unit27_sub": "8 snímků o družicích, Wi-Fi a zabezpečení a pak kvíz.",
"net_ex27_title": "Kvíz: Bezdrátové sítě",
"net_quiz_intro": "Vyberte u každé otázky jednu správnou odpověď a stiskněte Zkontrolovat.",
"net_quiz2_head": "Kvíz k základním pojmům sítí",
"net_quiz3_head": "Kvíz ke klient–server a cloudu",
"net_quiz4_head": "Kvíz k přenosu dat a zabezpečení",
"net_quiz5_head": "Kvíz k signálům a modulaci",
"net_quiz6_head": "Kvíz k cestě, kanálu a okruhu",
"net_quiz7_head": "Kvíz k multiplexu a směrům přenosu",
"net_quiz8_head": "Kvíz k přepojování",
"net_quiz9_head": "Kvíz k vrstevnatým modelům a protokolům",
"net_quiz10_head": "Kvíz k ISO/OSI a TCP/IP",
"net_quiz11_head": "Kvíz k 7 vrstvám ISO/OSI",
"net_quiz12_head": "Kvíz k architektuře TCP/IP",
"net_quiz13_head": "Kvíz k topologiím sítě",
"net_quiz14_head": "Kvíz k optice",
"net_quiz15_head": "Kvíz k ochraně a montáži optiky",
"net_quiz16_head": "Kvíz k metodám přístupu",
"net_quiz17_head": "Kvíz k aktivním prvkům",
"net_quiz18_head": "Kvíz ke switchi podrobně",
"net_quiz19_head": "Kvíz ke směrování",
"net_quiz20_head": "Kvíz ke směrovači a bráně",
"net_quiz21_head": "Kvíz k ethernetovým adresám a rámcům",
"net_quiz22_head": "Kvíz k IP paketu",
"net_quiz23_head": "Kvíz k ICMP",
"net_quiz24_head": "Kvíz k ARP",
"net_quiz25_head": "Kvíz k TCP a UDP",
"net_quiz26_head": "Kvíz k aplikačním protokolům",
"net_quiz27_head": "Kvíz k bezdrátovým sítím",
"net_slide_next": "Další",
"net_slide_prev": "Zpět",
"net_slide_start": "Přejít na cvičení",
"net_solution": "Ukázat řešení",
"hw_sub": "Vyberte lekci na cestě a začněte procvičovat.",
"hw_unit1": "Architektura počítače",
"hw_unit1_sub": "3 snímky o Von Neumannovi a schématu a pak kvíz.",
"hw_ex1_title": "Kvíz: architektura počítače",
"hw_quiz1_head": "Kvíz k architektuře počítače",
"hw_unit2": "Historie počítačů",
"hw_unit2_sub": "6 snímků o generacích počítačů a pak kvíz.",
"hw_ex2_title": "Kvíz: historie počítačů",
"hw_quiz2_head": "Kvíz k historii počítačů",
"hw_unit3": "Zobrazování dat v počítači",
"hw_unit3_sub": "2 snímky o bitu a bytu a pak kvíz.",
"hw_ex3_title": "Kvíz: zobrazování dat",
"hw_quiz3_head": "Kvíz k zobrazování dat v počítači",
"czech_sub": "Vyberte oblast a začněte procvičovat.",
"Literatura": "Literatura",
"Mluvnice": "Mluvnice",
"Maturitní četba": "Maturitní četba",
"reading_sub": "Vyberte knihu, kterou chcete studovat.",
"book_1984_sub": "Antiutopický román • 1949",
"cetba1984_sub": "Přehled knihy, klíčové pojmy a cvičení.",
"lit_quiz_title": "Kvíz: George Orwell – 1984",
"lit_quiz_sub": "U každé otázky vyberte jednu správnou odpověď.",
"lit_plot_title": "Sestavte děj",
"lit_plot_sub": "Přetáhněte části příběhu do správného pořadí.",
"lit_next": "Další",
"lit_show_result": "Zobrazit výsledek",
"lit_again": "Zkusit znovu",
"lit_question_fmt": "Otázka %d / %d",
"lit_score_fmt": "Skóre: %d / %d",
"lit_finished": "Hotovo!",
"lit_finished_text": "Prošli jste všemi otázkami.",
"cetbaFuks_sub": "Přehled knihy, klíčové pojmy a cvičení.",
"lit_fuks_quiz_title": "Kvíz: Spalovač mrtvol",
"lit_fuks_quiz_sub": "U každé otázky vyberte jednu správnou odpověď.",
"lit_fuks_plot_title": "Sestavte děj",
"lit_fuks_plot_sub": "Přetáhněte části příběhu do správného pořadí."
},
"en": {
"back": "Back",
"settings": "Settings",
"settings_title": "Settings",
"mode": "MODE",
"mode_dark": "Dark",
"mode_light": "Light",
"theme": "THEME",
"language": "LANGUAGE",
"welcome_body": "maturita.C is an educational app for high-school and gymnasium students to prepare for their maturita exam.\n\nThe program is written in C by students from SSŠVT!",
"continue": "Continue",
"roadmap_title": "Learning path",
"roadmap_sub": "Pick a unit on the path and start practicing.",
"unit1_sub": "Choose an exercise and complete it.",
"check": "Check",
"finish": "Finish",
"show_sample": "Show sample",
"your_answer": "Your answer…",
"feedback_ok": "Great!",
"feedback_retry": "Not quite right yet. Try again.",
"feedback_retry_short": "Not quite right yet.",
"feedback_sentences": "Some sentences are not correct. Try again.",
"tip_ss": "You can type “ss” instead of “ß” on the keyboard!",
"hint_umlauts": "No German keyboard? You can type ä as ae, ö as oe, ü as ue and ß as ss.",
"sample_fmt": "Question: %s\nSample: %s (English: %s)",
"sub_dialog": "Fill in the missing words in the dialogue.",
"sub_assembly": "Drag the words into the correct order.",
"sub_choice_num": "Choose the correct numeral.",
"sub_free": "Answer in your own words, then click Finish.",
"sub_zahlen": "Choose the correct word for the given number.",
"sub_wieviel": "Count the items and choose the amount.",
"sub_reihe": "Fill in the missing numeral.",
"sub_verb": "Choose the correct verb form.",
"sub_wer": "Choose the correct question word.",
"sub_bild": "Choose the correct verb form for the picture.",
"sub_gruss": "Sort the phrases into greetings and farewells.",
"sub_land": "Match the symbols to the correct country.",
"Vokabeltraining": "Vocabulary training",
"sub_translate": "Translate the word. Wrong answers come back at the end.",
"trans_wrong": "Wrong. Correct: %s",
"trans_incomplete": "Almost – something is missing.",
"trans_progress": "Done: %d / %d",
"trans_done": "Done! You mastered all the words.",
"assign_hint": "Pick a group and click a card. Click a card inside a group to send it back.",
"unit2_sub": "Choose an exercise and complete it.",
"Bei uns zu Hause": "At Home with Us",
"unit3_sub": "Choose an exercise and complete it.",
"sub3_pair": "Match each word with its counterpart.",
"sub3_poss": "Fill in the possessive pronouns (nominative).",
"sub3_haustier": "Fill in ein/kein in the correct form.",
"sub3_buchst": "Fill in the missing letters.",
"sub3_marco": "Fill in the words from the box.",
"sub3_sort": "Sort the words into two groups.",
"sub3_tabelle": "Fill the possessive pronouns into the table.",
"sub3_akk": "Fill in the possessive pronouns (accusative).",
"sub3_nicht": "Choose kein/keine/keinen/nicht.",
"sub3_satz": "Build the correct sentences.",
"sub3_sehen": "Write an “Ich sehe…” sentence.",
"sub3_wem": "Whose is it? Fill in the pronoun.",
"sub3_gibt": "Fill in “Es gibt” + ein/eine/einen.",
"sub3_saetze": "Complete the sentences following the example.",
"sub3_wochen": "Fill in the missing letters.",
"wordbank": "Word bank:",
"sample_line": "Sample: %s",
"sub_verben": "Fill in the verb in the correct form.",
"sub_ausin": "Choose the correct preposition: aus or in.",
"sub_sprich": "Fill in the verb “sprechen”.",
"sub_nation": "Fill in the nationality – use the words in the box.",
"sub_woher": "Where are the people from? Type the country.",
"sub_laender": "Type the full country name (the missing letters are shown as _).",
"sub_verb2": "Choose the correct verb form.",
"sub_euro": "Write the amount in words.",
"sub_wortsuchen": "Find the hidden word and type it.",
"sub_ordne": "Match each phrase with the right question word and answer.",
"sub_luecke": "Fill the missing words into the text.",
"sub_verbinde": "Match each noun with the right verb.",
"sub_zahlpaar": "Match the numbers with the German words.",
"sub_steckbrief": "Complete the information about yourself.",
"sub_berufe": "Guess each job before the hangman drawing is finished.",
"sub_bistdu": "Answer according to the flag – what is your nationality?",
"hm_progress": "Word %d / %d",
"hm_hint": "Hint",
"hm_wrong": "Word guessed!",
"hm_fail": "Hanged! The word was “%s”.",
"hm_retry": "Try again",
"hm_done": "Great! You guessed all the jobs.",
"hm_guess": "Guess a letter",
"stats": "Statistics",
"stats_title": "Statistics",
"stats_sub": "Overview of your progress.",
"stats_ex_label": "Exercises",
"stats_pct_label": "Done",
"stats_units_label": "Units",
"stats_section": "Subjects",
"stats_locked": "Locked",
"stats_complete": "Complete",
"stats_ex_fmt": "%d / %d",
"stats_pct_fmt": "%d %%",
"stats_units_fmt": "%d / %d",
"subjects_title": "Subjects",
"Český jazyk a literatura": "Czech Language and Literature",
"Občanská nauka": "Civics",
"English": "English",
"Deutsch": "Deutsch",
"Matematika": "Mathematics",
"Fyzika": "Physics",
"Základy Přírodopisných věd": "Fundamentals of Natural Sciences",
"Technická grafika": "Technical Drawing",
"Prezentační grafika": "Presentation Graphics",
"Správa počítačových sítí": "Computer Network Administration",
"Programování": "Programming",
"Technické vybavení": "Computer Hardware",
"Programové vybavení": "Software",
"net_sub": "Pick a unit on the path and start practicing.",
"net_years_title": "Computer Network Administration",
"net_years_sub": "Pick a school year and start practicing.",
"net_year1": "Year 1",
"net_year2": "Year 2",
"net_year3": "Year 3",
"net_year4": "Year 4",
"net_year_locked_sub": "No content yet",
"net_unit1": "IP Address Calculation",
"net_unit1_sub": "4 slides with the method, then an exercise.",
"net_ex_title": "Exercise: IP address calculation",
"net_unit2": "Basic concepts and network types",
"net_unit2_sub": "5 slides with the concepts, then a quiz.",
"net_ex2_title": "Quiz: basic networking concepts",
"net_unit3": "Client–Server networks and Cloud",
"net_unit3_sub": "4 slides about servers and cloud, then a quiz.",
"net_ex3_title": "Quiz: client–server and cloud",
"net_unit4": "Data transmission and security",
"net_unit4_sub": "4 slides about transmission and security, then a quiz.",
"net_ex4_title": "Quiz: data transmission and security",
"net_unit5": "Signals and modulation",
"net_unit5_sub": "4 slides about signals and modulation, then a quiz.",
"net_ex5_title": "Quiz: signals and modulation",
"net_unit6": "Path, channel and circuit",
"net_unit6_sub": "4 slides about path, channel and circuit, then a quiz.",
"net_ex6_title": "Quiz: path, channel and circuit",
"net_unit7": "Multiplexing and transmission directions",
"net_unit7_sub": "4 slides about multiplexing and directions, then a quiz.",
"net_ex7_title": "Quiz: multiplexing and transmission directions",
"net_unit8": "Switching",
"net_unit8_sub": "4 slides about switching, then a quiz.",
"net_ex8_title": "Quiz: switching",
"net_unit9": "Layered models and protocols",
"net_unit9_sub": "4 slides about layers and protocols, then a quiz.",
"net_ex9_title": "Quiz: layered models and protocols",
"net_unit10": "ISO/OSI and TCP/IP models",
"net_unit10_sub": "3 slides about ISO/OSI and TCP/IP, then a quiz.",
"net_ex10_title": "Quiz: ISO/OSI and TCP/IP",
"net_unit11": "Detailed review of the 7 ISO/OSI layers",
"net_unit11_sub": "8 slides about each layer, then a quiz.",
"net_ex11_title": "Quiz: 7 ISO/OSI layers",
"net_unit12": "Basics of TCP/IP architecture",
"net_unit12_sub": "6 slides about TCP/IP and protocols, then a quiz.",
"net_ex12_title": "Quiz: TCP/IP architecture",
"net_unit13": "Network topologies",
"net_unit13_sub": "4 slides about bus, star and ring, then a quiz.",
"net_ex13_title": "Quiz: network topologies",
"net_unit14": "Fiber optics",
"net_unit14_sub": "5 slides about optical fibers, then a quiz.",
"net_ex14_title": "Quiz: fiber optics",
"net_unit15": "Fiber protection and installation",
"net_unit15_sub": "3 slides about protection and installation, then a quiz.",
"net_ex15_title": "Quiz: fiber protection and installation",
"net_unit16": "Access methods",
"net_unit16_sub": "4 slides about collisions and CSMA, then a quiz.",
"net_ex16_title": "Quiz: access methods",
"net_unit17": "Active network devices",
"net_unit17_sub": "7 slides about hubs, switches and routers, then a quiz.",
"net_ex17_title": "Quiz: active network devices",
"net_unit18": "Switch in detail",
"net_unit18_sub": "5 slides about CAM and switching modes, then a quiz.",
"net_ex18_title": "Quiz: switch in detail",
"net_unit19": "Routing",
"net_unit19_sub": "7 slides about routing algorithms, then a quiz.",
"net_ex19_title": "Quiz: routing",
"net_unit20": "Router and gateway",
"net_unit20_sub": "7 slides about routers and gateways, then a quiz.",
"net_ex20_title": "Quiz: router and gateway",
"net_unit21": "Ethernet addresses and frames",
"net_unit21_sub": "8 slides about MAC and frame types, then a quiz.",
"net_ex21_title": "Quiz: Ethernet addresses and frames",
"net_unit22": "IP packet",
"net_unit22_sub": "8 slides about the IP datagram and header, then a quiz.",
"net_ex22_title": "Quiz: IP packet",
"net_unit23": "ICMP",
"net_unit23_sub": "5 slides about ICMP messages and traceroute, then a quiz.",
"net_ex23_title": "Quiz: ICMP",
"net_unit24": "ARP",
"net_unit24_sub": "5 slides about ARP/RARP and cache, then a quiz.",
"net_ex24_title": "Quiz: ARP",
"net_unit25": "TCP and UDP",
"net_unit25_sub": "6 slides about the transport layer, then a quiz.",
"net_ex25_title": "Quiz: TCP and UDP",
"net_unit26": "Application protocols",
"net_unit26_sub": "7 slides about ports and services, then a quiz.",
"net_ex26_title": "Quiz: Application protocols",
"net_unit27": "Wireless networks",
"net_unit27_sub": "8 slides about satellites, Wi-Fi and security, then a quiz.",
"net_ex27_title": "Quiz: Wireless networks",
"net_quiz_intro": "Choose one correct answer for each question and press Check.",
"net_quiz2_head": "Quiz on basic networking concepts",
"net_quiz3_head": "Quiz on client–server and cloud",
"net_quiz4_head": "Quiz on data transmission and security",
"net_quiz5_head": "Quiz on signals and modulation",
"net_quiz6_head": "Quiz on path, channel and circuit",
"net_quiz7_head": "Quiz on multiplexing and transmission directions",
"net_quiz8_head": "Quiz on switching",
"net_quiz9_head": "Quiz on layered models and protocols",
"net_quiz10_head": "Quiz on ISO/OSI and TCP/IP",
"net_quiz11_head": "Quiz on the 7 ISO/OSI layers",
"net_quiz12_head": "Quiz on TCP/IP architecture",
"net_quiz13_head": "Quiz on network topologies",
"net_quiz14_head": "Quiz on fiber optics",
"net_quiz15_head": "Quiz on fiber protection and installation",
"net_quiz16_head": "Quiz on access methods",
"net_quiz17_head": "Quiz on active network devices",
"net_quiz18_head": "Quiz on switches in detail",
"net_quiz19_head": "Quiz on routing",
"net_quiz20_head": "Quiz on router and gateway",
"net_quiz21_head": "Quiz on Ethernet addresses and frames",
"net_quiz22_head": "Quiz on the IP packet",
"net_quiz23_head": "Quiz on ICMP",
"net_quiz24_head": "Quiz on ARP",
"net_quiz25_head": "Quiz on TCP and UDP",
"net_quiz26_head": "Quiz on application protocols",
"net_quiz27_head": "Quiz on wireless networks",
"net_slide_next": "Next",
"net_slide_prev": "Back",
"net_slide_start": "Go to the exercise",
"net_solution": "Show solution",
"hw_sub": "Pick a unit on the path and start practicing.",
"hw_unit1": "Computer architecture",
"hw_unit1_sub": "3 slides about von Neumann and the model, then a quiz.",
"hw_ex1_title": "Quiz: computer architecture",
"hw_quiz1_head": "Quiz on computer architecture",
"hw_unit2": "History of computers",
"hw_unit2_sub": "6 slides about computer generations, then a quiz.",
"hw_ex2_title": "Quiz: history of computers",
"hw_quiz2_head": "Quiz on the history of computers",
"hw_unit3": "Data representation",
"hw_unit3_sub": "2 slides about bits and bytes, then a quiz.",
"hw_ex3_title": "Quiz: data representation",
"hw_quiz3_head": "Quiz on data representation in computers",
"czech_sub": "Pick an area and start practicing.",
"Literatura": "Literature",
"Mluvnice": "Grammar",
"Maturitní četba": "Maturita reading list",
"reading_sub": "Pick a book to study.",
"book_1984_sub": "Dystopian novel • 1949",
"cetba1984_sub": "Book overview, key terms and exercises.",
"lit_quiz_title": "Quiz: George Orwell – 1984",
"lit_quiz_sub": "Pick one correct answer for each question.",
"lit_plot_title": "Order the plot",
"lit_plot_sub": "Drag the story parts into the correct order.",
"lit_next": "Next",
"lit_show_result": "Show result",
"lit_again": "Try again",
"lit_question_fmt": "Question %d / %d",
"lit_score_fmt": "Score: %d / %d",
"lit_finished": "Done!",
"lit_finished_text": "You have finished all the questions.",
"cetbaFuks_sub": "Book overview, key terms and exercises.",
"lit_fuks_quiz_title": "Quiz: The Cremator",
"lit_fuks_quiz_sub": "Pick one correct answer for each question.",
"lit_fuks_plot_title": "Order the plot",
"lit_fuks_plot_sub": "Drag the story parts into the correct order."
}
}

View file

@ -0,0 +1,539 @@
{
"id": "fuks-spalovac-mrtvol",
"subject": "czech",
"number": 3,
"title": {
"cs": "Ladislav Fuks – Spalovač mrtvol",
"en": "Ladislav Fuks – Spalovač mrtvol"
},
"subtitle": {
"cs": "Projděte si přehled a pak si vyzkoušejte kvíz a řazení děje.",
"en": "Read the overview, then try the quiz and the plot ordering."
},
"slides": [
{
"kicker": "Ladislav Fuks – Spalovač mrtvol",
"title": "O knize",
"tip": null,
"lines": [
"Autor: Ladislav Fuks (1923–1994)",
"Žánr: psychologický román s prvky grotesky a hororu",
"Poprvé vydáno: 1967",
"Děj: Praha v době protektorátu (konec 30. let)",
"Vyprávění: ich-forma – vypráví sám pan Kopfrkingl"
]
},
{
"kicker": "Ladislav Fuks – Spalovač mrtvol",
"title": "Doba a svět",
"tip": null,
"lines": [
"Děj se odehrává v protektorátu Čechy a Morava za nastupujícího nacismu.",
"Atmosféru tvoří strach, přizpůsobování a touha zalíbit se moci.",
"Krematorium je symbolem smrti a odlidštění.",
"Antisemitismus a árijská ideologie postupně prostupují společnost.",
"Obyčejný úředník se mění v nástroj zla."
]
},
{
"kicker": "Ladislav Fuks – Spalovač mrtvol",
"title": "Postavy",
"tip": null,
"lines": [
"Pan Kopfrkingl – zaměstnanec krematoria, vypravěč a vrah své rodiny.",
"Lakmé – jeho žena, židovského původu.",
"Zina a Mil – jeho děti.",
"Willi Reinke – Němec, který Kopfrkingla přivede k nacismu.",
"Dr. Bettelheim – lékař, s nímž Kopfrkingl rozmlouvá o smrti."
]
},
{
"kicker": "Ladislav Fuks – Spalovač mrtvol",
"title": "Klíčové motivy",
"tip": null,
"lines": [
"Krematorium – symbol smrti a zvrácené „očisty“.",
"Kniha mrtvých – tibetský text, který si Kopfrkingl překrucuje.",
"Reinkarnace a „osvobození“ – záminka pro vraždy.",
"Masky a přetvářka – zlo se tváří jako zdvořilost a řád.",
"Groteska a černý humor – hrůza podaná klidným, slavnostním tónem.",
"Voda a Vltava – motiv smrti a plynutí."
]
}
],
"tasks": [
{
"number": 1,
"title": "Kvíz",
"heading": "Kvíz ke knize",
"subtitleKey": "lit_quiz_sub",
"items": [
{
"kind": "choice",
"prompt": "Kdo je hlavní postava románu a jaké je jeho povolání?",
"options": [
"Pan Kopfrkingl – zaměstnanec krematoria",
"Willi Reinke – nacistický důstojník",
"Dr. Bettelheim – psychiatr",
"Pan Kopfrkingl – ředitel školy"
],
"correct": 0,
"hint": "Hlavní postavou je pan Kopfrkingl, spořádaný zaměstnanec krematoria, který svou práci vnímá jako poslání."
},
{
"kind": "choice",
"prompt": "V jakém prostředí pan Kopfrkingl pracuje?",
"options": [
"V nemocnici",
"V krematoriu",
"V divadle",
"Ve škole"
],
"correct": 1,
"hint": "Kopfrkingl pracuje v krematoriu, kde má na starosti zpopelňování zemřelých."
},
{
"kind": "choice",
"prompt": "V jaké historické době se děj románu odehrává?",
"options": [
"Za první republiky",
"Za protektorátu Čechy a Morava (před a během 2. světové války)",
"Po roce 1989",
"V 19. století"
],
"correct": 1,
"hint": "Román se odehrává v době nastupujícího nacismu a protektorátu, který umožňuje Kopfrkinglovu proměnu."
},
{
"kind": "choice",
"prompt": "Jak se proměňuje hlavní hrdina v průběhu příběhu?",
"options": [
"Z laskavého spořádaného muže v chladnokrevného vraha a fanatika",
"Z vraha v kajícníka",
"Z chudáka v boháče",
"Vůbec se nemění"
],
"correct": 0,
"hint": "Kopfrkingl se postupně mění v člověka, který vraždí své blízké a ospravedlňuje to vyšším dobrem."
},
{
"kind": "choice",
"prompt": "Jaký je vztah pana Kopfrkingla k jeho rodině na začátku knihy?",
"options": [
"Nenávidí je",
"Je starostlivý, zdvořilý a především spořádaný",
"S rodinou se nezná",
"Týrá je"
],
"correct": 1,
"hint": "Na začátku působí jako vzorný manžel a otec, posedlý řádem a zdvořilostí."
},
{
"kind": "choice",
"prompt": "Jak se jmenuje manželka pana Kopfrkingla a jaký je jejich vztah?",
"options": [
"Marie – milují se",
"Lakmé – formálně zdvořilý, chladný vztah",
"Zina – nenávidí se",
"Julie – jsou rozvedení"
],
"correct": 1,
"hint": "Manželka se jmenuje Lakmé. Jejich vztah je spíš zdvořilá forma než opravdová láska."
},
{
"kind": "choice",
"prompt": "Jaký je vztah pana Kopfrkingla k dětem, Zině a Mílovi?",
"options": [
"Miluje je víc než sebe",
"Chová se k nim odtažitě, jako k součásti řádu",
"Nevidí je",
"Jsou adoptované"
],
"correct": 1,
"hint": "K dětem je odměřený; vnímá je spíš jako součást svého spořádaného světa než jako milované bytosti."
},
{
"kind": "choice",
"prompt": "Kdo je Willi Reinke a jakou roli hraje v proměně hlavního hrdiny?",
"options": [
"Soused z krematoria",
"Německý úředník a bývalý voják, který Kopfrkingla ovlivní",
"Kopfrkinglův bratr",
"Lékař v nemocnici"
],
"correct": 1,
"hint": "Reinke je Němec, který v Kopfrkinglovi probudí touhu po árijském původu a kariéře."
},
{
"kind": "choice",
"prompt": "Jak Willi Reinke ovlivňuje Kopfrkinglovo smýšlení?",
"options": [
"Přesvědčí ho, aby se přidal k nacistům a byl „lepší“",
"Nabádá ho k odchodu do zahraničí",
"Učí ho tibetštinu",
"Nijak ho neovlivní"
],
"correct": 0,
"hint": "Reinke v něm posiluje antisemitismus a touhu po moci a uznání v novém režimu."
},
{
"kind": "choice",
"prompt": "Jaký je Kopfrkinglův vztah ke krematoriu a smrti obecně?",
"options": [
"Bojí se jich",
"Má je rád, kremaci vnímá jako krásný obřad osvobození",
"Nechce tam pracovat",
"Je mu to lhostejné"
],
"correct": 1,
"hint": "Smrt a kremace jsou pro něj estetickým a filozofickým rituálem, ne něčím hrůzným."
},
{
"kind": "choice",
"prompt": "Jak pan Kopfrkingl vnímá a popisuje proces kremace?",
"options": [
"Jako nutné zlo",
"Jako vznešený, očistný obřad, o kterém rád mluví",
"Jako hroznou povinnost",
"Nikdy o něm nemluví"
],
"correct": 1,
"hint": "Kopfrkingl o kremaci hovoří básnivě a s nadšením, což ostře kontrastuje s hrůzou jeho činů."
},
{
"kind": "choice",
"prompt": "Jaká je symbolika krematoria v celém románu?",
"options": [
"Symbol naděje",
"Smrt, odlidštění a zvrácená ideologie, která z lidí dělá jen těla",
"Symbol lásky",
"Symbol mládí"
],
"correct": 1,
"hint": "Krematorium je ústředním symbolem – odhaluje chladnou mašinerii smrti, do níž se ideologie snaží proměnit člověka."
},
{
"kind": "choice",
"prompt": "Co symbolizuje Kopfrkinglova záliba v tibetské Knize mrtvých?",
"options": [
"Jeho touhu po vědění a zvrácenou víru ve vlastní výjimečnost",
"Historický zájem o Asii",
"Strach ze smrti",
"Lásku k manželce"
],
"correct": 0,
"hint": "Kopfrkingl si z ní vybírá jen to, co se hodí k ospravedlnění jeho činů a jeho pocitu vyvolenosti."
},
{
"kind": "choice",
"prompt": "Jak se v knize projevuje motiv buddhismu a reinkarnace?",
"options": [
"Kopfrkingl jimi ospravedlňuje smrt jako „osvobození duše“",
"Postavy se modlí k Bohu",
"Nikde se neobjevuje",
"Je to hlavní téma manželky"
],
"correct": 0,
"hint": "Reinkarnace a „osvobození od utrpení“ se v jeho mysli zvrhávají v právo zabíjet."
},
{
"kind": "choice",
"prompt": "Jak Kopfrkingl zneužívá filozofické a náboženské myšlenky?",
"options": [
"Ospravedlňuje jimi vlastní vraždy jako službu vyššímu dobru",
"Pomáhá jimi druhým",
"Odmítá je",
"Používá je jen v práci"
],
"correct": 0,
"hint": "Z vznešených idejí si staví masku, za kterou skrývá krutost a touhu po moci."
},
{
"kind": "choice",
"prompt": "Jak se postupně mění Kopfrkinglova mluva a způsob vyjadřování?",
"options": [
"Začíná mluvit vulgárně",
"Mění se v patetickou, ráznou řeč plnou frází o řádu a osvobození",
"Přestane mluvit",
"Od začátku mluví jen německy"
],
"correct": 1,
"hint": "Jeho zdvořilá mluvnost se postupně mění v ideologické fráze a bezcitný patos."
},
{
"kind": "choice",
"prompt": "Jaké opakující se fráze nebo návyky pana Kopfrkingla si všímáš?",
"options": [
"Neustále se ptá na počasí",
"Opakuje „to je takové“, „vidíte“ a lpí na zdvořilosti",
"Zpívá si",
"Mluví jen o jídle"
],
"correct": 1,
"hint": "Opakující se fráze a zdvořilostní obraty vytvářejí rytmus, který je čím dál tím děsivější."
},
{
"kind": "choice",
"prompt": "Jaký je vypravěčský styl románu (ich-forma, er-forma)?",
"options": [
"Er-forma bez vnitřního světa",
"Ich-forma – vypráví sám Kopfrkingl",
"Deníková forma",
"Forma dopisů"
],
"correct": 1,
"hint": "Ich-forma je klíčová: čtenář sleduje svět zkreslený Kopfrkinglovou myslí a sám postupně propadá jeho logice."
},
{
"kind": "choice",
"prompt": "Jak se v knize projevuje černý humor a groteska?",
"options": [
"Vážné scény jsou líčeny s odstupem a absurditou",
"Kniha je čistě vážná",
"Humor je jediný žánr",
"V knize se neprojevuje"
],
"correct": 0,
"hint": "Groteskní kontrast mezi slavnostním vyprávěním a hrůzou vytváří mrazivý černý humor."
},
{
"kind": "choice",
"prompt": "Jak kontrastuje banalita každodenního života s hrůzností činů?",
"options": [
"Vůbec nesouvisí",
"Všední detaily a zdvořilost činí hrůzu ještě děsivější",
"Hrůza je potlačena",
"Den je popsán jen jednou"
],
"correct": 1,
"hint": "Fuks zesiluje otřes tím, že vraždění je popisováno stejně klidně jako ranní káva nebo kremace."
},
{
"kind": "choice",
"prompt": "Jak se v knize projevuje antisemitismus?",
"options": [
"Od začátku otevřeně",
"Postupně, nejdřív v náznacích a vlivem Reinkeho",
"Nikdy",
"Jen mimochodem v závěru"
],
"correct": 1,
"hint": "Kopfrkinglův antisemitismus se rodí pozvolna a je živen dobovou atmosférou i Reinkem."
},
{
"kind": "choice",
"prompt": "Jak nacistická ideologie postupně prostupuje Kopfrkinglovo myšlení?",
"options": [
"Odmítá ji",
"Přijímá ji jako vyšší řád a ospravedlnění vlastní výjimečnosti",
"Nerozumí jí",
"Zajímá ho jen hudba"
],
"correct": 1,
"hint": "Ideologie se mu stává nástrojem kariéry i vnitřním ospravedlněním vražd."
},
{
"kind": "choice",
"prompt": "Jak se mění Kopfrkinglův vztah k manželce kvůli jejímu původu?",
"options": [
"Začne ji milovat víc",
"Odcizí se jí, protože je židovského původu",
"Nijak se nemění",
"Odstěhují se od sebe"
],
"correct": 1,
"hint": "Kvůli jejímu původu ji přestane vnímat jako rovnocennou a nakonec ji zabije."
},
{
"kind": "choice",
"prompt": "Co se stane s Kopfrkinglovou rodinou ve druhé polovině románu?",
"options": [
"Odstěhují se",
"Kopfrkingl svou ženu i děti zabije",
"Všichni přežijí",
"Zachrání je Reinke"
],
"correct": 1,
"hint": "Pod vlivem ideologie Kopfrkingl vyvraždí vlastní rodinu a považuje to za „osvobození“."
},
{
"kind": "choice",
"prompt": "Jak Kopfrkingl zdůvodňuje vraždu vlastní ženy?",
"options": [
"Zlobila se na něj",
"Že je židovského původu a překáží jeho kariéře",
"Že ho podváděla",
"Nijak ji nezdůvodňuje"
],
"correct": 1,
"hint": "Svou ženu zabije mimo jiné proto, že je židovského původu, tedy „méněcenná“ pro nový režim."
},
{
"kind": "choice",
"prompt": "Jakým způsobem Kopfrkingl vraždí své blízké?",
"options": [
"Ve zlosti a afektu",
"Chladně a s pocitem, že je „osvobozuje od utrpení“",
"Omylem",
"Na příkaz úřadů"
],
"correct": 1,
"hint": "Vraždí klidně, jako by vykonával bohulibý úkon, a svou krutost si vykládá jako soucit."
},
{
"kind": "choice",
"prompt": "Jak souvisí přesvědčení o „osvobození“ s jeho vražednými činy?",
"options": [
"Dává mu zdání vyššího smyslu, kterým si vraždy ospravedlňuje",
"Nemá s nimi nic společného",
"Brání mu v zabíjení",
"Je to jen náhodná fráze"
],
"correct": 0,
"hint": "Zvrácená víra v osvobození od utrpení mu slouží jako mravní alibi pro vraždy."
},
{
"kind": "choice",
"prompt": "Jaký je vztah pana Kopfrkingla k postavě Dr. Bettelheima?",
"options": [
"Obdivuje ho a rád s ním mluví",
"Nenávidí ho",
"Nikdy se nepotkají",
"Je to jeho nadřízený"
],
"correct": 0,
"hint": "S doktorem Bettelheimem vede Kopfrkingl své „filozofické“ rozhovory o smrti a lidskosti."
},
{
"kind": "choice",
"prompt": "Jak se mění společenské postavení pana Kopfrkingla v době okupace?",
"options": [
"Upadá",
"Stoupá – díky režimu a touze po árijském původu",
"Nemění se",
"Odchází do penze"
],
"correct": 1,
"hint": "Spolu s nacistickou mocí roste i Kopfrkinglova kariéra a sebevědomí."
},
{
"kind": "choice",
"prompt": "Jaký je symbolický význam jména Kopfrkingl?",
"options": [
"Zdánlivě úřednické, ale zní hrozivě – naznačuje zvrácenost",
"Je to běžné české jméno",
"Znamená „laskavý“",
"Je to pseudonym"
],
"correct": 0,
"hint": "Zvukomalebné jméno působí úředně a zároveň hrozivě, což podtrhuje dvojí tvář postavy."
},
{
"kind": "choice",
"prompt": "Jak funguje motiv masek, divadla a přetvářky?",
"options": [
"Postavy nosí masky dobroty, pod nimiž se skrývá krutost",
"V knize jsou jen skutečné divadelní masky",
"Motiv se neobjevuje",
"Masky symbolizují lásku"
],
"correct": 0,
"hint": "Přetvářka a předstíraná spořádanost jsou ústřední – zlo se tváří jako slušnost."
},
{
"kind": "choice",
"prompt": "Jaký je vztah mezi soukromým a kolektivním šílenstvím doby?",
"options": [
"Kopfrkinglovo osobní šílenství roste spolu s ideologií doby",
"Nijak nesouvisí",
"Doba je klidná a šťastná",
"Lidé režim jednomyslně odmítají"
],
"correct": 0,
"hint": "Fuks ukazuje, jak se individuální porucha napojí na kolektivní ideologii a čerpá z ní sílu."
},
{
"kind": "choice",
"prompt": "Jak končí román a co se stane s panem Kopfrkinglem na konci?",
"options": [
"Uteče do zahraničí",
"Je zatčen a zničen",
"Stane se hrdinou",
"Věří, že je vtělením Dalajlámy, a zůstává zaslepený"
],
"correct": 3,
"hint": "Kopfrkinglova zaslepenost vrcholí přesvědčením o vlastní božskosti; jeho zvrácená víra ho neopouští."
},
{
"kind": "choice",
"prompt": "Jak je Spalovač mrtvol adaptován filmově?",
"options": [
"Film Juraje Herze je vizuálně expresivní a patří k vrcholům české kinematografie",
"Byl zfilmován jen v zahraničí",
"Film neexistuje",
"Vznikl jako animovaný seriál"
],
"correct": 0,
"hint": "Známá filmová verze je od režiséra Juraje Herze z roku 1968."
},
{
"kind": "choice",
"prompt": "Které další dílo Ladislava Fukse se dá srovnat s touto knihou?",
"options": [
"Pan Theodor Mundstock nebo Myši Natálie Mooshabrové",
"Farma zvířat",
"R.U.R.",
"Babička"
],
"correct": 0,
"hint": "Fuks se opakovaně zabýval osudem židovských postav a atmosférou strachu; srovnat lze např. Pana Theodora Mundstocka."
},
{
"kind": "choice",
"prompt": "Jaká jsou hlavní témata románu?",
"options": [
"Moc, manipulace, ideologie, násilí a přetvářka",
"Láska a dobrodružství",
"Sport a přátelství",
"Příroda a vesmír"
],
"correct": 0,
"hint": "Kniha zkoumá, jak se obyčejný člověk přizpůsobí zlu a stane se jeho nástrojem."
},
{
"kind": "choice",
"prompt": "Jaké poselství podle tebe chtěl Ladislav Fuks knihou předat?",
"options": [
"Varování před manipulací a tím, jak se zlo rodí z banality a lhostejnosti",
"Oslavu války",
"Návod k podnikání",
"Kritiku školství"
],
"correct": 0,
"hint": "Fuks ukazuje, že hrůza může vzniknout z poslušnosti a touhy být „lepší“ než ostatní."
}
]
},
{
"number": 2,
"title": "Děj",
"heading": "Seřaďte děj",
"subtitleKey": "lit_plot_sub",
"items": [
{
"kind": "assembly",
"prompt": "Přetáhněte části příběhu do správného pořadí:",
"words": [
"Kopfrkingl je spořádaným zaměstnancem krematoria",
"Seznámí se s Willim Reinkem",
"Začne věřit v árijskou nadřazenost",
"Postupně se odcizí své rodině",
"Zabije ženu a děti jako „osvobození“",
"Na konci se považuje za vtělení Dalajlámy"
],
"meaning": "Správné pořadí: spořádaný úředník → setkání s Reinkem → árijská ideologie → odcizení rodině → vraždy → přesvědčení o vlastní božskosti."
}
]
}
]
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,481 @@
{
"id": "orwell-1984",
"subject": "czech",
"number": 2,
"title": {
"cs": "George Orwell – 1984",
"en": "George Orwell – 1984"
},
"subtitle": {
"cs": "Projděte si přehled a pak si vyzkoušejte kvíz a řazení děje.",
"en": "Read the overview, then try the quiz and the plot ordering."
},
"slides": [
{
"kicker": "George Orwell – 1984",
"title": "O knize",
"tip": null,
"lines": [
"Autor: George Orwell (vl. jm. Eric Arthur Blair)",
"Žánr: antiutopický (dystopický) román",
"Poprvé vydáno: 1949",
"Děj: Londýn, provincie Letopočet 1, stát Oceánie",
"Vyprávění: v er-formě, po deníkových zápiscích Winstona Smithe"
]
},
{
"kicker": "George Orwell – 1984",
"title": "Svět a strana",
"tip": null,
"lines": [
"Svět je rozdělen na tři supervelmoci: Oceánii, Eurasii a Východasii, které spolu vedou trvalou válku.",
"Vládne jediná strana (Angsoc) v čele s kultem Velkého bratra.",
"Ministerstvo pravdy – přepisuje historii a vyrábí „pravdu“.",
"Ministerstvo lásky – mučírny a likvidace nepohodlných.",
"Ministerstvo hojnosti – plánuje hospodářství a drží lidi v nedostatku.",
"Ministerstvo míru – vede válku."
]
},
{
"kicker": "George Orwell – 1984",
"title": "Postavy",
"tip": null,
"lines": [
"Winston Smith – hlavní hrdina, přepisuje historii, touží po svobodě.",
"Julie – mladá členka strany, Winstonova tajná láska.",
"O'Brien – člen vnitřní strany, předstírá odboj a Winstona zničí.",
"Emmanuel Goldstein – nepřítel státu, údajný vůdce Bratrstva.",
"Velký bratr – symbol všemocné moci strany.",
"Pan Charrington – zdánlivý obchodník, ve skutečnosti agent."
]
},
{
"kicker": "George Orwell – 1984",
"title": "Klíčové pojmy",
"tip": null,
"lines": [
"Newspeak – uměle zjednodušovaný jazyk, který omezuje myšlení.",
"Dvojité myšlení – ochota věřit dvěma protikladným pravdám.",
"Zločin myšlení – už samotná zakázaná myšlenka.",
"Policie myšlení – tajná policie, která zatýká a mučí.",
"Bratrstvo – možná neexistující odbojová organizace.",
"Místnost 101 – nejhlubší mučírna, pro každého jeho největší strach."
]
}
],
"tasks": [
{
"number": 1,
"title": "Kvíz",
"heading": "Kvíz ke knize",
"subtitleKey": "lit_quiz_sub",
"items": [
{
"kind": "choice",
"prompt": "Kdo je hlavní hrdina románu a co dělá?",
"options": [
"Winston Smith – přepisuje historii na Ministerstvu pravdy",
"O'Brien – řídí Ministerstvo lásky",
"Emmanuel Goldstein – vede Bratrstvo",
"Winston Smith – velí Policii myšlení"
],
"correct": 0,
"hint": "Winston Smith je vnější člen strany, který na Ministerstvu pravdy upravuje staré záznamy a novinové články."
},
{
"kind": "choice",
"prompt": "Ve kterém fiktivním státě se děj odehrává?",
"options": [
"V Eurasii",
"V Oceánii",
"Ve Východasii",
"V Angsocu"
],
"correct": 1,
"hint": "Příběh se odehrává v Oceánii, v provincii Letopočet 1 (bývalá Anglie), jejímž hlavním městem je Londýn."
},
{
"kind": "choice",
"prompt": "Co znamená slogan „VÁLKA JE MÍR, SVOBODA JE OTROCTVÍ, NEVĚDOMOST JE SÍLA“?",
"options": [
"Jsou to tři zásady Bratrstva",
"Jsou to názvy ministerstev",
"Jde o hesla prolety",
"Jsou to paradoxní hesla, kterými strana ospravedlňuje útlak"
],
"correct": 3,
"hint": "Strana používá dvojité myšlení: hesla znějí vznešeně, ale ve skutečnosti obhajují válku, otroctví a nevědomost."
},
{
"kind": "choice",
"prompt": "Kdo je Velký bratr a je reálnou osobou?",
"options": [
"Skutečný vůdce Bratrstva",
"Winstonův nadřízený na ministerstvu",
"Symbol všemocné moci a kultu osobnosti; možná vůbec neexistuje",
"O'Brienův pseudonym"
],
"correct": 2,
"hint": "Velký bratr je tvář režimu. Není jasné, zda jde o živou osobu, nebo jen o symbol, který drží strach a poslušnost."
},
{
"kind": "choice",
"prompt": "Co je Newspeak (novořeč) a proč ho strana zavádí?",
"options": [
"Tajná řeč Bratrstva",
"Zjednodušená angličtina omezující slovní zásobu, aby nešlo vyslovit zakázané myšlenky",
"Úřední jazyk prolety",
"Šifra Policie myšlení"
],
"correct": 1,
"hint": "Newspeak neustále ubírá slova. Když pro svobodu nebo odpor neexistuje slovo, nelze o nich ani myslet."
},
{
"kind": "choice",
"prompt": "Co je teleobrazovka (telescreen) a jak funguje?",
"options": [
"Televize určená jen pro zábavu",
"Zařízení, které vysílá i sleduje; nelze ji úplně vypnout",
"Rozhlasová stanice",
"Počítač Ministerstva pravdy"
],
"correct": 1,
"hint": "Telescreen funguje obousměrně – neustále sleduje občany. Vypnout lze jen zvuk, obraz běží dál."
},
{
"kind": "choice",
"prompt": "Jak se jmenují tři supervelmoci světa 1984?",
"options": [
"Oceánie, Eurasie a Východasie",
"Oceánie, Evropa a Asie",
"Anglie, Eurasie a Ostasie",
"Oceánie, Východasie a Afrika"
],
"correct": 0,
"hint": "Svět je rozdělen na tři státy ve věčném, ale nerozhodném konfliktu: Oceánii, Eurasii a Východasii."
},
{
"kind": "choice",
"prompt": "Čím se zabývá Ministerstvo pravdy?",
"options": [
"Vede válku",
"Dohlíží na morálku a sňatky",
"Upravuje a přepisuje historii, noviny a archivy",
"Rozděluje potraviny"
],
"correct": 2,
"hint": "Ministerstvo pravdy vyrábí „pravdu“ – Winston v něm přepisuje staré záznamy tak, aby vždy odpovídaly současné linii strany."
},
{
"kind": "choice",
"prompt": "K čemu slouží Ministerstvo lásky?",
"options": [
"Zajišťuje sňatky a lásku",
"Je to budova bez oken, kde se mučí a likvidují nepřátelé strany",
"Vyrábí zbraně",
"Školí děti"
],
"correct": 1,
"hint": "Ministerstvo lásky je opakem svého názvu – sídlí tam mučírny a nachází se v něm i obávaná místnost 101."
},
{
"kind": "choice",
"prompt": "V čem je paradox Ministerstva hojnosti?",
"options": [
"Má na starosti hospodářství, ale ve skutečnosti drží lidi v nedostatku",
"Rozdává jídlo a zboží zdarma",
"Zajišťuje dostatek pro všechny",
"Starají se o něj prolety"
],
"correct": 0,
"hint": "Ministerstvo hojnosti plánuje výrobu, ale ekonomika je záměrně nastavena tak, aby lidé žili v trvalém nedostatku."
},
{
"kind": "choice",
"prompt": "Co je Ministerstvo míru a čím se zabývá?",
"options": [
"Uzavírá mírové smlouvy",
"Dojednává příměří",
"Ve skutečnosti vede válku",
"Velí Policii myšlení"
],
"correct": 2,
"hint": "Název je přesně opačný: Ministerstvo míru řídí válku, protože trvalý válečný stav pomáhá udržet moc strany."
},
{
"kind": "choice",
"prompt": "Kdo je Julie a jaký vztah s ní Winston naváže?",
"options": [
"Winstonova žena",
"Mladá členka strany, s níž Winston prožije tajnou milostnou aféru",
"O'Brienova asistentka",
"Proletka a obchodnice"
],
"correct": 1,
"hint": "Julie pracuje v oddělení beletrie a naoko je vzornou členkou strany. S Winstonem vytvoří tajný milostný vztah."
},
{
"kind": "choice",
"prompt": "Proč je vztah Winstona a Julie zakázaný?",
"options": [
"Strana zakazuje lásku a sexualitu jako zločin; vztah je navíc tajný",
"Julie je vdaná za člena vnitřní strany",
"Winston je jejím nadřízeným",
"Julie patří k proletům"
],
"correct": 0,
"hint": "Strana potlačuje citové a sexuální vazby, protože oddanost má patřit jen straně. Tajný vztah je proto dvojnásob nebezpečný."
},
{
"kind": "choice",
"prompt": "Kdo je O'Brien a jakou roli hraje v příběhu?",
"options": [
"Skutečný vůdce Bratrstva",
"Vysoce postavený člen vnitřní strany, který Winstona nakonec mučí a „vyléčí“",
"Winstonův přítel z dětství",
"Proletářský básník"
],
"correct": 1,
"hint": "Winston věří, že O'Brien je odbojář. Ve skutečnosti je to věrný člen vnitřní strany, který ho dostane do místnosti 101."
},
{
"kind": "choice",
"prompt": "Co je Bratrstvo a existuje vůbec?",
"options": [
"Skutečná armáda Oceánie",
"Náboženská organizace",
"Údajná tajná organizace odporu; není jisté, zda existuje",
"Odbory prolety"
],
"correct": 2,
"hint": "Bratrstvo zmiňuje Goldstein i O'Brien, ale kniha nikdy nepotvrdí, že doopravdy existuje – může jít o past strany."
},
{
"kind": "choice",
"prompt": "Kdo je Emmanuel Goldstein?",
"options": [
"Ministr pravdy",
"Oficiální nepřítel státu a údajný vůdce Bratrstva",
"Winstonův kolega",
"Velký bratrův dvojník"
],
"correct": 1,
"hint": "Goldstein je terčem dvouminutovky nenávisti. Má ztělesňovat nepřítele, proti kterému strana sjednocuje občany."
},
{
"kind": "choice",
"prompt": "Co je dvojité myšlení (doublethink)?",
"options": [
"Schopnost myslet ve dvou jazycích",
"Zdvojení osobnosti",
"Schopnost věřit dvěma protikladným tvrzením zároveň",
"Tajné zapisování myšlenek"
],
"correct": 2,
"hint": "Dvojité myšlení je jádro stranické ideologie – člověk přijme i zjevné rozpory, jako „válka je mír“."
},
{
"kind": "choice",
"prompt": "Proč strana neustále přepisuje minulost?",
"options": [
"Aby opravila chyby v archivech",
"Aby měla vždy pravdu a nepřítel jí nemohl dokázat opak",
"Aby ušetřila papír",
"Aby potěšila prolety"
],
"correct": 1,
"hint": "Kdo ovládá minulost, ovládá i budoucnost. Přepisováním historie strana udržuje absolutní kontrolu nad pravdou."
},
{
"kind": "choice",
"prompt": "Co je zločin myšlení (thoughtcrime)?",
"options": [
"Pouhá myšlenka nebo názor, který se nelíbí straně",
"Krádež státního majetku",
"Nelegální obchod",
"Opomenutí dvouminutovky"
],
"correct": 0,
"hint": "Strana trestá už samotné myšlenky. Winston se proviní tím, že si do deníku napíše „Dolů s Velkým bratrem“."
},
{
"kind": "choice",
"prompt": "Kdo je pan Charrington a jaké je jeho skutečné postavení?",
"options": [
"Winstonův přítel a majitel hospody",
"Proletářský básník",
"Zdánlivě starý majitel obchodu, ve skutečnosti agent Policie myšlení",
"Člen Bratrstva"
],
"correct": 2,
"hint": "Charrington Winstonovi pronajme pokoj bez teleobrazovky, ale právě on nakonec oba milence udá."
},
{
"kind": "choice",
"prompt": "Co je místnost 101?",
"options": [
"Winstonova cela na policii",
"Skladiště knih",
"Místnost, ve které každého čeká jeho největší strach",
"Zasedací místnost vnitřní strany"
],
"correct": 2,
"hint": "Místnost 101 je vrcholný mučicí nástroj. Winstonovi v ní hrozí krysy – jeho největší fobie."
},
{
"kind": "choice",
"prompt": "Jak skončí vztah Winstona a Julie?",
"options": [
"Utečou spolu do Eurasie",
"Vezmou se a žijí svobodně",
"Winston se stane členem Bratrstva",
"Zradí se a nakonec se přestanou milovat"
],
"correct": 3,
"hint": "Oba jsou po mučení zlomení a vzájemně se zradí. Na konci už k sobě nic necítí."
},
{
"kind": "choice",
"prompt": "Co znamená závěrečná věta „Miloval Velkého bratra“?",
"options": [
"Winstonův vzdor se změnil v pokornou lásku k režimu",
"Winston se stal novým vůdcem",
"Byl to jen Winstonův sen",
"Julie mu to řekla"
],
"correct": 0,
"hint": "Winston byl vnitřně zlomen. Přestal být sám sebou a nakonec režim opravdu přijal za svůj."
},
{
"kind": "choice",
"prompt": "Které tvrzení NENÍ jedním z hlavních témat románu?",
"options": [
"Moc a absolutní kontrola",
"Pravda a přepisování historie",
"Svoboda a identita",
"Idylický venkovský život"
],
"correct": 3,
"hint": "1984 je temná antiutopie. Hlavními tématy jsou moc, kontrola, manipulace s pravdou a ztráta svobody, ne venkovská idyla."
},
{
"kind": "choice",
"prompt": "V jakém roce byl román 1984 poprvé vydán?",
"options": [
"1936",
"1949",
"1954",
"1984"
],
"correct": 1,
"hint": "Román vyšel v roce 1949; letopočet 1984 označuje blízkou budoucnost, do níž Orwell příběh zasadil."
},
{
"kind": "choice",
"prompt": "Které další Orwellovo dílo se dá s 1984 srovnat?",
"options": [
"Farma zvířat",
"Velký Gatsby",
"Zločin a trest",
"Babička"
],
"correct": 0,
"hint": "Obě Orwellova díla varují před totalitou. Farma zvířat ji líčí alegoricky pomocí zvířat, 1984 očima jedince."
},
{
"kind": "choice",
"prompt": "Co je „dvouminutovka nenávisti“?",
"options": [
"Krátká každodenní akce, při níž občané společně nenávidí Goldsteina a nepřítele",
"Pravidelná modlitba ke Velkému bratrovi",
"Sportovní přestávka ve škole",
"Porada vedení ministerstva"
],
"correct": 0,
"hint": "Slouží k uvolnění a přesměrování agrese občanů na nepřítele státu, čímž strana posiluje svou moc."
},
{
"kind": "choice",
"prompt": "Proč je podle knihy válka pro systém nezbytná?",
"options": [
"Přináší mír a blahobyt",
"Udržuje strach, spotřebovává výrobu a zachovává chudobu i hierarchii",
"Rozvíjí vědu a techniku",
"Sjednocuje vládu s prolety"
],
"correct": 1,
"hint": "Trvalá válka pohlcuje nadbytečnou produkci a udržuje lidi ve strachu i chudobě, takže se nemohou vzbouřit."
},
{
"kind": "choice",
"prompt": "Jaký je rozdíl mezi vnějšími členy strany a prolety?",
"options": [
"Proleti mají více práv než členové strany",
"Vnější členové jsou dozíráni, kdežto proleti žijí v chudobě a strana o ně nestojí",
"Není mezi nimi žádný rozdíl",
"Proleti tvoří vedení strany"
],
"correct": 1,
"hint": "Proleti tvoří asi 85 % obyvatel. Žijí v bídě a jsou považováni za neškodné, dokud si neuvědomí svou sílu."
},
{
"kind": "choice",
"prompt": "Jak strana kontroluje sexualitu a osobní vztahy?",
"options": [
"Podporuje volnou lásku a nezávislost",
"Potlačuje lásku a sexualitu, děti vede k oddanosti straně",
"Zakazuje veškerá manželství",
"Povoluje vztahy jen s prolety"
],
"correct": 1,
"hint": "Strana chce, aby veškerá oddanost a energie patřily jí. Proto podporuje zdrženlivost a vede děti v Antisexuální lize mládeže."
},
{
"kind": "choice",
"prompt": "Co symbolizuje papírové těžítko, které si Winston koupí?",
"options": [
"Jeho nenávist ke Goldsteinovi",
"Kousek krásy a minulosti, kterou už nelze vrátit",
"Moc Velkého bratra",
"Lásku k Julii"
],
"correct": 1,
"hint": "Těžítko je drobný, krásný předmět z minulosti. Pro Winstona ztělesňuje svět, který strana zničila."
},
{
"kind": "choice",
"prompt": "Jaké poselství chtěl Orwell románem nejspíš předat?",
"options": [
"Varování před totalitou a ztrátou svobody i pravdy",
"Oslavu technického pokroku",
"Návod, jak založit stát",
"Kritiku sportu a zábavy"
],
"correct": 0,
"hint": "1984 je varování: když lidé přestanou bránit pravdu a svobodu, mohou o ně nenávratně přijít."
}
]
},
{
"number": 2,
"title": "Děj",
"heading": "Seřaďte děj",
"subtitleKey": "lit_plot_sub",
"items": [
{
"kind": "assembly",
"prompt": "Přetáhněte části příběhu do správného pořadí:",
"words": [
"Winston upravuje historii na Ministerstvu pravdy",
"Seznámí se s Julií a začnou se tajně scházet",
"Pronajme si pokoj nad obchodem pana Charringtona",
"Přečte Goldsteinovu knihu u O'Briena",
"Jsou zatčeni Policií myšlení",
"Winston se zlomí v místnosti 101"
],
"meaning": "Správné pořadí: přepisování historie → seznámení s Julií → pronájem pokoje → četba Goldsteinovy knihy → zatčení → místnost 101."
}
]
}
]
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,125 @@
{
"id": "unit-01",
"subject": "hardware",
"number": 1,
"titleKey": "hw_unit1",
"subtitleKey": "hw_unit1_sub",
"slides": [
{
"kicker": "1 / 3 • Úvod",
"title": "Architektura počítače",
"tip": "Architektura popisuje, z jakých částí se počítač skládá a jak spolu spolupracují.",
"lines": [
"Architektura počítače je základní model, podle kterého jsou počítače navrhovány.",
"Nejznámější model je Von Neumannovo schéma.",
"Používá se dodnes u většiny běžných počítačů."
]
},
{
"kicker": "2 / 3 • Osobnost",
"title": "John von Neumann",
"tip": "Tip: zapamatuj si jméno – podle něj se jmenuje celé schéma.",
"lines": [
"John von Neumann byl americký matematik.",
"Pracoval na poli digitálních počítačů a operační teorie kvantové mechaniky.",
"Je autorem Von Neumannovy algebry.",
"Byl tvůrcem teorie her.",
"Podílel se na vývoji atomové a vodíkové bomby."
]
},
{
"kicker": "3 / 3 • Schéma",
"title": "Von Neumannovo schéma",
"tip": "ALU = aritmeticko-logická jednotka",
"lines": [
"Von Neumannovo schéma má pět hlavních částí:",
"ALU – aritmeticko-logická jednotka (výpočty a logika)",
"Řadič – řídí činnost ostatních částí",
"Operační paměť – uchovává program i data",
"Vstupní jednotka – přivádí data do počítače",
"Výstupní jednotka – předává výsledky ven"
]
}
],
"tasks": [
{
"number": 1,
"title": "Quiz",
"subtitleKey": "hw_quiz1_head",
"items": [
{
"kind": "choice",
"prompt": "Kdo je autorem Von Neumannova schématu?",
"options": [
"Alan Turing",
"John von Neumann",
"Bill Gates",
"Steve Jobs"
],
"correct": 1,
"hint": "John von Neumann – americký matematik"
},
{
"kind": "choice",
"prompt": "Čím byl John von Neumann?",
"options": [
"Americký matematik",
"Německý fyzik",
"Anglický chemik",
"Francouzský lékař"
],
"correct": 0,
"hint": "Americký matematik s objevy v digitálních počítačích"
},
{
"kind": "choice",
"prompt": "Co znamená zkratka ALU?",
"options": [
"Automatická logická síť",
"Aritmeticko-logická jednotka",
"Analogová linková sestava",
"Adresová listová sekce"
],
"correct": 1,
"hint": "ALU = aritmeticko-logická jednotka"
},
{
"kind": "choice",
"prompt": "Která část Von Neumannova schématu řídí ostatní části?",
"options": [
"Operační paměť",
"Vstupní jednotka",
"Řadič",
"Výstupní jednotka"
],
"correct": 2,
"hint": "Řadič řídí činnost ostatních částí"
},
{
"kind": "choice",
"prompt": "Kde se podle Von Neumannova schématu uchovává program i data?",
"options": [
"Jen na pevném disku",
"V operační paměti",
"Jen ve vstupní jednotce",
"Jen v ALU"
],
"correct": 1,
"hint": "Operační paměť uchovává program i data"
},
{
"kind": "choice",
"prompt": "Čím se John von Neumann také zabýval?",
"options": [
"Jen malířstvím",
"Teorií her",
"Jen botanikou",
"Jen hudbou"
],
"correct": 1,
"hint": "Byl tvůrcem teorie her"
}
]
}
]
}

View file

@ -0,0 +1,177 @@
{
"id": "unit-02",
"subject": "hardware",
"number": 2,
"titleKey": "hw_unit2",
"subtitleKey": "hw_unit2_sub",
"slides": [
{
"kicker": "1 / 6 • Přehled",
"title": "Historie počítačů",
"tip": "Generace se dělí podle klíčové technologie své doby.",
"lines": [
"1. Předchůdci – do 30. let 19. století",
"2. Nultá generace – 1938–1944",
"3. První generace – 1944–1955",
"4. Druhá generace – 1955–1964",
"5. Třetí generace – 1964–1971",
"Další snímky shrnují znaky jednotlivých generací."
]
},
{
"kicker": "2 / 6 • 0. generace",
"title": "Počítače 0. generace (1938–1944)",
"tip": "Tip: počítačů bylo málo a často sloužily armádě.",
"lines": [
"Existovalo jen několik počítačů na světě.",
"Většinou měly vojenský účel."
]
},
{
"kicker": "3 / 6 • 1. generace",
"title": "Počítače 1. generace (1944–1955)",
"tip": "Klíčový objev: elektronka",
"lines": [
"Objev elektronky umožnil tuto generaci.",
"Ještě neexistoval software v dnešním smyslu.",
"Data a programy se zadávaly pomocí děrných štítků."
]
},
{
"kicker": "4 / 6 • 2. generace",
"title": "Počítače 2. generace (1955–1964)",
"tip": "Klíčový objev: tranzistor",
"lines": [
"Objev tranzistoru nahradil elektronky.",
"Používala se hlavní externí paměť.",
"Počítače měly menší rozměry než v 1. generaci."
]
},
{
"kicker": "5 / 6 • 3. generace",
"title": "Počítače 3. generace (1964–1971)",
"tip": "Klíčový objev: integrovaný obvod",
"lines": [
"Objev integrovaného obvodu.",
"Stále se používala hlavní externí paměť.",
"Rozměry počítačů se dál zmenšovaly."
]
},
{
"kicker": "6 / 6 • 4. generace",
"title": "Počítače 4. generace",
"tip": "Klíčové: programovatelné mikroprocesory",
"lines": [
"Nastupují programovatelné mikroprocesory.",
"1972 – první počítačová hra",
"1973 – první disketa",
"1979 – první CD"
]
}
],
"tasks": [
{
"number": 1,
"title": "Quiz",
"subtitleKey": "hw_quiz2_head",
"items": [
{
"kind": "choice",
"prompt": "Kdy probíhala nultá generace počítačů?",
"options": [
"1938–1944",
"1944–1955",
"1955–1964",
"1964–1971"
],
"correct": 0,
"hint": "Nultá generace: 1938–1944"
},
{
"kind": "choice",
"prompt": "Co bylo typické pro počítače 0. generace?",
"options": [
"Byly v každé domácnosti",
"Jen několik kusů, často vojenský účel",
"Měly mikroprocesory",
"Používaly CD"
],
"correct": 1,
"hint": "Jen několik počítačů, většinou vojenský účel"
},
{
"kind": "choice",
"prompt": "Která technologie patří k 1. generaci?",
"options": [
"Tranzistor",
"Elektronka",
"Integrovaný obvod",
"Mikroprocesor"
],
"correct": 1,
"hint": "1. generace – objev elektronky"
},
{
"kind": "choice",
"prompt": "Čím se zadávaly data v 1. generaci?",
"options": [
"USB fleškou",
"Děrnými štítky",
"CD",
"Disketou"
],
"correct": 1,
"hint": "1. generace – děrné štítky"
},
{
"kind": "choice",
"prompt": "Která technologie patří k 2. generaci?",
"options": [
"Elektronka",
"Tranzistor",
"Mikroprocesor",
"CD"
],
"correct": 1,
"hint": "2. generace – objev tranzistoru"
},
{
"kind": "choice",
"prompt": "Která technologie patří k 3. generaci?",
"options": [
"Elektronka",
"Tranzistor",
"Integrovaný obvod",
"Disketa"
],
"correct": 2,
"hint": "3. generace – integrovaný obvod"
},
{
"kind": "choice",
"prompt": "Co je typické pro 4. generaci?",
"options": [
"Jen děrné štítky",
"Programovatelné mikroprocesory",
"Jen vojenské použití",
"Žádný software"
],
"correct": 1,
"hint": "4. generace – programovatelné mikroprocesory"
},
{
"kind": "choice",
"prompt": "Kdy vznikla první počítačová hra (podle lekce)?",
"options": [
"1964",
"1972",
"1973",
"1979"
],
"correct": 1,
"hint": "1972 – první počítačová hra"
}
]
}
]
}

View file

@ -0,0 +1,86 @@
{
"id": "unit-03",
"subject": "hardware",
"number": 3,
"titleKey": "hw_unit3",
"subtitleKey": "hw_unit3_sub",
"slides": [
{
"kicker": "1 / 2 • Bit",
"title": "Bit – nejmenší jednotka informace",
"tip": "Bit = binary digit (dvojková číslice)",
"lines": [
"Bit je dvojková číslice – nabývá hodnoty 0 nebo 1.",
"Je to nejmenší zobrazitelná jednotka informace.",
"Z bitů se skládají větší jednotky (např. byte)."
]
},
{
"kicker": "2 / 2 • Byte",
"title": "Byte – adresovatelná jednotka paměti",
"tip": "Tip: 1 B = 8 b",
"lines": [
"Byte je nejmenší adresovatelná jednotka paměti.",
"Počítač tedy paměť adresuje po bytech, ne po jednotlivých bitech.",
"Jeden byte obvykle obsahuje 8 bitů."
]
}
],
"tasks": [
{
"number": 1,
"title": "Quiz",
"subtitleKey": "hw_quiz3_head",
"items": [
{
"kind": "choice",
"prompt": "Co je bit?",
"options": [
"Největší jednotka paměti",
"Dvojková číslice – nejmenší jednotka informace",
"Jen klávesa na klávesnici",
"Typ procesoru"
],
"correct": 1,
"hint": "Bit = dvojková číslice, nejmenší jednotka informace"
},
{
"kind": "choice",
"prompt": "Jaké hodnoty může nabývat bit?",
"options": [
"Jen 0",
"Jen 1",
"0 nebo 1",
"0 až 255"
],
"correct": 2,
"hint": "Bit nabývá hodnoty 0 nebo 1"
},
{
"kind": "choice",
"prompt": "Co je byte?",
"options": [
"Nejmenší adresovatelná jednotka paměti",
"Nejmenší zobrazitelná jednotka informace",
"Jen síťový kabel",
"Jen grafická karta"
],
"correct": 0,
"hint": "Byte = nejmenší adresovatelná jednotka paměti"
},
{
"kind": "choice",
"prompt": "Kolik bitů má obvykle jeden byte?",
"options": [
"2",
"4",
"8",
"16"
],
"correct": 2,
"hint": "1 B = 8 b"
}
]
}
]
}

View file

@ -0,0 +1,178 @@
{
"id": "unit-01",
"subject": "networks",
"number": 1,
"titleKey": "net_unit1",
"subtitleKey": "net_unit1_sub",
"slides": [
{
"kicker": "1 / 4 • VLSM",
"title": "Podsíťování sítí",
"tip": "Tip: prefix /n = počet jedniček v masce. /24 = 255.255.255.0",
"lines": [
"Cíl: rozdělit jednu IP síť na menší podsítě podle počtu uzlů.",
"Pamatuj si tři role adres v každém bloku:",
"síťová = první adresa bloku",
"broadcast = poslední adresa bloku",
"uzly = vše mezi nimi (síť + 1 až broadcast − 1)"
]
},
{
"kicker": "2 / 4 • Velikosti",
"title": "Jak velký blok potřebuji?",
"tip": "Počet bitů pro uzly h = log₂(velikost), prefix = 32 − h",
"lines": [
"Pro X uzlů potřebuješ blok, do kterého se vejde X + 2 adresy (síť a broadcast).",
"Velikost bloku je vždy mocnina dvojky – vezmi nejmenší blok, který je větší nebo roven X + 2.",
"60 uzlů → 62 → blok 64 → /26",
"30 uzlů → 32 → blok 32 → /27",
"14 uzlů → 16 → blok 16 → /28",
"7 uzlů → 9 → blok 16 → /28"
]
},
{
"kicker": "3 / 4 • Postup",
"title": "Jak na to krok za krokem",
"tip": null,
"lines": [
"1. Seřaď podsítě od největší po nejmenší.",
"2. Pro každou najdi potřebný prefix.",
"3. Začni na síťové adrese ze zadání.",
"4. Rozsah podsítě = od síťové adresy po broadcast.",
"5. Rozsah uzlů = síťová + 1 až broadcast − 1.",
"6. Posuň se hned za broadcast a pokračuj dál."
]
},
{
"kicker": "4 / 4 • Příklad",
"title": "Ukázka: 10.0.0.0/24",
"tip": "A: 60 uzlů, B: 30, C: 14, D: 7",
"lines": [
"A → 10.0.0.0/26 uzly 10.0.0.1–10.0.0.62 broadcast 10.0.0.63",
"B → 10.0.0.64/27 uzly 10.0.0.65–10.0.0.94 broadcast 10.0.0.95",
"C → 10.0.0.96/28 uzly 10.0.0.97–10.0.0.110 broadcast 10.0.0.111",
"D → 10.0.0.112/28 uzly 10.0.0.113–10.0.0.126 broadcast 10.0.0.127"
]
}
],
"tasks": [
{
"number": 1,
"title": "VLSM",
"subtitleKey": "net_ex1_title",
"items": [
{
"kind": "subnet",
"scenario": "1.) Síť má adresu 10.0.0.0/24. Rozdělte ji do podsítí s následujícími požadavky:\n\nPodsíť A: 60 uzlů\nPodsíť B: 30 uzlů\nPodsíť C: 14 uzlů\nPodsíť D: 7 uzlů",
"solution": "A: 10.0.0.0/26 síť 10.0.0.0 uzly 10.0.0.1–10.0.0.62 broadcast 10.0.0.63\nB: 10.0.0.64/27 síť 10.0.0.64 uzly 10.0.0.65–10.0.0.94 broadcast 10.0.0.95\nC: 10.0.0.96/28 síť 10.0.0.96 uzly 10.0.0.97–10.0.0.110 broadcast 10.0.0.111\nD: 10.0.0.112/28 síť 10.0.0.112 uzly 10.0.0.113–10.0.0.126 broadcast 10.0.0.127",
"blocks": [
{
"prefix": 26,
"network": "10.0.0.0",
"broadcast": "10.0.0.63",
"firstHost": "10.0.0.1",
"lastHost": "10.0.0.62"
},
{
"prefix": 27,
"network": "10.0.0.64",
"broadcast": "10.0.0.95",
"firstHost": "10.0.0.65",
"lastHost": "10.0.0.94"
},
{
"prefix": 28,
"network": "10.0.0.96",
"broadcast": "10.0.0.111",
"firstHost": "10.0.0.97",
"lastHost": "10.0.0.110"
},
{
"prefix": 28,
"network": "10.0.0.112",
"broadcast": "10.0.0.127",
"firstHost": "10.0.0.113",
"lastHost": "10.0.0.126"
}
]
},
{
"kind": "subnet",
"scenario": "2.) Síť má adresu 172.16.0.0/16. Rozdělte ji do podsítí s následujícími požadavky:\n\nPodsíť A: 500 uzlů\nPodsíť B: 200 uzlů\nPodsíť C: 100 uzlů\nPodsíť D: 50 uzlů",
"solution": "A: 172.16.0.0/23 síť 172.16.0.0 uzly 172.16.0.1–172.16.1.254 broadcast 172.16.1.255\nB: 172.16.2.0/24 síť 172.16.2.0 uzly 172.16.2.1–172.16.2.254 broadcast 172.16.2.255\nC: 172.16.3.0/25 síť 172.16.3.0 uzly 172.16.3.1–172.16.3.126 broadcast 172.16.3.127\nD: 172.16.3.128/26 síť 172.16.3.128 uzly 172.16.3.129–172.16.3.190 broadcast 172.16.3.191",
"blocks": [
{
"prefix": 23,
"network": "172.16.0.0",
"broadcast": "172.16.1.255",
"firstHost": "172.16.0.1",
"lastHost": "172.16.1.254"
},
{
"prefix": 24,
"network": "172.16.2.0",
"broadcast": "172.16.2.255",
"firstHost": "172.16.2.1",
"lastHost": "172.16.2.254"
},
{
"prefix": 25,
"network": "172.16.3.0",
"broadcast": "172.16.3.127",
"firstHost": "172.16.3.1",
"lastHost": "172.16.3.126"
},
{
"prefix": 26,
"network": "172.16.3.128",
"broadcast": "172.16.3.191",
"firstHost": "172.16.3.129",
"lastHost": "172.16.3.190"
}
]
},
{
"kind": "subnet",
"scenario": "3.) Síť má adresu 192.168.10.0/24. Rozdělte ji do podsítí s následujícími požadavky:\n\nPodsíť A: 120 uzlů\nPodsíť B: 70 uzlů\nPodsíť C: 35 uzlů\nPodsíť D: 15 uzlů",
"solution": "Do sítě /24 se tyto požadavky nevejdou: A i B potřebují /25 (2 × 128 = 256 adres), na C a D už nezbude žádný blok. Při zachování velikostí by bylo nutné větší síť (např. /23).",
"blocks": null
},
{
"kind": "subnet",
"scenario": "4.) Síť má adresu 192.168.2.0/25. Rozdělte ji do podsítí s následujícími požadavky:\n\nPodsíť A: 50 uzlů\nPodsíť B: 25 uzlů\nPodsíť C: 12 uzlů\nPodsíť D: 5 uzlů",
"solution": "A: 192.168.2.0/26 síť 192.168.2.0 uzly 192.168.2.1–192.168.2.62 broadcast 192.168.2.63\nB: 192.168.2.64/27 síť 192.168.2.64 uzly 192.168.2.65–192.168.2.94 broadcast 192.168.2.95\nC: 192.168.2.96/28 síť 192.168.2.96 uzly 192.168.2.97–192.168.2.110 broadcast 192.168.2.111\nD: 192.168.2.112/28 síť 192.168.2.112 uzly 192.168.2.113–192.168.2.126 broadcast 192.168.2.127",
"blocks": [
{
"prefix": 26,
"network": "192.168.2.0",
"broadcast": "192.168.2.63",
"firstHost": "192.168.2.1",
"lastHost": "192.168.2.62"
},
{
"prefix": 27,
"network": "192.168.2.64",
"broadcast": "192.168.2.95",
"firstHost": "192.168.2.65",
"lastHost": "192.168.2.94"
},
{
"prefix": 28,
"network": "192.168.2.96",
"broadcast": "192.168.2.111",
"firstHost": "192.168.2.97",
"lastHost": "192.168.2.110"
},
{
"prefix": 28,
"network": "192.168.2.112",
"broadcast": "192.168.2.127",
"firstHost": "192.168.2.113",
"lastHost": "192.168.2.126"
}
]
}
]
}
]
}

View file

@ -0,0 +1,173 @@
{
"id": "unit-02",
"subject": "networks",
"number": 2,
"titleKey": "net_unit2",
"subtitleKey": "net_unit2_sub",
"slides": [
{
"kicker": "1 / 5 • Jednotky",
"title": "Bit, byte a počítačová síť",
"tip": "Tip: 1 B = 8 b → 2⁸ = 256 možných hodnot",
"lines": [
"Bit (1 b): základní jednotka informace – 0 nebo 1.",
"0 = nevede proud, 1 = vede proud.",
"Byte (1 B): 1 B = 8 b, nabývá 256 hodnot.",
"Počítačová síť: propojení uzlů (PC a dalších prvků).",
"Vyžaduje hardwarové i softwarové prostředky."
]
},
{
"kicker": "2 / 5 • Propojení",
"title": "Trvalé, dočasné a důvody zapojení",
"tip": null,
"lines": [
"Trvalé propojení: kabely – síť zůstává připojená i po přenosu.",
"Dočasné propojení: modem, Wi‑Fi – po přenosu se odpojuje.",
"Důvody zapojení do sítě:",
"sdílení dat (nutná podpora aplikací)",
"sdílení HW (tiskárny, disky) a SW (databáze)",
"vyšší spolehlivost, bezpečnost (přístupová práva)",
"komunikace (e‑mail, hlas)"
]
},
{
"kicker": "3 / 5 • Pojmy",
"title": "Terminologie sítí",
"tip": "Architektura = topologie + standard",
"lines": [
"Uzel: jakékoli zařízení (stanice, server, aktivní prvek).",
"Topologie: způsob propojení (hvězda, kruh, sběrnice).",
"Standard: normy pro zpracování dat.",
"Architektura: topologie + standard."
]
},
{
"kicker": "4 / 5 • Rozlehlost",
"title": "WAN, MAN a LAN",
"tip": null,
"lines": [
"WAN: rozsáhlé sítě (státy, kontinenty), např. internet.",
"MAN: městské sítě – propojují lokální sítě.",
"LAN: lokální sítě (budova), kabeláž v řádu km,",
"jednotná architektura."
]
},
{
"kicker": "5 / 5 • Uzly",
"title": "Server, stanice a peer‑to‑peer",
"tip": null,
"lines": [
"Server: nadřazený, řídící, poskytuje služby, vyšší výkon.",
"Pracovní stanice: pro uživatele, jednodušší konfigurace.",
"Peer‑to‑peer: síť bez serverů – jen stanice,",
"které sdílejí data a tiskárny."
]
}
],
"tasks": [
{
"number": 1,
"title": "Quiz",
"subtitleKey": "net_quiz2_head",
"items": [
{
"kind": "choice",
"prompt": "Kolik bitů má jeden byte?",
"options": [
"4",
"8",
"16",
"32"
],
"correct": 1,
"hint": "1 B = 8 b"
},
{
"kind": "choice",
"prompt": "Kolik různých hodnot nabývá jeden byte?",
"options": [
"128",
"255",
"256",
"512"
],
"correct": 2,
"hint": "2⁸ = 256 hodnot (0–255)"
},
{
"kind": "choice",
"prompt": "Co je trvalé propojení sítě?",
"options": [
"Modem",
"Wi‑Fi",
"Kabely",
"Bluetooth"
],
"correct": 2,
"hint": "Kabely – síť zůstává připojená i po přenosu"
},
{
"kind": "choice",
"prompt": "Co označuje zkratka WAN?",
"options": [
"Lokální síť v budově",
"Městskou síť",
"Rozsáhlou síť (státy, kontinenty)",
"Bezdrátovou síť"
],
"correct": 2,
"hint": "WAN = Wide Area Network, např. internet"
},
{
"kind": "choice",
"prompt": "Co je topologie sítě?",
"options": [
"Norma pro zpracování dat",
"Způsob propojení uzlů",
"Název serveru",
"Rychlost přenosu"
],
"correct": 1,
"hint": "Např. hvězda, kruh, sběrnice"
},
{
"kind": "choice",
"prompt": "Architektura sítě je:",
"options": [
"Jen topologie",
"Jen standard",
"Topologie + standard",
"Pouze typ kabelu"
],
"correct": 2,
"hint": "Architektura = topologie + standard"
},
{
"kind": "choice",
"prompt": "Peer‑to‑peer síť znamená:",
"options": [
"Síť s jedním výkonným serverem",
"Síť bez serverů – stanice sdílejí data",
"Jen internetovou síť",
"Síť pouze s tiskárnami"
],
"correct": 1,
"hint": "Bez centrálního serveru – stanice si sdílejí data a tiskárny"
},
{
"kind": "choice",
"prompt": "Server v síti je:",
"options": [
"Jednoduchá stanice pro uživatele",
"Nadřazený uzel poskytující služby",
"Jen aktivní prvek kabeláže",
"Dočasné Wi‑Fi připojení"
],
"correct": 1,
"hint": "Server řídí a poskytuje služby, má vyšší výkon"
}
]
}
]
}

View file

@ -0,0 +1,161 @@
{
"id": "unit-03",
"subject": "networks",
"number": 3,
"titleKey": "net_unit3",
"subtitleKey": "net_unit3_sub",
"slides": [
{
"kicker": "1 / 4 • Servery",
"title": "Role serverů a typy podle služby",
"tip": null,
"lines": [
"Servery ověřují uživatele a povolují přístup.",
"Souborový server (fileserver): sdílení souborů.",
"Tiskový server: sdílení tiskáren.",
"Poštovní server: e‑mailové služby.",
"Databázový server: správa a přístup k databázím."
]
},
{
"kicker": "2 / 4 • Rozložení",
"title": "Centralizovaný a distribuovaný server",
"tip": null,
"lines": [
"Centralizovaný: jeden server v síti.",
"Výpadek = nedostupnost služby.",
"Distribuovaný: více serverů, každý obsluhuje část.",
"Vyšší spolehlivost a rychlost."
]
},
{
"kicker": "3 / 4 • Využití",
"title": "Vyhrazený, nevyhrazený a mainframe",
"tip": null,
"lines": [
"Nevyhrazený: slouží i jako stanice (levnější).",
"Vyhrazený: jen síťové služby – vysoký výkon a bezpečnost,",
"typicky v racku.",
"Mainframe: obří sálové počítače (IBM, Siemens),",
"stovky procesorů, OS Unix (AIX, Solaris).",
"Použití: banky a státní správa."
]
},
{
"kicker": "4 / 4 • Cloud",
"title": "Cloudové služby",
"tip": "Tip: SaaS = software jako služba",
"lines": [
"Cloud: globální síť vzdálených serverů propojených internetem.",
"Fungují jako jeden systém: ukládání dat, SaaS,",
"streamování, webová pošta.",
"Přístup online z jakéhokoli zařízení."
]
}
],
"tasks": [
{
"number": 1,
"title": "Quiz",
"subtitleKey": "net_quiz3_head",
"items": [
{
"kind": "choice",
"prompt": "Co typicky dělají servery v síti?",
"options": [
"Jen tisknou dokumenty",
"Ověřují uživatele a povolují přístup",
"Nahrazují kabely",
"Slouží jen jako Wi‑Fi"
],
"correct": 1,
"hint": "Ověřují uživatele a povolují přístup ke službám"
},
{
"kind": "choice",
"prompt": "Fileserver je:",
"options": [
"Tiskový server",
"Poštovní server",
"Souborový server",
"Databázový server"
],
"correct": 2,
"hint": "Fileserver = souborový server"
},
{
"kind": "choice",
"prompt": "Centralizovaný server znamená:",
"options": [
"Více serverů v síti",
"Jeden server – výpadek = nedostupnost služby",
"Server jen pro tisk",
"Cloud bez internetu"
],
"correct": 1,
"hint": "Jeden server – při výpadku je služba nedostupná"
},
{
"kind": "choice",
"prompt": "Distribuovaný model serverů nabízí hlavně:",
"options": [
"Nižší spolehlivost",
"Vyšší spolehlivost a rychlost",
"Jen levnější kabely",
"Odstranění uživatelů"
],
"correct": 1,
"hint": "Více serverů, každý obsluhuje část – vyšší spolehlivost"
},
{
"kind": "choice",
"prompt": "Nevyhrazený server:",
"options": [
"Slouží jen síťovým službám",
"Slouží i jako pracovní stanice",
"Je vždy mainframe",
"Nemůže ověřovat uživatele"
],
"correct": 1,
"hint": "Slouží zároveň i jako pracovní stanice"
},
{
"kind": "choice",
"prompt": "Vyhrazený server je určen:",
"options": [
"Jen jako stanice uživatele",
"Jen pro síťové služby",
"Jen pro Bluetooth",
"Jen pro dočasné Wi‑Fi"
],
"correct": 1,
"hint": "Jen síťové služby – vysoký výkon a bezpečnost, často v racku"
},
{
"kind": "choice",
"prompt": "Mainframe se typicky používá:",
"options": [
"Jen v domácnostech",
"V bankách a státní správě",
"Jen pro streamování hudby",
"Jen jako tiskárna"
],
"correct": 1,
"hint": "Obří sálové počítače (IBM, Siemens) pro banky a státní správu"
},
{
"kind": "choice",
"prompt": "Cloud a SaaS znamenají především:",
"options": [
"Lokální síť bez internetu",
"Vzdálené servery a software jako služba online",
"Jen jeden kabel v budově",
"Peer‑to‑peer bez serverů"
],
"correct": 1,
"hint": "SaaS = software jako služba, přístup online z libovolného zařízení"
}
]
}
]
}

View file

@ -0,0 +1,156 @@
{
"id": "unit-04",
"subject": "networks",
"number": 4,
"titleKey": "net_unit4",
"subtitleKey": "net_unit4_sub",
"slides": [
{
"kicker": "1 / 4 • Přenos",
"title": "Paralelní a sériový přenos",
"tip": null,
"lines": [
"Paralelní: více bitů současně po více vodičích.",
"Problém: přeslechy (rušení) při delších kabelech",
"a vysokých frekvencích.",
"Sériový: bit po bitu po jednom vodiči.",
"Umožňuje vyšší frekvence i délky – celkově rychlejší."
]
},
{
"kicker": "2 / 4 • Časování",
"title": "Asynchronní přenos",
"tip": "Tip: Start-bit = 0, Stop-bit = 1",
"lines": [
"Data se posílají v blocích (znaky 5–8 bitů).",
"Start-bit (0) synchronizuje začátek znaku.",
"Stop-bit (1) znak ukončuje."
]
},
{
"kicker": "3 / 4 • Časování",
"title": "Synchronní přenos",
"tip": null,
"lines": [
"Řízeno společným hodinovým signálem.",
"Přesné časování mezi vysílačem a přijímačem."
]
},
{
"kicker": "4 / 4 • Zabezpečení",
"title": "Parita, checksum a CRC",
"tip": "Tip: CRC je nejbezpečnější z těchto tří metod",
"lines": [
"Parita: nejslabší – přidá bit pro sudý/lichý počet jedniček.",
"Checksum: součet znaků jako dvojkových čísel.",
"CRC (cyklické kódy): nejbezpečnější,",
"počítá se z jednotlivých bitů v bloku."
]
}
],
"tasks": [
{
"number": 1,
"title": "Quiz",
"subtitleKey": "net_quiz4_head",
"items": [
{
"kind": "choice",
"prompt": "Paralelní přenos znamená:",
"options": [
"Bit po bitu po jednom vodiči",
"Více bitů současně po více vodičích",
"Jen Wi‑Fi bez kabelů",
"Jen cloudové ukládání"
],
"correct": 1,
"hint": "Více bitů najednou po více vodičích; problém jsou přeslechy"
},
{
"kind": "choice",
"prompt": "Hlavní problém paralelního přenosu při delších kabelech je:",
"options": [
"Absence serveru",
"Přeslechy (rušení)",
"Chybějící SaaS",
"Peer‑to‑peer režim"
],
"correct": 1,
"hint": "Přeslechy vznikají při delších kabelech a vysokých frekvencích"
},
{
"kind": "choice",
"prompt": "Sériový přenos je oproti paralelnímu celkově:",
"options": [
"Vždy pomalejší",
"Rychlejší díky vyšším frekvencím a délkám",
"Jen pro tiskárny",
"Bez jakéhokoli kabelu"
],
"correct": 1,
"hint": "Bit po bitu po jednom vodiči – vyšší frekvence i délky"
},
{
"kind": "choice",
"prompt": "V asynchronním přenosu start-bit má hodnotu:",
"options": [
"1",
"0",
"8",
"255"
],
"correct": 1,
"hint": "Start-bit (0) synchronizuje, Stop-bit (1) ukončuje znak"
},
{
"kind": "choice",
"prompt": "Synchronní přenos je řízen:",
"options": [
"Jen stop-bitem",
"Společným hodinovým signálem",
"Jen paritou",
"Jen checksumem"
],
"correct": 1,
"hint": "Řízeno společným hodinovým signálem a přesným časováním"
},
{
"kind": "choice",
"prompt": "Nejslabší metoda zabezpečení z uvedených je:",
"options": [
"CRC",
"Checksum",
"Parita",
"Mainframe"
],
"correct": 2,
"hint": "Nejslabší – přidá bit pro sudý/lichý počet jedniček"
},
{
"kind": "choice",
"prompt": "Checksum spočívá v:",
"options": [
"Přidání start-bitu",
"Součtu znaků jako dvojkových čísel",
"Jen Wi‑Fi šifrování",
"Výměně kabelů"
],
"correct": 1,
"hint": "Součet znaků jako dvojkových čísel"
},
{
"kind": "choice",
"prompt": "CRC (cyklické kódy) je z uvedených metod:",
"options": [
"Nejslabší",
"Stejně slabá jako parita",
"Nejbezpečnější",
"Jen pro paralelní přenos"
],
"correct": 2,
"hint": "Nejbezpečnější – počítá se z jednotlivých bitů v bloku"
}
]
}
]
}

View file

@ -0,0 +1,156 @@
{
"id": "unit-05",
"subject": "networks",
"number": 5,
"titleKey": "net_unit5",
"subtitleKey": "net_unit5_sub",
"slides": [
{
"kicker": "1 / 4 • Signál",
"title": "Analogový a digitální signál",
"tip": "Tip: f = 1/T",
"lines": [
"Analogový: spojitý, nabývá libovolných hodnot.",
"Digitální: diskrétní, hodnoty 0 a 1.",
"Parametry signálu:",
"perioda T, frekvence f = 1/T,",
"amplituda A, fázový posun Φ."
]
},
{
"kicker": "2 / 4 • Modulace",
"title": "Základní typy modulace",
"tip": "Modulace = přenos v přeloženém pásmu",
"lines": [
"AM – amplitudová modulace.",
"FM – frekvenční modulace.",
"PM – fázová modulace."
]
},
{
"kicker": "3 / 4 • Kombinované",
"title": "QPSK a 256-QAM",
"tip": null,
"lines": [
"QPSK: 2 bity v jednom prvku (symbolu).",
"256-QAM: 8 bitů v jednom prvku."
]
},
{
"kicker": "4 / 4 • Rychlosti",
"title": "Modulační, přenosová a šířka pásma",
"tip": null,
"lines": [
"Modulační rychlost: počet změn signálu za sekundu [Baud, Bd/s].",
"Přenosová rychlost: velikost přenesené informace [bit/s, bps].",
"Šířka pásma: rozsah frekvencí –",
"určuje maximální možnou rychlost."
]
}
],
"tasks": [
{
"number": 1,
"title": "Quiz",
"subtitleKey": "net_quiz5_head",
"items": [
{
"kind": "choice",
"prompt": "Digitální signál nabývá:",
"options": [
"Libovolných spojitých hodnot",
"Diskrétních hodnot 0 a 1",
"Jen frekvence FM",
"Jen šířky pásma"
],
"correct": 1,
"hint": "Analogový je spojitý, digitální má diskrétní hodnoty 0 a 1"
},
{
"kind": "choice",
"prompt": "Frekvence f je:",
"options": [
"Stejná jako amplituda",
"f = 1/T",
"Jen fázový posun",
"Počet bitů v QPSK"
],
"correct": 1,
"hint": "Frekvence f = 1/T, kde T je perioda"
},
{
"kind": "choice",
"prompt": "AM, FM a PM jsou:",
"options": [
"Typy kabelů",
"Základní typy modulace",
"Jen CRC metody",
"Typy serverů"
],
"correct": 1,
"hint": "AM mění amplitudu, FM frekvenci, PM fázi"
},
{
"kind": "choice",
"prompt": "QPSK přenáší v jednom prvku:",
"options": [
"1 bit",
"2 bity",
"8 bitů",
"256 bitů"
],
"correct": 1,
"hint": "QPSK nese 2 bity v prvku, 256-QAM nese 8 bitů"
},
{
"kind": "choice",
"prompt": "256-QAM přenáší v jednom prvku:",
"options": [
"2 bity",
"4 bity",
"8 bitů",
"16 bitů"
],
"correct": 2,
"hint": "QPSK nese 2 bity v prvku, 256-QAM nese 8 bitů"
},
{
"kind": "choice",
"prompt": "Modulační rychlost se udává v:",
"options": [
"Jen bit/s",
"Baud (Bd/s)",
"Jen metrech",
"Jen hertzech amplitudy"
],
"correct": 1,
"hint": "Modulační rychlost = počet změn signálu za sekundu [Bd/s]"
},
{
"kind": "choice",
"prompt": "Přenosová rychlost vyjadřuje:",
"options": [
"Počet změn signálu za sekundu",
"Velikost přenesené informace [bit/s]",
"Jen fázový posun",
"Jen periodu T"
],
"correct": 1,
"hint": "Přenosová rychlost = množství informace za sekundu [bit/s]"
},
{
"kind": "choice",
"prompt": "Šířka pásma určuje:",
"options": [
"Jen barvu kabelu",
"Maximální možnou přenosovou rychlost",
"Jen počet serverů",
"Jen start-bit"
],
"correct": 1,
"hint": "Šířka pásma určuje maximální možnou přenosovou rychlost"
}
]
}
]
}

View file

@ -0,0 +1,139 @@
{
"id": "unit-06",
"subject": "networks",
"number": 6,
"titleKey": "net_unit6",
"subtitleKey": "net_unit6_sub",
"slides": [
{
"kicker": "1 / 4 • Spoj",
"title": "Datový spoj",
"tip": null,
"lines": [
"Datový spoj umožňuje výměnu informací.",
"Může být dvoubodový nebo vícebodový."
]
},
{
"kicker": "2 / 4 • Cesta",
"title": "Přenosová cesta",
"tip": null,
"lines": [
"Přenosová cesta je fyzické médium.",
"Příklady: metalické nebo optické kabely,",
"mikrovlny, družice."
]
},
{
"kicker": "3 / 4 • Kanál a okruh",
"title": "Přenosový kanál a okruh",
"tip": "Tip: okruh = dva kanály → obousměrný přenos",
"lines": [
"Přenosový kanál: jednosměrný souhrn prostředků pro spojení.",
"Okruh: tvořen dvěma kanály – umožňuje obousměrný přenos."
]
},
{
"kicker": "4 / 4 • Trunking",
"title": "Spojování cest",
"tip": null,
"lines": [
"Trunking spojuje více cest do jednoho okruhu.",
"Cíl: zvýšení šířky pásma."
]
}
],
"tasks": [
{
"number": 1,
"title": "Quiz",
"subtitleKey": "net_quiz6_head",
"items": [
{
"kind": "choice",
"prompt": "Datový spoj slouží k:",
"options": [
"Jen měření amplitudy",
"Výměně informací",
"Jen FM modulaci",
"Jen tisku dokumentů"
],
"correct": 1,
"hint": "Datový spoj umožňuje výměnu informací mezi uzly"
},
{
"kind": "choice",
"prompt": "Přenosová cesta je:",
"options": [
"Jen softwarová služba",
"Fyzické médium přenosu",
"Jen CRC kód",
"Jen peer‑to‑peer účet"
],
"correct": 1,
"hint": "Fyzické médium: kabely, mikrovlny nebo družice"
},
{
"kind": "choice",
"prompt": "Mezi přenosová média patří:",
"options": [
"Jen parita",
"Kabely, mikrovlny a družice",
"Jen Baud",
"Jen start-bit"
],
"correct": 1,
"hint": "Fyzické médium: kabely, mikrovlny nebo družice"
},
{
"kind": "choice",
"prompt": "Přenosový kanál je:",
"options": [
"Obousměrný vždy",
"Jednosměrný souhrn prostředků pro spojení",
"Jen název serveru",
"Jen typ QPSK"
],
"correct": 1,
"hint": "Kanál je jednosměrný souhrn prostředků pro spojení"
},
{
"kind": "choice",
"prompt": "Okruh vzniká:",
"options": [
"Z jednoho kanálu",
"Ze dvou kanálů pro obousměrný přenos",
"Jen z parity",
"Jen z Wi‑Fi hesla"
],
"correct": 1,
"hint": "Okruh tvoří dva kanály pro obousměrný přenos"
},
{
"kind": "choice",
"prompt": "Trunking znamená:",
"options": [
"Odpojení všech kabelů",
"Spojení více cest do jednoho okruhu",
"Jen asynchronní stop-bit",
"Jen digitální 0 a 1"
],
"correct": 1,
"hint": "Trunking spojuje více cest do jednoho okruhu"
},
{
"kind": "choice",
"prompt": "Hlavní cíl trunkingu je:",
"options": [
"Snížit šířku pásma",
"Zvýšit šířku pásma",
"Zrušit kanály",
"Nahradit mainframe"
],
"correct": 1,
"hint": "Trunking zvyšuje šířku pásma spojením cest"
}
]
}
]
}

View file

@ -0,0 +1,152 @@
{
"id": "unit-07",
"subject": "networks",
"number": 7,
"titleKey": "net_unit7",
"subtitleKey": "net_unit7_sub",
"slides": [
{
"kicker": "1 / 4 • Multiplex",
"title": "Rozdělení kanálu",
"tip": null,
"lines": [
"Multiplex: rozdělení jednoho kanálu",
"na více logických podkanálů."
]
},
{
"kicker": "2 / 4 • Typy",
"title": "FDM, TDM a STDM",
"tip": null,
"lines": [
"FDM (frekvenční): každému kanálu část frekvenčního pásma.",
"TDM (časový): každému kanálu vyhrazený časový slot.",
"STDM (statistický): kapacita dle potřeby (paketový přenos),",
"negarantuje 100% dostupnost."
]
},
{
"kicker": "3 / 4 • Směry",
"title": "Simplex, poloduplex a duplex",
"tip": null,
"lines": [
"Simplex: jen jeden směr (např. optické vlákno).",
"Poloduplex (half-duplex): oběma směry, ne současně.",
"Duplex (full-duplex): oběma směry současně."
]
},
{
"kicker": "4 / 4 • Agregace",
"title": "Sdílení kapacity",
"tip": null,
"lines": [
"Agregace: sdílení kapacity kanálu více uživateli."
]
}
],
"tasks": [
{
"number": 1,
"title": "Quiz",
"subtitleKey": "net_quiz7_head",
"items": [
{
"kind": "choice",
"prompt": "Multiplex znamená:",
"options": [
"Jen jeden uživatel na kanál",
"Rozdělení jednoho kanálu na více logických podkanálů",
"Jen CRC kontrolu",
"Jen digitální 0 a 1"
],
"correct": 1,
"hint": "Multiplex dělí jeden kanál na více logických podkanálů"
},
{
"kind": "choice",
"prompt": "FDM přiděluje každému kanálu:",
"options": [
"Časový slot",
"Část frekvenčního pásma",
"Jen start-bit",
"Jen mainframe"
],
"correct": 1,
"hint": "FDM: každému kanálu část frekvenčního pásma"
},
{
"kind": "choice",
"prompt": "TDM přiděluje každému kanálu:",
"options": [
"Část frekvenčního pásma",
"Vyhrazený časový slot",
"Jen SaaS účet",
"Jen paritu"
],
"correct": 1,
"hint": "TDM: každému kanálu vyhrazený časový slot"
},
{
"kind": "choice",
"prompt": "STDM:",
"options": [
"Garantuje vždy 100% dostupnost",
"Přiděluje kapacitu dle potřeby a negarantuje 100%",
"Je jen simplex",
"Je jen trunking kabelů"
],
"correct": 1,
"hint": "STDM přiděluje kapacitu dle potřeby, negarantuje 100 %"
},
{
"kind": "choice",
"prompt": "Simplex umožňuje přenos:",
"options": [
"Oběma směry současně",
"Jen jedním směrem",
"Oběma směry, ale ne současně",
"Jen přes CRC"
],
"correct": 1,
"hint": "Simplex = jen jeden směr přenosu"
},
{
"kind": "choice",
"prompt": "Half-duplex znamená:",
"options": [
"Jen jeden směr",
"Oběma směry, ne současně",
"Oběma směry současně",
"Jen FDM"
],
"correct": 1,
"hint": "Half-duplex = oběma směry, ale ne současně"
},
{
"kind": "choice",
"prompt": "Full-duplex znamená:",
"options": [
"Jen jeden směr",
"Oběma směry, ne současně",
"Oběma směry současně",
"Jen STDM"
],
"correct": 2,
"hint": "Full-duplex = oběma směry současně"
},
{
"kind": "choice",
"prompt": "Agregace je:",
"options": [
"Sdílení kapacity kanálu více uživateli",
"Jen fázová modulace",
"Jen dvoubodový spoj",
"Jen stop-bit"
],
"correct": 0,
"hint": "Agregace = sdílení kapacity kanálu více uživateli"
}
]
}
]
}

View file

@ -0,0 +1,153 @@
{
"id": "unit-08",
"subject": "networks",
"number": 8,
"titleKey": "net_unit8",
"subtitleKey": "net_unit8_sub",
"slides": [
{
"kicker": "1 / 4 • Okruhy",
"title": "Přepojování okruhů",
"tip": "Tip: spojovaná služba – jako telefon",
"lines": [
"Cesta se vytýčí předem (spojovaná služba).",
"Kapacita je garantována.",
"Přenos probíhá v reálném čase."
]
},
{
"kicker": "2 / 4 • Pakety",
"title": "Přepojování paketů",
"tip": null,
"lines": [
"Data se dělí na pakety s adresami.",
"Každý paket může jít jinou cestou."
]
},
{
"kicker": "3 / 4 • Hodnocení",
"title": "Výhody a nevýhody paketů",
"tip": null,
"lines": [
"Výhody: reakce na zátěž, lepší využití kapacity.",
"Nevýhody: negarantuje pořadí ani plynulost.",
"Nevhodné pro video v reálném čase."
]
},
{
"kicker": "4 / 4 • Virtuální",
"title": "Virtuální spoje",
"tip": null,
"lines": [
"Cesta je vytýčena předem,",
"ale prostředky se využívají jen při průchodu paketu.",
"Výsledek: zrychlení datagramů."
]
}
],
"tasks": [
{
"number": 1,
"title": "Quiz",
"subtitleKey": "net_quiz8_head",
"items": [
{
"kind": "choice",
"prompt": "Přepojování okruhů znamená:",
"options": [
"Pakety jdou vždy jinou cestou",
"Cesta se vytýčí předem a kapacita je garantována",
"Jen FDM multiplex",
"Jen half-duplex"
],
"correct": 1,
"hint": "Cesta se vytýčí předem, kapacita je garantována"
},
{
"kind": "choice",
"prompt": "Přepojování okruhů je vhodné pro:",
"options": [
"Jen náhodné datagramy bez cesty",
"Přenos v reálném čase (jako telefon)",
"Jen CRC",
"Jen peer‑to‑peer tisk"
],
"correct": 1,
"hint": "Cesta se vytýčí předem, kapacita je garantována"
},
{
"kind": "choice",
"prompt": "Při přepojování paketů:",
"options": [
"Vždy existuje jedna pevná cesta",
"Každý paket může jít jinou cestou",
"Kapacita je vždy garantována",
"Neexistují adresy"
],
"correct": 1,
"hint": "Data se dělí na pakety, každý může jít jinou cestou"
},
{
"kind": "choice",
"prompt": "Výhoda přepojování paketů je:",
"options": [
"Garantované pořadí vždy",
"Reakce na zátěž a využití kapacity",
"Jen simplex",
"Jen trunking"
],
"correct": 1,
"hint": "Výhody: reakce na zátěž a lepší využití kapacity"
},
{
"kind": "choice",
"prompt": "Nevýhoda přepojování paketů je:",
"options": [
"Nereaguje na zátěž",
"Negarantuje pořadí ani plynulost",
"Nelze použít adresy",
"Vždy blokuje kanál jako telefon"
],
"correct": 1,
"hint": "Negarantuje pořadí ani plynulost – nevhodné pro živé video"
},
{
"kind": "choice",
"prompt": "Pro video v reálném čase je přepojování paketů:",
"options": [
"Ideální vždy",
"Spíše nevhodné kvůli plynulosti",
"Jediná možná metoda",
"Stejné jako AM modulace"
],
"correct": 1,
"hint": "Negarantuje pořadí ani plynulost – nevhodné pro živé video"
},
{
"kind": "choice",
"prompt": "Virtuální spoje:",
"options": [
"Nemají předem žádnou cestu",
"Mají cestu předem, prostředky jen při průchodu paketu",
"Jsou jen FDM",
"Jsou jen parita"
],
"correct": 1,
"hint": "Cesta je předem, prostředky se použijí jen při průchodu paketu"
},
{
"kind": "choice",
"prompt": "Virtuální spoje slouží hlavně k:",
"options": [
"Zrušení paketů",
"Zrychlení datagramů",
"Jen simplexu",
"Jen analogovému signálu"
],
"correct": 1,
"hint": "Virtuální spoje zrychlují přenos datagramů"
}
]
}
]
}

View file

@ -0,0 +1,151 @@
{
"id": "unit-09",
"subject": "networks",
"number": 9,
"titleKey": "net_unit9",
"subtitleKey": "net_unit9_sub",
"slides": [
{
"kicker": "1 / 4 • Proč",
"title": "Standardizace a dekompozice",
"tip": null,
"lines": [
"Standardizace zajišťuje kompatibilitu mezi výrobci.",
"Dekompozice: rozklad složitého problému přenosu na vrstvy."
]
},
{
"kicker": "2 / 4 • Vrstvy",
"title": "Komunikace mezi vrstvami",
"tip": "Tip: SAP = Service Access Point",
"lines": [
"Vrstvy komunikují jen se sousedy přes rozhraní (SAP)."
]
},
{
"kicker": "3 / 4 • Protokol",
"title": "Pravidla komunikace",
"tip": null,
"lines": [
"Protokol: pravidla komunikace mezi stejnými vrstvami",
"na různých uzlech."
]
},
{
"kicker": "4 / 4 • Jednotky",
"title": "Hlavičky a PDU",
"tip": null,
"lines": [
"Každá vrstva přidá k datům hlavičku (header).",
"Rámec – linková vrstva.",
"Paket – síťová vrstva.",
"Segment – transportní vrstva."
]
}
],
"tasks": [
{
"number": 1,
"title": "Quiz",
"subtitleKey": "net_quiz9_head",
"items": [
{
"kind": "choice",
"prompt": "Standardizace v sítích především:",
"options": [
"Zvyšuje nekompatibilitu",
"Zajišťuje kompatibilitu mezi výrobci",
"Ruší protokoly",
"Nahrazuje kabely"
],
"correct": 1,
"hint": "Standardizace zajišťuje kompatibilitu mezi výrobci"
},
{
"kind": "choice",
"prompt": "Dekompozice znamená:",
"options": [
"Spojení všeho do jedné vrstvy",
"Rozklad složitého přenosu na vrstvy",
"Jen FDM",
"Jen CRC"
],
"correct": 1,
"hint": "Dekompozice rozkládá přenos na jednotlivé vrstvy"
},
{
"kind": "choice",
"prompt": "Vrstvy komunikují:",
"options": [
"Se všemi vrstvami najednou",
"Jen se sousedy přes rozhraní (SAP)",
"Jen přes satelit",
"Jen bez protokolu"
],
"correct": 1,
"hint": "Vrstvy komunikují jen se sousedy přes rozhraní SAP"
},
{
"kind": "choice",
"prompt": "Protokol je:",
"options": [
"Fyzický kabel",
"Pravidla komunikace mezi stejnými vrstvami",
"Jen amplituda signálu",
"Jen tiskový server"
],
"correct": 1,
"hint": "Protokol = pravidla mezi stejnými vrstvami na různých uzlech"
},
{
"kind": "choice",
"prompt": "Každá vrstva k datům typicky přidá:",
"options": [
"Jen stop-bit",
"Hlavičku (header)",
"Jen šířku pásma",
"Jen mainframe"
],
"correct": 1,
"hint": "Každá vrstva přidává k datům hlavičku (header)"
},
{
"kind": "choice",
"prompt": "Jednotka linkové vrstvy se nazývá:",
"options": [
"Segment",
"Paket",
"Rámec",
"Baud"
],
"correct": 2,
"hint": "Rámec = linková, paket = síťová, segment = transportní"
},
{
"kind": "choice",
"prompt": "Jednotka síťové vrstvy se nazývá:",
"options": [
"Rámec",
"Paket",
"Segment",
"Simplex"
],
"correct": 1,
"hint": "Rámec = linková, paket = síťová, segment = transportní"
},
{
"kind": "choice",
"prompt": "Jednotka transportní vrstvy se nazývá:",
"options": [
"Rámec",
"Paket",
"Segment",
"Trunk"
],
"correct": 2,
"hint": "Rámec = linková, paket = síťová, segment = transportní"
}
]
}
]
}

View file

@ -0,0 +1,120 @@
{
"id": "unit-10",
"subject": "networks",
"number": 10,
"titleKey": "net_unit10",
"subtitleKey": "net_unit10_sub",
"slides": [
{
"kicker": "1 / 3 • ISO/OSI",
"title": "Sedmivrstvý model",
"tip": null,
"lines": [
"ISO/OSI: 7vrstvý model.",
"Je univerzální – popisuje komunikaci obecně.",
"V praxi je ale pomalejší: každá vrstva řešila spolehlivost."
]
},
{
"kicker": "2 / 3 • TCP/IP",
"title": "Praktická architektura",
"tip": null,
"lines": [
"TCP/IP: architektura, která zvítězila v praxi.",
"Vycházela ze skutečných potřeb sítí.",
"Spolehlivost řeší až vyšší vrstvy."
]
},
{
"kicker": "3 / 3 • Srovnání",
"title": "Proč zvítězil TCP/IP",
"tip": "Tip: méně režie na nižších vrstvách = rychlejší provoz",
"lines": [
"ISO/OSI: teoretický, univerzální, těžší a pomalejší.",
"TCP/IP: praktický, jednodušší dělení odpovědností.",
"Proto se TCP/IP stal základem dnešního internetu."
]
}
],
"tasks": [
{
"number": 1,
"title": "Quiz",
"subtitleKey": "net_quiz10_head",
"items": [
{
"kind": "choice",
"prompt": "Model ISO/OSI má:",
"options": [
"4 vrstvy",
"5 vrstev",
"7 vrstev",
"10 vrstev"
],
"correct": 2,
"hint": "ISO/OSI má 7 vrstev a je univerzální model"
},
{
"kind": "choice",
"prompt": "ISO/OSI je především:",
"options": [
"Jen typ kabelu",
"Univerzální referenční model",
"Jen tiskový server",
"Jen FDM"
],
"correct": 1,
"hint": "ISO/OSI má 7 vrstev a je univerzální model"
},
{
"kind": "choice",
"prompt": "Proč je ISO/OSI v praxi pomalejší?",
"options": [
"Nemá žádné vrstvy",
"Každá vrstva řešila spolehlivost",
"Nepoužívá protokoly",
"Nemá SAP"
],
"correct": 1,
"hint": "V praxi je ISO/OSI pomalejší – spolehlivost řeší každá vrstva"
},
{
"kind": "choice",
"prompt": "TCP/IP v praxi:",
"options": [
"Prohrál proti ISO/OSI",
"Zvítězil jako praktická architektura",
"Je jen analogový signál",
"Je jen half-duplex"
],
"correct": 1,
"hint": "TCP/IP zvítězil, protože vycházel ze skutečných potřeb"
},
{
"kind": "choice",
"prompt": "TCP/IP vycházel z:",
"options": [
"Jen teoretických schémat bez praxe",
"Skutečných potřeb sítí",
"Jen parity",
"Jen trunkingu"
],
"correct": 1,
"hint": "TCP/IP zvítězil, protože vycházel ze skutečných potřeb"
},
{
"kind": "choice",
"prompt": "V TCP/IP spolehlivost řeší:",
"options": [
"Vždy jen fyzická vrstva",
"Až vyšší vrstvy",
"Jen metalické kabely",
"Jen mainframe"
],
"correct": 1,
"hint": "V TCP/IP řeší spolehlivost až vyšší vrstvy"
}
]
}
]
}

View file

@ -0,0 +1,181 @@
{
"id": "unit-11",
"subject": "networks",
"number": 11,
"titleKey": "net_unit11",
"subtitleKey": "net_unit11_sub",
"slides": [
{
"kicker": "1 / 8 • Přehled",
"title": "Tři skupiny vrstev",
"tip": "Tip: přenos → přizpůsobení → aplikace",
"lines": [
"Vrstvy orientované na přenos: fyzická, spojová, síťová.",
"Přizpůsobovací vrstva: transportní.",
"Vrstvy orientované na aplikace: relační, prezentační,",
"aplikační."
]
},
{
"kicker": "2 / 8 • Fyzická",
"title": "Přenos bitů",
"tip": null,
"lines": [
"Přenos bitů po médiu.",
"Definuje napětí, kabely, konektory, kódování a modulaci.",
"Řeší také duplex / simplex."
]
},
{
"kicker": "3 / 8 • Spojová",
"title": "Rámce a MAC",
"tip": "Tip: podvrstvy MAC (přístup k médiu) a LLC (logické řízení)",
"lines": [
"Tvorba rámců a fyzické (MAC) adresy.",
"Kontrola chyb (CRC) a řízení toku mezi sousedními uzly.",
"Podvrstvy: MAC a LLC."
]
},
{
"kicker": "4 / 8 • Síťová",
"title": "Směrování paketů",
"tip": null,
"lines": [
"Směrování (routing) paketů.",
"Funguje i v sítích bez přímého spojení mezi uzly."
]
},
{
"kicker": "5 / 8 • Transportní",
"title": "Segmenty end-to-end",
"tip": "Tip: v routerech transportní vrstva není",
"lines": [
"Rozklad zpráv na segmenty a jejich zpětné složení.",
"Kontrola pořadí a náprava chyb.",
"Existuje jen v koncových uzlech – není v routerech."
]
},
{
"kicker": "6 / 8 • Relační",
"title": "Relace a dialog",
"tip": null,
"lines": [
"Navazuje, udržuje a ukončuje relace.",
"Řídí dialog („neskákat si do řeči“).",
"Synchronizace – navázání po přerušení."
]
},
{
"kicker": "7 / 8 • Prezentační",
"title": "Formát dat",
"tip": null,
"lines": [
"Formátování dat (např. ASCII).",
"Komprese a šifrování.",
"Převod mezi různými standardy."
]
},
{
"kicker": "8 / 8 • Aplikační",
"title": "Rozhraní pro programy",
"tip": null,
"lines": [
"Rozhraní pro aplikace a služby.",
"Například e-mail, HTTP, FTP nebo terminál."
]
}
],
"tasks": [
{
"number": 1,
"title": "Quiz",
"subtitleKey": "net_quiz11_head",
"items": [
{
"kind": "choice",
"prompt": "Fyzická vrstva se stará o:",
"options": [
"Přenos bitů",
"Směrování paketů",
"Navazování relací",
"Formátování ASCII"
],
"correct": 0,
"hint": "Fyzická vrstva přenáší bity a řeší médium"
},
{
"kind": "choice",
"prompt": "MAC a LLC jsou podvrstvy:",
"options": [
"Síťové vrstvy",
"Spojové (linkové) vrstvy",
"Transportní vrstvy",
"Aplikační vrstvy"
],
"correct": 1,
"hint": "Spojová vrstva má podvrstvy MAC a LLC"
},
{
"kind": "choice",
"prompt": "Směrování paketů v sítích bez přímého spojení řeší:",
"options": [
"Fyzická vrstva",
"Relační vrstva",
"Síťová vrstva",
"Prezentační vrstva"
],
"correct": 2,
"hint": "Síťová vrstva směruje pakety i bez přímého spojení"
},
{
"kind": "choice",
"prompt": "Transportní vrstva existuje:",
"options": [
"Jen v koncových uzlech",
"I v routerech",
"Jen ve switchech",
"Jen na hubu"
],
"correct": 0,
"hint": "Transportní vrstva je jen v koncových uzlech, ne v routerech"
},
{
"kind": "choice",
"prompt": "Relační vrstva především:",
"options": [
"Definuje napětí a kabely",
"Navazuje, udržuje a ukončuje relace",
"Přidává MAC adresy",
"Směruje pakety"
],
"correct": 1,
"hint": "Relační vrstva řídí relace, dialog a synchronizaci"
},
{
"kind": "choice",
"prompt": "Komprese a šifrování patří do vrstvy:",
"options": [
"Fyzické",
"Spojové",
"Prezentační",
"Síťové"
],
"correct": 2,
"hint": "Prezentační vrstva formátuje data, komprimuje a šifruje"
},
{
"kind": "choice",
"prompt": "Aplikační vrstva poskytuje:",
"options": [
"CRC mezi sousedy",
"Rozhraní pro programy (HTTP, FTP, e-mail…)",
"Jen duplex/simplex",
"Jen směrování"
],
"correct": 1,
"hint": "Aplikační vrstva je rozhraní pro programy a služby"
}
]
}
]
}

View file

@ -0,0 +1,172 @@
{
"id": "unit-12",
"subject": "networks",
"number": 12,
"titleKey": "net_unit12",
"subtitleKey": "net_unit12_sub",
"slides": [
{
"kicker": "1 / 6 • Mapování",
"title": "TCP/IP vs ISO/OSI",
"tip": null,
"lines": [
"Aplikační TCP/IP = aplikační + prezentační + relační ISO/OSI.",
"Transportní TCP/IP = transportní ISO/OSI.",
"Síťová (internetová) = síťová ISO/OSI.",
"Vrstva síťového rozhraní = linková + fyzická ISO/OSI."
]
},
{
"kicker": "2 / 6 • Vznik",
"title": "Nejprve protokoly",
"tip": "Tip: opačný postup než u ISO/OSI",
"lines": [
"Nejprve vznikly protokoly pro Internet.",
"Až poté byl definován model TCP/IP.",
"U ISO/OSI to bylo naopak: nejdřív model, pak protokoly."
]
},
{
"kicker": "3 / 6 • Aplikace",
"title": "Aplikační protokoly",
"tip": null,
"lines": [
"HTTP, HTTPS, FTP, DHCP, DNS.",
"Pošta: POP3, IMAP, SMTP."
]
},
{
"kicker": "4 / 6 • Transport",
"title": "TCP a UDP",
"tip": null,
"lines": [
"TCP: spolehlivý, spojovaný.",
"UDP: nespolehlivý, rychlý."
]
},
{
"kicker": "5 / 6 • Síť",
"title": "Internetové protokoly",
"tip": null,
"lines": [
"IP – základní přenos paketů.",
"ICMP, ARP.",
"Směrování: RIP, OSPF."
]
},
{
"kicker": "6 / 6 • Rozhraní",
"title": "Síťové rozhraní",
"tip": null,
"lines": [
"Ethernet, Token Ring, PPP, ATM.",
"Odpovídá linkové a fyzické vrstvě ISO/OSI."
]
}
],
"tasks": [
{
"number": 1,
"title": "Quiz",
"subtitleKey": "net_quiz12_head",
"items": [
{
"kind": "choice",
"prompt": "Aplikační vrstva TCP/IP odpovídá v ISO/OSI:",
"options": [
"Jen aplikační vrstvě",
"Aplikační + prezentační + relační",
"Jen fyzické vrstvě",
"Linkové + síťové"
],
"correct": 1,
"hint": "Aplikační TCP/IP = aplikační + prezentační + relační ISO/OSI"
},
{
"kind": "choice",
"prompt": "Vrstva síťového rozhraní TCP/IP odpovídá:",
"options": [
"Jen síťové ISO/OSI",
"Linkové + fyzické ISO/OSI",
"Jen transportní ISO/OSI",
"Jen aplikační ISO/OSI"
],
"correct": 1,
"hint": "Síťové rozhraní TCP/IP = linková + fyzická ISO/OSI"
},
{
"kind": "choice",
"prompt": "Model TCP/IP vznikl:",
"options": [
"Nejdřív model, pak protokoly",
"Nejdřív protokoly, pak model",
"Současně s Token Ring",
"Jen jako náhrada za ATM"
],
"correct": 1,
"hint": "U TCP/IP vznikly nejdřív protokoly, až pak model"
},
{
"kind": "choice",
"prompt": "TCP je:",
"options": [
"Nespolehlivý a rychlý",
"Spolehlivý a spojovaný",
"Jen směrovací protokol",
"Jen fyzické kódování"
],
"correct": 1,
"hint": "TCP je spolehlivý a spojovaný"
},
{
"kind": "choice",
"prompt": "UDP je:",
"options": [
"Spolehlivý a spojovaný",
"Nespolehlivý a rychlý",
"Jen e-mailový protokol",
"Jen ARP"
],
"correct": 1,
"hint": "UDP je nespolehlivý a rychlý"
},
{
"kind": "choice",
"prompt": "HTTP, DNS a SMTP patří do vrstvy:",
"options": [
"Aplikační",
"Transportní",
"Síťové",
"Síťového rozhraní"
],
"correct": 0,
"hint": "HTTP, DNS a SMTP jsou aplikační protokoly"
},
{
"kind": "choice",
"prompt": "IP, ICMP a OSPF patří do vrstvy:",
"options": [
"Aplikační",
"Transportní",
"Síťové (internetové)",
"Síťového rozhraní"
],
"correct": 2,
"hint": "IP, ICMP a OSPF patří na síťovou (internetovou) vrstvu"
},
{
"kind": "choice",
"prompt": "Ethernet a PPP patří do vrstvy:",
"options": [
"Aplikační",
"Transportní",
"Síťové",
"Síťového rozhraní"
],
"correct": 3,
"hint": "Ethernet a PPP patří na vrstvu síťového rozhraní"
}
]
}
]
}

View file

@ -0,0 +1,130 @@
{
"id": "unit-13",
"subject": "networks",
"number": 13,
"titleKey": "net_unit13",
"subtitleKey": "net_unit13_sub",
"slides": [
{
"kicker": "1 / 4 • Přehled",
"title": "Topologie sítě",
"tip": null,
"lines": [
"Topologie popisuje, jak jsou uzly propojené.",
"Základní typy: sběrnice, hvězda a kruh."
]
},
{
"kicker": "2 / 4 • Sběrnice",
"title": "Bus",
"tip": "Tip: dříve typicky koaxiální kabel",
"lines": [
"Médium sdílí všichni – jednoduchá a levná topologie.",
"Malá délka kabelů.",
"Nevýhody: nízká bezpečnost (všichni slyší vše).",
"Přerušení kabelu nebo terminátoru vyřadí celou síť."
]
},
{
"kicker": "3 / 4 • Hvězda",
"title": "Star",
"tip": "Tip: kroucená dvoulinka nebo optika",
"lines": [
"Uzly jdou přes centrální prvek (hub nebo switch).",
"Výhody: odolnost proti poruchám kabelů k uzlům,",
"snadná diagnostika.",
"Nevýhody: aktivní prvky a více kabeláže."
]
},
{
"kicker": "4 / 4 • Kruh",
"title": "Ring",
"tip": "Tip: často zdvojený kruh pro odolnost",
"lines": [
"Kabely tvoří souvislý kruh.",
"Zprávy obíhají, dokud nenajdou adresáta."
]
}
],
"tasks": [
{
"number": 1,
"title": "Quiz",
"subtitleKey": "net_quiz13_head",
"items": [
{
"kind": "choice",
"prompt": "Topologie sběrnice (bus) znamená, že:",
"options": [
"Každý uzel má vlastní centrální switch",
"Médium sdílí všichni",
"Kabely tvoří jen zdvojený kruh",
"Používá jen OSPF"
],
"correct": 1,
"hint": "Sběrnice: médium sdílí všichni"
},
{
"kind": "choice",
"prompt": "Nevýhoda sběrnice je hlavně:",
"options": [
"Že potřebuje hub u každého uzlu",
"Nízká bezpečnost a náchylnost na poruchu kabelu",
"Že nelze použít koaxiál",
"Že zprávy neobíhají"
],
"correct": 1,
"hint": "Sběrnice: nízká bezpečnost a porucha kabelu vyřadí síť"
},
{
"kind": "choice",
"prompt": "Topologie hvězda propojuje uzly přes:",
"options": [
"Jen jeden společný koaxiál bez centra",
"Centrální prvek (hub nebo switch)",
"Jen Token Ring",
"Jen ATM"
],
"correct": 1,
"hint": "Hvězda: uzly jdou přes hub nebo switch"
},
{
"kind": "choice",
"prompt": "Výhoda hvězdy je:",
"options": [
"Že přerušení jednoho kabelu k uzlu nevyřadí celou síť",
"Že všichni slyší vše",
"Že nepotřebuje žádnou kabeláž",
"Že nemá aktivní prvky"
],
"correct": 0,
"hint": "Hvězda: porucha kabelu k uzlu nevyřadí celou síť"
},
{
"kind": "choice",
"prompt": "Hvězda typicky používá:",
"options": [
"Jen koaxiální kabel",
"Kroucenou dvoulinku nebo optiku",
"Jen simplexní rádio",
"Jen FDM"
],
"correct": 1,
"hint": "Hvězda používá kroucenou dvoulinku nebo optiku"
},
{
"kind": "choice",
"prompt": "V topologii kruh (ring):",
"options": [
"Zprávy obíhají, dokud nenajdou adresáta",
"Každý paket jde jen přes DNS",
"Médium sdílí hub bez kabelů",
"Neexistuje žádná kabeláž"
],
"correct": 0,
"hint": "V kruhu zprávy obíhají, dokud nenajdou adresáta"
}
]
}
]
}

View file

@ -0,0 +1,140 @@
{
"id": "unit-14",
"subject": "networks",
"number": 14,
"titleKey": "net_unit14",
"subtitleKey": "net_unit14_sub",
"slides": [
{
"kicker": "1 / 5 • Princip",
"title": "Světlo místo proudu",
"tip": "Tip: obousměrný provoz = dvě vlákna",
"lines": [
"Elektrické impulsy se převádí na světlo (LED nebo laser).",
"Zpět je zachytí fotodioda.",
"Přenos jedním vláknem je jednosměrný."
]
},
{
"kicker": "2 / 5 • Výhody",
"title": "Proč optika",
"tip": null,
"lines": [
"Imunita vůči elektromagnetickému rušení.",
"Obrovská šířka pásma a dosah až kolem 100 km.",
"Lepší bezpečnost proti odposlechu.",
"Malý průměr a nízká váha."
]
},
{
"kicker": "3 / 5 • Nevýhody",
"title": "Cena a montáž",
"tip": null,
"lines": [
"Náročná montáž.",
"Dražší aktivní prvky a konektory."
]
},
{
"kicker": "4 / 5 • MM",
"title": "Mnohovidová vlákna",
"tip": null,
"lines": [
"Větší jádro: 50 / 62,5 µm.",
"Více cest světla (vidů).",
"Levnější zdroje – typicky LED."
]
},
{
"kicker": "5 / 5 • SM",
"title": "Jednovidová vlákna",
"tip": null,
"lines": [
"Malé jádro: 7–10 µm.",
"Jen jeden paprsek osou vlákna.",
"Velký dosah, vyžaduje laser."
]
}
],
"tasks": [
{
"number": 1,
"title": "Quiz",
"subtitleKey": "net_quiz14_head",
"items": [
{
"kind": "choice",
"prompt": "Princip optiky je:",
"options": [
"Převod el. impulsů na světlo a zpět fotodiodou",
"Jen směrování paketů OSPF",
"Jen sdílená sběrnice",
"Jen UDP spojení"
],
"correct": 0,
"hint": "Optika: el. impulsy → světlo (LED/laser) → fotodioda"
},
{
"kind": "choice",
"prompt": "Jedním vláknem je přenos:",
"options": [
"Vždy plně duplexní bez výjimky",
"Jednosměrný (obousměrně dvě vlákna)",
"Jen poloviční kruh",
"Jen Token Ring"
],
"correct": 1,
"hint": "Jedno vlákno = jednosměrný přenos; obousměrně dvě vlákna"
},
{
"kind": "choice",
"prompt": "Výhoda optiky není:",
"options": [
"Imunita vůči el.mag. rušení",
"Velký dosah a šířka pásma",
"Levná a snadná montáž konektorů",
"Lepší ochrana proti odposlechu"
],
"correct": 2,
"hint": "Nevýhody: náročná montáž a cena prvků/konektorů"
},
{
"kind": "choice",
"prompt": "Mnohovidové vlákno (MM) má:",
"options": [
"Jádro 7–10 µm a vyžaduje laser",
"Větší jádro 50/62,5 µm a více vidů",
"Jen koaxiální stínění",
"Jen jednu MAC adresu"
],
"correct": 1,
"hint": "MM: jádro 50/62,5 µm a více vidů"
},
{
"kind": "choice",
"prompt": "Jednovidové vlákno (SM):",
"options": [
"Má větší jádro a LED zdroj",
"Má malé jádro, jeden paprsek a laser",
"Nepřenáší světlo",
"Funguje jen jako hub"
],
"correct": 1,
"hint": "SM: jádro 7–10 µm, jeden paprsek, laser"
},
{
"kind": "choice",
"prompt": "Levnější zdroje (LED) typicky patří k:",
"options": [
"Jednovidovým vláknům",
"Mnohovidovým vláknům",
"Jen kruhové topologii",
"Jen ATM"
],
"correct": 1,
"hint": "MM typicky používá levnější LED zdroje"
}
]
}
]
}

View file

@ -0,0 +1,118 @@
{
"id": "unit-15",
"subject": "networks",
"number": 15,
"titleKey": "net_unit15",
"subtitleKey": "net_unit15_sub",
"slides": [
{
"kicker": "1 / 3 • Primární",
"title": "Ochrana 250 µm",
"tip": null,
"lines": [
"Primární ochrana má průměr 250 µm.",
"Chrání samotné vlákno hned po výrobě."
]
},
{
"kicker": "2 / 3 • Sekundární",
"title": "Ochrana 900 µm",
"tip": "Tip: těsná pro svislé, volná pro vodorovné instalace",
"lines": [
"Sekundární ochrana má průměr 900 µm.",
"Těsná varianta: vhodné pro svislé instalace.",
"Volná varianta: vhodné pro vodorovné instalace."
]
},
{
"kicker": "3 / 3 • Montáž",
"title": "Lámání a sváření",
"tip": "Tip: kolmost lomu do 0,5°",
"lines": [
"Vyžaduje precizní lámání lámačkou.",
"Spojování se dělá svářečkou optických vláken."
]
}
],
"tasks": [
{
"number": 1,
"title": "Quiz",
"subtitleKey": "net_quiz15_head",
"items": [
{
"kind": "choice",
"prompt": "Primární ochrana optického vlákna má průměr:",
"options": [
"900 µm",
"250 µm",
"50 µm",
"7–10 µm"
],
"correct": 1,
"hint": "Primární ochrana: 250 µm"
},
{
"kind": "choice",
"prompt": "Sekundární ochrana má průměr:",
"options": [
"250 µm",
"900 µm",
"62,5 µm",
"1 km"
],
"correct": 1,
"hint": "Sekundární ochrana: 900 µm"
},
{
"kind": "choice",
"prompt": "Těsná sekundární ochrana je vhodná hlavně:",
"options": [
"Pro svislé instalace",
"Jen pro kruhovou topologii",
"Jen pro UDP",
"Jen pro hub"
],
"correct": 0,
"hint": "Těsná sekundární ochrana: svislé instalace"
},
{
"kind": "choice",
"prompt": "Volná sekundární ochrana je vhodná hlavně:",
"options": [
"Pro vodorovné instalace",
"Jen pro ATM",
"Jen pro LED bez vlákna",
"Jen pro sběrnici"
],
"correct": 0,
"hint": "Volná sekundární ochrana: vodorovné instalace"
},
{
"kind": "choice",
"prompt": "Montáž optiky vyžaduje:",
"options": [
"Jen nůžky na koaxiál",
"Precizní lámání a svářečku vláken",
"Jen hub bez konektorů",
"Jen Token Ring"
],
"correct": 1,
"hint": "Montáž: lámačka a svářečka optických vláken"
},
{
"kind": "choice",
"prompt": "Při lámání se požaduje kolmost zhruba:",
"options": [
"Do 0,5°",
"Do 45°",
"Přesně 90° bez tolerance",
"Bez významu"
],
"correct": 0,
"hint": "Lámání vyžaduje kolmost asi 0,5°"
}
]
}
]
}

View file

@ -0,0 +1,129 @@
{
"id": "unit-16",
"subject": "networks",
"number": 16,
"titleKey": "net_unit16",
"subtitleKey": "net_unit16_sub",
"slides": [
{
"kicker": "1 / 4 • Kolize",
"title": "Sdílené médium",
"tip": null,
"lines": [
"Kolize nastane, když na sdíleném médiu vysílá více uzlů najednou."
]
},
{
"kicker": "2 / 4 • Aloha",
"title": "Vysílej kdykoliv",
"tip": "Tip: časté kolize, primitivní metoda",
"lines": [
"Uzel vysílá kdykoliv chce.",
"Když nepřijde potvrzení, pošle data znovu.",
"Kolize jsou časté."
]
},
{
"kicker": "3 / 4 • CSMA/CD",
"title": "Ethernet",
"tip": "Tip: Carrier Sense + Collision Detection",
"lines": [
"Nejdřív se poslouchá nosná (Carrier Sense).",
"Je-li ticho, uzel vysílá.",
"Při kolizi se vysílání zastaví a zkusí se znovu",
"po náhodném čase."
]
},
{
"kicker": "4 / 4 • CSMA/CA",
"title": "Wi-Fi",
"tip": "Tip: Collision Avoidance přes RTS/CTS",
"lines": [
"Předchází kolizím (Collision Avoidance).",
"RTS (Request to Send) a CTS (Clear to Send)",
"rezervují médium před vysíláním."
]
}
],
"tasks": [
{
"number": 1,
"title": "Quiz",
"subtitleKey": "net_quiz16_head",
"items": [
{
"kind": "choice",
"prompt": "Kolize nastane, když:",
"options": [
"Na sdíleném médiu vysílá více uzlů najednou",
"Router vypne OSPF",
"Vlákno má ochranu 250 µm",
"Použijeme jen TCP"
],
"correct": 0,
"hint": "Kolize = více uzlů vysílá na sdíleném médiu najednou"
},
{
"kind": "choice",
"prompt": "Metoda Aloha znamená především:",
"options": [
"Vysílej kdykoliv; bez potvrzení pošli znovu",
"Vždy rezervuj médium RTS/CTS",
"Nikdy nevysílej při tichu",
"Jen směrování RIP"
],
"correct": 0,
"hint": "Aloha: vysílej kdykoliv, při chybě znovu"
},
{
"kind": "choice",
"prompt": "CSMA/CD se typicky pojí s:",
"options": [
"Wi-Fi",
"Ethernetem",
"Jen SMTP",
"Jen ATM"
],
"correct": 1,
"hint": "CSMA/CD patří k Ethernetu"
},
{
"kind": "choice",
"prompt": "Při CSMA/CD uzel před vysíláním:",
"options": [
"Ignoruje médium",
"Monitoruje nosnou (Carrier Sense)",
"Vždy čeká přesně 1 hodinu",
"Použije jen laser"
],
"correct": 1,
"hint": "CSMA/CD nejdřív poslouchá nosnou (Carrier Sense)"
},
{
"kind": "choice",
"prompt": "Když CSMA/CD detekuje kolizi:",
"options": [
"Pokračuje vysíláním bez změny",
"Zastaví vysílání a zkusí to po náhodném čase",
"Přepne síť na sběrnici",
"Smaže IP adresu"
],
"correct": 1,
"hint": "Po kolizi CSMA/CD čeká náhodný čas a zkusí znovu"
},
{
"kind": "choice",
"prompt": "CSMA/CA na Wi-Fi předchází kolizím pomocí:",
"options": [
"RTS a CTS",
"Jen CRC bez nosné",
"Jen hubu",
"Jen koaxiálu"
],
"correct": 0,
"hint": "CSMA/CA rezervuje médium přes RTS/CTS"
}
]
}
]
}

View file

@ -0,0 +1,162 @@
{
"id": "unit-17",
"subject": "networks",
"number": 17,
"titleKey": "net_unit17",
"subtitleKey": "net_unit17_sub",
"slides": [
{
"kicker": "1 / 7 • Přehled",
"title": "Aktivní prvky",
"tip": null,
"lines": [
"Aktivní prvky zpracovávají nebo převádí signál / data.",
"Liší se podle vrstvy ISO/OSI, na které pracují."
]
},
{
"kicker": "2 / 7 • Repeater",
"title": "Opakovač",
"tip": "Tip: fyzická vrstva",
"lines": [
"Zesiluje signál a tím zvyšuje dosah."
]
},
{
"kicker": "3 / 7 • Transceiver",
"title": "Konvertor",
"tip": "Tip: fyzická vrstva",
"lines": [
"Převádí signál mezi různými médii.",
"Například metaliku na optiku."
]
},
{
"kicker": "4 / 7 • Hub",
"title": "Rozbočovač",
"tip": "Tip: fyzická vrstva – „hloupý“ prvek",
"lines": [
"Co přijme na jednom portu, rozešle do všech ostatních."
]
},
{
"kicker": "5 / 7 • Bridge/Switch",
"title": "Most a přepínač",
"tip": "Tip: linková vrstva – rámce a MAC",
"lines": [
"Pracují s rámci a MAC adresami.",
"Switch dělí síť na kolizní domény."
]
},
{
"kicker": "6 / 7 • Router",
"title": "Směrovač",
"tip": "Tip: síťová vrstva – pakety a IP",
"lines": [
"Pracuje s pakety a IP adresami.",
"Propojuje různé sítě."
]
},
{
"kicker": "7 / 7 • Gateway",
"title": "Brána",
"tip": "Tip: vyšší vrstvy",
"lines": [
"Převádí mezi odlišnými architekturami nebo protokoly."
]
}
],
"tasks": [
{
"number": 1,
"title": "Quiz",
"subtitleKey": "net_quiz17_head",
"items": [
{
"kind": "choice",
"prompt": "Repeater (opakovač) hlavně:",
"options": [
"Zesiluje signál a zvyšuje dosah",
"Směruje IP pakety",
"Převádí mezi protokoly vyšších vrstev",
"Rezervuje médium RTS/CTS"
],
"correct": 0,
"hint": "Repeater zesiluje signál na fyzické vrstvě"
},
{
"kind": "choice",
"prompt": "Transceiver typicky:",
"options": [
"Převádí signál mezi různými médii",
"Dělí síť jen podle DNS",
"Je vždy jen brána",
"Pracuje jen s UDP"
],
"correct": 0,
"hint": "Transceiver převádí mezi médii (např. metalika ↔ optika)"
},
{
"kind": "choice",
"prompt": "Hub (rozbočovač) je:",
"options": [
"„Hloupý“ prvek – data z jednoho portu pošle všem ostatním",
"Směrovač na síťové vrstvě",
"Jen laserový zdroj",
"Jen MAC filtr bez portů"
],
"correct": 0,
"hint": "Hub rozešle přijatá data do všech ostatních portů"
},
{
"kind": "choice",
"prompt": "Bridge a switch pracují na vrstvě:",
"options": [
"Fyzické",
"Linkové (rámce a MAC)",
"Aplikační",
"Jen transportní"
],
"correct": 1,
"hint": "Bridge/switch = linková vrstva, rámce a MAC"
},
{
"kind": "choice",
"prompt": "Switch mimo jiné:",
"options": [
"Dělí síť na kolizní domény",
"Nikdy nepoužívá MAC adresy",
"Funguje jen jako hub bez portů",
"Převádí jen ASCII"
],
"correct": 0,
"hint": "Switch dělí síť na kolizní domény"
},
{
"kind": "choice",
"prompt": "Router pracuje s:",
"options": [
"Pakety a IP adresami a propojuje sítě",
"Jen koaxiálem bez IP",
"Jen huby bez směrování",
"Jen RTS/CTS"
],
"correct": 0,
"hint": "Router = síťová vrstva, pakety a IP, propojuje sítě"
},
{
"kind": "choice",
"prompt": "Gateway (brána) především:",
"options": [
"Převádí mezi odlišnými architekturami/protokoly",
"Jen zesiluje bitový signál",
"Jen tvoří kolize",
"Jen lámání vlákna"
],
"correct": 0,
"hint": "Gateway převádí mezi odlišnými architekturami/protokoly"
}
]
}
]
}

View file

@ -0,0 +1,139 @@
{
"id": "unit-18",
"subject": "networks",
"number": 18,
"titleKey": "net_unit18",
"subtitleKey": "net_unit18_sub",
"slides": [
{
"kicker": "1 / 5 • Funkce",
"title": "Učení MAC adres",
"tip": "Tip: CAM tabulka = adresa → port",
"lines": [
"Switch se učí MAC adresy odesílatelů.",
"Ukládá je do CAM tabulky.",
"Rámce pak posílá jen na konkrétní port."
]
},
{
"kicker": "2 / 5 • Metody",
"title": "Tři způsoby přepínání",
"tip": null,
"lines": [
"Cut-through, store-and-forward a fragment-free.",
"Liší se rychlostí a kontrolou chyb."
]
},
{
"kicker": "3 / 5 • Cut-through",
"title": "Hned po adrese",
"tip": null,
"lines": [
"Čte jen adresu příjemce a hned posílá dál.",
"Nejrychlejší metoda.",
"Nevýhoda: posílá i chybné rámce."
]
},
{
"kicker": "4 / 5 • Store-and-forward",
"title": "Celý rámec + CRC",
"tip": null,
"lines": [
"Přijme celý rámec a zkontroluje CRC.",
"Teprve pak rámec pošle.",
"Nejpomalejší, ale nejbezpečnější metoda."
]
},
{
"kicker": "5 / 5 • Fragment-free",
"title": "Modifikovaný cut-through",
"tip": "Tip: prvních 64 bytů = detekce kolizí",
"lines": [
"Čte prvních 64 bytů rámce.",
"To stačí k odhalení kolizních fragmentů.",
"Kompromis mezi rychlostí a bezpečností."
]
}
],
"tasks": [
{
"number": 1,
"title": "Quiz",
"subtitleKey": "net_quiz18_head",
"items": [
{
"kind": "choice",
"prompt": "Switch se učí MAC adresy a ukládá je do:",
"options": [
"CAM tabulky",
"Jen DNS cache",
"Jen ARP bez portů",
"Jen OSPF"
],
"correct": 0,
"hint": "MAC adresy switch ukládá do CAM tabulky"
},
{
"kind": "choice",
"prompt": "Díky CAM tabulce switch posílá rámce:",
"options": [
"Jen na konkrétní port",
"Vždy na všechny porty jako hub",
"Jen na routery",
"Jen přes laser"
],
"correct": 0,
"hint": "Podle CAM tabulky posílá rámec jen na cílový port"
},
{
"kind": "choice",
"prompt": "Cut-through přepínání:",
"options": [
"Čte jen adresu příjemce a hned posílá",
"Vždy čeká na celé CRC",
"Nikdy neposílá rámce",
"Pracuje jen s IP směrováním"
],
"correct": 0,
"hint": "Cut-through čte adresu příjemce a hned přeposílá"
},
{
"kind": "choice",
"prompt": "Nevýhoda cut-through je:",
"options": [
"Že posílá i chybné rámce",
"Že je nejpomalejší",
"Že nemá MAC adresy",
"Že vyžaduje gateway"
],
"correct": 0,
"hint": "Cut-through je rychlý, ale propustí i chyby"
},
{
"kind": "choice",
"prompt": "Store-and-forward:",
"options": [
"Přijme celý rámec, zkontroluje CRC a pak pošle",
"Čte jen první 2 byty",
"Nikdy nekontroluje chyby",
"Funguje jen jako Aloha"
],
"correct": 0,
"hint": "Store-and-forward kontroluje celý rámec včetně CRC"
},
{
"kind": "choice",
"prompt": "Fragment-free (modifikovaný cut-through) čte:",
"options": [
"Prvních 64 bytů (detekce kolizí)",
"Jen poslední byte",
"Celý paket IP včetně směrování",
"Jen SMTP hlavičku"
],
"correct": 0,
"hint": "Fragment-free čte prvních 64 bytů kvůli kolizím"
}
]
}
]
}

View file

@ -0,0 +1,173 @@
{
"id": "unit-19",
"subject": "networks",
"number": 19,
"titleKey": "net_unit19",
"subtitleKey": "net_unit19_sub",
"slides": [
{
"kicker": "1 / 7 • Princip",
"title": "Směrování paketů",
"tip": "Tip: 3. vrstva ISO/OSI – síťová",
"lines": [
"Rozhodování o dalším směru přenosu paketů do cílové sítě.",
"Probíhá na síťové vrstvě modelu ISO/OSI.",
"K rozhodování se používají směrovací algoritmy."
]
},
{
"kicker": "2 / 7 • Adaptivní",
"title": "Dynamické algoritmy",
"tip": null,
"lines": [
"Reagují na změny v síti (výpadek linky, přetížení).",
"Vyžadují pravidelnou výměnu informací mezi směrovači."
]
},
{
"kicker": "3 / 7 • Neadaptivní",
"title": "Statické algoritmy",
"tip": null,
"lines": [
"Nereagují na změny v síti.",
"Cesty pevně definuje správce.",
"Při výpadku části sítě může spojení spadnout."
]
},
{
"kicker": "4 / 7 • Řízení",
"title": "Centralizované a izolované",
"tip": null,
"lines": [
"Centralizované: centrum počítá cesty pro všechny směrovače.",
"Výpadek centra = pád směrování; v praxi skoro nepoužívané.",
"Izolované: směrovač se rozhoduje sám z lokálních informací.",
"Izolované se používá spíš jako doplněk při přetížení."
]
},
{
"kicker": "5 / 7 • Distribuované",
"title": "Výměna mezi směrovači",
"tip": null,
"lines": [
"Směrovače si vyměňují informace.",
"Každý si udržuje vlastní směrovací tabulku."
]
},
{
"kicker": "6 / 7 • Tabulka",
"title": "Next Hop a metrika",
"tip": "Tip: nižší metrika = lepší / rychlejší trasa",
"lines": [
"Záznam: cílová síť, next hop a metrika.",
"Next hop = nejbližší soused, kterému paket předáme.",
"Metrika číselně ohodnocuje cestu.",
"Při více cestách se volí ta s nejnižší metrikou."
]
},
{
"kicker": "7 / 7 • Distance Vector",
"title": "Příklad algoritmu",
"tip": "Tip: např. výměna tabulek každých 30 s",
"lines": [
"Směrovače znají jen své přímé sousedy.",
"Pravidelně si posílají kopie směrovacích tabulek.",
"Informace se šíří krok za krokem po celé síti.",
"Každý směrovač si spočítá nejlepší trasu do známých sítí."
]
}
],
"tasks": [
{
"number": 1,
"title": "Quiz",
"subtitleKey": "net_quiz19_head",
"items": [
{
"kind": "choice",
"prompt": "Směrování probíhá na vrstvě:",
"options": [
"Fyzické",
"Síťové (3. ISO/OSI)",
"Jen aplikační",
"Jen linkové"
],
"correct": 1,
"hint": "Směrování běží na síťové (3.) vrstvě ISO/OSI"
},
{
"kind": "choice",
"prompt": "Adaptivní (dynamické) algoritmy:",
"options": [
"Reagují na změny a vyměňují informace mezi směrovači",
"Nikdy nemění cesty",
"Fungují jen na fyzické vrstvě",
"Nepoužívají metriku"
],
"correct": 0,
"hint": "Adaptivní algoritmy reagují na změny a sdílí stav sítě"
},
{
"kind": "choice",
"prompt": "Neadaptivní (statické) směrování:",
"options": [
"Má cesty pevně nastavené správcem",
"Samo opraví každý výpadek",
"Nepotřebuje směrovací tabulku",
"Běží jen na Wi-Fi"
],
"correct": 0,
"hint": "Statické cesty nastaví správce a samy se nemění"
},
{
"kind": "choice",
"prompt": "Centralizované směrování v praxi:",
"options": [
"Se skoro nepoužívá – výpadek centra shodí směrování",
"Je jediný standard internetu",
"Neexistuje žádné centrum",
"Pracuje jen s MAC adresami"
],
"correct": 0,
"hint": "Centralizované směrování padá s výpadkem centra"
},
{
"kind": "choice",
"prompt": "Distribuované směrování znamená, že:",
"options": [
"Směrovače si vyměňují informace a každý má vlastní tabulku",
"Jedno centrum řídí všechny cesty",
"Směrovač nezná žádné sousedy",
"Pakety jdou jen přes hub"
],
"correct": 0,
"hint": "Distribuované: výměna informací a vlastní tabulky"
},
{
"kind": "choice",
"prompt": "Metrika ve směrovací tabulce:",
"options": [
"Číselně hodnotí cestu – nižší je lepší",
"Je vždy jen MAC adresa",
"Nemá vliv na výběr trasy",
"Nahrazuje next hop"
],
"correct": 0,
"hint": "Nižší metrika = lepší / rychlejší trasa"
},
{
"kind": "choice",
"prompt": "Distance Vector typicky:",
"options": [
"Posílá sousedům kopie tabulek a šíří informace krok za krokem",
"Počítá cesty jen v jednom centru",
"Ignoruje přímé sousedy",
"Pracuje jen na aplikační vrstvě"
],
"correct": 0,
"hint": "Distance Vector: výměna tabulek se sousedy (např. každých 30 s)"
}
]
}
]
}

View file

@ -0,0 +1,172 @@
{
"id": "unit-20",
"subject": "networks",
"number": 20,
"titleKey": "net_unit20",
"subtitleKey": "net_unit20_sub",
"slides": [
{
"kicker": "1 / 7 • Router",
"title": "Síťová vrstva",
"tip": "Tip: má vlastní IP a je vidět např. pingen",
"lines": [
"Pracuje na 3. (síťové) vrstvě.",
"Segmentuje síť na podsítě nebo propojuje LAN do většího celku.",
"Umožňuje přístup na WAN (např. internet).",
"Podporuje soustavy protokolů (např. TCP/IP) a má vlastní adresu."
]
},
{
"kicker": "2 / 7 • Bezpečnost",
"title": "Firewall na routeru",
"tip": null,
"lines": [
"Router s firewallem filtruje pakety.",
"Může zakázat nebo povolit určité síťové služby."
]
},
{
"kicker": "3 / 7 • Multiprotokol",
"title": "Různé standardy",
"tip": null,
"lines": [
"Multiprotokolový router spojuje různé soustavy protokolů.",
"Například TCP/IP se sítěmi IPX/SPX.",
"Umí i různý síťový hardware (Ethernet ↔ Token Ring).",
"Konvertuje formát rámců i paketů."
]
},
{
"kicker": "4 / 7 • Routing",
"title": "Směrování a tabulky",
"tip": null,
"lines": [
"Každému paketu hledá vhodnou cestu k cíli.",
"Zohledňuje např. okamžité zatížení sítí.",
"Používá směrovací tabulky a musí znát topologii.",
"Směrovače si tabulky vyměňují (např. broadcastem)."
]
},
{
"kicker": "5 / 7 • Broadcast",
"title": "Broadcastové domény",
"tip": null,
"lines": [
"Router rozděluje síť na broadcastové domény.",
"Broadcasty lze filtrovat – nešíří se na všechny porty."
]
},
{
"kicker": "6 / 7 • Gateway",
"title": "Brána",
"tip": "Tip: pracuje až na aplikační vrstvě (i nižších)",
"lines": [
"Připojuje LAN na zcela odlišné prostředí.",
"Například mainframe nebo GSM síť.",
"Často PC s kartou/emulací terminálu nebo s modemem na WAN."
]
},
{
"kicker": "7 / 7 • Poznámka",
"title": "Gateway vs router",
"tip": null,
"lines": [
"Někdy se pod pojmem brána rozumí spíše router.",
"Gateway také označuje připojení LAN na WAN / internet."
]
}
],
"tasks": [
{
"number": 1,
"title": "Quiz",
"subtitleKey": "net_quiz20_head",
"items": [
{
"kind": "choice",
"prompt": "Směrovač (router) pracuje na vrstvě:",
"options": [
"Fyzické",
"Síťové (3.)",
"Jen aplikační",
"Jen prezentační"
],
"correct": 1,
"hint": "Router pracuje na síťové (3.) vrstvě"
},
{
"kind": "choice",
"prompt": "Router mimo jiné umožňuje:",
"options": [
"Segmentaci na podsítě a přístup na WAN",
"Jen lámání optiky",
"Jen Aloha bez IP",
"Jen cut-through bez MAC"
],
"correct": 0,
"hint": "Router segmentuje podsítě a zajišťuje WAN přístup"
},
{
"kind": "choice",
"prompt": "Firewall na routeru dokáže:",
"options": [
"Filtrovat pakety a řídit služby",
"Jen zesílit bitový signál",
"Jen uložit CAM bez IP",
"Jen nahradit fotodiodu"
],
"correct": 0,
"hint": "Firewall filtruje pakety a služby"
},
{
"kind": "choice",
"prompt": "Multiprotokolový router:",
"options": [
"Propojí sítě různých protokolů/standardů",
"Umí jen jeden protokol",
"Nepracuje s IP adresou",
"Nikdy nekonvertuje rámce"
],
"correct": 0,
"hint": "Multiprotokolový router spojuje různé protokoly/standardy"
},
{
"kind": "choice",
"prompt": "Router rozděluje síť na:",
"options": [
"Broadcastové domény",
"Jen fyzické kabely bez domén",
"Jen aplikační servery",
"Jen Token Ring bez Ethernetu"
],
"correct": 0,
"hint": "Router dělí síť na broadcastové domény"
},
{
"kind": "choice",
"prompt": "Brána (gateway) typicky:",
"options": [
"Připojuje LAN na odlišné prostředí (mainframe, GSM, WAN)",
"Pracuje jen jako hub",
"Nikdy nemá software",
"Funguje jen na fyzické vrstvě"
],
"correct": 0,
"hint": "Gateway připojuje LAN na odlišné prostředí"
},
{
"kind": "choice",
"prompt": "Poznámka k pojmu brána:",
"options": [
"Někdy se jí myslí spíše router / LAN–WAN připojení",
"Vždy znamená jen fotodiodu",
"Nikdy nesouvisí s internetem",
"Nahrazuje jen lámačku vláken"
],
"correct": 0,
"hint": "Pojem brána někdy označuje router nebo LAN–WAN připojení"
}
]
}
]
}

View file

@ -0,0 +1,180 @@
{
"id": "unit-21",
"subject": "networks",
"number": 21,
"titleKey": "net_unit21",
"subtitleKey": "net_unit21_sub",
"slides": [
{
"kicker": "1 / 8 • Ethernet",
"title": "Síťový hardware",
"tip": null,
"lines": [
"Jeden ze standardů síťového hardware.",
"Dnes nejpoužívanější."
]
},
{
"kicker": "2 / 8 • Adresy",
"title": "48bitová MAC",
"tip": "Tip: IEEE přidělí první 3 B, výrobce další 3 B",
"lines": [
"Adresa má 48 bitů (6 bytů) a je celosvětově jedinečná.",
"Je pevně v síťové kartě už od výroby.",
"Výrobci dostávají bloky adres od IEEE."
]
},
{
"kicker": "3 / 8 • Rámce",
"title": "Typy hlaviček",
"tip": null,
"lines": [
"Rámec = skupina bitů na linkové vrstvě.",
"Hlavička má adresy odesílatele/příjemce a typ obsahu.",
"Typy: Ethernet II, IEEE 802.3, 802.3 SNAP, raw 802.3."
]
},
{
"kicker": "4 / 8 • Ethernet II",
"title": "DIX + EtherType",
"tip": "Tip: EtherType > 1500 (např. IP = 0800h)",
"lines": [
"Původní DIX Ethernet.",
"Hlavička: příjemce 6 B, odesílatel 6 B, EtherType 2 B.",
"EtherType identifikuje protokol (IP, IPX…)."
]
},
{
"kicker": "5 / 8 • IEEE 802.3",
"title": "Délka místo typu",
"tip": null,
"lines": [
"Místo EtherType je údaj o délce (vždy ≤ 1500).",
"Uvnitř je rámec 802.2 (stejný i pro Token Ring).",
"Typ protokolu je v SAP mezi linkovou a síťovou vrstvou."
]
},
{
"kicker": "6 / 8 • Raw 802.3",
"title": "Novell / IPX",
"tip": null,
"lines": [
"„Holý“ rámec 802.3 bez 802.2 (Novell).",
"Funguje jen v prostředí IPX.",
"Paket začíná dvěma byty FFFFh."
]
},
{
"kicker": "7 / 8 • SNAP",
"title": "802.2 SNAP",
"tip": "Tip: byty 15–16 za délkou = AAAAh",
"lines": [
"Vložen do rámce 802.3.",
"Rozšiřuje identifikaci protokolu až na 5 bytů."
]
},
{
"kicker": "8 / 8 • Velikost",
"title": "Min / max / MTU",
"tip": null,
"lines": [
"Max. Ethernet II: 1500 + 18 = 1518 B.",
"Min. velikost: 64 B (kolizní okénko).",
"MTU je obvyklé omezení velikosti na routerech."
]
}
],
"tasks": [
{
"number": 1,
"title": "Quiz",
"subtitleKey": "net_quiz21_head",
"items": [
{
"kind": "choice",
"prompt": "Ethernetová adresa má:",
"options": [
"48 bitů (6 bytů) a je jedinečná",
"Jen 16 bitů",
"Jen 32 bitů IP",
"Jen 8 bitů SAP"
],
"correct": 0,
"hint": "MAC adresa Ethernetu má 48 bitů a je unikátní"
},
{
"kind": "choice",
"prompt": "První 3 byty MAC adresy přiděluje:",
"options": [
"IEEE výrobci",
"Jen uživatel v BIOS",
"Jen DNS server",
"Jen Aloha"
],
"correct": 0,
"hint": "IEEE přidělí výrobci první 3 byty adresy"
},
{
"kind": "choice",
"prompt": "Ethernet II v hlavičce používá:",
"options": [
"EtherType (např. IP = 0800h, hodnota > 1500)",
"Jen délku ≤ 1500 bez typu",
"Jen FFFFh bez adres",
"Jen AAAAh bez MAC"
],
"correct": 0,
"hint": "Ethernet II: EtherType > 1500 (IP = 0800h)"
},
{
"kind": "choice",
"prompt": "IEEE 802.3 místo EtherType má:",
"options": [
"Údaj o délce (≤ 1500) a uvnitř 802.2",
"Jen cut-through",
"Jen 64 B MTU bez délky",
"Jen laserovou hlavičku"
],
"correct": 0,
"hint": "IEEE 802.3: délka ≤ 1500 a rámec 802.2"
},
{
"kind": "choice",
"prompt": "Raw 802.3 (Novell):",
"options": [
"Bez 802.2, jen IPX, začíná FFFFh",
"Funguje jen s HTTP",
"Má vždy EtherType 0800h",
"Nemá žádné adresy"
],
"correct": 0,
"hint": "Raw 802.3: Novell/IPX, začíná FFFFh"
},
{
"kind": "choice",
"prompt": "Maximální rámec Ethernet II je:",
"options": [
"1518 B (1500 + 18)",
"64 B",
"900 µm",
"250 µm"
],
"correct": 0,
"hint": "Max. Ethernet II = 1518 B"
},
{
"kind": "choice",
"prompt": "Minimální velikost rámce plyne z:",
"options": [
"Kolizního okénka (64 B)",
"Jen z MTU routeru",
"Jen z SNAP AAAAh",
"Jen z Token Ring"
],
"correct": 0,
"hint": "Min. rámec 64 B kvůli koliznímu okénku"
}
]
}
]
}

View file

@ -0,0 +1,198 @@
{
"id": "unit-22",
"subject": "networks",
"number": 22,
"titleKey": "net_unit22",
"subtitleKey": "net_unit22_sub",
"slides": [
{
"kicker": "1 / 8 • Paket",
"title": "Header a data",
"tip": null,
"lines": [
"Paket/segment: hlavička (pro partnerskou vrstvu) + data.",
"Protokol určuje strukturu a pravidla stejnolehlých vrstev.",
"Může běžet na různých platformách."
]
},
{
"kicker": "2 / 8 • TCP/IP",
"title": "Protokoly po vrstvách",
"tip": null,
"lines": [
"Aplikační: aplikační protokoly.",
"Transportní: TCP, UDP.",
"Síťová: IP, ARP, RARP, ICMP, IGMP, RIP, OSPF.",
"Síťové rozhraní: Ethernet, Token Ring, ATM, PPP…"
]
},
{
"kicker": "3 / 8 • IP",
"title": "Univerzální přenos",
"tip": "Tip: nespojovaný a nespolehlivý",
"lines": [
"Jediný přenosový protokol TCP/IP nad libovolnou technologií.",
"Pracuje s virtuálními pakety – IP datagramy.",
"Stará se o směrování a přenos; velikost volí odesílatel.",
"Nezaručuje pořadí, dobu ani nepoškozené doručení."
]
},
{
"kicker": "4 / 8 • Routing",
"title": "Routing vs forwarding",
"tip": null,
"lines": [
"Routing: rozhodnutí o dalším směru datagramu.",
"Forwarding: vložení do linkového rámce a odeslání.",
"Linkové adresy se mění podle konkrétní sítě."
]
},
{
"kicker": "5 / 8 • Datagram",
"title": "Velikost a HLEN",
"tip": "Tip: Length 5 = 20 B hlavičky",
"lines": [
"Velikost: od 576 B do 64 kB.",
"Části: hlavička + data.",
"HLEN: délka hlavičky ve 32bitových slovech.",
"TOTAL LENGTH: délka celého paketu v bytech."
]
},
{
"kicker": "6 / 8 • Hlavička",
"title": "Klíčová pole",
"tip": null,
"lines": [
"VERSION (IPv4 = 4), LENGTH, TOTAL LENGTH.",
"IDENTIFICATION + FLAGS + OFFSET = fragmentace.",
"TTL: čítač směrovačů proti zacyklení.",
"PROTOCOL, HEADER CHECKSUM, zdrojová/cílová IP."
]
},
{
"kicker": "7 / 8 • PROTOCOL",
"title": "Čísla protokolů",
"tip": null,
"lines": [
"1 = ICMP, 2 = IGMP, 6 = TCP.",
"17 = UDP, 89 = OSPF.",
"Položka je 8bitová → max. 256 hodnot."
]
},
{
"kicker": "8 / 8 • Fragmentace",
"title": "MTU a Ethernet",
"tip": "Tip: Ethernet MTU = 1500 B",
"lines": [
"Fragmentace, když se datagram nevejde do menšího rámce.",
"MTU udává max. velikost rámce podle hardwaru.",
"DF = nefragmentuj; MF = další fragmenty."
]
}
],
"tasks": [
{
"number": 1,
"title": "Quiz",
"subtitleKey": "net_quiz22_head",
"items": [
{
"kind": "choice",
"prompt": "IP protokol je:",
"options": [
"Nespojovaný a nespolehlivý přenos datagramů",
"Spolehlivý a spojovaný jako TCP",
"Jen fyzické kódování",
"Jen EtherType bez IP"
],
"correct": 0,
"hint": "IP je nespojovaný a nespolehlivý"
},
{
"kind": "choice",
"prompt": "Routing u IP znamená:",
"options": [
"Rozhodnutí o dalším směru datagramu",
"Jen CRC na lince",
"Jen MAC učení ve switchi",
"Jen lámání vlákna"
],
"correct": 0,
"hint": "Routing = rozhodnutí o směru datagramu"
},
{
"kind": "choice",
"prompt": "Forwarding u IP znamená:",
"options": [
"Vložení do linkového rámce a odeslání",
"Jen výpočet metriky OSPF",
"Jen DNS překlad",
"Jen Aloha"
],
"correct": 0,
"hint": "Forwarding = vložení do rámce a odeslání"
},
{
"kind": "choice",
"prompt": "HLEN (LENGTH) s hodnotou 5 znamená:",
"options": [
"Hlavičku dlouhou 20 B",
"TTL 5",
"PROTOCOL = 5",
"MTU 5"
],
"correct": 0,
"hint": "HLEN 5 = 5 × 4 B = 20 B hlavičky"
},
{
"kind": "choice",
"prompt": "TTL v IP hlavičce:",
"options": [
"Počítá průchody směrovači a brání zacyklení",
"Udává EtherType",
"Nahrazuje MAC adresu",
"Je vždy 1500"
],
"correct": 0,
"hint": "TTL snižují směrovače proti zacyklení"
},
{
"kind": "choice",
"prompt": "Číslo protokolu 6 v IP hlavičce znamená:",
"options": [
"TCP",
"UDP",
"ICMP",
"OSPF"
],
"correct": 0,
"hint": "PROTOCOL 6 = TCP"
},
{
"kind": "choice",
"prompt": "Číslo protokolu 17 znamená:",
"options": [
"UDP",
"TCP",
"IGMP",
"ARP"
],
"correct": 0,
"hint": "PROTOCOL 17 = UDP"
},
{
"kind": "choice",
"prompt": "Ethernet MTU je typicky:",
"options": [
"1500 B",
"64 kB",
"576 B bez dat",
"20 B hlavičky"
],
"correct": 0,
"hint": "Ethernet MTU = 1500 B"
}
]
}
]
}

View file

@ -0,0 +1,139 @@
{
"id": "unit-23",
"subject": "networks",
"number": 23,
"titleKey": "net_unit23",
"subtitleKey": "net_unit23_sub",
"slides": [
{
"kicker": "1 / 5 • ICMP",
"title": "Řídicí zprávy",
"tip": "Tip: síťová vrstva, ale nese se v IP datagramu",
"lines": [
"Zasílá informace o chybách a stavu přenosu.",
"ICMP paket jede přímo v IP datagramu.",
"Směrovače s ním pracují i bez transportní vrstvy."
]
},
{
"kicker": "2 / 5 • Zprávy I",
"title": "Quench, TTL, Unreachable",
"tip": null,
"lines": [
"Source Quench: hrozí zahlcení – zpomal odesílání.",
"Time Exceeded: TTL kleslo na 0, paket zahozen (tracert).",
"Destination Unreachable: cíl (síť/uzel/protokol/port)",
"není dostupný."
]
},
{
"kicker": "3 / 5 • Zprávy II",
"title": "Redirect a Echo",
"tip": null,
"lines": [
"Redirect: přesměruj přenos na jiný (rychlejší) router.",
"Echo Request / Echo Reply: test dostupnosti (ping)."
]
},
{
"kicker": "4 / 5 • Traceroute",
"title": "Princip TTL",
"tip": "Tip: typicky max. 30 skoků",
"lines": [
"Odešle paket s TTL = 1; první router odpoví Time Exceeded.",
"Pak TTL = 2, 3… a tak se zjišťují skoky na cestě.",
"Pokračuje, dokud paket nedorazí do cíle nebo nevyprší limit."
]
},
{
"kicker": "5 / 5 • Shrnutí",
"title": "K čemu ICMP slouží",
"tip": null,
"lines": [
"Diagnostika a hlášení chyb na síťové vrstvě.",
"Základ příkazů ping a traceroute / tracert."
]
}
],
"tasks": [
{
"number": 1,
"title": "Quiz",
"subtitleKey": "net_quiz23_head",
"items": [
{
"kind": "choice",
"prompt": "ICMP slouží především k:",
"options": [
"Zasílání informací o chybách a stavu přenosu",
"Jen směrování OSPF tabulek",
"Jen lámání optiky",
"Jen EtherType 0800h"
],
"correct": 0,
"hint": "ICMP posílá informace o chybách a stavu přenosu"
},
{
"kind": "choice",
"prompt": "ICMP paket se přenáší:",
"options": [
"Přímo v IP datagramu",
"Jen jako Ethernet II bez IP",
"Jen v UDP bez IP",
"Jen jako MAC bez datagramu"
],
"correct": 0,
"hint": "ICMP jede uvnitř IP datagramu"
},
{
"kind": "choice",
"prompt": "Source Quench znamená:",
"options": [
"Varování před zahlcením – zpomal odesílání",
"TTL kleslo na 0",
"Cíl je nedostupný",
"Echo Reply"
],
"correct": 0,
"hint": "Source Quench = zpomal odesílání kvůli zahlcení"
},
{
"kind": "choice",
"prompt": "Time Exceeded nastane, když:",
"options": [
"TTL v IP hlavičce klesne na 0",
"Router nemá firewall",
"Použijeme jen SNAP",
"MTU je 1500"
],
"correct": 0,
"hint": "Time Exceeded = TTL kleslo na 0"
},
{
"kind": "choice",
"prompt": "Echo Request / Reply využívá příkaz:",
"options": [
"ping",
"lámačka",
"cut-through",
"hub"
],
"correct": 0,
"hint": "Echo Request/Reply = ping"
},
{
"kind": "choice",
"prompt": "Traceroute zjišťuje cestu tak, že:",
"options": [
"Postupně zvyšuje TTL a čeká na Time Exceeded",
"Posílá jen ARP bez IP",
"Ignoruje směrovače",
"Používá jen CAM tabulku"
],
"correct": 0,
"hint": "Traceroute zvyšuje TTL a čte Time Exceeded"
}
]
}
]
}

View file

@ -0,0 +1,138 @@
{
"id": "unit-24",
"subject": "networks",
"number": 24,
"titleKey": "net_unit24",
"subtitleKey": "net_unit24_sub",
"slides": [
{
"kicker": "1 / 5 • ARP / RARP",
"title": "Převod adres",
"tip": null,
"lines": [
"ARP: z IP adresy na MAC (fyzickou) adresu.",
"RARP: z MAC adresy na IP adresu."
]
},
{
"kicker": "2 / 5 • Způsoby",
"title": "Jak zjistit MAC",
"tip": null,
"lines": [
"Tabulkový převod: ruční správa (malé sítě).",
"Výpočet: funkce, kde MAC plyne z IP.",
"Dotaz a odpověď: dynamické zjišťování (nejčastější)."
]
},
{
"kicker": "3 / 5 • Mechanismus",
"title": "Broadcast a unicast",
"tip": "Tip: „Kdo má IP x.x.x.x?“",
"lines": [
"ARP využívá broadcast v Ethernetu.",
"Odpoví jen uzel s danou IP.",
"Odpověď jde unicastem a obsahuje MAC adresu."
]
},
{
"kicker": "4 / 5 • Cache",
"title": "Dočasná paměť",
"tip": "Tip: zobrazení příkazem arp -a",
"lines": [
"Výsledky se ukládají do ARP cache.",
"Dotazy se tak nemusí stále opakovat.",
"Položky: dynamické (učené) nebo statické (ruční)."
]
},
{
"kicker": "5 / 5 • Shrnutí",
"title": "K čemu ARP slouží",
"tip": null,
"lines": [
"Propojuje síťovou adresu (IP) s linkovou (MAC).",
"Bez ARP by IP datagram nešel vložit do Ethernet rámce."
]
}
],
"tasks": [
{
"number": 1,
"title": "Quiz",
"subtitleKey": "net_quiz24_head",
"items": [
{
"kind": "choice",
"prompt": "ARP převádí:",
"options": [
"IP adresu na MAC adresu",
"MAC adresu na IP adresu",
"TCP na UDP",
"TTL na MTU"
],
"correct": 0,
"hint": "ARP: IP → MAC"
},
{
"kind": "choice",
"prompt": "RARP převádí:",
"options": [
"MAC adresu na IP adresu",
"IP adresu na MAC adresu",
"EtherType na SAP",
"OSPF na RIP"
],
"correct": 0,
"hint": "RARP: MAC → IP"
},
{
"kind": "choice",
"prompt": "Nejčastější způsob ARP je:",
"options": [
"Dotaz a odpověď v síti",
"Jen ruční tabulka bez sítě",
"Jen cut-through",
"Jen ICMP Echo"
],
"correct": 0,
"hint": "Nejčastější je dynamický dotaz a odpověď"
},
{
"kind": "choice",
"prompt": "ARP dotaz v Ethernetu používá:",
"options": [
"Broadcast",
"Jen unicast bez broadcastu",
"Jen laser",
"Jen Token Ring bez IP"
],
"correct": 0,
"hint": "ARP dotaz jde broadcastem"
},
{
"kind": "choice",
"prompt": "ARP odpověď typicky přichází jako:",
"options": [
"Unicast s MAC adresou uzlu",
"Broadcast bez MAC",
"Jen Time Exceeded",
"Jen Source Quench"
],
"correct": 0,
"hint": "ARP odpověď je unicast s MAC"
},
{
"kind": "choice",
"prompt": "ARP cache slouží k tomu, aby:",
"options": [
"Se nemusely stále opakovat stejné dotazy",
"Se nahradil směrovač",
"Se zrušila IP adresa",
"Se vypnul Ethernet"
],
"correct": 0,
"hint": "ARP cache ukládá výsledky dočasně (arp -a)"
}
]
}
]
}

View file

@ -0,0 +1,164 @@
{
"id": "unit-25",
"subject": "networks",
"number": 25,
"titleKey": "net_unit25",
"subtitleKey": "net_unit25_sub",
"slides": [
{
"kicker": "1 / 6 • TCP vs UDP",
"title": "Základní rozdíly",
"tip": null,
"lines": [
"TCP: spolehlivý a spojovaný přenos.",
"Potvrzování, kontrola pořadí a řízení toku.",
"UDP: nespolehlivý a nespojovaný.",
"Minimální režie – neřeší potvrzení ani pořadí."
]
},
{
"kicker": "2 / 6 • Porty",
"title": "Multiplex a demultiplex",
"tip": null,
"lines": [
"Porty rozlišují aplikace (procesy) na jednom uzlu.",
"Multiplex: sběr dat od aplikací do segmentů/datagramů.",
"Demultiplex: doručení příchozích dat správné aplikaci",
"podle čísla portu."
]
},
{
"kicker": "3 / 6 • TCP",
"title": "Spojení a vlastnosti",
"tip": "Tip: three-way handshake",
"lines": [
"Navázání spojení: třícestné podání ruky.",
"Streamově orientovaný a full duplex.",
"Jen unicast – bez multicastu a broadcastu."
]
},
{
"kicker": "4 / 6 • Nevýhody TCP",
"title": "Režie a real-time",
"tip": null,
"lines": [
"Větší režie: hlavička 20 B (UDP má 8 B).",
"Nevhodný pro real-time média: ztráta segmentu",
"zastaví proud a čeká se na znovudoručení.",
"Bez multihomingu – výpadek rozhraní shodí spojení."
]
},
{
"kicker": "5 / 6 • Alternativy",
"title": "SCTP a DCCP",
"tip": null,
"lines": [
"SCTP: multihoming a multistreaming (až 64k proudů).",
"DCCP: datagramy s řízením zahlcení",
"(streaming, IP telefonie)."
]
},
{
"kicker": "6 / 6 • Shrnutí",
"title": "Kdy co použít",
"tip": null,
"lines": [
"TCP: spolehlivá data (web, pošta, soubory).",
"UDP: rychlost a nízká režie (DNS, hry, real-time)."
]
}
],
"tasks": [
{
"number": 1,
"title": "Quiz",
"subtitleKey": "net_quiz25_head",
"items": [
{
"kind": "choice",
"prompt": "TCP je:",
"options": [
"Spolehlivý a spojovaný",
"Nespolehlivý a nespojovaný",
"Jen fyzická vrstva",
"Jen ARP cache"
],
"correct": 0,
"hint": "TCP = spolehlivý a spojovaný"
},
{
"kind": "choice",
"prompt": "UDP je:",
"options": [
"Nespolehlivý a nespojovaný s malou režií",
"Spolehlivý se třícestným handshake",
"Jen store-and-forward",
"Jen ICMP Time Exceeded"
],
"correct": 0,
"hint": "UDP = nespolehlivý, nespojovaný, malá režie"
},
{
"kind": "choice",
"prompt": "Porty slouží k:",
"options": [
"Rozlišení aplikací na jednom uzlu",
"Jen směrování IP",
"Jen MAC učení",
"Jen lámání vlákna"
],
"correct": 0,
"hint": "Porty rozlišují aplikace na uzlu"
},
{
"kind": "choice",
"prompt": "Navázání TCP spojení používá:",
"options": [
"Třícestné podání ruky (three-way handshake)",
"Jen broadcast ARP",
"Jen Source Quench",
"Jen cut-through"
],
"correct": 0,
"hint": "TCP se navazuje three-way handshake"
},
{
"kind": "choice",
"prompt": "Hlavička TCP má typicky:",
"options": [
"20 B (UDP 8 B)",
"8 B (UDP 20 B)",
"1500 B",
"64 B min. rámec"
],
"correct": 0,
"hint": "TCP hlavička 20 B, UDP 8 B"
},
{
"kind": "choice",
"prompt": "TCP není vhodný pro real-time média hlavně proto, že:",
"options": [
"Ztráta segmentu zastaví proud a čeká se na znovudoručení",
"Nemá žádné porty",
"Nepodporuje unicast",
"Nemá hlavičku"
],
"correct": 0,
"hint": "TCP čeká na znovudoručení – špatné pro real-time"
},
{
"kind": "choice",
"prompt": "SCTP oproti TCP nabízí:",
"options": [
"Multihoming a multistreaming",
"Jen Ethernet II",
"Jen RARP",
"Jen hub flooding"
],
"correct": 0,
"hint": "SCTP: multihoming a multistreaming"
}
]
}
]
}

View file

@ -0,0 +1,185 @@
{
"id": "unit-26",
"subject": "networks",
"number": 26,
"titleKey": "net_unit26",
"subtitleKey": "net_unit26_sub",
"slides": [
{
"kicker": "1 / 7 • Úvod",
"title": "Aplikační protokoly",
"tip": null,
"lines": [
"Na uzlu běží více síťových aplikací současně.",
"Běžící aplikace = v operační paměti (1+ procesů).",
"Pracují podle pravidel – protokolů aplikační vrstvy.",
"Data posílají po síti přes TCP nebo UDP."
]
},
{
"kicker": "2 / 7 • Porty",
"title": "Procesy a porty",
"tip": null,
"lines": [
"Port v TCP/UDP říká, které aplikaci data patří.",
"Jeden proces může používat více portů.",
"Dva procesy nesmí používat stejný port."
]
},
{
"kicker": "3 / 7 • Rozsahy",
"title": "Well-known, registrované, dynamické",
"tip": "Tip: IANA",
"lines": [
"Well-known: 0–1023 – vyhrazené službám (norma IANA).",
"Registrované: 1024–49151 – IANA jen registruje použití.",
"Dynamické/privátní: 49152–65535 – volně k použití."
]
},
{
"kicker": "4 / 7 • Spojení",
"title": "Pětice hodnot",
"tip": null,
"lines": [
"Aplikační spojení určuje pětice:",
"(transport, IP1, port1, IP2, port2).",
"Klient osloví server na well-known portu."
]
},
{
"kicker": "5 / 7 • Více spojení",
"title": "Stejný server, různí klienti",
"tip": null,
"lines": [
"Na jeden uzel (např. web server) může být",
"více aplikačních spojení z různých klientů.",
"Rozlišují je IP a porty klientů (i transport)."
]
},
{
"kicker": "6 / 7 • Porty I",
"title": "Časté well-known služby",
"tip": null,
"lines": [
"20/21 FTP, 22 SSH, 23 Telnet, 25 SMTP, 53 DNS.",
"67/68 DHCP, 80 HTTP, 110 POP3, 143 IMAP.",
"123 NTP, 161 SNMP, 389 LDAP, 443 HTTPS."
]
},
{
"kicker": "7 / 7 • Porty II",
"title": "Zabezpečené a registrované",
"tip": null,
"lines": [
"587 SMTPS, 636 LDAPS, 993 IMAPS, 995 POP3S.",
"Registrované: 3306 MySQL, 3389 RDP, 5900 VNC.",
"Dynamické porty: 49152–65535."
]
}
],
"tasks": [
{
"number": 1,
"title": "Quiz",
"subtitleKey": "net_quiz26_head",
"items": [
{
"kind": "choice",
"prompt": "Aplikační protokoly typicky posílají data přes:",
"options": [
"TCP nebo UDP",
"Jen fyzickou vrstvu",
"Jen ARP",
"Jen CAM tabulku"
],
"correct": 0,
"hint": "Aplikace posílají data přes TCP/UDP"
},
{
"kind": "choice",
"prompt": "Dva běžící procesy:",
"options": [
"Nesmí používat stejný port",
"Musí sdílet jeden port",
"Nesmí mít žádný port",
"Používají jen MAC"
],
"correct": 0,
"hint": "Dva procesy nesmí sdílet stejný port"
},
{
"kind": "choice",
"prompt": "Well-known porty mají rozsah:",
"options": [
"0–1023",
"1024–49151",
"49152–65535",
"Jen 80–443"
],
"correct": 0,
"hint": "Well-known: 0–1023 (IANA)"
},
{
"kind": "choice",
"prompt": "Dynamické (privátní) porty jsou:",
"options": [
"49152–65535",
"0–1023",
"Jen 20–21",
"Jen 3306"
],
"correct": 0,
"hint": "Dynamické porty: 49152–65535"
},
{
"kind": "choice",
"prompt": "Aplikační spojení určuje:",
"options": [
"Pětice (transport, IP1, port1, IP2, port2)",
"Jen MAC adresa",
"Jen název souboru",
"Jen VLAN ID"
],
"correct": 0,
"hint": "Spojení = pětice hodnot"
},
{
"kind": "choice",
"prompt": "HTTP a HTTPS používají porty:",
"options": [
"80 a 443",
"22 a 23",
"25 a 110",
"53 a 67"
],
"correct": 0,
"hint": "HTTP 80, HTTPS 443"
},
{
"kind": "choice",
"prompt": "SSH a DNS mají porty:",
"options": [
"22 a 53",
"80 a 443",
"3389 a 5900",
"20 a 21"
],
"correct": 0,
"hint": "SSH 22, DNS 53"
},
{
"kind": "choice",
"prompt": "MySQL a RDP jsou typicky na portech:",
"options": [
"3306 a 3389",
"80 a 443",
"67 a 68",
"161 a 162"
],
"correct": 0,
"hint": "MySQL 3306, RDP 3389"
}
]
}
]
}

View file

@ -0,0 +1,196 @@
{
"id": "unit-27",
"subject": "networks",
"number": 27,
"titleKey": "net_unit27",
"subtitleKey": "net_unit27_sub",
"slides": [
{
"kicker": "1 / 8 • Úvod",
"title": "Bezdrátové sítě",
"tip": null,
"lines": [
"Patří sem hlavně: družicové, mikrovlnné a optické spoje.",
"Dále Wi-Fi (LAN), WiMAX/LTE (MAN/WAN) a další."
]
},
{
"kicker": "2 / 8 • Družice",
"title": "Geostacionární spoje",
"tip": null,
"lines": [
"Geostacionární družice: ~36 000 km, stále nad stejným bodem.",
"Pasivní odrážejí; aktivní mají transpondéry (převod + zisk).",
"C-band ~6/4 GHz; KU-band 12–14 GHz (menší antény).",
"Nevýhoda: zpoždění ~250–300 ms (RTT až ~600 ms)."
]
},
{
"kicker": "3 / 8 • VSAT",
"title": "Přístup a topologie",
"tip": null,
"lines": [
"Bod–bod, broadcast (TV) i multiple access.",
"VSAT: terminály + centrální hub.",
"Komunikace jen terminál ↔ hub (ne přímo mezi terminály)."
]
},
{
"kicker": "4 / 8 • Mikrovlny",
"title": "Pozemní spoje",
"tip": "Tip: přímá viditelnost",
"lines": [
"Frekvence 1–12 GHz, směrování parabolou, malý rozptyl.",
"Dosah typicky do ~50 km; retranslační stanice.",
"Troposférické spoje: odraz ve ~16 km, dosah až ~500 km."
]
},
{
"kicker": "5 / 8 • Wi-Fi pojmy",
"title": "AP, BSS, ESS",
"tip": null,
"lines": [
"AP = přístupový bod; STA komunikují přes AP (ne přímo).",
"BSS = stanice v buňce BSA; ESS = více buněk přes DS.",
"Roaming při překrytí; ad-hoc = P2P bez AP; hotspot = Wi-Fi.",
"ESSID identifikuje síť při přístupu k AP."
]
},
{
"kicker": "6 / 8 • Standardy",
"title": "802.11 a … ax",
"tip": null,
"lines": [
"b: 11 Mb/s, 2,4 GHz; a/g: 54 Mb/s; n: MIMO, až 600 Mb/s.",
"ac (Wi-Fi 5): 256-QAM, široké kanály; ax (Wi-Fi 6): OFDMA.",
"OFDM: mnoho ortogonálních subnosných v kanálu."
]
},
{
"kicker": "7 / 8 • Zabezpečení",
"title": "WEP → WPA3",
"tip": null,
"lines": [
"WEP: RC4, krátký IV – prolomeno (2001), nepoužívat.",
"WPA: TKIP; WPA2: AES (doporučený základ).",
"WPA3: SAE místo PSK, silnější ochrana hesla.",
"802.1X/EAP: ověření klienta přes AP (RADIUS)."
]
},
{
"kicker": "8 / 8 • Shrnutí",
"title": "Dosahy a další",
"tip": null,
"lines": [
"PAN <10 m (Bluetooth); LAN = Wi-Fi; MAN = WiMAX.",
"WAN/mobilita: 802.16e, LTE; licencovaná pásma = méně rušení.",
"Používej WPA2/WPA3 + AES, ne WEP."
]
}
],
"tasks": [
{
"number": 1,
"title": "Quiz",
"subtitleKey": "net_quiz27_head",
"items": [
{
"kind": "choice",
"prompt": "Geostacionární družice létají asi:",
"options": [
"36 000 km nad Zemí",
"16 km v troposféře",
"50 km nad povrchem",
"Jen v nízké orbitě LEO"
],
"correct": 0,
"hint": "Geostacionární družice ~36 000 km"
},
{
"kind": "choice",
"prompt": "Hlavní nevýhoda družicového spoje je:",
"options": [
"Velké zpoždění (~250–300 ms)",
"Žádný broadcast",
"Jen kabelové médium",
"Absence portů"
],
"correct": 0,
"hint": "Družice: zpoždění stovky ms"
},
{
"kind": "choice",
"prompt": "V síti VSAT komunikace probíhá:",
"options": [
"Jen mezi terminálem a hubem",
"Přímo mezi dvěma terminály",
"Jen přes ARP",
"Jen přes WEP"
],
"correct": 0,
"hint": "VSAT: jen terminál ↔ hub"
},
{
"kind": "choice",
"prompt": "Mikrovlnný spoj typicky vyžaduje:",
"options": [
"Přímou viditelnost (dosah ~50 km)",
"Jen metalický kabel",
"Jen port 80",
"Jen WEP klíč"
],
"correct": 0,
"hint": "Mikrovlny: přímá viditelnost ~50 km"
},
{
"kind": "choice",
"prompt": "V infrastruktuře Wi-Fi stanice (STA):",
"options": [
"Komunikují přes AP, ne přímo mezi sebou",
"Vždy jen ad-hoc bez AP",
"Nepoužívají ESSID",
"Běží jen na 36 000 km"
],
"correct": 0,
"hint": "STA komunikují přes AP"
},
{
"kind": "choice",
"prompt": "802.11ax (Wi-Fi 6) přináší zejména:",
"options": [
"OFDMA a vysokou kapacitu",
"Jen WEP",
"Jen C-band 4/6 GHz",
"Jen troposférický odraz"
],
"correct": 0,
"hint": "Wi-Fi 6 (ax): OFDMA"
},
{
"kind": "choice",
"prompt": "WEP je dnes:",
"options": [
"Prolomený a nevhodný",
"Nejbezpečnější volba",
"Totéž co WPA3-SAE",
"Povinný od roku 2006"
],
"correct": 0,
"hint": "WEP prolomen – nepoužívat"
},
{
"kind": "choice",
"prompt": "Doporučené zabezpečení Wi-Fi je:",
"options": [
"WPA2/WPA3 s AES (ne WEP)",
"Jen Open system WEP",
"Jen Shared key WEP",
"Bez hesla vždy"
],
"correct": 0,
"hint": "Používej WPA2/WPA3 + AES"
}
]
}
]
}

63
content/manifest.json Normal file
View file

@ -0,0 +1,63 @@
{
"subjects": [
"deutsch",
"networks",
"hardware",
"czech",
"civics",
"english",
"math",
"physics",
"science",
"tech-graphics",
"presentation",
"programming",
"software"
],
"lessons": {
"deutsch": [
"unit-01",
"unit-02",
"unit-03"
],
"networks": [
"unit-01",
"unit-02",
"unit-03",
"unit-04",
"unit-05",
"unit-06",
"unit-07",
"unit-08",
"unit-09",
"unit-10",
"unit-11",
"unit-12",
"unit-13",
"unit-14",
"unit-15",
"unit-16",
"unit-17",
"unit-18",
"unit-19",
"unit-20",
"unit-21",
"unit-22",
"unit-23",
"unit-24",
"unit-25",
"unit-26",
"unit-27"
],
"hardware": [
"unit-01",
"unit-02",
"unit-03"
],
"czech": [
"mluvnice",
"orwell-1984",
"fuks-spalovac-mrtvol"
]
}
}

389
content/paths.json Normal file
View file

@ -0,0 +1,389 @@
[
{
"subject": "deutsch",
"units": [
{
"number": 1,
"lesson": "unit-01",
"title": {
"cs": "Neue Freunde",
"en": "Neue Freunde"
},
"unlocked": true
},
{
"number": 2,
"lesson": "unit-02",
"title": {
"cs": "Aus aller Welt",
"en": "Aus aller Welt"
},
"unlocked": true
},
{
"number": 3,
"lesson": "unit-03",
"title": {
"cs": "Bei uns zu Hause",
"en": "Bei uns zu Hause"
},
"unlocked": true
},
{
"number": 4,
"lesson": null,
"title": {
"cs": "Schule und Freizeit",
"en": "Schule und Freizeit"
},
"unlocked": false
},
{
"number": 5,
"lesson": null,
"title": {
"cs": "Guten Appetit!",
"en": "Guten Appetit!"
},
"unlocked": false
},
{
"number": 6,
"lesson": null,
"title": {
"cs": "Mein Tagesablauf",
"en": "Mein Tagesablauf"
},
"unlocked": false
},
{
"number": 7,
"lesson": null,
"title": {
"cs": "Meine Freunde",
"en": "Meine Freunde"
},
"unlocked": false
},
{
"number": 8,
"lesson": null,
"title": {
"cs": "Wir treffen uns in Salzburg",
"en": "Wir treffen uns in Salzburg"
},
"unlocked": false
},
{
"number": 9,
"lesson": null,
"title": {
"cs": "Mein Haus ist meine Burg",
"en": "Mein Haus ist meine Burg"
},
"unlocked": false
},
{
"number": 10,
"lesson": null,
"title": {
"cs": "Urlaub in Österreich",
"en": "Urlaub in Österreich"
},
"unlocked": false
}
]
},
{
"subject": "networks",
"year": 1,
"units": [
{
"number": 1,
"lesson": "unit-01",
"titleKey": "net_unit1",
"unlocked": true
},
{
"number": 2,
"lesson": "unit-02",
"titleKey": "net_unit2",
"unlocked": true
},
{
"number": 3,
"lesson": "unit-03",
"titleKey": "net_unit3",
"unlocked": true
},
{
"number": 4,
"lesson": "unit-04",
"titleKey": "net_unit4",
"unlocked": true
},
{
"number": 5,
"lesson": "unit-05",
"titleKey": "net_unit5",
"unlocked": true
},
{
"number": 6,
"lesson": "unit-06",
"titleKey": "net_unit6",
"unlocked": true
},
{
"number": 7,
"lesson": "unit-07",
"titleKey": "net_unit7",
"unlocked": true
},
{
"number": 8,
"lesson": "unit-08",
"titleKey": "net_unit8",
"unlocked": true
},
{
"number": 9,
"lesson": "unit-09",
"titleKey": "net_unit9",
"unlocked": true
},
{
"number": 10,
"lesson": "unit-10",
"titleKey": "net_unit10",
"unlocked": true
},
{
"number": 11,
"lesson": "unit-11",
"titleKey": "net_unit11",
"unlocked": true
},
{
"number": 12,
"lesson": "unit-12",
"titleKey": "net_unit12",
"unlocked": true
},
{
"number": 13,
"lesson": "unit-13",
"titleKey": "net_unit13",
"unlocked": true
},
{
"number": 14,
"lesson": "unit-14",
"titleKey": "net_unit14",
"unlocked": true
},
{
"number": 15,
"lesson": "unit-15",
"titleKey": "net_unit15",
"unlocked": true
},
{
"number": 16,
"lesson": "unit-16",
"titleKey": "net_unit16",
"unlocked": true
},
{
"number": 17,
"lesson": "unit-17",
"titleKey": "net_unit17",
"unlocked": true
},
{
"number": 18,
"lesson": "unit-18",
"titleKey": "net_unit18",
"unlocked": true
},
{
"number": 19,
"lesson": "unit-19",
"titleKey": "net_unit19",
"unlocked": true
},
{
"number": 20,
"lesson": "unit-20",
"titleKey": "net_unit20",
"unlocked": true
},
{
"number": 21,
"lesson": "unit-21",
"titleKey": "net_unit21",
"unlocked": true
},
{
"number": 22,
"lesson": "unit-22",
"titleKey": "net_unit22",
"unlocked": true
},
{
"number": 23,
"lesson": "unit-23",
"titleKey": "net_unit23",
"unlocked": true
},
{
"number": 24,
"lesson": "unit-24",
"titleKey": "net_unit24",
"unlocked": true
},
{
"number": 25,
"lesson": "unit-25",
"titleKey": "net_unit25",
"unlocked": true
},
{
"number": 26,
"lesson": "unit-26",
"titleKey": "net_unit26",
"unlocked": true
},
{
"number": 27,
"lesson": "unit-27",
"titleKey": "net_unit27",
"unlocked": true
}
]
},
{
"subject": "hardware",
"units": [
{
"number": 1,
"lesson": "unit-01",
"titleKey": "hw_unit1",
"unlocked": true
},
{
"number": 2,
"lesson": "unit-02",
"titleKey": "hw_unit2",
"unlocked": true
},
{
"number": 3,
"lesson": "unit-03",
"titleKey": "hw_unit3",
"unlocked": true
},
{
"number": 4,
"lesson": null,
"titleKey": "hw_unit4",
"unlocked": false
},
{
"number": 5,
"lesson": null,
"titleKey": "hw_unit5",
"unlocked": false
},
{
"number": 6,
"lesson": null,
"titleKey": "hw_unit6",
"unlocked": false
},
{
"number": 7,
"lesson": null,
"titleKey": "hw_unit7",
"unlocked": false
},
{
"number": 8,
"lesson": null,
"titleKey": "hw_unit8",
"unlocked": false
},
{
"number": 9,
"lesson": null,
"titleKey": "hw_unit9",
"unlocked": false
},
{
"number": 10,
"lesson": null,
"titleKey": "hw_unit10",
"unlocked": false
}
]
},
{
"subject": "czech",
"units": [
{
"number": 1,
"lesson": null,
"route": null,
"title": {
"cs": "Literatura",
"en": "Literatura"
},
"unlocked": false
},
{
"number": 2,
"lesson": null,
"route": "/czech/mluvnice",
"title": {
"cs": "Mluvnice",
"en": "Mluvnice"
},
"unlocked": true
},
{
"number": 3,
"lesson": null,
"route": "/czech/reading",
"title": {
"cs": "Maturitní četba",
"en": "Maturitní četba"
},
"unlocked": true
}
]
},
{
"subject": "czech",
"branch": "reading",
"units": [
{
"number": 1,
"lesson": "orwell-1984",
"title": {
"cs": "George Orwell – 1984",
"en": "George Orwell – 1984"
},
"unlocked": true
},
{
"number": 2,
"lesson": "fuks-spalovac-mrtvol",
"title": {
"cs": "Ladislav Fuks – Spalovač mrtvol",
"en": "Ladislav Fuks – Spalovač mrtvol"
},
"unlocked": true
}
]
}
]

337
content/schema.ts Normal file
View file

@ -0,0 +1,337 @@
/**
* Shared lesson content schema.
*
* This is the single description of everything under `content/`. Both the
* extractor in `tools/` and the Next.js app in `web/` type against it, so a
* shape change here surfaces as a type error on both sides.
*/
export type Lang = 'cs' | 'en';
/** A short piece of text that may differ per UI language. */
export interface Localized {
cs: string;
en?: string;
}
/* ---------------------------------------------------------------- themes --- */
export interface ThemePalette {
crust: string;
base: string;
mantle: string;
surface0: string;
surface1: string;
surface2: string;
overlay: string;
text: string;
subtext: string;
accent: string;
accent2: string;
accent3: string;
success: string;
success2: string;
warning: string;
error: string;
onAccent: string;
node: string;
lockedBg: string;
lockedBorder: string;
rail: string;
finishDark: string;
finishLight: string;
finishStroke: string;
}
export interface Theme {
id: string;
name: string;
dark: ThemePalette;
light: ThemePalette;
}
/* -------------------------------------------------------------- subjects --- */
export interface Subject {
/** Stable slug used in URLs, e.g. `deutsch`, `networks`. */
id: string;
/** i18n key holding the display name. */
key: string;
icon: 'flag-de' | 'wifi' | 'chip' | 'book' | null;
unlocked: boolean;
/** Route the subject node opens, or null when it is a locked placeholder. */
route: string | null;
}
/* ----------------------------------------------------------------- units --- */
/**
* One node on a serpentine learning path.
*
* A node carries either a literal `title` (German unit names are the same in
* both languages) or a `titleKey` to look up in the translation tables.
*/
export interface UnitRef {
/** 1-based position on the path. */
number: number;
/** Slug of the lesson file under `content/lessons/<subject>/`, if any. */
lesson: string | null;
title?: Localized;
titleKey?: string;
/** Destination for nodes that open another path rather than a lesson. */
route?: string | null;
unlocked: boolean;
}
export interface Path {
subject: string;
/** Optional school-year grouping, as the networks subject uses. */
year?: number;
/** Set on secondary paths within a subject, e.g. the Czech reading list. */
branch?: string;
units: UnitRef[];
}
/* ----------------------------------------------------------- theory slides - */
export interface Slide {
/** Small label above the title, e.g. `1 / 4 • VLSM`. */
kicker: string;
title: string;
/** Highlighted aside shown under the title. */
tip: string | null;
lines: string[];
}
/* -------------------------------------------------------------- exercises -- */
/** Multiple choice. `correct` indexes into `options`. */
export interface ChoiceExercise {
kind: 'choice';
prompt: string;
options: string[];
correct: number;
hint?: string;
/** Czech gloss of the prompt, shown as a translation aid. */
meaning?: string;
}
/**
* A sentence split into literal segments with gaps between them.
*
* `segments.length === answers.length + 1`. Every gap variant in the desktop
* app (verb insertion, number words, sequences, possessives, letters) collapses
* into this one shape; `input` decides whether the gap is a dropdown fed from
* `pool` or a free text field.
*/
export interface FillExercise {
kind: 'fill';
segments: string[];
answers: string[];
input: 'select' | 'text';
/** Row label such as `1.` or `a)`. */
label?: string;
/** Group heading rendered above this row, e.g. a speaker's name. */
heading?: string;
/** Emoji or picture clue shown before the sentence. */
emoji?: string;
/** Extra clue such as the digits to spell out. */
clue?: string;
/** Czech translation revealed as a hint. */
meaning?: string;
/** Per-gap glosses, aligned with `answers`. */
glosses?: (string | null)[];
/**
* Which of the task's word lists feeds each gap, aligned with `answers`.
* Defaults to the task `pool`; `alt` selects `altPool`.
*/
gapPool?: ('main' | 'alt')[];
/** Gaps hold single letters rather than whole words, so render them narrow. */
letters?: boolean;
}
/** Free-form answer checked against a model answer rather than exactly. */
export interface FreeExercise {
kind: 'free';
question: string;
questionCs?: string;
sample: string;
sampleCs?: string;
}
/** Type the answer; `answers` lists all accepted spellings. */
export interface TypedExercise {
kind: 'typed';
prompt: string;
answers: string[];
meaning?: string;
/** Full model sentence revealed after checking, where one exists. */
model?: string;
/** Accepted answers are substrings of a longer sentence, not the whole input. */
substring?: boolean;
}
/**
* A prompt answered through several labelled fields, as the Czech grammar
* exercises use ("determine gender, number and case").
*/
export interface FieldsExercise {
kind: 'fields';
prompt: string;
fields: { label: string; answers: string[] }[];
/** The full correct answer, revealed after checking. */
solution?: string;
}
/**
* Self-assessed exercise: think of the answer, then reveal the model solution.
* Used where no automatic check is meaningful (morpheme analysis, punctuation,
* rewriting direct speech).
*/
export interface RevealExercise {
kind: 'reveal';
prompt: string;
solution: string;
}
/** Drag or click words into order to build a sentence. */
export interface AssemblyExercise {
kind: 'assembly';
prompt: string;
words: string[];
meaning?: string;
}
/** Sort labelled items into named groups. */
export interface AssignExercise {
kind: 'assign';
groups: string[];
items: { emoji: string | null; label: string; group: number; meaning?: string }[];
}
/**
* A dialogue where some replies are missing.
*
* `answer` may list several accepted words separated by `|`, matching the
* desktop app's answer comparison.
*/
export interface DialogueExercise {
kind: 'dialogue';
title: string;
rows: {
speaker: string;
before: string;
answer: string;
after: string;
meaning?: string;
}[];
}
/** Guess the word letter by letter. */
export interface HangmanExercise {
kind: 'hangman';
word: string;
hint: string;
}
/** Open profile question with a model answer ("Steckbrief"). */
export interface ProfileExercise {
kind: 'profile';
stem: string;
sample: string;
}
/** Editable grid, e.g. the possessive-pronoun table. */
export interface TableExercise {
kind: 'table';
columns: string[];
rows: string[];
/** `answers[row][col]`; a prefilled cell has `given: true`. */
answers: { value: string; given?: boolean }[][];
}
/** VLSM subnetting: split a network into four blocks. */
export interface SubnetExercise {
kind: 'subnet';
scenario: string;
solution: string;
blocks: {
prefix: number;
network: string;
broadcast: string;
firstHost: string;
lastHost: string;
}[] | null;
}
export type Exercise =
| ChoiceExercise
| FillExercise
| FreeExercise
| TypedExercise
| FieldsExercise
| RevealExercise
| AssemblyExercise
| AssignExercise
| DialogueExercise
| HangmanExercise
| ProfileExercise
| TableExercise
| SubnetExercise;
/* ---------------------------------------------------------------- lessons -- */
/** A group of exercises presented as one task on the unit map. */
export interface Task {
/** 1-based bubble number on the unit map. */
number: number;
/** Short label for the map bubble. */
title: string;
/** Longer heading for the exercise page, where it differs from `title`. */
heading?: string;
/** i18n key for the instruction line. */
subtitleKey: string | null;
/** Literal instruction, where the desktop app passes text instead of a key. */
subtitle?: string;
/** Worked example shown above the items. */
example?: string;
/** Extra note under the instruction. */
note?: string;
/** Words offered to every `select` gap in this task. */
pool?: string[];
/** Second word list, selected per gap via `FillExercise.gapPool`. */
altPool?: string[];
items: Exercise[];
}
export interface VocabSection {
header: string;
items: { prompt: string; answers: string[]; meaning?: string }[];
}
export interface Lesson {
/** File slug, unique within the subject. */
id: string;
subject: string;
/** Position on the subject path. */
number: number;
/** Literal title, or `titleKey` when the text comes from the i18n tables. */
title?: Localized;
titleKey?: string;
subtitle?: Localized;
subtitleKey?: string;
/** Theory shown before the exercises. */
slides: Slide[];
tasks: Task[];
/** Optional vocabulary trainer branch. */
vocab?: VocabSection[];
}
/* --------------------------------------------------------------- manifest -- */
export interface ContentManifest {
subjects: Subject[];
paths: Path[];
/** Lesson ids per subject, in path order. */
lessons: Record<string, string[]>;
}

93
content/subjects.json Normal file
View file

@ -0,0 +1,93 @@
[
{
"id": "deutsch",
"icon": "flag-de",
"route": "/deutsch",
"key": "Deutsch",
"unlocked": true
},
{
"id": "networks",
"icon": "wifi",
"route": "/networks",
"key": "Správa počítačových sítí",
"unlocked": true
},
{
"id": "hardware",
"icon": "chip",
"route": "/hardware",
"key": "Technické vybavení",
"unlocked": true
},
{
"id": "czech",
"icon": "book",
"route": "/czech",
"key": "Český jazyk a literatura",
"unlocked": true
},
{
"id": "civics",
"icon": null,
"route": null,
"key": "Občanská nauka",
"unlocked": false
},
{
"id": "english",
"icon": null,
"route": null,
"key": "English",
"unlocked": false
},
{
"id": "math",
"icon": null,
"route": null,
"key": "Matematika",
"unlocked": false
},
{
"id": "physics",
"icon": null,
"route": null,
"key": "Fyzika",
"unlocked": false
},
{
"id": "science",
"icon": null,
"route": null,
"key": "Základy Přírodopisných věd",
"unlocked": false
},
{
"id": "tech-graphics",
"icon": null,
"route": null,
"key": "Technická grafika",
"unlocked": false
},
{
"id": "presentation",
"icon": null,
"route": null,
"key": "Prezentační grafika",
"unlocked": false
},
{
"id": "programming",
"icon": null,
"route": null,
"key": "Programování",
"unlocked": false
},
{
"id": "software",
"icon": null,
"route": null,
"key": "Programové vybavení",
"unlocked": false
}
]

562
content/themes.json Normal file
View file

@ -0,0 +1,562 @@
[
{
"id": "catppuccin",
"name": "Catppuccin",
"dark": {
"crust": "#11111b",
"base": "#1e1e2e",
"mantle": "#181825",
"surface0": "#313244",
"surface1": "#45475a",
"surface2": "#585b70",
"overlay": "#6c7086",
"text": "#cdd6f4",
"subtext": "#a6adc8",
"accent": "#cba6f7",
"accent2": "#b4befe",
"accent3": "#89b4fa",
"success": "#a6e3a1",
"success2": "#94e2d5",
"warning": "#f9e2af",
"error": "#f38ba8",
"onAccent": "#1e1e2e",
"node": "#2b2d40",
"lockedBg": "#24263a",
"lockedBorder": "#383b52",
"rail": "#27293c",
"finishDark": "#1f202e",
"finishLight": "#3b3e58",
"finishStroke": "#52556b"
},
"light": {
"crust": "#dce0e8",
"base": "#eff1f5",
"mantle": "#e6e9ef",
"surface0": "#ccd0da",
"surface1": "#bcc0cc",
"surface2": "#acb0be",
"overlay": "#9ca0b0",
"text": "#4c4f69",
"subtext": "#6c6f85",
"accent": "#8839ef",
"accent2": "#7287fd",
"accent3": "#1e66f5",
"success": "#40a02b",
"success2": "#179299",
"warning": "#df8e1d",
"error": "#d20f39",
"onAccent": "#eff1f5",
"node": "#e6e9ef",
"lockedBg": "#dce0e8",
"lockedBorder": "#bcc0cc",
"rail": "#ccd0da",
"finishDark": "#dce0e8",
"finishLight": "#bcc0cc",
"finishStroke": "#9ca0b0"
}
},
{
"id": "nord",
"name": "Nord",
"dark": {
"crust": "#2e3440",
"base": "#3b4252",
"mantle": "#2e3440",
"surface0": "#434c5e",
"surface1": "#4c566a",
"surface2": "#616e88",
"overlay": "#7b88a1",
"text": "#eceff4",
"subtext": "#d8dee9",
"accent": "#88c0d0",
"accent2": "#81a1c1",
"accent3": "#5e81ac",
"success": "#a3be8c",
"success2": "#8fbcbb",
"warning": "#ebcb8b",
"error": "#bf616a",
"onAccent": "#2e3440",
"node": "#434c5e",
"lockedBg": "#3b4252",
"lockedBorder": "#4c566a",
"rail": "#2e3440",
"finishDark": "#2e3440",
"finishLight": "#434c5e",
"finishStroke": "#4c566a"
},
"light": {
"crust": "#d8dee9",
"base": "#eceff4",
"mantle": "#e5e9f0",
"surface0": "#d8dee9",
"surface1": "#c7ceda",
"surface2": "#b0b8c8",
"overlay": "#7b88a1",
"text": "#2e3440",
"subtext": "#4c566a",
"accent": "#5e81ac",
"accent2": "#81a1c1",
"accent3": "#5e81ac",
"success": "#a3be8c",
"success2": "#8fbcbb",
"warning": "#ebcb8b",
"error": "#bf616a",
"onAccent": "#eceff4",
"node": "#e5e9f0",
"lockedBg": "#d8dee9",
"lockedBorder": "#c7ceda",
"rail": "#d8dee9",
"finishDark": "#d8dee9",
"finishLight": "#c7ceda",
"finishStroke": "#7b88a1"
}
},
{
"id": "dracula",
"name": "Dracula",
"dark": {
"crust": "#191a21",
"base": "#282a36",
"mantle": "#21222c",
"surface0": "#44475a",
"surface1": "#6272a4",
"surface2": "#707eb0",
"overlay": "#6272a4",
"text": "#f8f8f2",
"subtext": "#bfbfb2",
"accent": "#bd93f9",
"accent2": "#ff79c6",
"accent3": "#8be9fd",
"success": "#50fa7b",
"success2": "#8be9fd",
"warning": "#f1fa8c",
"error": "#ff5555",
"onAccent": "#282a36",
"node": "#383a4a",
"lockedBg": "#2f3240",
"lockedBorder": "#44475a",
"rail": "#21222c",
"finishDark": "#191a21",
"finishLight": "#383a4a",
"finishStroke": "#6272a4"
},
"light": {
"crust": "#d5d7e0",
"base": "#f8f8f2",
"mantle": "#efefea",
"surface0": "#e2e2d8",
"surface1": "#d0d0c4",
"surface2": "#bfbfb2",
"overlay": "#9a9a8c",
"text": "#282a36",
"subtext": "#44475a",
"accent": "#7c3aed",
"accent2": "#d946a6",
"accent3": "#0891b2",
"success": "#16a34a",
"success2": "#0e7490",
"warning": "#ca8a04",
"error": "#dc2626",
"onAccent": "#f8f8f2",
"node": "#efefea",
"lockedBg": "#e2e2d8",
"lockedBorder": "#d0d0c4",
"rail": "#e2e2d8",
"finishDark": "#d5d7e0",
"finishLight": "#d0d0c4",
"finishStroke": "#9a9a8c"
}
},
{
"id": "rose-pine",
"name": "Rose Pine",
"dark": {
"crust": "#191724",
"base": "#1f1d2e",
"mantle": "#1f1d2e",
"surface0": "#26233a",
"surface1": "#403d52",
"surface2": "#524f67",
"overlay": "#6e6a86",
"text": "#e0def4",
"subtext": "#908caa",
"accent": "#c4a7e7",
"accent2": "#ebbcba",
"accent3": "#9ccfd8",
"success": "#31748f",
"success2": "#9ccfd8",
"warning": "#f6c177",
"error": "#eb6f92",
"onAccent": "#191724",
"node": "#26233a",
"lockedBg": "#21202e",
"lockedBorder": "#403d52",
"rail": "#191724",
"finishDark": "#191724",
"finishLight": "#26233a",
"finishStroke": "#524f67"
},
"light": {
"crust": "#f2e9e1",
"base": "#faf4ed",
"mantle": "#fffaf3",
"surface0": "#f2e9e1",
"surface1": "#dfdad9",
"surface2": "#cecacd",
"overlay": "#9893a5",
"text": "#575279",
"subtext": "#797593",
"accent": "#907aa9",
"accent2": "#d7827e",
"accent3": "#56949f",
"success": "#286983",
"success2": "#56949f",
"warning": "#ea9d34",
"error": "#b4637a",
"onAccent": "#faf4ed",
"node": "#fffaf3",
"lockedBg": "#f2e9e1",
"lockedBorder": "#dfdad9",
"rail": "#f2e9e1",
"finishDark": "#f2e9e1",
"finishLight": "#dfdad9",
"finishStroke": "#9893a5"
}
},
{
"id": "ocean",
"name": "Ocean",
"dark": {
"crust": "#16161e",
"base": "#1a1b26",
"mantle": "#16161e",
"surface0": "#24283b",
"surface1": "#414868",
"surface2": "#565f89",
"overlay": "#565f89",
"text": "#c0caf5",
"subtext": "#a9b1d6",
"accent": "#7aa2f7",
"accent2": "#bb9af7",
"accent3": "#7dcfff",
"success": "#9ece6a",
"success2": "#73daca",
"warning": "#e0af68",
"error": "#f7768e",
"onAccent": "#1a1b26",
"node": "#24283b",
"lockedBg": "#1f2335",
"lockedBorder": "#3b4261",
"rail": "#16161e",
"finishDark": "#16161e",
"finishLight": "#24283b",
"finishStroke": "#414868"
},
"light": {
"crust": "#d6dae8",
"base": "#e1e2ec",
"mantle": "#d5d6e2",
"surface0": "#c8c9d6",
"surface1": "#b4b6c5",
"surface2": "#9aa0b5",
"overlay": "#7a8199",
"text": "#2e3446",
"subtext": "#4a5168",
"accent": "#2e7de9",
"accent2": "#7847bd",
"accent3": "#007197",
"success": "#387068",
"success2": "#007197",
"warning": "#8c6c3e",
"error": "#c64343",
"onAccent": "#e1e2ec",
"node": "#d5d6e2",
"lockedBg": "#c8c9d6",
"lockedBorder": "#b4b6c5",
"rail": "#c8c9d6",
"finishDark": "#d6dae8",
"finishLight": "#b4b6c5",
"finishStroke": "#7a8199"
}
},
{
"id": "gruvbox",
"name": "Gruvbox",
"dark": {
"crust": "#1d2021",
"base": "#282828",
"mantle": "#1d2021",
"surface0": "#3c3836",
"surface1": "#504945",
"surface2": "#665c54",
"overlay": "#928374",
"text": "#ebdbb2",
"subtext": "#a89984",
"accent": "#fe8019",
"accent2": "#d3869b",
"accent3": "#83a598",
"success": "#b8bb26",
"success2": "#8ec07c",
"warning": "#fabd2f",
"error": "#fb4934",
"onAccent": "#1d2021",
"node": "#3c3836",
"lockedBg": "#32302f",
"lockedBorder": "#504945",
"rail": "#1d2021",
"finishDark": "#1d2021",
"finishLight": "#3c3836",
"finishStroke": "#504945"
},
"light": {
"crust": "#ebdbb2",
"base": "#fbf1c7",
"mantle": "#f2e5bc",
"surface0": "#ebdbb2",
"surface1": "#d5c4a1",
"surface2": "#bdae93",
"overlay": "#928374",
"text": "#3c3836",
"subtext": "#504945",
"accent": "#d65d0e",
"accent2": "#b16286",
"accent3": "#076678",
"success": "#79740e",
"success2": "#427b58",
"warning": "#b57614",
"error": "#9d0006",
"onAccent": "#fbf1c7",
"node": "#f2e5bc",
"lockedBg": "#ebdbb2",
"lockedBorder": "#d5c4a1",
"rail": "#ebdbb2",
"finishDark": "#ebdbb2",
"finishLight": "#d5c4a1",
"finishStroke": "#928374"
}
},
{
"id": "solarized",
"name": "Solarized",
"dark": {
"crust": "#002b36",
"base": "#073642",
"mantle": "#002b36",
"surface0": "#586e75",
"surface1": "#657b83",
"surface2": "#839496",
"overlay": "#93a1a1",
"text": "#fdf6e3",
"subtext": "#93a1a1",
"accent": "#268bd2",
"accent2": "#6c71c4",
"accent3": "#2aa198",
"success": "#859900",
"success2": "#2aa198",
"warning": "#b58900",
"error": "#dc322f",
"onAccent": "#002b36",
"node": "#073642",
"lockedBg": "#002b36",
"lockedBorder": "#586e75",
"rail": "#002b36",
"finishDark": "#002b36",
"finishLight": "#073642",
"finishStroke": "#586e75"
},
"light": {
"crust": "#eee8d5",
"base": "#fdf6e3",
"mantle": "#eee8d5",
"surface0": "#eee8d5",
"surface1": "#e2dbc6",
"surface2": "#c9c0a6",
"overlay": "#657b83",
"text": "#586e75",
"subtext": "#657b83",
"accent": "#268bd2",
"accent2": "#6c71c4",
"accent3": "#2aa198",
"success": "#859900",
"success2": "#2aa198",
"warning": "#b58900",
"error": "#dc322f",
"onAccent": "#fdf6e3",
"node": "#eee8d5",
"lockedBg": "#e6dfc8",
"lockedBorder": "#93a1a1",
"rail": "#eee8d5",
"finishDark": "#eee8d5",
"finishLight": "#93a1a1",
"finishStroke": "#586e75"
}
},
{
"id": "everforest",
"name": "Everforest",
"dark": {
"crust": "#1e2326",
"base": "#272e33",
"mantle": "#2d353b",
"surface0": "#374145",
"surface1": "#4f5b58",
"surface2": "#7a8478",
"overlay": "#859289",
"text": "#d3c6aa",
"subtext": "#a7c080",
"accent": "#a7c080",
"accent2": "#d699b6",
"accent3": "#7fbbb3",
"success": "#a7c080",
"success2": "#83c092",
"warning": "#dbbc7f",
"error": "#e67e80",
"onAccent": "#272e33",
"node": "#374145",
"lockedBg": "#2d353b",
"lockedBorder": "#4f5b58",
"rail": "#1e2326",
"finishDark": "#1e2326",
"finishLight": "#374145",
"finishStroke": "#4f5b58"
},
"light": {
"crust": "#e6e2cc",
"base": "#fdf6e3",
"mantle": "#f4f0d9",
"surface0": "#efebd4",
"surface1": "#e0dcc7",
"surface2": "#a6b0a0",
"overlay": "#939f91",
"text": "#5c6a72",
"subtext": "#829181",
"accent": "#8da101",
"accent2": "#df69ba",
"accent3": "#35a77c",
"success": "#8da101",
"success2": "#35a77c",
"warning": "#dfa000",
"error": "#f85552",
"onAccent": "#fdf6e3",
"node": "#f4f0d9",
"lockedBg": "#efebd4",
"lockedBorder": "#e0dcc7",
"rail": "#e6e2cc",
"finishDark": "#e6e2cc",
"finishLight": "#e0dcc7",
"finishStroke": "#939f91"
}
},
{
"id": "monokai",
"name": "Monokai",
"dark": {
"crust": "#1d1e19",
"base": "#272822",
"mantle": "#1e1f1a",
"surface0": "#3e3d32",
"surface1": "#49483e",
"surface2": "#75715e",
"overlay": "#75715e",
"text": "#f8f8f2",
"subtext": "#a59f85",
"accent": "#f92672",
"accent2": "#ae81ff",
"accent3": "#66d9ef",
"success": "#a6e22e",
"success2": "#a1efe4",
"warning": "#e6db74",
"error": "#f92672",
"onAccent": "#272822",
"node": "#3e3d32",
"lockedBg": "#2e2f29",
"lockedBorder": "#49483e",
"rail": "#1d1e19",
"finishDark": "#1d1e19",
"finishLight": "#3e3d32",
"finishStroke": "#49483e"
},
"light": {
"crust": "#e6e3d4",
"base": "#faf8f0",
"mantle": "#f3f0e4",
"surface0": "#e8e4d4",
"surface1": "#d6d2c0",
"surface2": "#b8b49e",
"overlay": "#8a8674",
"text": "#2d2a2e",
"subtext": "#5b5854",
"accent": "#e14775",
"accent2": "#7c5cbf",
"accent3": "#1c7d9b",
"success": "#5b8c2a",
"success2": "#1c7d9b",
"warning": "#b08900",
"error": "#e14775",
"onAccent": "#faf8f0",
"node": "#f3f0e4",
"lockedBg": "#e8e4d4",
"lockedBorder": "#d6d2c0",
"rail": "#e6e3d4",
"finishDark": "#e6e3d4",
"finishLight": "#d6d2c0",
"finishStroke": "#8a8674"
}
},
{
"id": "one-dark",
"name": "One Dark",
"dark": {
"crust": "#21252b",
"base": "#282c34",
"mantle": "#21252b",
"surface0": "#3b4048",
"surface1": "#4b5263",
"surface2": "#5c6370",
"overlay": "#5c6370",
"text": "#abb2bf",
"subtext": "#7f848e",
"accent": "#c678dd",
"accent2": "#61afef",
"accent3": "#56b6c2",
"success": "#98c379",
"success2": "#56b6c2",
"warning": "#e5c07b",
"error": "#e06c75",
"onAccent": "#282c34",
"node": "#3b4048",
"lockedBg": "#2c313a",
"lockedBorder": "#4b5263",
"rail": "#21252b",
"finishDark": "#21252b",
"finishLight": "#3b4048",
"finishStroke": "#4b5263"
},
"light": {
"crust": "#e5e5e6",
"base": "#fafafa",
"mantle": "#f0f0f0",
"surface0": "#e5e5e6",
"surface1": "#d0d0d2",
"surface2": "#a0a1a7",
"overlay": "#a0a1a7",
"text": "#383a42",
"subtext": "#696c77",
"accent": "#a626a4",
"accent2": "#4078f2",
"accent3": "#0184bc",
"success": "#50a14f",
"success2": "#0184bc",
"warning": "#c18401",
"error": "#e45649",
"onAccent": "#fafafa",
"node": "#f0f0f0",
"lockedBg": "#e5e5e6",
"lockedBorder": "#d0d0d2",
"rail": "#e5e5e6",
"finishDark": "#e5e5e6",
"finishLight": "#d0d0d2",
"finishStroke": "#a0a1a7"
}
}
]

14
package.json Normal file
View file

@ -0,0 +1,14 @@
{
"name": "maturita",
"version": "1.0.0",
"private": true,
"description": "Educational maturita app: GTK 4 desktop client, Next.js web client, shared lesson content.",
"scripts": {
"content": "node tools/build-content.mjs",
"web": "npm --prefix web run dev",
"web:build": "npm --prefix web run build",
"web:install": "npm --prefix web install",
"desktop": "make -C desktop run",
"desktop:build": "make -C desktop"
}
}

239
tools/build-content.mjs Normal file
View file

@ -0,0 +1,239 @@
#!/usr/bin/env node
/**
* Regenerate `content/` from the desktop C sources.
*
* The C app remains the authoring surface for now, so this is a one-way
* export: run it after changing lesson data in `desktop/` to refresh the JSON
* the web app reads. It is deliberately strict — a shape it does not recognise
* throws rather than emitting a half-empty lesson.
*/
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseArray } from './lib/cparse.mjs';
import { readFileSync } from 'node:fs';
import { extractThemes } from './extract/themes.mjs';
import { extractI18n } from './extract/i18n.mjs';
import { extractNetworks } from './extract/networks.mjs';
import { extractDeutschUnit1 } from './extract/deutsch.mjs';
import { extractDeutschUnit2 } from './extract/deutsch-u2.mjs';
import { extractDeutschUnit3 } from './extract/deutsch-u3.mjs';
import { extractHardware } from './extract/hardware.mjs';
import { extractMluvnice } from './extract/mluvnice.mjs';
import { extractCetba } from './extract/cetba.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const DESKTOP = resolve(ROOT, 'desktop');
const CONTENT = resolve(ROOT, 'content');
const write = (relPath, data) => {
const file = resolve(CONTENT, relPath);
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, JSON.stringify(data, null, 2) + '\n');
return relPath;
};
/* ------------------------------------------------------------- subjects --- */
/** Mirrors `sub_keys` in subjects.c; only four subjects have content today. */
const SUBJECTS = [
{ id: 'deutsch', icon: 'flag-de', route: '/deutsch' },
{ id: 'networks', icon: 'wifi', route: '/networks' },
{ id: 'hardware', icon: 'chip', route: '/hardware' },
{ id: 'czech', icon: 'book', route: '/czech' },
{ id: 'civics', icon: null, route: null },
{ id: 'english', icon: null, route: null },
{ id: 'math', icon: null, route: null },
{ id: 'physics', icon: null, route: null },
{ id: 'science', icon: null, route: null },
{ id: 'tech-graphics', icon: null, route: null },
{ id: 'presentation', icon: null, route: null },
{ id: 'programming', icon: null, route: null },
{ id: 'software', icon: null, route: null },
];
function extractSubjects() {
const keys = parseArray(readFileSync(`${DESKTOP}/subjects.c`, 'utf8'), 'sub_keys').map(String);
if (keys.length !== SUBJECTS.length) {
throw new Error(`subjects.c has ${keys.length} keys but the extractor knows ${SUBJECTS.length}`);
}
return SUBJECTS.map((s, i) => ({ ...s, key: keys[i], unlocked: s.route !== null }));
}
/* ---------------------------------------------------------------- paths --- */
/** German unit titles from `unit_meta_init` in main.c. */
const DEUTSCH_TITLES = [
'Neue Freunde', 'Aus aller Welt', 'Bei uns zu Hause',
'Schule und Freizeit', 'Guten Appetit!', 'Mein Tagesablauf',
'Meine Freunde', 'Wir treffen uns in Salzburg',
'Mein Haus ist meine Burg', 'Urlaub in Österreich',
];
/**
* `HW_UNITS` bubbles are drawn but only `HW_LESSONS` of them are playable, so
* the trailing nodes stay locked placeholders as they do on the desktop map.
*/
const HW_UNITS = 10;
/** `cz_keys` in czech.c; "Literatura" has no content yet. */
const CZECH_NODES = [
{ number: 1, title: 'Literatura', route: null },
{ number: 2, title: 'Mluvnice', route: '/czech/mluvnice' },
{ number: 3, title: 'Maturitní četba', route: '/czech/reading' },
];
function buildPaths({ networkLessons, deutschLessons, hardwareLessons, cetbaLessons }) {
const done = new Set(deutschLessons.map((l) => l.number));
const deutsch = {
subject: 'deutsch',
units: DEUTSCH_TITLES.map((title, i) => ({
number: i + 1,
// Units 1-3 are playable in the desktop app; the rest are placeholders.
lesson: done.has(i + 1) ? `unit-${String(i + 1).padStart(2, '0')}` : null,
title: { cs: title, en: title },
unlocked: i < 3 && done.has(i + 1),
})),
};
const networks = {
subject: 'networks',
year: 1,
units: networkLessons.map((l) => ({
number: l.number,
lesson: l.id,
titleKey: l.titleKey,
unlocked: true,
})),
};
const byNumber = new Map(hardwareLessons.map((l) => [l.number, l]));
const hardware = {
subject: 'hardware',
units: Array.from({ length: HW_UNITS }, (_, i) => {
const lesson = byNumber.get(i + 1);
return {
number: i + 1,
lesson: lesson?.id ?? null,
titleKey: lesson?.titleKey ?? `hw_unit${i + 1}`,
unlocked: Boolean(lesson),
};
}),
};
const czech = {
subject: 'czech',
units: CZECH_NODES.map((node) => ({
number: node.number,
lesson: null,
route: node.route,
title: { cs: node.title, en: node.title },
unlocked: node.route !== null,
})),
};
const reading = {
subject: 'czech',
branch: 'reading',
units: cetbaLessons.map((l, i) => ({
number: i + 1,
lesson: l.id,
title: l.title,
unlocked: true,
})),
};
return [deutsch, networks, hardware, czech, reading];
}
/* ----------------------------------------------------------------- main --- */
function main() {
rmSync(resolve(CONTENT, 'lessons'), { recursive: true, force: true });
const written = [];
written.push(write('themes.json', extractThemes(DESKTOP)));
const i18n = extractI18n(DESKTOP);
written.push(write('i18n/ui.json', i18n.ui));
written.push(write('i18n/content.json', i18n.content));
const subjects = extractSubjects();
written.push(write('subjects.json', subjects));
const networkLessons = extractNetworks(DESKTOP);
for (const lesson of networkLessons) {
written.push(write(`lessons/networks/${lesson.id}.json`, lesson));
}
const deutschLessons = [
extractDeutschUnit1(DESKTOP),
extractDeutschUnit2(DESKTOP),
extractDeutschUnit3(DESKTOP),
];
for (const lesson of deutschLessons) {
written.push(write(`lessons/deutsch/${lesson.id}.json`, lesson));
}
const hardwareLessons = extractHardware(DESKTOP);
for (const lesson of hardwareLessons) {
written.push(write(`lessons/hardware/${lesson.id}.json`, lesson));
}
const czechLessons = [extractMluvnice(DESKTOP), ...extractCetba(DESKTOP)];
for (const lesson of czechLessons) {
written.push(write(`lessons/czech/${lesson.id}.json`, lesson));
}
const cetbaLessons = czechLessons.filter((l) => l.id !== 'mluvnice');
const paths = buildPaths({
networkLessons,
deutschLessons,
hardwareLessons,
cetbaLessons,
});
written.push(write('paths.json', paths));
written.push(write('manifest.json', {
subjects: subjects.map((s) => s.id),
lessons: {
deutsch: deutschLessons.map((l) => l.id),
networks: networkLessons.map((l) => l.id),
hardware: hardwareLessons.map((l) => l.id),
czech: czechLessons.map((l) => l.id),
},
}));
const stats = {
'network lessons': networkLessons.length,
'network slides': networkLessons.reduce((a, l) => a + l.slides.length, 0),
'network items': networkLessons.reduce(
(a, l) => a + l.tasks.reduce((b, t) => b + t.items.length, 0), 0),
'deutsch tasks': deutschLessons.reduce((a, l) => a + l.tasks.length, 0),
'deutsch items': deutschLessons.reduce(
(a, l) => a + l.tasks.reduce((b, t) => b + t.items.length, 0), 0),
'vocab words': deutschLessons.reduce(
(a, l) => a + (l.vocab ?? []).reduce((b, s) => b + s.items.length, 0), 0),
'hardware lessons': hardwareLessons.length,
'hardware slides': hardwareLessons.reduce((a, l) => a + l.slides.length, 0),
'hardware items': hardwareLessons.reduce(
(a, l) => a + l.tasks.reduce((b, t) => b + t.items.length, 0), 0),
'czech lessons': czechLessons.length,
'czech tasks': czechLessons.reduce((a, l) => a + l.tasks.length, 0),
'czech items': czechLessons.reduce(
(a, l) => a + l.tasks.reduce((b, t) => b + t.items.length, 0), 0),
'ui strings': Object.keys(i18n.ui.cs).length,
'content strings': Object.keys(i18n.content.cs).length,
themes: 10,
};
console.log(`wrote ${written.length} files to content/`);
for (const [k, v] of Object.entries(stats)) console.log(` ${k}: ${v}`);
}
main();

114
tools/extract/cetba.mjs Normal file
View file

@ -0,0 +1,114 @@
import { readFileSync } from 'node:fs';
import { parseArray, parseArrayInFunction, trimNulls } from '../lib/cparse.mjs';
/**
* Maturita reading list ("Četba"): one lesson per book.
*
* Each book page shows four reference cards followed by two exercises, so the
* cards become the lesson's theory slides and the quiz and plot ordering
* become its two tasks.
*/
/**
* Lesson numbers continue after Mluvnice, so every Czech lesson has a distinct
* number for the statistics page; the reading path numbers its own nodes.
*/
const BOOKS = [
{
id: 'orwell-1984',
number: 2,
title: 'George Orwell – 1984',
page: 'build_cetba1984_page',
quiz: 'lit_qs',
plot: 'lit_plot_items',
plotMeaning: 'lit_plot_meaning',
cards: [
['about', 'O knize'],
['world', 'Svět a strana'],
['people', 'Postavy'],
['terms', 'Klíčové pojmy'],
],
},
{
id: 'fuks-spalovac-mrtvol',
number: 3,
title: 'Ladislav Fuks – Spalovač mrtvol',
page: 'build_cetba_fuks_page',
quiz: 'fuks_qs',
plot: 'fuks_plot_items',
plotMeaning: 'fuks_plot_meaning',
cards: [
['about', 'O knize'],
['world', 'Doba a svět'],
['people', 'Postavy'],
['terms', 'Klíčové motivy'],
],
},
];
/** `LitQ`: question, opts[4], correct, explanation. */
function litChoice(row, label) {
const [prompt, options, correct, expl] = row;
const list = trimNulls(options ?? []).map(String);
if (typeof correct !== 'number' || correct < 0 || correct >= list.length) {
throw new Error(`${label}: correct index ${correct} out of range for ${list.length} options`);
}
return {
kind: 'choice',
prompt: String(prompt),
options: list,
correct,
...(expl ? { hint: String(expl) } : {}),
};
}
export function extractCetba(desktopDir) {
const src = readFileSync(`${desktopDir}/cetba.c`, 'utf8');
return BOOKS.map((book) => {
const slides = book.cards.map(([arrayName, cardTitle]) => ({
kicker: book.title,
title: cardTitle,
tip: null,
lines: trimNulls(parseArrayInFunction(src, book.page, arrayName)).map(String),
}));
const plotMeanings = parseArray(src, book.plotMeaning).map(String);
return {
id: book.id,
subject: 'czech',
number: book.number,
title: { cs: book.title, en: book.title },
subtitle: {
cs: 'Projděte si přehled a pak si vyzkoušejte kvíz a řazení děje.',
en: 'Read the overview, then try the quiz and the plot ordering.',
},
slides,
tasks: [
{
number: 1,
title: 'Kvíz',
heading: 'Kvíz ke knize',
subtitleKey: 'lit_quiz_sub',
items: parseArray(src, book.quiz)
.map((row, i) => litChoice(row, `${book.id} q${i + 1}`)),
},
{
number: 2,
title: 'Děj',
heading: 'Seřaďte děj',
subtitleKey: 'lit_plot_sub',
items: parseArray(src, book.plot).map(([prompt, words], i) => ({
kind: 'assembly',
prompt: prompt ? String(prompt) : '',
words: trimNulls(words).map(String),
...(plotMeanings[i] ? { meaning: plotMeanings[i] } : {}),
})),
},
],
};
});
}

View file

@ -0,0 +1,196 @@
import { parseArray, trimNulls } from '../lib/cparse.mjs';
/** Shared conversions for the three Deutsch units. */
export const opt = (v) => (v === null || v === undefined || v === '' ? undefined : String(v));
/** Split the pipe-separated accepted-answer lists used by TypedQ and friends. */
export const alternatives = (s) =>
String(s ?? '').split('|').map((a) => a.trim()).filter(Boolean);
/** `VerbQ`/`SeqQ` `{before, after, answer}` becomes a single-gap fill sentence. */
export function verbFill(row, meaning, extra = {}) {
const [before, after, answer] = row;
return {
kind: 'fill',
segments: [before ?? '', after ?? ''],
answers: [String(answer)],
input: 'select',
...(meaning ? { meaning: String(meaning) } : {}),
...extra,
};
}
/**
* `U3Fill` alternates literal segments and answers: s0, a0, s1, a1, s2, a2, s3.
* Trailing NULLs mean the row uses fewer than three gaps.
*/
export function u3Fill(row, { gloss } = {}) {
const [num, ...rest] = row;
const mean = rest.pop();
const parts = rest;
const segments = [];
const answers = [];
for (let i = 0; i < parts.length; i++) {
if (i % 2 === 0) segments.push(parts[i] ?? '');
else if (parts[i] !== null && parts[i] !== undefined) answers.push(String(parts[i]));
}
// Drop segments that only exist to pad unused gaps.
while (segments.length > answers.length + 1) segments.pop();
return {
kind: 'fill',
segments,
answers,
input: 'select',
...(opt(num) ? { label: String(num) } : {}),
...(opt(mean) ? { meaning: String(mean) } : {}),
...(gloss ? { glosses: [String(gloss)] } : {}),
};
}
/** `U3Let`: s0, a0, s1, a1, s2, a2, s3, a3, s4 — gaps hold missing letters. */
export function u3Letters(row) {
const [num, s0, a0, s1, a1, s2, a2, s3, a3, s4, mean] = row;
const rawSegments = [s0, s1, s2, s3, s4];
const rawAnswers = [a0, a1, a2, a3];
const answers = rawAnswers.filter((a) => a !== null && a !== undefined).map(String);
const segments = rawSegments.slice(0, answers.length + 1).map((s) => s ?? '');
return {
kind: 'fill',
segments,
answers,
input: 'text',
letters: true,
...(opt(num) ? { label: String(num) } : {}),
...(opt(mean) ? { meaning: String(mean) } : {}),
};
}
/**
* `Ex4Row`: seg[5], ans[4] (missing letters), czech, word[4], gmean[4], gaps.
* Each gap's gloss pairs the completed German word with its Czech meaning.
*/
export function ex4Letters(row) {
const [seg, ans, czech, word, gmean, gaps] = row;
const answers = ans.slice(0, gaps).map(String);
const segments = seg.slice(0, gaps + 1).map((s) => s ?? '');
const glosses = answers.map((_, i) => {
const w = opt(word?.[i]);
const m = opt(gmean?.[i]);
if (w && m) return `${w} — ${m}`;
return w ?? m ?? null;
});
return {
kind: 'fill',
segments,
answers,
input: 'text',
letters: true,
...(opt(czech) ? { meaning: String(czech) } : {}),
glosses,
};
}
/** `Ex2Row`: num, seg[4], ans[3], gaps — grouped under an `Ex2Item`'s translation. */
export function ex2Row(row, czech) {
const [num, seg, ans, gaps] = row;
return {
kind: 'fill',
segments: seg.slice(0, gaps + 1).map((s) => s ?? ''),
answers: ans.slice(0, gaps).map(String),
input: 'select',
...(opt(num) ? { label: String(num) } : {}),
...(opt(czech) ? { meaning: String(czech) } : {}),
};
}
/** `ChoiceQ`: prompt, options[5], n_options, correct. */
export function choiceItem(row, meaning) {
const [prompt, options, n, correct] = row;
const list = trimNulls(options ?? []).map(String);
const count = typeof n === 'number' ? n : list.length;
if (typeof correct !== 'number' || correct < 0 || correct >= count) {
throw new Error(`choice '${prompt}': correct index ${correct} out of range`);
}
return {
kind: 'choice',
prompt: String(prompt),
options: list.slice(0, count),
correct,
...(opt(meaning) ? { meaning: String(meaning) } : {}),
};
}
/** `TypedQ`: prompt, pipe-separated answers, Czech meaning. */
export function typedItem(row) {
const [prompt, answers, meaning] = row;
return {
kind: 'typed',
prompt: String(prompt),
answers: alternatives(answers),
...(opt(meaning) ? { meaning: String(meaning) } : {}),
};
}
/** `FreeQ`: question, Czech question, sample, Czech sample. */
export function freeItem(row) {
const [question, questionCs, sample, sampleCs] = row;
return {
kind: 'free',
question: String(question),
...(opt(questionCs) ? { questionCs: String(questionCs) } : {}),
sample: String(sample),
...(opt(sampleCs) ? { sampleCs: String(sampleCs) } : {}),
};
}
/** `AssemblyItem`: prompt, words[6], n. */
export function assemblyItem(row, meaning) {
const [prompt, words] = row;
return {
kind: 'assembly',
prompt: opt(prompt) ?? '',
words: trimNulls(words).map(String),
...(opt(meaning) ? { meaning: String(meaning) } : {}),
};
}
/** One `assign` exercise built from `AssignItem[]`, group labels and meanings. */
export function assignExercise(src, itemsName, groupsName, meaningName) {
const meanings = meaningName ? parseArray(src, meaningName) : [];
return {
kind: 'assign',
groups: parseArray(src, groupsName).map(String),
items: parseArray(src, itemsName).map(([emoji, label, group], i) => ({
emoji: emoji ?? null,
label: String(label),
group,
...(opt(meanings[i]) ? { meaning: String(meanings[i]) } : {}),
})),
};
}
/** Vocabulary trainer sections: `TransSection[]` referencing `TypedQ[]` arrays. */
export function vocabSections(src, sectionsName) {
return parseArray(src, sectionsName).map(([header, arrayRef]) => {
const name = arrayRef?.__c;
if (!name) throw new Error(`vocab section '${header}' has no data array`);
return {
header: String(header),
items: parseArray(src, name).map(([prompt, answers, meaning]) => ({
prompt: String(prompt),
answers: alternatives(answers),
...(opt(meaning) ? { meaning: String(meaning) } : {}),
})),
};
});
}

View file

@ -0,0 +1,159 @@
import { readFileSync } from 'node:fs';
import { parseArray, parseString } from '../lib/cparse.mjs';
import {
choiceItem,
freeItem,
opt,
typedItem,
verbFill,
vocabSections,
} from './deutsch-helpers.mjs';
/**
* Deutsch Unit 2 ("Familie und Freunde").
*
* Task order, titles and instruction keys come from the `u2exN` builders at the
* bottom of exercise_u2.c; each one names the `const` arrays holding its data.
*/
export function extractDeutschUnit2(desktopDir) {
const src = readFileSync(`${desktopDir}/exercise_u2.c`, 'utf8');
const tasks = [];
/** `build_verb_ex`: one pool feeding single-gap sentences. */
const verbTask = (number, title, subtitleKey, prefix, { gloss = false } = {}) => {
const meanings = parseArray(src, `${prefix}_mean`);
const glosses = gloss ? parseArray(src, `${prefix}_gloss`) : null;
return {
number,
title,
subtitleKey,
pool: parseArray(src, `${prefix}_pool`).map(String),
items: parseArray(src, `${prefix}_rows`).map((row, i) =>
verbFill(row, meanings[i], glosses && opt(glosses[i])
? { glosses: [String(glosses[i])] }
: {})),
};
};
const choiceTask = (number, title, subtitleKey, prefix) => {
const meanings = parseArray(src, `${prefix}_mean`);
return {
number,
title,
subtitleKey,
items: parseArray(src, `${prefix}_rows`).map((row, i) => choiceItem(row, meanings[i])),
};
};
const typedTask = (number, title, subtitleKey, rowsName, bankName) => ({
number,
title,
subtitleKey,
...(bankName ? { note: parseString(src, bankName) } : {}),
items: parseArray(src, rowsName).map(typedItem),
});
/* 1 — Verben konjugieren: two people, each a headed group over one pool. */
{
const pool = parseArray(src, 'g01_pool').map(String);
const groups = [
['Peter Fritsch:', 'g01_peter', 'g01_peter_mean'],
['Jana Nová und Pavol Korčák:', 'g01_jana', 'g01_jana_mean'],
];
const items = groups.flatMap(([heading, rowsName, meanName]) => {
const meanings = parseArray(src, meanName);
return parseArray(src, rowsName).map((row, i) =>
verbFill(row, meanings[i], i === 0 ? { heading } : {}));
});
tasks.push({
number: 1,
title: 'Verben konjugieren',
subtitleKey: 'sub_verben',
pool,
items,
});
}
tasks.push(choiceTask(2, 'aus oder in', 'sub_ausin', 'g02'));
tasks.push(choiceTask(3, 'Fragewörter', 'sub_wer', 'g03'));
tasks.push(verbTask(4, 'sprechen', 'sub_sprich', 'g04'));
tasks.push(typedTask(5, 'Nationalitäten', 'sub_nation', 'g05_rows', 'g05_bank'));
tasks.push(typedTask(6, 'Woher?', 'sub_woher', 'g06_rows'));
tasks.push(typedTask(7, 'Länder schreiben', 'sub_laender', 'g07_rows'));
tasks.push(verbTask(8, 'Verben einsetzen', 'sub_verb2', 'g08'));
/* 9 — Freie Antwort */
tasks.push({
number: 9,
title: 'Freie Antwort',
subtitleKey: 'sub_free',
items: parseArray(src, 'ex9_free_qs').map(freeItem),
});
tasks.push(verbTask(10, 'Euro', 'sub_euro', 'g10'));
tasks.push(typedTask(11, 'Wörter suchen', 'sub_wortsuchen', 'g11_rows'));
tasks.push(choiceTask(12, 'Was ist richtig?', 'sub_verb2', 'g12'));
/* 13 — Ordne zu: a Fragewort dropdown and an answer dropdown per row. */
{
const meanings = parseArray(src, 'g13_mean');
tasks.push({
number: 13,
title: 'Ordne zu',
subtitleKey: 'sub_ordne',
pool: parseArray(src, 'g13_fw_pool').map(String),
altPool: parseArray(src, 'g13_ans_pool').map(String),
items: parseArray(src, 'g13_rows').map(([mid, fw, ans], i) => ({
kind: 'fill',
segments: ['', ` ${mid} → `, ''],
answers: [String(fw), String(ans)],
input: 'select',
gapPool: ['main', 'alt'],
label: `${i + 1}.`,
...(opt(meanings[i]) ? { meaning: String(meanings[i]) } : {}),
})),
});
}
tasks.push(verbTask(14, 'Lückentext', 'sub_luecke', 'g14', { gloss: true }));
tasks.push(verbTask(15, 'Verbinde', 'sub_verbinde', 'g15', { gloss: true }));
tasks.push(verbTask(16, 'Zahlen', 'sub_zahlpaar', 'g16'));
/* 17 — Steckbrief */
tasks.push({
number: 17,
title: 'Steckbrief',
subtitleKey: 'sub_steckbrief',
items: parseArray(src, 's01_rows').map(([stem, sample]) => ({
kind: 'profile',
stem: String(stem),
sample: String(sample),
})),
});
/* 18 — Berufe: hangman, one round per profession. */
{
const words = parseArray(src, 'hm_words').map(String);
const tips = parseArray(src, 'hm_tips').map(String);
tasks.push({
number: 18,
title: 'Berufe',
subtitleKey: 'sub_berufe',
items: words.map((word, i) => ({ kind: 'hangman', word, hint: tips[i] })),
});
}
tasks.push(typedTask(19, 'Nationalität', 'sub_bistdu', 's03_rows'));
return {
id: 'unit-02',
subject: 'deutsch',
number: 2,
title: { cs: 'Aus aller Welt', en: 'Aus aller Welt' },
slides: [],
tasks,
vocab: vocabSections(src, 'u2_trans_sections'),
};
}

View file

@ -0,0 +1,174 @@
import { readFileSync } from 'node:fs';
import { parseArray, parseString } from '../lib/cparse.mjs';
import {
alternatives,
assemblyItem,
assignExercise,
ex2Row,
ex4Letters,
opt,
typedItem,
u3Fill,
u3Letters,
vocabSections,
} from './deutsch-helpers.mjs';
/**
* Deutsch Unit 3 ("Bei uns zu Hause").
*
* Most tasks go through `u3_build_drop` (dropdown gaps over a shared pool);
* the rest are hand-built pages whose data arrays are read here directly.
*/
export function extractDeutschUnit3(desktopDir) {
const src = readFileSync(`${desktopDir}/exercise_u3.c`, 'utf8');
const tasks = [];
/** `u3_build_drop`: `U3Fill` rows sharing one dropdown pool. */
const dropTask = (number, title, subtitleKey, prefix, { example, gloss } = {}) => {
const glosses = gloss ? parseArray(src, `${prefix}_gloss`) : null;
return {
number,
title,
subtitleKey,
...(example ? { example } : {}),
pool: parseArray(src, `${prefix}_pool`).map(String),
items: parseArray(src, `${prefix}_rows`).map((row, i) =>
u3Fill(row, { gloss: glosses ? opt(glosses[i]) : undefined })),
};
};
tasks.push(dropTask(1, 'Familienpaare', 'sub3_pair', 'ex1_u3', {
example: 'Beispiel: der Vater ↔ die Mutter',
}));
/* 2 — mein oder dein: rows grouped per item, each item has one translation. */
tasks.push({
number: 2,
title: 'mein oder dein',
subtitleKey: 'sub3_poss',
example:
'Beispiel: Ist das dein Vater? – Nein, das ist nicht mein Vater. ' +
'Das ist sein Onkel.',
pool: parseArray(src, 'ex2_u3_pool').map(String),
items: parseArray(src, 'ex2_u3_items').flatMap(([czech, rowsRef]) => {
const name = rowsRef?.__c;
if (!name) throw new Error(`ex2_u3_items row group for '${czech}' has no data array`);
return parseArray(src, name).map((row) => ex2Row(row, czech));
}),
});
tasks.push(dropTask(3, 'ein / kein', 'sub3_haustier', 'ex3_u3', {
example:
'Beispiel: Hast du eine Katze? – Nein, ich habe keine Katze. ' +
'Ich habe einen Hund.',
}));
/* 4 — Lückentext: missing letters inside words. */
tasks.push({
number: 4,
title: 'Lückentext',
subtitleKey: 'sub3_buchst',
items: parseArray(src, 'ex4_u3_rows').map(ex4Letters),
});
/* 5 — Marcos Familie */
tasks.push({
number: 5,
title: 'Marcos Familie',
subtitleKey: 'sub3_marco',
note: parseString(src, 'ex5_u3_bank'),
items: parseArray(src, 'ex5_u3_rows').map(typedItem),
});
/* 6 — Sortieren */
tasks.push({
number: 6,
title: 'Sortieren',
subtitleKey: 'sub3_sort',
items: [assignExercise(src, 'ex6_u3_items', 'ex6_u3_groups', 'ex6_u3_meaning')],
});
/* 7 — Possessivtabelle: nouns down the side, persons across the top. */
{
const persons = ['ich', 'du', 'er', 'sie', 'es', 'wir', 'ihr', 'sie / Sie'];
const nouns = ['der Garten', 'die Idee', 'das Fest', 'die Geschwister'];
const forms = [
['mein', 'dein', 'sein', 'ihr', 'sein', 'unser', 'euer', 'ihr'],
['meine', 'deine', 'seine', 'ihre', 'seine', 'unsere', 'eure', 'ihre'],
['mein', 'dein', 'sein', 'ihr', 'sein', 'unser', 'euer', 'ihr'],
['meine', 'deine', 'seine', 'ihre', 'seine', 'unsere', 'eure', 'ihre'],
];
tasks.push({
number: 7,
title: 'Possessivtabelle',
subtitleKey: 'sub3_tabelle',
items: [{
kind: 'table',
columns: persons,
rows: nouns,
answers: forms.map((row) => row.map((value) => ({ value }))),
}],
});
}
tasks.push(dropTask(8, 'Akkusativ', 'sub3_akk', 'ex8_u3', {
example: 'Beispiel: die Inliner (ich) → Ich brauche meine Inliner.',
gloss: true,
}));
tasks.push(dropTask(9, 'kein / nicht', 'sub3_nicht', 'ex9_u3'));
/* 10 — Sätze bauen */
{
const meanings = parseArray(src, 'ex10_u3_meaning');
tasks.push({
number: 10,
title: 'Sätze bauen',
subtitleKey: 'sub3_satz',
items: parseArray(src, 'ex10_u3_items').map((row, i) => assemblyItem(row, meanings[i])),
});
}
/* 11 + 14 — `u3_build_kw`: free sentence checked for the required keywords. */
const keywordTask = (number, title, subtitleKey, qsName, example) => ({
number,
title,
subtitleKey,
example,
items: parseArray(src, qsName).map(([prompt, answers, mean, german]) => ({
kind: 'typed',
prompt: String(prompt),
answers: alternatives(answers),
substring: true,
...(opt(mean) ? { meaning: String(mean) } : {}),
...(opt(german) ? { model: String(german) } : {}),
})),
});
tasks.push(keywordTask(11, 'Was siehst du?', 'sub3_sehen', 'ex11_u3_qs',
'Beispiel: Ich sehe eine Burg. / Ich sehe ein Schloss.'));
tasks.push(dropTask(12, 'Wem gehört das?', 'sub3_wem', 'ex12_u3'));
tasks.push(dropTask(13, 'Es gibt …', 'sub3_gibt', 'ex13_u3'));
tasks.push(keywordTask(14, 'Beschreiben', 'sub3_saetze', 'ex14_u3_qs',
'Beispiel: Ich sehe einen Jungen. Er liest ein Buch.'));
/* 15 — Wochenende: missing letters again, but with no per-gap glosses. */
tasks.push({
number: 15,
title: 'Wochenende',
subtitleKey: 'sub3_wochen',
items: parseArray(src, 'ex15_u3_rows').map(u3Letters),
});
return {
id: 'unit-03',
subject: 'deutsch',
number: 3,
title: { cs: 'Bei uns zu Hause', en: 'Bei uns zu Hause' },
slides: [],
tasks,
vocab: vocabSections(src, 'u3_trans_sections'),
};
}

249
tools/extract/deutsch.mjs Normal file
View file

@ -0,0 +1,249 @@
import { readFileSync } from 'node:fs';
import { parseArray, trimNulls } from '../lib/cparse.mjs';
/**
* Deutsch Unit 1 ("Neue Freunde").
*
* The desktop app defines each exercise's data as a `const` array next to its
* `build_*` function; the task order and titles come from
* `build_exercise_page` in main.c and `u1_ex_names` in globals.c.
*/
const opt = (v) => (v === null || v === undefined || v === '' ? undefined : String(v));
/** Split the pipe-separated accepted-answer lists used by TypedQ. */
const alternatives = (s) => String(s).split('|').map((a) => a.trim()).filter(Boolean);
/** `{before, after, answer}` becomes a single-gap fill sentence. */
function verbFill(row, meaning, extra = {}) {
const [before, after, answer] = row;
return {
kind: 'fill',
segments: [before ?? '', after ?? ''],
answers: [String(answer)],
input: 'select',
...(meaning ? { meaning: String(meaning) } : {}),
...extra,
};
}
export function extractDeutschUnit1(desktopDir) {
const u1 = readFileSync(`${desktopDir}/exercise_u1.c`, 'utf8');
const common = readFileSync(`${desktopDir}/exercise_common.c`, 'utf8');
const tasks = [];
/* 1 — Dialog: three dialogues sharing one word pool, one gap per line. */
{
const meanings = parseArray(common, 'ex1_meaning');
const pool = parseArray(common, 'ex1_pool');
const dialogues = [
['Sich vorstellen', parseArray(common, 'ex1_d1')],
['Begrüßung', parseArray(common, 'ex1_d2')],
['Verabschiedung', parseArray(common, 'ex1_d3')],
];
let i = 0;
const items = dialogues.map(([title, rows]) => ({
kind: 'dialogue',
title,
rows: rows.map(([speaker, before, answer, after]) => ({
speaker: speaker ?? '',
before: before ?? '',
answer: String(answer),
after: after ?? '',
meaning: opt(meanings[i++]),
})),
}));
tasks.push({ number: 1, title: 'Dialog', subtitleKey: 'sub_dialog', pool, items });
}
/* 2 — Sätze bilden */
{
const meanings = parseArray(common, 'ex2_meaning');
tasks.push({
number: 2,
title: 'Sätze bilden',
subtitleKey: 'sub_assembly',
items: parseArray(common, 'ex2_items').map(([prompt, words], i) => ({
kind: 'assembly',
prompt: opt(prompt) ?? '',
words: trimNulls(words).map(String),
meaning: opt(meanings[i]),
})),
});
}
/* 3 + 9 — multiple choice */
const choiceTask = (number, title, subtitleKey, qsName, meanName) => {
const meanings = parseArray(u1, meanName);
return {
number,
title,
subtitleKey,
items: parseArray(u1, qsName).map(([prompt, options, n, correct], i) => ({
kind: 'choice',
prompt: String(prompt),
options: trimNulls(options).map(String).slice(0, n),
correct,
meaning: opt(meanings[i]),
})),
};
};
tasks.push(choiceTask(3, 'Was ist richtig?', 'sub_choice_num', 'ex3_questions', 'ex3_meaning'));
/* 4 — Freie Antwort */
tasks.push({
number: 4,
title: 'Freie Antwort',
subtitleKey: 'sub_free',
items: parseArray(u1, 'ex4_questions').map(([question, questionCs, sample, sampleCs]) => ({
kind: 'free',
question: String(question),
questionCs: opt(questionCs),
sample: String(sample),
sampleCs: opt(sampleCs),
})),
});
/* 5 — Zahlen: digits shown as the clue, pick the German word. */
{
const meanings = parseArray(u1, 'ex5_meaning');
tasks.push({
number: 5,
title: 'Zahlen',
subtitleKey: 'sub_zahlen',
pool: parseArray(u1, 'ex5_pool'),
items: parseArray(u1, 'ex5_data').map(([digits, answer], i) => ({
kind: 'fill',
segments: ['', ''],
answers: [String(answer)],
input: 'select',
clue: String(digits),
meaning: opt(meanings[i]),
})),
});
}
/* 6 — Wie viel?: the emoji is repeated `count` times as the clue. */
{
const meanings = parseArray(u1, 'ex6_meaning');
tasks.push({
number: 6,
title: 'Wie viel?',
subtitleKey: 'sub_wieviel',
pool: parseArray(u1, 'ex6_pool'),
items: parseArray(u1, 'ex6_data').map(([emoji, count, noun, answer], i) => ({
kind: 'fill',
segments: ['', ` ${noun}`],
answers: [String(answer)],
input: 'select',
emoji: Array.from({ length: count }, () => emoji).join(' '),
meaning: opt(meanings[i]),
})),
});
}
/* 7 — Zahlenreihe */
{
const meanings = parseArray(u1, 'ex7_meaning');
tasks.push({
number: 7,
title: 'Zahlenreihe',
subtitleKey: 'sub_reihe',
pool: parseArray(u1, 'ex7_pool'),
items: parseArray(u1, 'ex7_data').map((row, i) => verbFill(row, meanings[i])),
});
}
/* 8 — Verb einsetzen */
{
const meanings = parseArray(u1, 'ex8_meaning');
tasks.push({
number: 8,
title: 'Verb einsetzen',
subtitleKey: 'sub_verb',
pool: parseArray(u1, 'ex8_pool'),
items: parseArray(u1, 'ex8_data').map((row, i) => verbFill(row, meanings[i])),
});
}
tasks.push(choiceTask(9, 'Wer? Wie? Wo?', 'sub_wer', 'ex9_questions', 'ex9_meaning'));
/* 10 — Wörter trennen: split a run-together string into words. */
{
const meanings = parseArray(u1, 'ex10_meaning');
tasks.push({
number: 10,
title: 'Wörter trennen',
subtitleKey: 'sub_assembly',
items: parseArray(u1, 'ex10_items').map(([prompt, words], i) => ({
kind: 'assembly',
prompt: opt(prompt) ?? '',
words: trimNulls(words).map(String),
meaning: opt(meanings[i]),
})),
});
}
/* 11 + 13 — sort items into groups */
const assignTask = (number, title, subtitleKey, itemsName, groupsName, meanName) => {
const meanings = parseArray(u1, meanName);
return {
number,
title,
subtitleKey,
items: [{
kind: 'assign',
groups: parseArray(u1, groupsName).map(String),
items: parseArray(u1, itemsName).map(([emoji, label, group], i) => ({
emoji: emoji ?? null,
label: String(label),
group,
meaning: opt(meanings[i]),
})),
}],
};
};
tasks.push(assignTask(11, 'Grußformen', 'sub_gruss', 'ex11_items', 'ex11_groups', 'ex11_meaning'));
/* 12 — Was macht er/sie? */
{
const meanings = parseArray(u1, 'ex12_meaning');
tasks.push({
number: 12,
title: 'Was macht er/sie?',
subtitleKey: 'sub_bild',
pool: parseArray(u1, 'ex12_pool'),
items: parseArray(u1, 'ex12_data').map(([emoji, before, after, answer], i) =>
verbFill([before, after, answer], meanings[i], { emoji: String(emoji) })),
});
}
tasks.push(assignTask(13, 'Länder', 'sub_land', 'ex13_items', 'ex13_groups', 'ex13_meaning'));
/* Vocabulary trainer branch: one section per textbook page. */
const vocab = parseArray(u1, 'u1_trans_sections').map(([header, arrayRef]) => {
const name = arrayRef?.__c;
if (!name) throw new Error(`vocab section '${header}' has no data array`);
return {
header: String(header),
items: parseArray(u1, name).map(([prompt, answers, meaning]) => ({
prompt: String(prompt),
answers: alternatives(answers),
meaning: opt(meaning),
})),
};
});
return {
id: 'unit-01',
subject: 'deutsch',
number: 1,
title: { cs: 'Neue Freunde', en: 'Neue Freunde' },
slides: [],
tasks,
vocab,
};
}

View file

@ -0,0 +1,73 @@
import { readFileSync } from 'node:fs';
import { parseArrayInFunction, trimNulls } from '../lib/cparse.mjs';
/**
* Hardware ("Technické vybavení").
*
* Structurally identical to the network lessons: each `build_hw_unitN_page`
* holds a local `NetSlide slides[]` and each `build_hw_unitN_exercise_page`
* holds a local `ChoiceQ qs[]` with a parallel `hints[]`.
*/
const HW_LESSONS = 3;
function toSlide(row, label) {
if (!Array.isArray(row)) throw new Error(`${label}: slide is not a braced list`);
const [kicker, title, tip, lines] = row;
return {
kicker: kicker ?? '',
title: title ?? '',
tip: tip ?? null,
lines: trimNulls(lines ?? []).map(String),
};
}
function toChoice(row, hint, label) {
if (!Array.isArray(row)) throw new Error(`${label}: question is not a braced list`);
const [prompt, options, nOptions, correct] = row;
const list = trimNulls(options ?? []).map(String);
const n = typeof nOptions === 'number' ? nOptions : list.length;
if (typeof correct !== 'number' || correct < 0 || correct >= n) {
throw new Error(`${label}: correct index ${correct} out of range for ${n} options`);
}
return {
kind: 'choice',
prompt: String(prompt),
options: list.slice(0, n),
correct,
...(hint ? { hint: String(hint) } : {}),
};
}
export function extractHardware(desktopDir) {
const src = readFileSync(`${desktopDir}/hw.c`, 'utf8');
const lessons = [];
for (let n = 1; n <= HW_LESSONS; n++) {
const slides = parseArrayInFunction(src, `build_hw_unit${n}_page`, 'slides')
.map((row, i) => toSlide(row, `hw unit ${n} slide ${i + 1}`));
const exFn = `build_hw_unit${n}_exercise_page`;
const qs = parseArrayInFunction(src, exFn, 'qs');
const hints = parseArrayInFunction(src, exFn, 'hints');
lessons.push({
id: `unit-${String(n).padStart(2, '0')}`,
subject: 'hardware',
number: n,
titleKey: `hw_unit${n}`,
subtitleKey: `hw_unit${n}_sub`,
slides,
tasks: [{
number: 1,
title: 'Quiz',
subtitleKey: `hw_quiz${n}_head`,
items: qs.map((row, i) => toChoice(row, hints[i], `hw unit ${n} q${i + 1}`)),
}],
});
}
return lessons;
}

35
tools/extract/i18n.mjs Normal file
View file

@ -0,0 +1,35 @@
import { readFileSync } from 'node:fs';
import { parseArray } from '../lib/cparse.mjs';
/**
* `tr_ui` holds UI chrome keyed by short identifiers; `tr_content` holds
* lesson text keyed by the Czech string itself (its `cs` column is usually
* NULL, meaning "the key already is the Czech text").
*/
function toTable(entries, { keyIsCzech }) {
const cs = {};
const en = {};
for (const row of entries) {
if (!Array.isArray(row)) continue;
const [key, csText, enText] = row;
if (key === null || key === undefined) continue; // terminator
cs[key] = csText ?? (keyIsCzech ? key : null);
if (enText !== null && enText !== undefined) en[key] = enText;
}
// A null Czech value in tr_ui would render as an empty label; drop it.
for (const k of Object.keys(cs)) if (cs[k] === null) delete cs[k];
return { cs, en };
}
export function extractI18n(desktopDir) {
const src = readFileSync(`${desktopDir}/i18n.c`, 'utf8');
return {
ui: toTable(parseArray(src, 'tr_ui'), { keyIsCzech: false }),
content: toTable(parseArray(src, 'tr_content'), { keyIsCzech: true }),
};
}

145
tools/extract/mluvnice.mjs Normal file
View file

@ -0,0 +1,145 @@
import { readFileSync } from 'node:fs';
import { parseArray, trimNulls } from '../lib/cparse.mjs';
/**
* Czech grammar ("Mluvnice"), 20 exercises in one lesson.
*
* Unlike the other subjects, mluvnice.c passes its instructions as literal
* Czech strings rather than i18n keys, so they are copied verbatim from the
* `build_mluvnice_exercise_page` switch below.
*/
/** One row per `case` in `build_mluvnice_exercise_page`, in the same order. */
const EXERCISES = [
['typed', 'Pravopis – i/y po obojetných souhláskách', 'Doplňte i/í nebo y/ý.', null],
['choice', 'Pravopis – velká/malá písmena', 'Vyberte správnou variantu.', null],
['typed', 'Slovní druhy', 'Určete slovní druh podtržených slov.',
'Do pole napište název slovního druhu.'],
['typed', 'Mluvnické kategorie – podstatná jména',
'Určete rod, číslo a pád podtržených slov.',
'Vyplňte tři pole: rod, číslo a pád.'],
['typed', 'Mluvnické kategorie – slovesa',
'Určete osobu, číslo, čas a způsob u těchto sloves.',
'U rozkazovacího a podmiňovacího způsobu napište do pole čas „neurčitý“.'],
['reveal', 'Slovotvorba', 'Rozeberte slova na morfémy.',
'Určete předponu, kořen, příponu a koncovku.'],
['typed', 'Větné členy', 'Určete větný člen podtržených výrazů.', null],
['typed', 'Druhy vedlejších vět', 'Určete druh vedlejší věty.', null],
['typed', 'Synonyma', 'Ke každému slovu napište synonymum.', null],
['typed', 'Antonyma', 'Napište opak.', null],
['typed', 'Pravopis – s/z na začátku slova', 'Vyberte správnou předponu s-/z-.', null],
['reveal', 'Skladba – najděte chybu',
'Každá věta obsahuje jednu chybu. Najděte ji a opravte.', null],
['typed', 'Tvarosloví – správný tvar', 'Dejte slovo do správného tvaru.', null],
['choice', 'Výběr ze čtyř možností', 'Vyberte správnou odpověď.', null],
['reveal', 'Přímá a nepřímá řeč', 'Přepište přímou řeč na nepřímou.', null],
['reveal', 'Slovní zásoba – rozdíly', 'Vysvětlete rozdíl mezi těmito dvojicemi slov.', null],
['reveal', 'Interpunkce', 'Doplňte čárky tam, kde patří.', null],
['typed', 'Obrazná pojmenování',
'Určete, o jaký druh obrazného pojmenování jde.', null],
['typed', 'Stylistika – slohové útvary',
'Přiřaďte ukázku ke správnému slohovému útvaru.', null],
['choice', 'Souhrnné opakování', 'Vyberte správnou odpověď.', null],
];
/** Only exercise 14 ships per-question explanations. */
const CHOICE_EXPLANATIONS = { 14: 'e14_expl' };
/** `MluvItem`: prompt, shown, nblank, hint[5], answers[5]. */
function fieldsItem(row, label) {
const [prompt, shown, nblank, hints, answers] = row;
if (typeof nblank !== 'number' || nblank < 1) {
throw new Error(`${label}: expected at least one blank, got ${nblank}`);
}
return {
kind: 'fields',
prompt: String(prompt),
fields: Array.from({ length: nblank }, (_, i) => ({
label: String(hints[i] ?? ''),
answers: String(answers[i] ?? '').split('|').map((a) => a.trim()).filter(Boolean),
})),
...(shown ? { solution: String(shown) } : {}),
};
}
function choiceItem(row, expl, label) {
const [prompt, options, nOptions, correct] = row;
const list = trimNulls(options ?? []).map(String);
const n = typeof nOptions === 'number' ? nOptions : list.length;
if (typeof correct !== 'number' || correct < 0 || correct >= n) {
throw new Error(`${label}: correct index ${correct} out of range for ${n} options`);
}
return {
kind: 'choice',
prompt: String(prompt),
options: list.slice(0, n),
correct,
...(expl ? { hint: String(expl) } : {}),
};
}
export function extractMluvnice(desktopDir) {
const src = readFileSync(`${desktopDir}/mluvnice.c`, 'utf8');
const names = parseArray(src, 'mluv_names').map(String);
if (names.length !== EXERCISES.length) {
throw new Error(`mluv_names has ${names.length} entries but the extractor knows ${EXERCISES.length}`);
}
const tasks = EXERCISES.map(([type, heading, subtitle, note], index) => {
const number = index + 1;
const label = `mluvnice ex${number}`;
const base = {
number,
// Short label for the map bubble; the page shows the full heading.
title: names[index],
heading,
subtitleKey: null,
subtitle,
...(note ? { note } : {}),
};
if (type === 'choice') {
const explName = CHOICE_EXPLANATIONS[number];
const expls = explName ? parseArray(src, explName).map(String) : [];
return {
...base,
items: parseArray(src, `e${number}_qs`)
.map((row, i) => choiceItem(row, expls[i], `${label} q${i + 1}`)),
};
}
if (type === 'reveal') {
return {
...base,
items: parseArray(src, `e${number}_items`).map(([prompt, solution]) => ({
kind: 'reveal',
prompt: String(prompt),
solution: String(solution),
})),
};
}
return {
...base,
items: parseArray(src, `e${number}_items`)
.map((row, i) => fieldsItem(row, `${label} item ${i + 1}`)),
};
});
return {
id: 'mluvnice',
subject: 'czech',
number: 1,
title: { cs: 'Mluvnice', en: 'Czech grammar' },
subtitle: {
cs: 'Vyberte cvičení a dokončete je.',
en: 'Pick an exercise and complete it.',
},
slides: [],
tasks,
};
}

View file

@ -0,0 +1,97 @@
import { readFileSync } from 'node:fs';
import { parseArray, parseArrayInFunction, trimNulls } from '../lib/cparse.mjs';
const NET_LESSONS = 27;
/** `NetSlide` is positional: kicker, title, tip, line[8]. */
function toSlide(row, label) {
if (!Array.isArray(row)) throw new Error(`${label}: slide is not a braced list`);
const [kicker, title, tip, lines] = row;
return {
kicker: kicker ?? '',
title: title ?? '',
tip: tip ?? null,
lines: trimNulls(lines ?? []).map((l) => String(l)),
};
}
/** `ChoiceQ` is positional: prompt, options[5], n_options, correct. */
function toChoice(row, hint, label) {
if (!Array.isArray(row)) throw new Error(`${label}: question is not a braced list`);
const [prompt, options, nOptions, correct] = row;
const list = trimNulls(options ?? []).map((o) => String(o));
const n = typeof nOptions === 'number' ? nOptions : list.length;
if (typeof correct !== 'number' || correct < 0 || correct >= n) {
throw new Error(`${label}: correct index ${correct} out of range for ${n} options`);
}
const out = { kind: 'choice', prompt: String(prompt), options: list.slice(0, n), correct };
if (hint) out.hint = String(hint);
return out;
}
/** Unit 1 is the VLSM walkthrough: four scenarios plus a per-block answer key. */
function subnetTasks(src) {
const tasks = parseArray(src, 'net_tasks');
const answers = parseArray(src, 'net_ans');
return tasks.map(([scenario, solution], i) => {
const rows = answers[i] ?? [];
const blocks = rows
.map(([prefix, network, broadcast, firstHost, lastHost]) => ({
prefix, network, broadcast, firstHost, lastHost,
}))
// A prefix of -1 marks a scenario that has no valid allocation.
.filter((b) => typeof b.prefix === 'number' && b.prefix > 0);
return {
kind: 'subnet',
scenario: String(scenario),
solution: String(solution),
blocks: blocks.length ? blocks : null,
};
});
}
export function extractNetworks(desktopDir) {
const src = readFileSync(`${desktopDir}/net.c`, 'utf8');
const lessons = [];
for (let n = 1; n <= NET_LESSONS; n++) {
const slides = parseArrayInFunction(src, `build_net_unit${n}_page`, 'slides')
.map((row, i) => toSlide(row, `net unit ${n} slide ${i + 1}`));
let tasks;
if (n === 1) {
tasks = [{
number: 1,
title: 'VLSM',
subtitleKey: 'net_ex1_title',
items: subnetTasks(src),
}];
} else {
const qs = parseArray(src, `net${n}_qs`);
const hints = parseArray(src, `net${n}_hints`);
tasks = [{
number: 1,
title: 'Quiz',
subtitleKey: `net_quiz${n}_head`,
items: qs.map((row, i) => toChoice(row, hints[i], `net unit ${n} q${i + 1}`)),
}];
}
lessons.push({
id: `unit-${String(n).padStart(2, '0')}`,
subject: 'networks',
number: n,
// Titles live in the i18n tables; the web app resolves these keys.
titleKey: `net_unit${n}`,
subtitleKey: `net_unit${n}_sub`,
slides,
tasks,
});
}
return lessons;
}

48
tools/extract/themes.mjs Normal file
View file

@ -0,0 +1,48 @@
import { readFileSync } from 'node:fs';
import { parseStruct } from '../lib/cparse.mjs';
/** Field order of `ThemePalette` in maturita.h, as the C initializers are positional. */
const FIELDS = [
'crust', 'base', 'mantle', 'surface0', 'surface1', 'surface2',
'overlay', 'text', 'subtext', 'accent', 'accent2', 'accent3',
'success', 'success2', 'warning', 'error', 'onAccent',
'node', 'lockedBg', 'lockedBorder', 'rail',
'finishDark', 'finishLight', 'finishStroke',
];
/** slug, display name, and the C variable prefix used in theme.c. */
const THEMES = [
['catppuccin', 'Catppuccin', 'cat'],
['nord', 'Nord', 'nord'],
['dracula', 'Dracula', 'dra'],
['rose-pine', 'Rose Pine', 'rose'],
['ocean', 'Ocean', 'ocean'],
['gruvbox', 'Gruvbox', 'gruv'],
['solarized', 'Solarized', 'sol'],
['everforest', 'Everforest', 'ever'],
['monokai', 'Monokai', 'mono'],
['one-dark', 'One Dark', 'one'],
];
function toPalette(values, label) {
if (values.length !== FIELDS.length) {
throw new Error(`${label}: expected ${FIELDS.length} colors, got ${values.length}`);
}
const out = {};
values.forEach((v, i) => {
if (typeof v !== 'number') throw new Error(`${label}: field ${FIELDS[i]} is not a hex literal`);
out[FIELDS[i]] = '#' + v.toString(16).padStart(6, '0');
});
return out;
}
export function extractThemes(desktopDir) {
const src = readFileSync(`${desktopDir}/theme.c`, 'utf8');
return THEMES.map(([id, name, prefix]) => ({
id,
name,
dark: toPalette(parseStruct(src, `${prefix}_dark`), `${id} dark`),
light: toPalette(parseStruct(src, `${prefix}_light`), `${id} light`),
}));
}

354
tools/lib/cparse.mjs Normal file
View file

@ -0,0 +1,354 @@
/**
* Minimal reader for the C initializer syntax used by the desktop sources.
*
* It understands only what the lesson data actually uses: string literals with
* adjacent concatenation, char literals, integers, `NULL`, identifiers,
* designated initializers and nested braces. Anything else throws so a bad
* extraction fails loudly instead of silently producing empty lessons.
*/
const IDENT_START = /[A-Za-z_]/;
const IDENT_PART = /[A-Za-z0-9_]/;
/** Strip comments while preserving string/char literals verbatim. */
export function stripComments(src) {
let out = '';
let i = 0;
while (i < src.length) {
const c = src[i];
if (c === '"' || c === "'") {
const quote = c;
out += c;
i++;
while (i < src.length) {
if (src[i] === '\\') {
out += src[i] + (src[i + 1] ?? '');
i += 2;
continue;
}
out += src[i];
if (src[i] === quote) {
i++;
break;
}
i++;
}
continue;
}
if (c === '/' && src[i + 1] === '*') {
const end = src.indexOf('*/', i + 2);
i = end === -1 ? src.length : end + 2;
out += ' ';
continue;
}
if (c === '/' && src[i + 1] === '/') {
const end = src.indexOf('\n', i);
i = end === -1 ? src.length : end;
out += ' ';
continue;
}
out += c;
i++;
}
return out;
}
const ESCAPES = {
n: '\n',
t: '\t',
r: '\r',
'0': '\0',
'\\': '\\',
'"': '"',
"'": "'",
a: '\x07',
b: '\b',
f: '\f',
v: '\v',
};
class Reader {
constructor(src, label) {
this.src = src;
this.label = label;
this.i = 0;
}
fail(msg) {
const at = this.src.slice(Math.max(0, this.i - 60), this.i + 60);
throw new Error(`${this.label}: ${msg} near\n...${at}...`);
}
ws() {
while (this.i < this.src.length && /\s/.test(this.src[this.i])) this.i++;
}
peek() {
this.ws();
return this.src[this.i];
}
eat(ch) {
this.ws();
if (this.src[this.i] !== ch) this.fail(`expected '${ch}', got '${this.src[this.i]}'`);
this.i++;
}
tryEat(ch) {
this.ws();
if (this.src[this.i] === ch) {
this.i++;
return true;
}
return false;
}
/** One string literal, honouring C's adjacent-literal concatenation. */
readString() {
let value = '';
let first = true;
for (;;) {
this.ws();
if (this.src[this.i] !== '"') {
if (first) this.fail('expected string literal');
return value;
}
first = false;
this.i++;
while (this.i < this.src.length && this.src[this.i] !== '"') {
if (this.src[this.i] === '\\') {
const esc = this.src[this.i + 1];
if (esc in ESCAPES) {
value += ESCAPES[esc];
this.i += 2;
continue;
}
if (esc === 'u' || esc === 'x') {
const m = /^\\(u[0-9a-fA-F]{4}|x[0-9a-fA-F]+)/.exec(this.src.slice(this.i));
if (!m) this.fail('bad escape');
const hex = m[1].slice(1);
value += String.fromCodePoint(parseInt(hex, 16));
this.i += m[0].length;
continue;
}
value += esc;
this.i += 2;
continue;
}
value += this.src[this.i];
this.i++;
}
if (this.src[this.i] !== '"') this.fail('unterminated string');
this.i++;
}
}
readIdent() {
this.ws();
if (!IDENT_START.test(this.src[this.i] ?? '')) this.fail('expected identifier');
let start = this.i;
while (this.i < this.src.length && IDENT_PART.test(this.src[this.i])) this.i++;
return this.src.slice(start, this.i);
}
/**
* A single initializer value. Braces become arrays, or plain objects when the
* body uses designated initializers.
*/
readValue() {
this.ws();
const c = this.src[this.i];
if (c === undefined) this.fail('unexpected end of input');
if (c === '"') return this.readString();
if (c === '{') return this.readBraced();
// Cast prefix such as `(int)` or `(const char *)`: skip it and read on.
if (c === '(') {
const cast = /^\(\s*(?:const\s+|unsigned\s+|signed\s+)*[A-Za-z_]\w*\s*\**\s*\)/.exec(
this.src.slice(this.i)
);
if (cast) {
this.i += cast[0].length;
return this.readValue();
}
}
if (c === "'") {
this.i++;
let ch;
if (this.src[this.i] === '\\') {
const esc = this.src[this.i + 1];
ch = esc in ESCAPES ? ESCAPES[esc] : esc;
this.i += 2;
} else {
ch = this.src[this.i];
this.i++;
}
this.eat("'");
return ch;
}
// Numeric or simple arithmetic/negation, e.g. `-1`, `4`, `G_N_ELEMENTS(x)`.
const numMatch = /^[+-]?(?:0[xX][0-9a-fA-F]+|\d+)(?![\w.])/.exec(this.src.slice(this.i));
if (numMatch) {
this.i += numMatch[0].length;
return Number(numMatch[0]);
}
if (IDENT_START.test(c)) {
const name = this.readIdent();
if (name === 'NULL') return null;
if (name === 'TRUE') return true;
if (name === 'FALSE') return false;
// Macro call or bare constant: keep the source text for the caller.
if (this.peek() === '(') {
let depth = 0;
const start = this.i;
while (this.i < this.src.length) {
if (this.src[this.i] === '(') depth++;
else if (this.src[this.i] === ')') {
depth--;
if (depth === 0) {
this.i++;
break;
}
}
this.i++;
}
return { __c: name + this.src.slice(start, this.i) };
}
return { __c: name };
}
this.fail(`unexpected character '${c}'`);
}
readBraced() {
this.eat('{');
const list = [];
const obj = {};
let designated = false;
for (;;) {
if (this.tryEat('}')) break;
if (this.peek() === '.') {
this.i++;
const field = this.readIdent();
this.eat('=');
obj[field] = this.readValue();
designated = true;
} else {
list.push(this.readValue());
}
if (this.tryEat(',')) continue;
this.eat('}');
break;
}
return designated ? obj : list;
}
}
/**
* Find an array/struct definition by name and parse its initializer.
* Matches e.g. `static const ChoiceQ net2_qs[] = { ... };`
*/
export function parseArray(src, name, { label = name } = {}) {
const clean = stripComments(src);
const re = new RegExp(
`\\b${name}\\s*(?:\\[[^\\]]*\\]\\s*)+=\\s*(?=\\{)`,
'g'
);
const m = re.exec(clean);
if (!m) throw new Error(`${label}: array '${name}' not found`);
const reader = new Reader(clean, label);
reader.i = m.index + m[0].length;
const value = reader.readValue();
if (!Array.isArray(value)) throw new Error(`${label}: '${name}' is not a braced list`);
return value;
}
/** Parse a scalar struct definition, e.g. `static const ThemePalette nord_dark = {...};` */
export function parseStruct(src, name, { label = name } = {}) {
const clean = stripComments(src);
const re = new RegExp(`\\b${name}\\s*=\\s*(?=\\{)`, 'g');
const m = re.exec(clean);
if (!m) throw new Error(`${label}: struct '${name}' not found`);
const reader = new Reader(clean, label);
reader.i = m.index + m[0].length;
return reader.readValue();
}
/**
* Same as parseArray but scoped to the body of a single function, so the many
* identically-named `static const NetSlide slides[]` locals stay distinct.
*/
export function parseArrayInFunction(src, fnName, arrayName) {
const clean = stripComments(src);
const fnRe = new RegExp(`\\b${fnName}\\s*\\([^)]*\\)\\s*\\{`, 'g');
const m = fnRe.exec(clean);
if (!m) throw new Error(`function '${fnName}' not found`);
// Walk to the matching closing brace so we only search this body.
let depth = 0;
let i = m.index + m[0].length - 1;
const start = i;
for (; i < clean.length; i++) {
if (clean[i] === '"' || clean[i] === "'") {
const q = clean[i++];
while (i < clean.length && clean[i] !== q) i += clean[i] === '\\' ? 2 : 1;
continue;
}
if (clean[i] === '{') depth++;
else if (clean[i] === '}') {
depth--;
if (depth === 0) break;
}
}
const body = clean.slice(start, i + 1);
return parseArray(body, arrayName, { label: `${fnName}/${arrayName}` });
}
/** Parse a scalar string definition, e.g. `const char *g05_bank = "a, b, c";` */
export function parseString(src, name, { label = name } = {}) {
const clean = stripComments(src);
const re = new RegExp(`\\b${name}\\s*=\\s*(?=")`, 'g');
const m = re.exec(clean);
if (!m) throw new Error(`${label}: string '${name}' not found`);
const reader = new Reader(clean, label);
reader.i = m.index + m[0].length;
return reader.readString();
}
/** Resolve `#define NAME value` integer macros. */
export function parseDefines(src) {
const out = {};
const re = /^[ \t]*#[ \t]*define[ \t]+([A-Za-z_]\w*)[ \t]+([^\n/]+)/gm;
let m;
while ((m = re.exec(src))) {
const raw = m[2].trim();
if (/^-?\d+$/.test(raw)) out[m[1]] = Number(raw);
}
return out;
}
/** Trailing NULLs are array terminators in the C data, not content. */
export function trimNulls(list) {
const out = [...list];
while (out.length && (out.at(-1) === null || out.at(-1) === undefined)) out.pop();
return out;
}

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

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

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

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

1076
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

24
web/package.json Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Some files were not shown because too many files have changed in this diff Show more