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>
174 lines
5.7 KiB
Svelte
174 lines
5.7 KiB
Svelte
<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}
|