mirror of
https://github.com/pepaskopec-blip/Graduately.git
synced 2026-10-28 07:34:34 +00:00
Add the Next.js web app and shared lesson content.
Move lessons out of the C sources into JSON so the web client can use the same material as the desktop app, and point release builds at desktop/. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
4708c69d9c
commit
f7470d333c
113 changed files with 25817 additions and 9 deletions
337
content/schema.ts
Normal file
337
content/schema.ts
Normal 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[]>;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue