add language learner project
Some checks failed
Test and deploy staging / staging (push) Has been cancelled

Dutch and Korean (explained in English) and English (explained in Dutch)
from A1 to C1, with a Hangul level for Korean. Lessons with notes, words
and sentences generate multiple-choice, typing, sentence building, cloze,
listening and matching exercises. Endless challenges: a seeded daily
challenge, sprint, survival, mixed practice, level tests, spaced-repetition
reviews and generated drills (numbers, clock times, de/het, plurals, Dutch
and Korean conjugation, particles, Hangul reading, irregular verbs).
Progress is stored in localStorage with export/import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 17:34:56 +00:00
parent afb1f191df
commit 438623d857
69 changed files with 12669 additions and 4 deletions

View File

@@ -0,0 +1,173 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { onMount } from 'svelte';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import ArrowRight from 'lucide-svelte/icons/arrow-right';
import { lessonSession } from '$lib/language-learner/exercises';
import { romanize } from '$lib/language-learner/hangul';
import { progress } from '$lib/language-learner/progress.svelte';
import { seeded } from '$lib/language-learner/random';
import { hasVoice } from '$lib/language-learner/speech';
import type { Exercise, SessionResult } from '$lib/language-learner/types';
import Results from '$lib/language-learner/ui/Results.svelte';
import Session from '$lib/language-learner/ui/Session.svelte';
import Speak from '$lib/language-learner/ui/Speak.svelte';
let { data } = $props();
const course = $derived(data.course);
const lesson = $derived(data.lesson);
const code = $derived(course.code);
let voice = $state(false);
let queue = $state<Exercise[] | null>(null);
let result = $state<SessionResult | null>(null);
let run = $state(0);
const audio = $derived(voice && progress.settings.audio);
const record = $derived(
progress.loaded ? progress.data.langs[code]?.lessons[lesson.id] : undefined
);
const showRoman = $derived(code === 'ko' && progress.settings.romanization);
onMount(async () => {
voice = await hasVoice(course.voice);
});
// Reset when navigating between lessons.
$effect(() => {
void lesson.id;
queue = null;
result = null;
});
function start() {
queue = lessonSession(course, lesson, seeded(`${lesson.id}:${Date.now()}`), {
typing: progress.typing(code),
audio
});
result = null;
run++;
scrollTo({ top: 0 });
}
function finish(r: SessionResult) {
queue = null;
if (r.reason !== 'done') return;
const score = Math.round((r.firstTry / Math.max(1, r.total)) * 100);
const bonus = 10 + (score === 100 ? 5 : 0);
progress.completeLesson(code, lesson.id, score, [...lesson.words, ...lesson.sentences]);
progress.addXp(code, bonus);
result = { ...r, xp: r.xp + bonus };
scrollTo({ top: 0 });
}
</script>
{#if queue}
{#key run}
<Session {course} {queue} requeue {audio} onfinish={finish} />
{/key}
{:else}
<a
href={resolve('/projects/language-learner/[lang]', { lang: code })}
class="mb-6 inline-flex items-center gap-1 text-sm ll-muted hover:text-[var(--site-text)]"
><ArrowLeft size={14} aria-hidden="true" /> {course.name}</a
>
{#if result}
<Results
{result}
{code}
glossLang={course.glossLang}
title="Lesson complete"
subtitle={lesson.title}
score={Math.round((result.firstTry / Math.max(1, result.total)) * 100)}
percent
>
<button type="button" class="ll-button ll-button-quiet" onclick={start}>Practise again</button
>
{#if data.next}
<a
href={resolve('/projects/language-learner/[lang]/lesson/[lesson]', {
lang: code,
lesson: data.next.id
})}
class="ll-button">Next: {data.next.title} <ArrowRight size={16} aria-hidden="true" /></a
>
{:else}
<a href={resolve('/projects/language-learner/[lang]', { lang: code })} class="ll-button"
>Back to {course.name}</a
>
{/if}
</Results>
{/if}
<header class="mb-8 flex flex-wrap items-end justify-between gap-4">
<div>
<p class="mb-1 text-sm ll-muted">
<span class="ll-badge mr-2">{data.level.band}</span>{data.level.title}
</p>
<h1 class="text-3xl font-semibold tracking-tight sm:text-4xl" lang={code}>{lesson.title}</h1>
<p class="mt-1 text-lg ll-muted">{lesson.summary}</p>
</div>
<div class="flex items-center gap-3">
{#if record}<span class="text-sm ll-muted">best {record.best}% · {record.runs}×</span>{/if}
<button type="button" class="ll-button" onclick={start}
>{record ? 'Practise' : 'Start lesson'}</button
>
</div>
</header>
<div class="grid gap-10 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
<article class="ll-notes prose" lang={course.glossLang}>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- trusted course content -->
{@html data.notesHtml}
</article>
<aside class="space-y-8">
<section>
<h2 class="mb-3 text-lg font-semibold">
{lesson.words.some((w) => w.kind === 'script') ? 'letters and words' : 'words'}
</h2>
<ul class="divide-y divide-[var(--site-border)] border-y border-[var(--site-border)]">
{#each lesson.words as w (w.id)}
<li class="flex items-center gap-3 py-2">
<span class="min-w-0 flex-1">
<span class="font-medium" lang={code}>{w.target.join(' / ')}</span>
{#if showRoman && w.kind === 'word'}<span class="ml-1 text-sm ll-muted"
>{romanize(w.target[0])}</span
>{/if}
<span
class="block text-sm ll-muted"
lang={w.kind === 'script' ? undefined : course.glossLang}
>{w.gloss.join(', ')}{w.note ? ` · ${w.note}` : ''}</span
>
</span>
{#if audio && (w.kind !== 'script' || /[가-힣]/.test(w.target[0]))}<Speak
text={w.target[0]}
voice={course.voice}
/>{/if}
</li>
{/each}
</ul>
</section>
{#if lesson.sentences.length}
<section>
<h2 class="mb-3 text-lg font-semibold">sentences</h2>
<ul class="divide-y divide-[var(--site-border)] border-y border-[var(--site-border)]">
{#each lesson.sentences as s (s.id)}
<li class="flex items-center gap-3 py-2">
<span class="min-w-0 flex-1">
<span lang={code}>{s.target[0]}</span>
<span class="block text-sm ll-muted" lang={course.glossLang}>{s.gloss[0]}</span>
</span>
{#if audio}<Speak text={s.target[0]} voice={course.voice} />{/if}
</li>
{/each}
</ul>
</section>
{/if}
<button type="button" class="ll-button w-full" onclick={start}
>{record ? 'Practise' : 'Start lesson'}</button
>
</aside>
</div>
{/if}