Files
personal-website/src/routes/projects/language-learner/+page.svelte
Stan Runge c2ba8febb4
Some checks failed
Test and deploy staging / staging (push) Failing after 5m29s
Expand website and game trackers
2026-09-25 22:43:04 +00:00

274 lines
8.8 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 * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
let resetTarget = $state<{ code: LangCode; name: string } | null>(null);
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 { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
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) {
resetTarget = { code, name };
}
</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">
<Card.Root
><Card.Content class="p-5">
<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>
</Card.Content></Card.Root
>
<Card.Root
><Card.Content class="p-5">
<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
/>
</Card.Content></Card.Root
>
<Card.Root
><Card.Content class="p-5">
<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>
</Card.Content></Card.Root
>
</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">
<Badge variant="outline">{c.code.toUpperCase()}</Badge>
{#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
variant={progress.settings.goal === goal ? 'secondary' : 'outline'}
size="sm"
type="button"
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" variant="outline" onclick={exportProgress}>Export</Button>
<Button variant="outline" type="button" 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 variant="outline" type="button" 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>
<AlertDialog.Root
open={!!resetTarget}
onOpenChange={(open) => {
if (!open) resetTarget = null;
}}
>
<AlertDialog.Content
><AlertDialog.Header
><AlertDialog.Title>Reset {resetTarget?.name}?</AlertDialog.Title><AlertDialog.Description
>This deletes all progress for this language and cannot be undone.</AlertDialog.Description
></AlertDialog.Header
><AlertDialog.Footer
><AlertDialog.Cancel>Cancel</AlertDialog.Cancel><AlertDialog.Action
onclick={() => {
if (resetTarget) progress.reset(resetTarget.code);
resetTarget = null;
}}>Reset progress</AlertDialog.Action
></AlertDialog.Footer
></AlertDialog.Content
>
</AlertDialog.Root>