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,245 @@
<script lang="ts">
import { resolve } from '$app/paths';
import ArrowRight from 'lucide-svelte/icons/arrow-right';
import Flame from 'lucide-svelte/icons/flame';
import ProgressBar from '$lib/components/ProgressBar.svelte';
import { COURSES } from '$lib/language-learner/courses';
import { progress } from '$lib/language-learner/progress.svelte';
import type { LangCode } from '$lib/language-learner/types';
let { data } = $props();
const goals = [20, 50, 100, 200];
let importError = $state('');
let fileInput = $state<HTMLInputElement>();
const completed = (code: LangCode) =>
Object.keys(progress.data.langs[code]?.lessons ?? {}).length;
function exportProgress() {
const blob = new Blob([progress.export()], { type: 'application/json' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `language-learner-${new Date().toISOString().slice(0, 10)}.json`;
link.click();
URL.revokeObjectURL(link.href);
}
async function importProgress(event: Event) {
const file = (event.currentTarget as HTMLInputElement).files?.[0];
if (!file) return;
try {
progress.import(await file.text());
importError = '';
} catch {
importError = 'That file is not a language learner backup.';
}
}
function reset(code: LangCode, name: string) {
if (confirm(`Delete all ${name} progress? This can't be undone.`)) progress.reset(code);
}
</script>
<header class="mb-10">
<h1 class="mb-3 text-4xl font-semibold tracking-tight sm:text-5xl">language learner</h1>
<p class="max-w-2xl text-lg ll-muted">
Dutch, Korean and English from the very first word to advanced. Structured lessons from A1 to
C1, spaced repetition, and endless daily challenges to grind.
</p>
</header>
{#if progress.loaded}
<section class="mb-10 grid gap-3 sm:grid-cols-3" aria-label="Today">
<div class="ll-card">
<p class="text-sm ll-muted">streak</p>
<p class="flex items-center gap-2 text-3xl font-semibold">
<Flame
size={26}
aria-hidden="true"
class={progress.streak() ? 'text-[var(--ll-gold)]' : 'll-muted'}
/>
{progress.streak()}
{progress.streak() === 1 ? 'day' : 'days'}
</p>
</div>
<div class="ll-card">
<p class="text-sm ll-muted">today</p>
<p class="mb-2 text-3xl font-semibold">
{progress.todayXp()}
<span class="text-base font-normal ll-muted">/ {progress.settings.goal} XP</span>
</p>
<ProgressBar
value={Math.min(progress.todayXp(), progress.settings.goal)}
max={progress.settings.goal}
label="Daily goal"
small
/>
</div>
<div class="ll-card">
<p class="text-sm ll-muted">total</p>
<p class="text-3xl font-semibold">
{Object.values(progress.data.langs).reduce((n, p) => n + (p?.xp ?? 0), 0)} XP
</p>
</div>
</section>
{/if}
<section aria-label="Languages" class="mb-14 grid gap-4 md:grid-cols-3">
{#each COURSES as c (c.code)}
{@const count = data.counts[c.code]}
<a
href={resolve('/projects/language-learner/[lang]', { lang: c.code })}
class="ll-card flex flex-col gap-3"
>
<div class="flex items-center justify-between">
<span class="ll-badge">{c.code.toUpperCase()}</span>
{#if progress.loaded && progress.streak(c.code)}
<span class="ll-pill"
><Flame size={14} aria-hidden="true" /> {progress.streak(c.code)}</span
>
{/if}
</div>
<div>
<h2 class="text-2xl font-semibold">{c.name}</h2>
<p class="ll-muted" lang={c.code}>{c.nativeName}</p>
</div>
<p class="text-sm ll-muted" lang={c.glossLang}>{c.tagline}</p>
<p class="text-sm ll-muted">
{count.lessons} lessons · {count.words} words · {count.levels[0]}–{count.levels.at(-1)}
</p>
<div class="mt-auto">
{#if progress.loaded}
<div class="mb-1 flex justify-between text-sm">
<span>{completed(c.code)} / {count.lessons} lessons</span>
{#if progress.due(c.code).length}
<span class="ll-muted">{progress.due(c.code).length} to review</span>
{/if}
</div>
<ProgressBar
value={completed(c.code)}
max={count.lessons}
label="{c.name} lessons done"
small
/>
{/if}
<span class="mt-4 inline-flex items-center gap-1 font-semibold"
>{completed(c.code) ? 'Continue' : 'Start'}
<ArrowRight size={16} aria-hidden="true" /></span
>
</div>
</a>
{/each}
</section>
<section class="mb-14 grid gap-8 md:grid-cols-2">
<div>
<h2 class="mb-4 text-2xl font-semibold">how it works</h2>
<ul class="space-y-3 ll-muted">
<li>
<strong class="text-[var(--site-text)]">Lessons</strong> explain one topic, then drill its words,
sentences and grammar. Wrong answers come back until you get them right.
</li>
<li>
<strong class="text-[var(--site-text)]">Reviews</strong> use spaced repetition: every word you
learn returns just before you'd forget it.
</li>
<li>
<strong class="text-[var(--site-text)]">Challenges</strong> are endless: a daily challenge, a
60-second sprint, survival with three lives, and generated drills for numbers, clock times, conjugation,
articles and particles.
</li>
<li>
<strong class="text-[var(--site-text)]">Start anywhere.</strong> Lessons are never locked, and
a level test lets you skip what you already know.
</li>
</ul>
</div>
<div>
<h2 class="mb-4 text-2xl font-semibold">settings</h2>
{#if progress.loaded}
<div class="ll-card space-y-4">
<label class="flex items-center justify-between gap-4">
<span
>Read words aloud <span class="block text-sm ll-muted">Uses your browser's voices</span
></span
>
<input
type="checkbox"
bind:checked={progress.settings.audio}
onchange={() => progress.save()}
/>
</label>
<label class="flex items-center justify-between gap-4">
<span>Show romanization for Korean</span>
<input
type="checkbox"
bind:checked={progress.settings.romanization}
onchange={() => progress.save()}
/>
</label>
{#each COURSES as c (c.code)}
<label class="flex items-center justify-between gap-4">
<span>Typing exercises in {c.name}</span>
<input
type="checkbox"
checked={progress.typing(c.code)}
onchange={(event) => {
progress.settings.typing[c.code] = event.currentTarget.checked;
progress.save();
}}
/>
</label>
{/each}
<div class="flex flex-wrap items-center justify-between gap-3">
<span>Daily goal</span>
<div class="flex gap-2">
{#each goals as goal (goal)}
<button
type="button"
class="ll-chip"
aria-pressed={progress.settings.goal === goal}
onclick={() => {
progress.settings.goal = goal;
progress.save();
}}>{goal} XP</button
>
{/each}
</div>
</div>
<div class="border-t border-[var(--site-border)] pt-4">
<p class="mb-3 text-sm ll-muted">
Progress is saved in this browser. Export it to move it to another device.
</p>
<div class="flex flex-wrap gap-2">
<button type="button" class="ll-button ll-button-quiet" onclick={exportProgress}
>Export</button
>
<button
type="button"
class="ll-button ll-button-quiet"
onclick={() => fileInput?.click()}>Import</button
>
<input
bind:this={fileInput}
type="file"
accept="application/json"
class="hidden"
onchange={importProgress}
/>
{#each COURSES as c (c.code)}
{#if progress.data.langs[c.code]}
<button
type="button"
class="ll-button ll-button-quiet"
onclick={() => reset(c.code, c.name)}>Reset {c.name}</button
>
{/if}
{/each}
</div>
{#if importError}<p class="mt-2 text-sm text-[var(--ll-wrong)]">{importError}</p>{/if}
</div>
</div>
{/if}
</div>
</section>