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:
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>
|
||||
Reference in New Issue
Block a user