Cards is the first app on the new 12-token mana-vereinsweite theming system (mana/docs/THEMING.md). Forest-Variant aus @mana/themes/variants/forest.css konsumiert via app.css-Import, data-theme="forest" in app.html. Token-Welt umgestellt — 158 renames + 304 hsl-wraps in 17 Files (Python-Refactor, BSD-sed war zu unzuverlässig): - --color-bg → --color-background - --color-fg → --color-foreground - --color-muted → --color-muted-foreground - --color-primary-fg → --color-primary-foreground - --color-danger → --color-error - bare var(--color-X) → hsl(var(--color-X)) durchgängig Bridge-Aliase in app.css mappen die shared-ui@0.1.x-Erwartungen (card, accent, surface-elevated-*, …) auf das 12er-Set. Mit shared-ui@2.0-Refactor entfällt diese Sektion. --brand-cards-forest als App-Identitäts-Hex separiert von Theme-Tokens. Header konsumiert PillTabGroup aus @mana/shared-ui@0.1.1 für die Routen-Navigation (Decks/Lernen/Library/Import/Stats) und den DE/EN-Sprach-Switcher — visuell konsistent mit Vereins-Standard. Cards' primary-Grün wurde dabei von 142 76% 36% (alter Live-Stand) auf 142 76% 28% verdunkelt, damit primary-foreground/primary- Kontrast WCAG-AA-konform (≥4.5) ist. Der alte Live-Stand hatte Ratio 3.35. i18n: deck_stack.aria_label, deck_detail.fan_aria, deck_detail. card_open, decks.card_count_more, study_session.manage_link. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
243 lines
8.2 KiB
Svelte
243 lines
8.2 KiB
Svelte
<!--
|
|
Anki-Import-UI: Datei wählen → Preview → Importieren → Done.
|
|
|
|
STRATEGIE-B-AUSNAHME: portiert aus
|
|
mana-monorepo/apps/cards/apps/web/src/lib/components/AnkiImport.svelte.
|
|
Original-Layout angepasst auf das oklch-Theme im neuen Repo
|
|
(var(--color-*)) und auf den server-authoritative Import-Pfad
|
|
ohne Media-Upload-Stage. Cloze-Skipped-Anzeige ergänzt — der neue
|
|
Importer reicht Cloze direkt durch.
|
|
-->
|
|
<script lang="ts">
|
|
import { parseApkg, type ParsedAnki } from '$lib/anki/parse.ts';
|
|
import { importParsedAnki, type ImportResult, type ImportProgress } from '$lib/anki/import.ts';
|
|
import { t, tn } from '$lib/i18n/index.svelte.ts';
|
|
|
|
let fileInput = $state<HTMLInputElement | null>(null);
|
|
let stage = $state<'idle' | 'parsing' | 'preview' | 'importing' | 'done' | 'error'>('idle');
|
|
let parsed = $state<ParsedAnki | null>(null);
|
|
let result = $state<ImportResult | null>(null);
|
|
let error = $state<string | null>(null);
|
|
let fileName = $state<string>('');
|
|
let progress = $state<ImportProgress>({ stage: 'decks', current: 0, total: 0 });
|
|
|
|
const typeBreakdown = $derived.by(() => {
|
|
if (!parsed) return { basic: 0, basicReverse: 0, cloze: 0 };
|
|
const counts = { basic: 0, basicReverse: 0, cloze: 0 };
|
|
for (const c of parsed.cards) {
|
|
if (c.type === 'basic') counts.basic++;
|
|
else if (c.type === 'basic-reverse') counts.basicReverse++;
|
|
else if (c.type === 'cloze') counts.cloze++;
|
|
}
|
|
return counts;
|
|
});
|
|
|
|
async function handleFile(file: File) {
|
|
error = null;
|
|
fileName = file.name;
|
|
stage = 'parsing';
|
|
try {
|
|
parsed = await parseApkg(file);
|
|
stage = 'preview';
|
|
} catch (e: unknown) {
|
|
error = e instanceof Error ? e.message : t('import.error_label', { msg: '?' });
|
|
stage = 'error';
|
|
}
|
|
}
|
|
|
|
function onPick(e: Event) {
|
|
const input = e.currentTarget as HTMLInputElement;
|
|
const f = input.files?.[0];
|
|
if (f) handleFile(f);
|
|
input.value = '';
|
|
}
|
|
|
|
function onDrop(e: DragEvent) {
|
|
e.preventDefault();
|
|
const f = e.dataTransfer?.files?.[0];
|
|
if (f) handleFile(f);
|
|
}
|
|
|
|
async function confirmImport() {
|
|
if (!parsed) return;
|
|
stage = 'importing';
|
|
progress = { stage: 'decks', current: 0, total: parsed.decks.length };
|
|
try {
|
|
result = await importParsedAnki(parsed, {
|
|
onProgress: (p) => {
|
|
progress = p;
|
|
},
|
|
});
|
|
stage = 'done';
|
|
} catch (e: unknown) {
|
|
error = e instanceof Error ? e.message : t('import.error_label', { msg: '?' });
|
|
stage = 'error';
|
|
}
|
|
}
|
|
|
|
function reset() {
|
|
stage = 'idle';
|
|
parsed = null;
|
|
result = null;
|
|
error = null;
|
|
fileName = '';
|
|
}
|
|
</script>
|
|
|
|
<div class="rounded-xl border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4">
|
|
<div class="mb-2 text-sm font-medium">{t('import.anki_label')}</div>
|
|
|
|
{#if stage === 'idle'}
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
<div
|
|
class="cursor-pointer rounded-lg border-2 border-dashed border-[hsl(var(--color-border))] px-4 py-6 text-center text-sm text-[hsl(var(--color-muted-foreground))] transition-colors hover:border-[hsl(var(--color-primary))] hover:text-[hsl(var(--color-foreground))]"
|
|
ondragover={(e) => e.preventDefault()}
|
|
ondrop={onDrop}
|
|
onclick={() => fileInput?.click()}
|
|
role="button"
|
|
tabindex="0"
|
|
onkeydown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
fileInput?.click();
|
|
}
|
|
}}
|
|
>
|
|
<div class="mb-1">{t('import.dropzone')}</div>
|
|
<div class="text-xs">{t('import.dropzone_hint')}</div>
|
|
</div>
|
|
<input
|
|
bind:this={fileInput}
|
|
type="file"
|
|
accept=".apkg,.colpkg"
|
|
class="hidden"
|
|
onchange={onPick}
|
|
/>
|
|
{:else if stage === 'parsing'}
|
|
<div class="py-6 text-center text-sm text-[hsl(var(--color-muted-foreground))]" aria-live="polite">
|
|
{t('import.parsing', { file: fileName })}
|
|
</div>
|
|
{:else if stage === 'preview' && parsed}
|
|
<div class="space-y-2 text-sm">
|
|
<div>
|
|
<span class="text-[hsl(var(--color-muted-foreground))]">{t('import.preview_found')}</span>
|
|
<code class="rounded bg-[hsl(var(--color-border))]/40 px-1 text-xs">{fileName}</code>:
|
|
</div>
|
|
<ul class="ml-4 list-disc">
|
|
<li>{tn('import.preview_decks', parsed.decks.length)}</li>
|
|
<li>
|
|
{tn('import.preview_cards', parsed.cards.length)}
|
|
{#if parsed.cards.length > 0}
|
|
<span class="text-[hsl(var(--color-muted-foreground))]">
|
|
{t('import.preview_breakdown', {
|
|
basic: typeBreakdown.basic,
|
|
basic_reverse: typeBreakdown.basicReverse,
|
|
cloze: typeBreakdown.cloze,
|
|
})}
|
|
</span>
|
|
{/if}
|
|
</li>
|
|
{#if parsed.mediaByFilename.size > 0}
|
|
<li class="text-[hsl(var(--color-muted-foreground))]">
|
|
{t('import.preview_media', { n: parsed.mediaByFilename.size })}
|
|
</li>
|
|
{/if}
|
|
{#if parsed.skipped > 0}
|
|
<li>{t('import.preview_skipped', { n: parsed.skipped })}</li>
|
|
{/if}
|
|
</ul>
|
|
{#if parsed.warnings.length > 0}
|
|
<details class="text-xs text-[hsl(var(--color-muted-foreground))]">
|
|
<summary class="cursor-pointer">{t('import.preview_warnings', { n: parsed.warnings.length })}</summary>
|
|
<ul class="mt-1 list-disc pl-4">
|
|
{#each parsed.warnings.slice(0, 10) as w (w)}<li>{w}</li>{/each}
|
|
</ul>
|
|
</details>
|
|
{/if}
|
|
<div class="flex justify-end gap-2 pt-2">
|
|
<button
|
|
class="rounded px-3 py-1.5 text-sm text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]"
|
|
onclick={reset}
|
|
>
|
|
{t('import.cancel')}
|
|
</button>
|
|
<button
|
|
class="rounded bg-[hsl(var(--color-primary))] px-4 py-1.5 text-sm text-[hsl(var(--color-primary-foreground))] hover:opacity-90"
|
|
onclick={confirmImport}
|
|
>
|
|
{t('import.import_now')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{:else if stage === 'importing'}
|
|
<div class="py-6 text-center text-sm text-[hsl(var(--color-muted-foreground))]" aria-live="polite">
|
|
{#if progress.stage === 'media'}
|
|
{t('import.stage_media', { current: progress.current, total: progress.total })}
|
|
{:else if progress.stage === 'decks'}
|
|
{t('import.stage_decks', { current: progress.current, total: progress.total })}
|
|
{:else if progress.stage === 'cards'}
|
|
{t('import.stage_cards', { current: progress.current, total: progress.total })}
|
|
{:else}
|
|
{t('import.stage_done')}
|
|
{/if}
|
|
<div
|
|
class="mx-auto mt-3 h-1 w-48 overflow-hidden rounded-full bg-[hsl(var(--color-border))]/40"
|
|
role="progressbar"
|
|
aria-valuemin="0"
|
|
aria-valuemax={progress.total}
|
|
aria-valuenow={progress.current}
|
|
>
|
|
<div
|
|
class="h-full bg-[hsl(var(--color-primary))] transition-all"
|
|
style="width: {progress.total === 0 ? 0 : (progress.current / progress.total) * 100}%"
|
|
></div>
|
|
</div>
|
|
</div>
|
|
{:else if stage === 'done' && result}
|
|
<div class="space-y-2 text-sm">
|
|
<div class="text-[hsl(var(--color-success))]">
|
|
{result.decksCreated === 1
|
|
? t('import.done_summary_one', { cards: result.cardsCreated })
|
|
: t('import.done_summary', { cards: result.cardsCreated, decks: result.decksCreated })}
|
|
</div>
|
|
{#if result.cardsSkippedDuplicate > 0}
|
|
<div class="text-[hsl(var(--color-muted-foreground))]">
|
|
{t('import.done_dupes', { n: result.cardsSkippedDuplicate })}
|
|
</div>
|
|
{/if}
|
|
{#if result.mediaUploaded > 0 || result.mediaFailed > 0}
|
|
<div class="text-[hsl(var(--color-muted-foreground))]">
|
|
{t('import.done_media', {
|
|
uploaded: result.mediaUploaded,
|
|
failed: result.mediaFailed,
|
|
})}
|
|
</div>
|
|
{/if}
|
|
{#if result.failed > 0}
|
|
<details class="text-[hsl(var(--color-error))]">
|
|
<summary class="cursor-pointer">{t('import.done_failures', { n: result.failed })}</summary>
|
|
<ul class="mt-1 list-disc pl-4 text-xs">
|
|
{#each result.failures.slice(0, 20) as msg (msg)}<li>{msg}</li>{/each}
|
|
</ul>
|
|
</details>
|
|
{/if}
|
|
<button
|
|
class="rounded px-3 py-1.5 text-sm text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]"
|
|
onclick={reset}
|
|
>
|
|
{t('import.done_more')}
|
|
</button>
|
|
</div>
|
|
{:else if stage === 'error'}
|
|
<div class="space-y-2 text-sm" role="alert">
|
|
<div class="text-[hsl(var(--color-error))]">{t('import.error_label', { msg: error ?? '?' })}</div>
|
|
<button
|
|
class="rounded px-3 py-1.5 text-sm text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]"
|
|
onclick={reset}
|
|
>
|
|
{t('import.retry')}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|