Some checks failed
Test and deploy staging / staging (push) Failing after 5m29s
274 lines
8.8 KiB
Svelte
274 lines
8.8 KiB
Svelte
<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>
|