add language learner project
Some checks failed
Test and deploy staging / staging (push) Has been cancelled
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:
@@ -10,6 +10,7 @@
|
||||
{ name: 'cs prices', href: '/projects/cs-prices', external: false },
|
||||
{ name: 'osu! map finder', href: '/projects/spotify-beatmap-finder', external: false },
|
||||
{ name: 'game tracker', href: '/projects/game-tracker', external: false },
|
||||
{ name: 'language learner', href: '/projects/language-learner', external: false },
|
||||
{ name: 'money tracker', href: '/projects/money-tracker', external: false },
|
||||
{ name: 'world opinions', href: '/projects/world-opinions', external: false },
|
||||
{ name: 'vash esports', href: 'https://esports.vash.software', external: true },
|
||||
|
||||
13
src/routes/projects/language-learner/+layout.svelte
Normal file
13
src/routes/projects/language-learner/+layout.svelte
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
import './language-learner.css';
|
||||
import { onMount } from 'svelte';
|
||||
import { progress } from '$lib/language-learner/progress.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
onMount(() => progress.load());
|
||||
</script>
|
||||
|
||||
<div class="ll mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
|
||||
{@render children()}
|
||||
</div>
|
||||
20
src/routes/projects/language-learner/+page.server.ts
Normal file
20
src/routes/projects/language-learner/+page.server.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { allEntries, allLessons } from '$lib/language-learner/course';
|
||||
import { COURSES, loadCourse } from '$lib/language-learner/courses';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
// Counted on the server so the overview doesn't download three full courses.
|
||||
export const load: PageServerLoad = async () => {
|
||||
const courses = await Promise.all(COURSES.map((c) => loadCourse(c.code)));
|
||||
return {
|
||||
counts: Object.fromEntries(
|
||||
courses.map((c) => [
|
||||
c.code,
|
||||
{
|
||||
lessons: allLessons(c).length,
|
||||
words: allEntries(c).filter((e) => e.kind === 'word').length,
|
||||
levels: c.levels.map((lv) => lv.band)
|
||||
}
|
||||
])
|
||||
) as Record<string, { lessons: number; words: number; levels: string[] }>
|
||||
};
|
||||
};
|
||||
245
src/routes/projects/language-learner/+page.svelte
Normal file
245
src/routes/projects/language-learner/+page.svelte
Normal 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>
|
||||
10
src/routes/projects/language-learner/[lang]/+layout.ts
Normal file
10
src/routes/projects/language-learner/[lang]/+layout.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { isLang, loadCourse } from '$lib/language-learner/courses';
|
||||
import { loadDrills } from '$lib/language-learner/drills';
|
||||
import type { LayoutLoad } from './$types';
|
||||
|
||||
export const load: LayoutLoad = async ({ params }) => {
|
||||
if (!isLang(params.lang)) error(404, 'Unknown language');
|
||||
const [course, drills] = await Promise.all([loadCourse(params.lang), loadDrills(params.lang)]);
|
||||
return { course, drills };
|
||||
};
|
||||
211
src/routes/projects/language-learner/[lang]/+page.svelte
Normal file
211
src/routes/projects/language-learner/[lang]/+page.svelte
Normal file
@@ -0,0 +1,211 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||
import Check from 'lucide-svelte/icons/check';
|
||||
import Flame from 'lucide-svelte/icons/flame';
|
||||
import ProgressBar from '$lib/components/ProgressBar.svelte';
|
||||
import { allLessons } from '$lib/language-learner/course';
|
||||
import { progress } from '$lib/language-learner/progress.svelte';
|
||||
import { dayKey } from '$lib/language-learner/srs';
|
||||
import { MODES } from '$lib/language-learner/modes';
|
||||
|
||||
let { data } = $props();
|
||||
const course = $derived(data.course);
|
||||
const code = $derived(course.code);
|
||||
const p = $derived(progress.loaded ? progress.data.langs[code] : undefined);
|
||||
const lessons = $derived(allLessons(course));
|
||||
const next = $derived(lessons.find((l) => !p?.lessons[l.id]));
|
||||
const due = $derived(progress.loaded ? progress.due(code).length : 0);
|
||||
const dailyScore = $derived(p?.daily[dayKey()]);
|
||||
const challenges = $derived(MODES.filter((m) => m.group === 'challenge'));
|
||||
const bestOf = (mode: string) => p?.bests[mode];
|
||||
</script>
|
||||
|
||||
<a
|
||||
href={resolve('/projects/language-learner')}
|
||||
class="mb-6 inline-flex items-center gap-1 text-sm ll-muted hover:text-[var(--site-text)]"
|
||||
><ArrowLeft size={14} aria-hidden="true" /> language learner</a
|
||||
>
|
||||
|
||||
<header class="mb-8 flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">{course.name}</h1>
|
||||
<p class="mt-2 text-lg ll-muted">
|
||||
<span lang={code}>{course.nativeName}</span> ·
|
||||
<span lang={course.glossLang}>{course.tagline}</span>
|
||||
</p>
|
||||
</div>
|
||||
{#if progress.loaded}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="ll-pill"
|
||||
><Flame size={14} aria-hidden="true" /> {progress.streak(code)} day streak</span
|
||||
>
|
||||
<span class="ll-pill">{progress.todayXp(code)} XP today</span>
|
||||
<span class="ll-pill">{p?.xp ?? 0} XP total</span>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<section class="mb-12 grid gap-4 md:grid-cols-2" aria-label="Up next">
|
||||
<div class="ll-card flex flex-col justify-between gap-4">
|
||||
{#if next}
|
||||
<div>
|
||||
<p class="text-sm ll-muted">
|
||||
{p && Object.keys(p.lessons).length ? 'next lesson' : 'start here'}
|
||||
</p>
|
||||
<h2 class="text-2xl font-semibold" lang={code}>{next.title}</h2>
|
||||
<p class="ll-muted">{next.summary}</p>
|
||||
</div>
|
||||
<a
|
||||
href={resolve('/projects/language-learner/[lang]/lesson/[lesson]', {
|
||||
lang: code,
|
||||
lesson: next.id
|
||||
})}
|
||||
class="ll-button self-start">Start lesson</a
|
||||
>
|
||||
{:else}
|
||||
<div>
|
||||
<p class="text-sm ll-muted">course complete</p>
|
||||
<h2 class="text-2xl font-semibold">Every lesson done</h2>
|
||||
<p class="ll-muted">Keep your words fresh with reviews and challenges.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="ll-card flex flex-col justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm ll-muted">reviews</p>
|
||||
<h2 class="text-2xl font-semibold">{due} due</h2>
|
||||
<p class="ll-muted">
|
||||
{#if !p || !Object.keys(p.cards).length}
|
||||
Words from finished lessons show up here when it's time to repeat them.
|
||||
{:else if due}
|
||||
Spaced repetition: review now to keep them.
|
||||
{:else}
|
||||
Nothing due. You can still practise your weakest words.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href={resolve('/projects/language-learner/[lang]/practice/[mode]', {
|
||||
lang: code,
|
||||
mode: 'review'
|
||||
})}
|
||||
class="ll-button self-start {due ? '' : 'll-button-quiet'}">Review</a
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-12" aria-labelledby="challenges">
|
||||
<div class="mb-4 flex flex-wrap items-end justify-between gap-3">
|
||||
<h2 id="challenges" class="text-2xl font-semibold">challenges</h2>
|
||||
<a
|
||||
href={resolve('/projects/language-learner/[lang]/words', { lang: code })}
|
||||
class="text-sm ll-muted underline underline-offset-2 hover:text-[var(--site-text)]"
|
||||
>all words</a
|
||||
>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{#each challenges as m (m.id)}
|
||||
<a
|
||||
href={resolve('/projects/language-learner/[lang]/practice/[mode]', {
|
||||
lang: code,
|
||||
mode: m.id
|
||||
})}
|
||||
class="ll-card"
|
||||
>
|
||||
<h3 class="font-semibold">{m.title}</h3>
|
||||
<p class="text-sm ll-muted">{m.description}</p>
|
||||
<p class="mt-2 text-sm">
|
||||
{#if m.id === 'daily'}
|
||||
{#if dailyScore !== undefined}<span class="ll-done">done today · {dailyScore}%</span
|
||||
>{:else}not done yet{/if}
|
||||
{:else if bestOf(m.id) !== undefined}
|
||||
best: {bestOf(m.id)}
|
||||
{/if}
|
||||
</p>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
<h3 class="mt-8 mb-3 text-lg font-semibold">endless drills</h3>
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each data.drills as d (d.id)}
|
||||
{@const skill = Object.entries(p?.skills ?? {}).filter(
|
||||
([k]) => k === d.id || k.startsWith(d.id + '-')
|
||||
)}
|
||||
{@const seen = skill.reduce((n, [, s]) => n + s.seen, 0)}
|
||||
{@const right = skill.reduce((n, [, s]) => n + s.right, 0)}
|
||||
<a
|
||||
href={resolve('/projects/language-learner/[lang]/practice/[mode]', {
|
||||
lang: code,
|
||||
mode: `drill-${d.id}`
|
||||
})}
|
||||
class="ll-card"
|
||||
>
|
||||
<h4 class="font-semibold">{d.title}</h4>
|
||||
<p class="text-sm ll-muted">{d.description}</p>
|
||||
{#if seen}<p class="mt-2 text-sm">
|
||||
{seen} answered · {Math.round((right / seen) * 100)}% right
|
||||
</p>{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="levels">
|
||||
<h2 id="levels" class="mb-4 text-2xl font-semibold">lessons</h2>
|
||||
<div class="space-y-10">
|
||||
{#each course.levels as lv (lv.id)}
|
||||
{@const done = lv.lessons.filter((l) => p?.lessons[l.id]).length}
|
||||
<div>
|
||||
<div class="mb-3 flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold">
|
||||
<span class="ll-badge mr-2">{lv.band}</span>{lv.title}
|
||||
</h3>
|
||||
<p class="text-sm ll-muted">{lv.description}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm ll-muted">{done}/{lv.lessons.length}</span>
|
||||
<a
|
||||
href={resolve('/projects/language-learner/[lang]/practice/[mode]', {
|
||||
lang: code,
|
||||
mode: `test-${lv.id}`
|
||||
})}
|
||||
class="ll-chip">{done === lv.lessons.length ? 'Level test' : 'Test out'}</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<ProgressBar value={done} max={lv.lessons.length} label="{lv.band} progress" small />
|
||||
</div>
|
||||
<ol class="grid gap-2 md:grid-cols-2">
|
||||
{#each lv.lessons as lesson, i (lesson.id)}
|
||||
{@const record = p?.lessons[lesson.id]}
|
||||
<li>
|
||||
<a
|
||||
href={resolve('/projects/language-learner/[lang]/lesson/[lesson]', {
|
||||
lang: code,
|
||||
lesson: lesson.id
|
||||
})}
|
||||
class="ll-card flex items-center gap-3 !p-3"
|
||||
>
|
||||
<span
|
||||
class="ll-lesson-dot"
|
||||
class:is-done={record}
|
||||
class:is-next={next?.id === lesson.id}
|
||||
>
|
||||
{#if record}<Check size={16} aria-label="done" />{:else}{i + 1}{/if}
|
||||
</span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate font-medium" lang={code}>{lesson.title}</span>
|
||||
<span class="block truncate text-sm ll-muted">{lesson.summary}</span>
|
||||
</span>
|
||||
{#if record}<span class="text-sm ll-muted">{record.best}%</span>{/if}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
11
src/routes/projects/language-learner/[lang]/+page.ts
Normal file
11
src/routes/projects/language-learner/[lang]/+page.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async ({ parent }) => {
|
||||
const { course } = await parent();
|
||||
return {
|
||||
seo: {
|
||||
title: `Learn ${course.name} · Language Learner`,
|
||||
description: `${course.name} lessons from ${course.levels[0].band} to ${course.levels.at(-1)!.band}, with spaced repetition and endless daily challenges.`
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { marked } from 'marked';
|
||||
import { allLessons } from '$lib/language-learner/course';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async ({ parent, params }) => {
|
||||
const { course } = await parent();
|
||||
const lessons = allLessons(course);
|
||||
const index = lessons.findIndex((l) => l.id === params.lesson);
|
||||
if (index < 0) error(404, 'Unknown lesson');
|
||||
const lesson = lessons[index];
|
||||
const level = course.levels.find((lv) => lv.id === lesson.level)!;
|
||||
return {
|
||||
lesson,
|
||||
level,
|
||||
next: lessons[index + 1] ?? null,
|
||||
// Notes are course content written in this repository, not user input.
|
||||
notesHtml: marked.parse(lesson.notes, { async: false }),
|
||||
seo: {
|
||||
title: `${lesson.title} · ${course.name} ${level.band}`,
|
||||
description: `${course.name} lesson: ${lesson.summary}. ${lesson.words.length} words with exercises.`
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,346 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { onMount } from 'svelte';
|
||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||
import { mixed, reviews, scopeOf } from '$lib/language-learner/challenges';
|
||||
import { progress } from '$lib/language-learner/progress.svelte';
|
||||
import { seeded, type Rng } from '$lib/language-learner/random';
|
||||
import { hasVoice } from '$lib/language-learner/speech';
|
||||
import { dayKey } from '$lib/language-learner/srs';
|
||||
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';
|
||||
|
||||
let { data } = $props();
|
||||
const course = $derived(data.course);
|
||||
const code = $derived(course.code);
|
||||
const mode = $derived(data.mode);
|
||||
|
||||
type Setup = {
|
||||
queue: Exercise[];
|
||||
more?: () => Exercise | null;
|
||||
lives?: number;
|
||||
seconds?: number;
|
||||
requeue?: boolean;
|
||||
xpPer?: number;
|
||||
};
|
||||
|
||||
let voice = $state(false);
|
||||
let setup = $state<Setup | null>(null);
|
||||
let result = $state<SessionResult | null>(null);
|
||||
type Headline = {
|
||||
title: string;
|
||||
score?: number;
|
||||
percent?: boolean;
|
||||
record: boolean;
|
||||
note?: string;
|
||||
};
|
||||
let headline = $state<Headline | null>(null);
|
||||
let run = $state(0);
|
||||
let drillLevel = $state(1);
|
||||
const audio = $derived(voice && progress.settings.audio);
|
||||
const p = $derived(progress.loaded ? progress.data.langs[code] : undefined);
|
||||
const scopeId = $derived(p?.scope ?? '');
|
||||
const scope = $derived(scopeOf(course, p, scopeId));
|
||||
const mixedMode = $derived(['daily', 'sprint', 'survival', 'endless'].includes(mode.kind));
|
||||
const bestKey = $derived(mode.kind === 'drill' ? `drill-${mode.id}` : mode.kind);
|
||||
const best = $derived(p?.bests[bestKey]);
|
||||
const dailyDone = $derived(mode.kind === 'daily' ? p?.daily[dayKey()] : undefined);
|
||||
const reviewInfo = $derived(
|
||||
mode.kind === 'review' && p
|
||||
? reviews(course, p, progress.due(code), seeded(0), { typing: false, audio: false })
|
||||
: null
|
||||
);
|
||||
|
||||
onMount(async () => {
|
||||
voice = await hasVoice(course.voice);
|
||||
});
|
||||
|
||||
// A sensible default drill level: the highest level you've started.
|
||||
$effect(() => {
|
||||
if (progress.loaded && mode.kind === 'drill')
|
||||
drillLevel = scopeOf(course, progress.data.langs[code]).level;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
void data.mode;
|
||||
setup = null;
|
||||
result = null;
|
||||
});
|
||||
|
||||
function start() {
|
||||
const opts = { typing: progress.typing(code), audio };
|
||||
const rng: Rng = mode.kind === 'daily' ? seeded(`${dayKey()}:${code}`) : seeded(Date.now());
|
||||
const lang = progress.lang(code);
|
||||
const drills = data.drills;
|
||||
switch (mode.kind) {
|
||||
case 'daily': {
|
||||
const next = mixed({ course, scope, drills, rng, opts, p: lang });
|
||||
setup = { queue: Array.from({ length: 20 }, next), xpPer: 3 };
|
||||
break;
|
||||
}
|
||||
case 'sprint':
|
||||
setup = {
|
||||
queue: [],
|
||||
more: mixed({ course, scope, drills, rng, opts, p: lang }),
|
||||
seconds: 60,
|
||||
xpPer: 1
|
||||
};
|
||||
break;
|
||||
case 'survival':
|
||||
setup = {
|
||||
queue: [],
|
||||
more: mixed({ course, scope, drills, rng, opts, p: lang, ramp: true }),
|
||||
lives: 3
|
||||
};
|
||||
break;
|
||||
case 'endless':
|
||||
setup = { queue: [], more: mixed({ course, scope, drills, rng, opts, p: lang }) };
|
||||
break;
|
||||
case 'review': {
|
||||
const r = reviews(course, lang, progress.due(code), rng, opts);
|
||||
setup = { queue: r.queue, more: r.more, requeue: true };
|
||||
break;
|
||||
}
|
||||
case 'drill': {
|
||||
const drill = drills.find((d) => d.id === mode.id)!;
|
||||
const level = drillLevel;
|
||||
setup = { queue: [], more: () => drill.make({ rng, level, opts, course }) };
|
||||
break;
|
||||
}
|
||||
case 'test': {
|
||||
const lv = course.levels.find((l) => l.id === mode.level)!;
|
||||
const next = mixed({
|
||||
course,
|
||||
scope: {
|
||||
lessons: lv.lessons,
|
||||
level: scopeOf(course, undefined, lv.id).level,
|
||||
label: lv.band
|
||||
},
|
||||
drills: [],
|
||||
rng,
|
||||
opts
|
||||
});
|
||||
setup = { queue: Array.from({ length: 20 }, next), xpPer: 2 };
|
||||
break;
|
||||
}
|
||||
}
|
||||
result = null;
|
||||
headline = null;
|
||||
run++;
|
||||
}
|
||||
|
||||
function finish(r: SessionResult) {
|
||||
const s = setup;
|
||||
setup = null;
|
||||
if (!s) return;
|
||||
const accuracy = Math.round((r.correct / Math.max(1, r.answered)) * 100);
|
||||
switch (mode.kind) {
|
||||
case 'daily': {
|
||||
if (r.answered < 20) {
|
||||
headline = {
|
||||
title: 'Daily challenge stopped',
|
||||
record: false,
|
||||
note: 'Finish all 20 to record your score.'
|
||||
};
|
||||
break;
|
||||
}
|
||||
const first = p?.daily[dayKey()] === undefined;
|
||||
progress.recordDaily(code, accuracy);
|
||||
if (first) {
|
||||
progress.addXp(code, 20);
|
||||
r = { ...r, xp: r.xp + 20 };
|
||||
}
|
||||
headline = {
|
||||
title: 'Daily challenge done',
|
||||
score: accuracy,
|
||||
percent: true,
|
||||
record: false,
|
||||
note: first
|
||||
? 'Your score for today is saved. +20 XP bonus.'
|
||||
: 'Only your first run of the day counts.'
|
||||
};
|
||||
break;
|
||||
}
|
||||
case 'sprint':
|
||||
case 'survival':
|
||||
case 'endless':
|
||||
case 'drill':
|
||||
headline = {
|
||||
title:
|
||||
mode.kind === 'survival'
|
||||
? 'Out of lives'
|
||||
: mode.kind === 'sprint'
|
||||
? "Time's up"
|
||||
: 'Session over',
|
||||
score: r.correct,
|
||||
record: r.correct > 0 && progress.recordBest(code, bestKey, r.correct)
|
||||
};
|
||||
break;
|
||||
case 'review':
|
||||
headline = { title: 'Review done', record: false };
|
||||
break;
|
||||
case 'test': {
|
||||
const passed = r.answered >= 20 && accuracy >= 80;
|
||||
const lv = course.levels.find((l) => l.id === mode.level)!;
|
||||
if (passed)
|
||||
for (const lesson of lv.lessons)
|
||||
progress.completeLesson(code, lesson.id, accuracy, [
|
||||
...lesson.words,
|
||||
...lesson.sentences
|
||||
]);
|
||||
headline = {
|
||||
title: passed ? `${lv.band} passed` : r.answered < 20 ? 'Test stopped' : 'Not yet',
|
||||
score: accuracy,
|
||||
percent: true,
|
||||
record: false,
|
||||
note: passed
|
||||
? `Every ${lv.band} lesson is now marked as done and its words are in your reviews.`
|
||||
: 'You need 80% on all 20 questions. The lessons will get you there.'
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
result = r;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if setup}
|
||||
{#key run}
|
||||
<Session
|
||||
{course}
|
||||
queue={setup.queue}
|
||||
more={setup.more}
|
||||
lives={setup.lives}
|
||||
seconds={setup.seconds}
|
||||
requeue={setup.requeue}
|
||||
xpPer={setup.xpPer}
|
||||
{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 && headline}
|
||||
<Results
|
||||
{result}
|
||||
{code}
|
||||
glossLang={course.glossLang}
|
||||
title={headline.title}
|
||||
subtitle={mode.title}
|
||||
score={headline.score}
|
||||
percent={headline.percent}
|
||||
record={headline.record}
|
||||
>
|
||||
{#if headline.note}<p class="mb-2 w-full ll-muted">{headline.note}</p>{/if}
|
||||
<button type="button" class="ll-button" onclick={start}>Again</button>
|
||||
<a
|
||||
href={resolve('/projects/language-learner/[lang]', { lang: code })}
|
||||
class="ll-button ll-button-quiet">Back to {course.name}</a
|
||||
>
|
||||
</Results>
|
||||
{/if}
|
||||
|
||||
<header class="mb-6">
|
||||
<h1 class="text-3xl font-semibold tracking-tight sm:text-4xl">{mode.title}</h1>
|
||||
<p class="mt-1 text-lg ll-muted">{mode.description}</p>
|
||||
</header>
|
||||
|
||||
{#if progress.loaded}
|
||||
<div class="ll-card max-w-2xl space-y-5">
|
||||
{#if mixedMode}
|
||||
<div>
|
||||
<p class="mb-2 font-semibold">Questions from</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="ll-chip"
|
||||
aria-pressed={scopeId === ''}
|
||||
onclick={() => {
|
||||
progress.lang(code).scope = '';
|
||||
progress.save();
|
||||
}}>What I have learned</button
|
||||
>
|
||||
{#each course.levels as lv (lv.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="ll-chip"
|
||||
aria-pressed={scopeId === lv.id}
|
||||
onclick={() => {
|
||||
progress.lang(code).scope = lv.id;
|
||||
progress.save();
|
||||
}}>{lv.band}</button
|
||||
>
|
||||
{/each}
|
||||
<button
|
||||
type="button"
|
||||
class="ll-chip"
|
||||
aria-pressed={scopeId === 'all'}
|
||||
onclick={() => {
|
||||
progress.lang(code).scope = 'all';
|
||||
progress.save();
|
||||
}}>Everything</button
|
||||
>
|
||||
</div>
|
||||
<p class="mt-2 text-sm ll-muted">
|
||||
{scope.lessons.length} lessons: {scope.lessons
|
||||
.slice(0, 4)
|
||||
.map((l) => l.title)
|
||||
.join(', ')}{scope.lessons.length > 4 ? ', …' : ''}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if mode.kind === 'drill'}
|
||||
<div>
|
||||
<p class="mb-2 font-semibold">Difficulty</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each [1, 2, 3, 4, 5] as n (n)}
|
||||
<button
|
||||
type="button"
|
||||
class="ll-chip"
|
||||
aria-pressed={drillLevel === n}
|
||||
onclick={() => (drillLevel = n)}>{['A1', 'A2', 'B1', 'B2', 'C1'][n - 1]}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if mode.kind === 'review' && reviewInfo}
|
||||
<p>
|
||||
{#if !reviewInfo.known}
|
||||
No words to review yet. Finish a lesson first.
|
||||
{:else}
|
||||
<strong>{reviewInfo.due}</strong> due now, {reviewInfo.known} words learned.
|
||||
{reviewInfo.due
|
||||
? 'Due words come first; after that you keep practising your weakest words.'
|
||||
: 'Nothing is due, so this practises your weakest words.'}
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
class="ll-button"
|
||||
disabled={mode.kind === 'review' && !reviewInfo?.known}
|
||||
onclick={start}>Start</button
|
||||
>
|
||||
{#if dailyDone !== undefined}
|
||||
<span class="ll-done">Done today: {dailyDone}%</span>
|
||||
{:else if best !== undefined && mode.kind !== 'daily'}
|
||||
<span class="ll-muted">Best: {best}</span>
|
||||
{/if}
|
||||
{#if mode.kind === 'survival' || mode.kind === 'endless' || mode.kind === 'drill'}
|
||||
<span class="text-sm ll-muted">Endless: stop any time with ✕.</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { MODES } from '$lib/language-learner/modes';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export type Mode =
|
||||
| {
|
||||
kind: 'daily' | 'sprint' | 'survival' | 'endless' | 'review';
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
| { kind: 'drill'; id: string; title: string; description: string }
|
||||
| { kind: 'test'; level: string; title: string; description: string };
|
||||
|
||||
export const load: PageLoad = async ({ parent, params }) => {
|
||||
const { course, drills } = await parent();
|
||||
let mode: Mode | undefined;
|
||||
const known = MODES.find((m) => m.id === params.mode);
|
||||
if (known) mode = { kind: known.id, title: known.title, description: known.description };
|
||||
const drill =
|
||||
params.mode.startsWith('drill-') && drills.find((d) => d.id === params.mode.slice(6));
|
||||
if (drill)
|
||||
mode = { kind: 'drill', id: drill.id, title: drill.title, description: drill.description };
|
||||
const level =
|
||||
params.mode.startsWith('test-') && course.levels.find((lv) => lv.id === params.mode.slice(5));
|
||||
if (level)
|
||||
mode = {
|
||||
kind: 'test',
|
||||
level: level.id,
|
||||
title: `${level.band} level test`,
|
||||
description: `20 questions from ${level.band} (${level.title}). Score 80% or more to mark every ${level.band} lesson as done.`
|
||||
};
|
||||
if (!mode) error(404, 'Unknown practice mode');
|
||||
return {
|
||||
mode,
|
||||
seo: { title: `${mode.title} · ${course.name}`, description: mode.description, index: false }
|
||||
};
|
||||
};
|
||||
139
src/routes/projects/language-learner/[lang]/words/+page.svelte
Normal file
139
src/routes/projects/language-learner/[lang]/words/+page.svelte
Normal file
@@ -0,0 +1,139 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { onMount } from 'svelte';
|
||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||
import { allLessons } from '$lib/language-learner/course';
|
||||
import { romanize } from '$lib/language-learner/hangul';
|
||||
import { progress } from '$lib/language-learner/progress.svelte';
|
||||
import { hasVoice } from '$lib/language-learner/speech';
|
||||
import { mastery, type Mastery } from '$lib/language-learner/srs';
|
||||
import { normalize } from '$lib/language-learner/text';
|
||||
import Speak from '$lib/language-learner/ui/Speak.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
const course = $derived(data.course);
|
||||
const code = $derived(course.code);
|
||||
|
||||
type Filter = 'all' | Mastery | 'sentences';
|
||||
let filter = $state<Filter>('all');
|
||||
let query = $state('');
|
||||
let voice = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
voice = await hasVoice(course.voice);
|
||||
});
|
||||
|
||||
const lessonTitle = $derived(new Map(allLessons(course).map((l) => [l.id, l.title])));
|
||||
// A word taught in several lessons is listed once, under the lesson that introduces it.
|
||||
const rows = $derived(
|
||||
course.levels
|
||||
.flatMap((lv) =>
|
||||
lv.lessons.flatMap((l) =>
|
||||
[...l.words, ...l.sentences].map((entry) => ({ entry, band: lv.band }))
|
||||
)
|
||||
)
|
||||
.filter((r, i, all) => all.findIndex((x) => x.entry.id === r.entry.id) === i)
|
||||
.map((r) => ({
|
||||
...r,
|
||||
mastery: mastery(progress.loaded ? progress.data.langs[code]?.cards[r.entry.id] : undefined)
|
||||
}))
|
||||
);
|
||||
const counts = $derived(
|
||||
rows.reduce(
|
||||
(acc, r) => {
|
||||
if (r.entry.kind !== 'sentence') acc[r.mastery]++;
|
||||
return acc;
|
||||
},
|
||||
{ new: 0, learning: 0, familiar: 0, known: 0, mastered: 0 } as Record<Mastery, number>
|
||||
)
|
||||
);
|
||||
const visible = $derived.by(() => {
|
||||
const q = normalize(query);
|
||||
return rows.filter(
|
||||
(r) =>
|
||||
(filter === 'all'
|
||||
? r.entry.kind !== 'sentence'
|
||||
: filter === 'sentences'
|
||||
? r.entry.kind === 'sentence'
|
||||
: r.entry.kind !== 'sentence' && r.mastery === filter) &&
|
||||
(!q || [...r.entry.target, ...r.entry.gloss].some((t) => normalize(t).includes(q)))
|
||||
);
|
||||
});
|
||||
const filters: { id: Filter; label: string }[] = [
|
||||
{ id: 'all', label: 'All words' },
|
||||
{ id: 'new', label: 'New' },
|
||||
{ id: 'learning', label: 'Learning' },
|
||||
{ id: 'familiar', label: 'Familiar' },
|
||||
{ id: 'known', label: 'Known' },
|
||||
{ id: 'mastered', label: 'Mastered' },
|
||||
{ id: 'sentences', label: 'Sentences' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<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
|
||||
>
|
||||
|
||||
<header class="mb-6">
|
||||
<h1 class="text-3xl font-semibold tracking-tight sm:text-4xl">{course.name} words</h1>
|
||||
<p class="mt-1 ll-muted">
|
||||
{#each ['learning', 'familiar', 'known', 'mastered'] as const as m (m)}
|
||||
<span class="mr-4 inline-flex items-center gap-1.5"
|
||||
><span class="ll-mastery" data-level={m}></span>{counts[m]} {m}</span
|
||||
>
|
||||
{/each}
|
||||
<span class="inline-flex items-center gap-1.5"
|
||||
><span class="ll-mastery"></span>{counts.new} new</span
|
||||
>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="mb-4 flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
class="ll-input max-w-xs"
|
||||
type="search"
|
||||
placeholder="Search"
|
||||
aria-label="Search words"
|
||||
bind:value={query}
|
||||
/>
|
||||
{#each filters as f (f.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="ll-chip"
|
||||
aria-pressed={filter === f.id}
|
||||
onclick={() => (filter = f.id)}>{f.label}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<p class="mb-2 text-sm ll-muted">{visible.length} shown</p>
|
||||
<ul class="divide-y divide-[var(--site-border)] border-y border-[var(--site-border)]">
|
||||
{#each visible.slice(0, 400) as r (r.entry.id)}
|
||||
<li class="flex items-center gap-3 py-2">
|
||||
<span class="ll-mastery" data-level={r.mastery} title={r.mastery}></span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="font-medium" lang={code}>{r.entry.target.join(' / ')}</span>
|
||||
{#if code === 'ko' && progress.settings.romanization && r.entry.kind !== 'script'}
|
||||
<span class="ml-1 text-sm ll-muted">{romanize(r.entry.target[0])}</span>
|
||||
{/if}
|
||||
<span
|
||||
class="block text-sm ll-muted"
|
||||
lang={r.entry.kind === 'script' ? undefined : course.glossLang}
|
||||
>{r.entry.gloss.join(', ')}</span
|
||||
>
|
||||
</span>
|
||||
<span class="hidden text-right text-xs ll-muted sm:block"
|
||||
>{r.band} · {lessonTitle.get(r.entry.lesson)}</span
|
||||
>
|
||||
{#if voice && progress.settings.audio}<Speak
|
||||
text={r.entry.target[0]}
|
||||
voice={course.voice}
|
||||
/>{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if visible.length > 400}<p class="mt-2 text-sm ll-muted">
|
||||
Showing the first 400. Search to narrow it down.
|
||||
</p>{/if}
|
||||
11
src/routes/projects/language-learner/[lang]/words/+page.ts
Normal file
11
src/routes/projects/language-learner/[lang]/words/+page.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async ({ parent }) => {
|
||||
const { course } = await parent();
|
||||
return {
|
||||
seo: {
|
||||
title: `${course.name} vocabulary · Language Learner`,
|
||||
description: `Every word and sentence in the ${course.name} course, with translations.`
|
||||
}
|
||||
};
|
||||
};
|
||||
450
src/routes/projects/language-learner/language-learner.css
Normal file
450
src/routes/projects/language-learner/language-learner.css
Normal file
@@ -0,0 +1,450 @@
|
||||
.ll {
|
||||
--ll-right: #3fb872;
|
||||
--ll-right-bg: rgba(63, 184, 114, 0.12);
|
||||
--ll-wrong: #e5645c;
|
||||
--ll-wrong-bg: rgba(229, 100, 92, 0.12);
|
||||
--ll-gold: #e8b64c;
|
||||
}
|
||||
|
||||
:root[data-theme='light'] .ll {
|
||||
--ll-right: #16804a;
|
||||
--ll-right-bg: rgba(22, 128, 74, 0.08);
|
||||
--ll-wrong: #c23a33;
|
||||
--ll-wrong-bg: rgba(194, 58, 51, 0.08);
|
||||
--ll-gold: #a86f00;
|
||||
}
|
||||
|
||||
.ll :lang(ko) {
|
||||
font-family: 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', var(--font-body), sans-serif;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
.ll-card {
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--site-surface);
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
a.ll-card:hover,
|
||||
button.ll-card:hover:not(:disabled) {
|
||||
background: var(--site-hover);
|
||||
}
|
||||
|
||||
.ll-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--site-text);
|
||||
color: var(--site-bg);
|
||||
padding: 0.55rem 1.1rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ll-button:hover:not(:disabled) {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.ll-button:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ll-button-quiet {
|
||||
background: transparent;
|
||||
color: var(--site-text);
|
||||
border: 1px solid var(--site-border);
|
||||
}
|
||||
.ll-button-quiet:hover:not(:disabled) {
|
||||
background: var(--site-hover);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ll-icon-button,
|
||||
.ll-speak {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--site-muted);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.ll-icon-button {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
}
|
||||
.ll-speak {
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.ll-icon-button:hover,
|
||||
.ll-speak:hover {
|
||||
color: var(--site-text);
|
||||
background: var(--site-hover);
|
||||
}
|
||||
|
||||
.ll-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 999px;
|
||||
padding: 0.15rem 0.6rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--site-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ll-combo {
|
||||
color: var(--ll-gold);
|
||||
border-color: var(--ll-gold);
|
||||
}
|
||||
.ll-heart {
|
||||
color: var(--ll-wrong);
|
||||
fill: var(--ll-wrong);
|
||||
}
|
||||
.ll-heart-lost {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.ll-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 999px;
|
||||
padding: 0.3rem 0.8rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.ll-chip:hover:not(:disabled) {
|
||||
background: var(--site-hover);
|
||||
color: var(--site-text);
|
||||
}
|
||||
.ll-chip[aria-pressed='true'] {
|
||||
border-color: var(--site-text);
|
||||
color: var(--site-text);
|
||||
}
|
||||
|
||||
.ll-muted {
|
||||
color: var(--site-muted);
|
||||
}
|
||||
|
||||
.ll-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2.25rem;
|
||||
border-radius: 0.4rem;
|
||||
border: 1px solid var(--site-border);
|
||||
padding: 0.1rem 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
|
||||
.ll-done {
|
||||
color: var(--ll-right);
|
||||
}
|
||||
|
||||
/* session */
|
||||
.ll-session {
|
||||
max-width: 42rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.ll-session-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.ll-stat-line {
|
||||
font-size: 0.875rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.ll-exercise {
|
||||
min-height: 18rem;
|
||||
}
|
||||
.ll-instruction {
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--site-muted);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.ll-prompt {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.ll-prompt-text {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.ll-roman {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 400;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.ll-context {
|
||||
margin-top: 0.5rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.ll-hint {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.ll-listen {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
border-radius: 1rem;
|
||||
background: var(--site-text);
|
||||
color: var(--site-bg);
|
||||
}
|
||||
.ll-slow {
|
||||
margin-left: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--site-muted);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.ll-options {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.ll-options {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.ll-options.ll-options-wide {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.ll-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.6rem;
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
font-size: 1.0625rem;
|
||||
background: var(--site-bg);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.ll-option:hover:not(:disabled) {
|
||||
background: var(--site-hover);
|
||||
}
|
||||
.ll-option kbd {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--site-muted);
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.3rem;
|
||||
padding: 0 0.35rem;
|
||||
}
|
||||
.ll-option.is-glyph span {
|
||||
font-size: 1.75rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.ll-option.is-right {
|
||||
border-color: var(--ll-right);
|
||||
background: var(--ll-right-bg);
|
||||
}
|
||||
.ll-option.is-wrong {
|
||||
border-color: var(--ll-wrong);
|
||||
background: var(--ll-wrong-bg);
|
||||
}
|
||||
.ll-option.is-dim {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.ll-option.is-selected {
|
||||
border-color: var(--site-text);
|
||||
}
|
||||
.ll-option.is-matched {
|
||||
border-color: var(--ll-right);
|
||||
background: var(--ll-right-bg);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.ll-type {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.ll-type input,
|
||||
.ll-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--site-bg);
|
||||
padding: 0.65rem 0.9rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
.ll-input {
|
||||
font-size: 1rem;
|
||||
padding: 0.45rem 0.75rem;
|
||||
}
|
||||
|
||||
.ll-build-line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-height: 3.5rem;
|
||||
border-bottom: 2px solid var(--site-border);
|
||||
padding: 0.5rem 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.ll-bank {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.ll-tile {
|
||||
border: 1px solid var(--site-border);
|
||||
border-bottom-width: 3px;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--site-bg);
|
||||
padding: 0.35rem 0.8rem;
|
||||
font-size: 1.0625rem;
|
||||
}
|
||||
.ll-tile:hover:not(:disabled) {
|
||||
background: var(--site-hover);
|
||||
}
|
||||
.ll-tile.is-used {
|
||||
visibility: hidden;
|
||||
}
|
||||
.ll-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.ll-match {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.ll-match > div {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
align-content: start;
|
||||
}
|
||||
.ll-match.is-shaking {
|
||||
animation: ll-shake 0.3s;
|
||||
}
|
||||
@keyframes ll-shake {
|
||||
25% {
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
75% {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
|
||||
.ll-feedback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border: 1px solid;
|
||||
}
|
||||
.ll-feedback.is-right {
|
||||
border-color: var(--ll-right);
|
||||
background: var(--ll-right-bg);
|
||||
color: var(--ll-right);
|
||||
}
|
||||
.ll-feedback.is-wrong {
|
||||
border-color: var(--ll-wrong);
|
||||
background: var(--ll-wrong-bg);
|
||||
color: var(--ll-wrong);
|
||||
}
|
||||
.ll-feedback p {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.ll-explain {
|
||||
margin-top: 0.25rem;
|
||||
color: var(--site-text);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
.ll-keys {
|
||||
margin-top: 2rem;
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
@media (hover: none) {
|
||||
.ll-keys,
|
||||
.ll-option kbd {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* lesson notes */
|
||||
.ll-notes {
|
||||
--tw-prose-body: var(--site-text);
|
||||
--tw-prose-headings: var(--site-text);
|
||||
--tw-prose-bold: var(--site-text);
|
||||
--tw-prose-bullets: var(--site-muted);
|
||||
--tw-prose-th-borders: var(--site-border);
|
||||
--tw-prose-td-borders: var(--site-border);
|
||||
--tw-prose-code: var(--site-text);
|
||||
max-width: none;
|
||||
}
|
||||
.ll-notes table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* level path */
|
||||
.ll-lesson-dot {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--site-border);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.ll-lesson-dot.is-done {
|
||||
border-color: var(--ll-right);
|
||||
background: var(--ll-right-bg);
|
||||
color: var(--ll-right);
|
||||
}
|
||||
.ll-lesson-dot.is-next {
|
||||
border-color: var(--site-text);
|
||||
color: var(--site-text);
|
||||
}
|
||||
|
||||
.ll-mastery {
|
||||
display: inline-block;
|
||||
width: 0.6rem;
|
||||
height: 0.6rem;
|
||||
border-radius: 999px;
|
||||
background: var(--site-border);
|
||||
}
|
||||
.ll-mastery[data-level='learning'] {
|
||||
background: var(--ll-wrong);
|
||||
}
|
||||
.ll-mastery[data-level='familiar'] {
|
||||
background: var(--ll-gold);
|
||||
}
|
||||
.ll-mastery[data-level='known'] {
|
||||
background: #5aa0e0;
|
||||
}
|
||||
.ll-mastery[data-level='mastered'] {
|
||||
background: var(--ll-right);
|
||||
}
|
||||
Reference in New Issue
Block a user