Files
personal-website/src/routes/projects/language-learner/[lang]/lesson/[lesson]/+page.svelte
Stan Runge 438623d857
Some checks failed
Test and deploy staging / staging (push) Has been cancelled
add language learner project
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>
2026-09-24 17:34:56 +00:00

174 lines
5.7 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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}