feat(web): CSV-Import/Export, Tab-Format-Import, PDF-Druckansicht

- CSV-Import: Dropzone für .csv-Dateien, unterstützt 2-spaltig (front,back)
  und 3-spaltig (type,front,back) inkl. cloze; Dedupe via contentHash
- CSV-Export: Button auf Deck-Detail-Seite, lädt type,front,back als .csv
- Tab-Format-Import (ehem. Quizlet): Textarea für tab-getrennte Zeilen;
  funktioniert mit Excel, Google Sheets, Notion und Quizlet-Extension;
  Anleitung erklärt Quizlet-Paywall-Workaround (Quizlet Exporter Extension)
- PDF-Druckansicht: Route /decks/[id]/print, A6-Karten mit alternierenden
  Vorder-/Rückseiten, CSS @page { size: A6 landscape } für Browser-Druck
- Import-Seite: Tab-Bar Anki | CSV | Tab-Format
- i18n: alle 5 Sprachen (DE/EN/FR/ES/IT) vollständig
- docs/FEATURE_IDEAS.md: strukturierte Feature-Liste als Planungsgrundlage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-11 18:27:39 +02:00
parent 9839737049
commit 926ff685c7
15 changed files with 1332 additions and 17 deletions

View file

@ -0,0 +1,246 @@
<script lang="ts">
import { parseCsv, type ParsedCsvCard } from '$lib/csv/parse.ts';
import { importCsvCards, type CsvImportResult, type CsvImportProgress } from '$lib/csv/import.ts';
import { t } from '$lib/i18n/index.svelte.ts';
let fileInput = $state<HTMLInputElement | null>(null);
let stage = $state<'idle' | 'parsing' | 'preview' | 'importing' | 'done' | 'error'>('idle');
let parsedCards = $state<ParsedCsvCard[]>([]);
let skipped = $state(0);
let warnings = $state<string[]>([]);
let result = $state<CsvImportResult | null>(null);
let error = $state<string | null>(null);
let fileName = $state('');
let deckName = $state('');
let progress = $state<CsvImportProgress>({ current: 0, total: 0 });
const typeBreakdown = $derived.by(() => {
const counts = { basic: 0, basicReverse: 0, cloze: 0 };
for (const c of parsedCards) {
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;
if (!deckName) deckName = file.name.replace(/\.csv$/i, '');
stage = 'parsing';
try {
const text = await file.text();
const parsed = parseCsv(text);
parsedCards = parsed.cards;
skipped = parsed.skipped;
warnings = parsed.warnings;
stage = 'preview';
} catch (e: unknown) {
error = e instanceof Error ? e.message : '?';
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 (!parsedCards.length || !deckName.trim()) return;
stage = 'importing';
progress = { current: 0, total: parsedCards.length };
try {
result = await importCsvCards(deckName.trim(), parsedCards, {
onProgress: (p) => {
progress = p;
},
});
stage = 'done';
} catch (e: unknown) {
error = e instanceof Error ? e.message : '?';
stage = 'error';
}
}
function reset() {
stage = 'idle';
parsedCards = [];
skipped = 0;
warnings = [];
result = null;
error = null;
fileName = '';
deckName = '';
}
</script>
<div class="rounded-xl border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4">
<div class="mb-3 text-sm font-medium">{t('import.csv_label')}</div>
{#if stage === 'idle' || stage === 'parsing'}
<label class="mb-3 block">
<span class="mb-1 block text-xs text-[hsl(var(--color-muted-foreground))]">{t('import.csv_deck_label')}</span>
<input
type="text"
class="w-full rounded border border-[hsl(var(--color-border))] bg-[hsl(var(--color-surface))] px-3 py-1.5 text-sm focus:border-[hsl(var(--color-primary))] focus:outline-none"
placeholder={t('import.csv_deck_placeholder')}
bind:value={deckName}
/>
</label>
{#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.csv_dropzone')}</div>
<div class="text-xs">{t('import.csv_dropzone_hint')}</div>
</div>
<input
bind:this={fileInput}
type="file"
accept=".csv,text/csv"
class="hidden"
onchange={onPick}
/>
{:else}
<div class="py-4 text-center text-sm text-[hsl(var(--color-muted-foreground))]" aria-live="polite">
{t('import.parsing', { file: fileName })}
</div>
{/if}
{:else if stage === 'preview'}
<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>
{parsedCards.length === 1
? t('import.preview_cards_one')
: t('import.preview_cards', { n: parsedCards.length })}
{#if parsedCards.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 skipped > 0}
<li>{t('import.preview_skipped', { n: skipped })}</li>
{/if}
</ul>
{#if warnings.length > 0}
<details class="text-xs text-[hsl(var(--color-muted-foreground))]">
<summary class="cursor-pointer">{t('import.preview_warnings', { n: warnings.length })}</summary>
<ul class="mt-1 list-disc pl-4">
{#each warnings.slice(0, 10) as w (w)}<li>{w}</li>{/each}
</ul>
</details>
{/if}
<label class="block pt-1">
<span class="mb-1 block text-xs text-[hsl(var(--color-muted-foreground))]">{t('import.csv_deck_label')}</span>
<input
type="text"
class="w-full rounded border border-[hsl(var(--color-border))] bg-[hsl(var(--color-surface))] px-3 py-1.5 text-sm focus:border-[hsl(var(--color-primary))] focus:outline-none"
placeholder={t('import.csv_deck_placeholder')}
bind:value={deckName}
/>
</label>
<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 disabled:opacity-40"
onclick={confirmImport}
disabled={!deckName.trim() || parsedCards.length === 0}
>
{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">
{t('import.stage_cards', { current: progress.current, total: progress.total })}
<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))]">
{t('import.csv_done_summary', { cards: result.cardsCreated, deck: deckName })}
</div>
{#if result.cardsSkippedDuplicate > 0}
<div class="text-[hsl(var(--color-muted-foreground))]">
{t('import.done_dupes', { n: result.cardsSkippedDuplicate })}
</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>

View file

@ -0,0 +1,192 @@
<script lang="ts">
import { parseQuizlet } from '$lib/quizlet/parse.ts';
import { importCsvCards, type CsvImportResult, type CsvImportProgress } from '$lib/csv/import.ts';
import { t } from '$lib/i18n/index.svelte.ts';
let stage = $state<'idle' | 'preview' | 'importing' | 'done' | 'error'>('idle');
let pasteText = $state('');
let deckName = $state('');
let cardCount = $state(0);
let skipped = $state(0);
let warnings = $state<string[]>([]);
let result = $state<CsvImportResult | null>(null);
let error = $state<string | null>(null);
let progress = $state<CsvImportProgress>({ current: 0, total: 0 });
let parsedCards = $state<import('$lib/csv/parse.ts').ParsedCsvCard[]>([]);
function handlePreview() {
if (!pasteText.trim()) return;
const parsed = parseQuizlet(pasteText);
parsedCards = parsed.cards;
cardCount = parsed.cards.length;
skipped = parsed.skipped;
warnings = parsed.warnings;
stage = 'preview';
}
async function confirmImport() {
if (!parsedCards.length || !deckName.trim()) return;
stage = 'importing';
progress = { current: 0, total: parsedCards.length };
try {
result = await importCsvCards(deckName.trim(), parsedCards, {
onProgress: (p) => {
progress = p;
},
});
stage = 'done';
} catch (e: unknown) {
error = e instanceof Error ? e.message : '?';
stage = 'error';
}
}
function reset() {
stage = 'idle';
pasteText = '';
deckName = '';
parsedCards = [];
cardCount = 0;
skipped = 0;
warnings = [];
result = null;
error = null;
}
</script>
<div class="rounded-xl border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4">
<div class="mb-3 text-sm font-medium">{t('import.quizlet_label')}</div>
{#if stage === 'idle'}
<p class="mb-3 text-xs text-[hsl(var(--color-muted-foreground))]">{t('import.quizlet_hint')}</p>
<label class="mb-3 block">
<span class="mb-1 block text-xs text-[hsl(var(--color-muted-foreground))]">{t('import.quizlet_deck_label')}</span>
<input
type="text"
class="w-full rounded border border-[hsl(var(--color-border))] bg-[hsl(var(--color-surface))] px-3 py-1.5 text-sm focus:border-[hsl(var(--color-primary))] focus:outline-none"
placeholder={t('import.quizlet_deck_placeholder')}
bind:value={deckName}
/>
</label>
<label class="block">
<span class="mb-1 block text-xs text-[hsl(var(--color-muted-foreground))]">{t('import.quizlet_paste_label')}</span>
<textarea
class="h-36 w-full resize-y rounded border border-[hsl(var(--color-border))] bg-[hsl(var(--color-surface))] px-3 py-2 font-mono text-xs focus:border-[hsl(var(--color-primary))] focus:outline-none"
placeholder={t('import.quizlet_placeholder')}
bind:value={pasteText}
></textarea>
</label>
<div class="mt-3 flex justify-end">
<button
class="rounded bg-[hsl(var(--color-primary))] px-4 py-1.5 text-sm text-[hsl(var(--color-primary-foreground))] hover:opacity-90 disabled:opacity-40"
onclick={handlePreview}
disabled={!pasteText.trim()}
>
{t('import.quizlet_preview')}
</button>
</div>
{:else if stage === 'preview'}
<div class="space-y-2 text-sm">
<ul class="ml-4 list-disc">
<li>
{cardCount === 1
? t('import.preview_cards_one')
: t('import.preview_cards', { n: cardCount })}
</li>
{#if skipped > 0}
<li>{t('import.preview_skipped', { n: skipped })}</li>
{/if}
</ul>
{#if warnings.length > 0}
<details class="text-xs text-[hsl(var(--color-muted-foreground))]">
<summary class="cursor-pointer">{t('import.preview_warnings', { n: warnings.length })}</summary>
<ul class="mt-1 list-disc pl-4">
{#each warnings.slice(0, 10) as w (w)}<li>{w}</li>{/each}
</ul>
</details>
{/if}
<label class="block pt-1">
<span class="mb-1 block text-xs text-[hsl(var(--color-muted-foreground))]">{t('import.quizlet_deck_label')}</span>
<input
type="text"
class="w-full rounded border border-[hsl(var(--color-border))] bg-[hsl(var(--color-surface))] px-3 py-1.5 text-sm focus:border-[hsl(var(--color-primary))] focus:outline-none"
placeholder={t('import.quizlet_deck_placeholder')}
bind:value={deckName}
/>
</label>
<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 disabled:opacity-40"
onclick={confirmImport}
disabled={!deckName.trim() || parsedCards.length === 0}
>
{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">
{t('import.stage_cards', { current: progress.current, total: progress.total })}
<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))]">
{t('import.csv_done_summary', { cards: result.cardsCreated, deck: deckName })}
</div>
{#if result.cardsSkippedDuplicate > 0}
<div class="text-[hsl(var(--color-muted-foreground))]">
{t('import.done_dupes', { n: result.cardsSkippedDuplicate })}
</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>

View file

@ -0,0 +1,46 @@
import type { Card } from '@cards/domain';
export function exportToCsv(cards: Card[]): string {
const rows: string[] = ['type,front,back'];
for (const card of cards) {
const f = card.fields as Record<string, string>;
let type: string;
let front: string;
let back: string;
if (card.type === 'cloze') {
type = 'cloze';
front = f.text ?? '';
back = f.extra ?? '';
} else if (card.type === 'basic' || card.type === 'basic-reverse') {
type = card.type;
front = f.front ?? '';
back = f.back ?? '';
} else {
// image-occlusion, audio-front etc. — not representable in CSV
continue;
}
rows.push([type, front, back].map(escapeCsv).join(','));
}
return rows.join('\n');
}
export function downloadCsv(filename: string, content: string): void {
const blob = new Blob(['' + content], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
function escapeCsv(value: string): string {
if (value.includes(',') || value.includes('"') || value.includes('\n') || value.includes('\r')) {
return '"' + value.replace(/"/g, '""') + '"';
}
return value;
}

View file

@ -0,0 +1,75 @@
import { cardContentHash } from '@cards/domain';
import { createDeck } from '$lib/api/decks.ts';
import { createCard, listCardHashes } from '$lib/api/cards.ts';
import type { ParsedCsvCard } from './parse.ts';
export interface CsvImportResult {
cardsCreated: number;
cardsSkippedDuplicate: number;
failed: number;
failures: string[];
}
export interface CsvImportProgress {
current: number;
total: number;
}
export async function importCsvCards(
deckName: string,
cards: ParsedCsvCard[],
opts: { onProgress?: (p: CsvImportProgress) => void } = {}
): Promise<CsvImportResult> {
const result: CsvImportResult = {
cardsCreated: 0,
cardsSkippedDuplicate: 0,
failed: 0,
failures: [],
};
const existingHashes = new Set<string>();
try {
const r = await listCardHashes();
for (const h of r.hashes) existingHashes.add(h);
} catch {
// Dedupe stays off on error
}
let deckId: string;
try {
const deck = await createDeck({ name: deckName });
deckId = deck.id;
} catch (e) {
result.failures.push(`Deck "${deckName}": ${errMessage(e)}`);
result.failed = cards.length;
return result;
}
for (let i = 0; i < cards.length; i++) {
opts.onProgress?.({ current: i, total: cards.length });
const card = cards[i];
const hash = await cardContentHash({ type: card.type, fields: card.fields });
if (existingHashes.has(hash)) {
result.cardsSkippedDuplicate++;
continue;
}
try {
await createCard({ deck_id: deckId, type: card.type, fields: card.fields });
result.cardsCreated++;
existingHashes.add(hash);
} catch (e) {
result.failed++;
result.failures.push(errMessage(e));
}
}
opts.onProgress?.({ current: cards.length, total: cards.length });
return result;
}
function errMessage(e: unknown): string {
if (e instanceof Error) return e.message;
return String(e);
}

View file

@ -0,0 +1,124 @@
export interface ParsedCsv {
cards: ParsedCsvCard[];
skipped: number;
warnings: string[];
}
export interface ParsedCsvCard {
type: 'basic' | 'basic-reverse' | 'cloze';
fields: Record<string, string>;
}
export function parseCsv(text: string): ParsedCsv {
const rows = parseCsvContent(text);
const cards: ParsedCsvCard[] = [];
let skipped = 0;
const warnings: string[] = [];
if (rows.length === 0) return { cards, skipped, warnings };
// Detect header and column layout
const first = rows[0].map((c) => c.trim().toLowerCase());
let startIdx = 0;
let hasTypeCol = false;
if (first[0] === 'type' || first[0] === 'typ') {
startIdx = 1;
hasTypeCol = true;
} else if (first[0] === 'front' || first[0] === 'vorderseite' || first[0] === 'term') {
startIdx = 1;
hasTypeCol = false;
}
for (let i = startIdx; i < rows.length; i++) {
const row = rows[i];
if (row.every((f) => !f.trim())) continue;
if (hasTypeCol) {
const typeRaw = (row[0] ?? '').trim().toLowerCase();
const front = (row[1] ?? '').trim();
const back = (row[2] ?? '').trim();
if (typeRaw === 'basic' || typeRaw === 'basic-reverse') {
if (!front) {
skipped++;
continue;
}
cards.push({ type: typeRaw, fields: { front, back } });
} else if (typeRaw === 'cloze') {
if (!front) {
skipped++;
continue;
}
cards.push({ type: 'cloze', fields: { text: front, ...(back ? { extra: back } : {}) } });
} else {
warnings.push(`Zeile ${i + 1}: Unbekannter Typ "${row[0] ?? ''}"`);
skipped++;
}
} else {
// 2-column: front, back → basic
const front = (row[0] ?? '').trim();
const back = (row[1] ?? '').trim();
if (!front) {
skipped++;
continue;
}
cards.push({ type: 'basic', fields: { front, back } });
}
}
return { cards, skipped, warnings };
}
function parseCsvContent(text: string): string[][] {
const rows: string[][] = [];
let fields: string[] = [];
let field = '';
let inQuotes = false;
const normalized = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
let i = 0;
while (i < normalized.length) {
const ch = normalized[i];
if (inQuotes) {
if (ch === '"') {
if (normalized[i + 1] === '"') {
field += '"';
i += 2;
} else {
inQuotes = false;
i++;
}
} else {
field += ch;
i++;
}
} else {
if (ch === '"') {
inQuotes = true;
i++;
} else if (ch === ',') {
fields.push(field);
field = '';
i++;
} else if (ch === '\n') {
fields.push(field);
rows.push(fields);
fields = [];
field = '';
i++;
} else {
field += ch;
i++;
}
}
}
if (field || fields.length > 0) {
fields.push(field);
rows.push(fields);
}
return rows;
}

View file

@ -53,6 +53,8 @@ export const de: TranslationNode = {
card_delete_confirm: 'Karte wirklich löschen? Reviews werden mit gelöscht.',
fan_aria: 'Aufgefächerte Karten von Stapel "{name}"',
card_open: 'Karte öffnen — {type}',
export_csv: 'CSV',
print_cards: 'Drucken',
},
deck_stack: {
aria_label: 'Stapel "{name}" — {cards} Karten, {due} fällig',
@ -200,6 +202,45 @@ export const de: TranslationNode = {
done_more: 'Weitere Datei',
error_label: 'Fehler: {msg}',
retry: 'Erneut versuchen',
tab_anki: 'Anki',
tab_csv: 'CSV',
tab_quizlet: 'Tab-Format',
csv_label: 'CSV-Datei importieren',
csv_dropzone: '📄 CSV-Datei hier ablegen oder klicken',
csv_dropzone_hint:
'Format: type,front,back — erste Zeile optional als Kopfzeile. Unterstützt: basic, basic-reverse, cloze.',
csv_deck_label: 'Deck-Name',
csv_deck_placeholder: 'Mein importiertes Deck',
csv_done_summary: '✓ {cards} Karten in "{deck}" angelegt.',
csv_format_title: 'CSV-Format',
csv_format_example:
'type,front,back\nbasic,Was ist 2+2?,4\nbasic-reverse,Paris,Hauptstadt Frankreichs\ncloze,"Die {{c1::Mitochondrien}} sind die Kraftwerke",',
csv_format_note: 'Ohne type-Spalte: alle Karten werden als basic angelegt.',
quizlet_label: 'Tab-getrenntes Format importieren',
quizlet_hint:
'Funktioniert mit Quizlet (via Browser-Extension), Excel, Notion, Google Sheets und jedem anderen Tool, das tab-getrennte Zeilen kopieren kann.',
quizlet_paste_label: 'Text einfügen (Begriff Tab Definition)',
quizlet_placeholder: 'Begriff\tDefinition',
quizlet_deck_label: 'Deck-Name',
quizlet_deck_placeholder: 'Mein importiertes Deck',
quizlet_preview: 'Vorschau',
quizlet_how_title: 'Woher bekomme ich das Format?',
quizlet_how_1:
'Excel / Google Sheets: zwei Spalten auswählen, kopieren → hier einfügen.',
quizlet_how_2:
'Notion-Tabelle: Spalten markieren, kopieren → hier einfügen.',
quizlet_how_3:
'Quizlet: Export ist kostenpflichtig. Gratis-Workaround: Browser-Extension „Quizlet Exporter" installieren → Tab-CSV exportieren → hier einfügen.',
},
print: {
title: '{deck} drucken',
loading: 'Lade…',
btn: 'Drucken',
back: '← Zurück',
n_cards_one: '1 Karte',
n_cards: '{n} Karten',
front: 'Vorderseite',
back_side: 'Rückseite',
},
inbox_banner: {
label: '📥 Inbox',

View file

@ -50,6 +50,8 @@ export const en: TranslationNode = {
card_delete_confirm: 'Really delete card? Reviews will be deleted with it.',
fan_aria: 'Fanned cards from stack "{name}"',
card_open: 'Open card — {type}',
export_csv: 'CSV',
print_cards: 'Print',
},
deck_stack: {
aria_label: 'Stack "{name}" — {cards} cards, {due} due',
@ -197,6 +199,43 @@ export const en: TranslationNode = {
done_more: 'Another file',
error_label: 'Error: {msg}',
retry: 'Try again',
tab_anki: 'Anki',
tab_csv: 'CSV',
tab_quizlet: 'Tab format',
csv_label: 'Import CSV file',
csv_dropzone: '📄 Drop CSV file here or click',
csv_dropzone_hint:
'Format: type,front,back — header row is optional. Supported: basic, basic-reverse, cloze.',
csv_deck_label: 'Deck name',
csv_deck_placeholder: 'My imported deck',
csv_done_summary: '✓ {cards} cards added to "{deck}".',
csv_format_title: 'CSV format',
csv_format_example:
'type,front,back\nbasic,What is 2+2?,4\nbasic-reverse,Paris,Capital of France\ncloze,"The {{c1::mitochondria}} are the powerhouse",',
csv_format_note: 'Without a type column all cards are created as basic.',
quizlet_label: 'Import tab-separated format',
quizlet_hint:
'Works with Quizlet (via browser extension), Excel, Notion, Google Sheets, or any tool that can copy tab-separated rows.',
quizlet_paste_label: 'Paste text (term Tab definition)',
quizlet_placeholder: 'Term\tDefinition',
quizlet_deck_label: 'Deck name',
quizlet_deck_placeholder: 'My imported deck',
quizlet_preview: 'Preview',
quizlet_how_title: 'Where to get this format',
quizlet_how_1: 'Excel / Google Sheets: select two columns, copy → paste here.',
quizlet_how_2: 'Notion table: select columns, copy → paste here.',
quizlet_how_3:
'Quizlet: export is paywalled. Free workaround: install the "Quizlet Exporter" browser extension → export as tab CSV → paste here.',
},
print: {
title: 'Print {deck}',
loading: 'Loading…',
btn: 'Print',
back: '← Back',
n_cards_one: '1 card',
n_cards: '{n} cards',
front: 'Front',
back_side: 'Back',
},
inbox_banner: {
label: '📥 Inbox',

View file

@ -50,6 +50,8 @@ export const es: TranslationNode = {
card_delete_confirm: '¿Eliminar la tarjeta? Los repasos se eliminarán junto con ella.',
fan_aria: 'Tarjetas desplegadas del mazo "{name}"',
card_open: 'Abrir tarjeta — {type}',
export_csv: 'CSV',
print_cards: 'Imprimir',
},
deck_stack: {
aria_label: 'Mazo "{name}" — {cards} tarjetas, {due} pendientes',
@ -197,6 +199,43 @@ export const es: TranslationNode = {
done_more: 'Otro archivo',
error_label: 'Error: {msg}',
retry: 'Reintentar',
tab_anki: 'Anki',
tab_csv: 'CSV',
tab_quizlet: 'Formato tab',
csv_label: 'Importar archivo CSV',
csv_dropzone: '📄 Arrastra un archivo CSV aquí o haz clic',
csv_dropzone_hint:
'Formato: type,front,back — cabecera opcional. Admite: basic, basic-reverse, cloze.',
csv_deck_label: 'Nombre del mazo',
csv_deck_placeholder: 'Mi mazo importado',
csv_done_summary: '✓ {cards} tarjetas añadidas a "{deck}".',
csv_format_title: 'Formato CSV',
csv_format_example:
'type,front,back\nbasic,What is 2+2?,4\nbasic-reverse,Paris,Capital of France\ncloze,"The {{c1::mitochondria}} are the powerhouse",',
csv_format_note: 'Sin columna type, todas las tarjetas se crean como basic.',
quizlet_label: 'Importar formato tabulado',
quizlet_hint:
'Funciona con Quizlet (mediante extensión), Excel, Notion, Google Sheets o cualquier herramienta que copie filas separadas por tabuladores.',
quizlet_paste_label: 'Pegar texto (término Tabulador definición)',
quizlet_placeholder: 'Término\tDefinición',
quizlet_deck_label: 'Nombre del mazo',
quizlet_deck_placeholder: 'Mi mazo importado',
quizlet_preview: 'Vista previa',
quizlet_how_title: '¿De dónde viene este formato?',
quizlet_how_1: 'Excel / Google Sheets: selecciona dos columnas, copia → pega aquí.',
quizlet_how_2: 'Tabla de Notion: selecciona las columnas, copia → pega aquí.',
quizlet_how_3:
'Quizlet: la exportación es de pago. Alternativa gratuita: instala la extensión "Quizlet Exporter" → exporta como CSV tabulado → pega aquí.',
},
print: {
title: 'Imprimir {deck}',
loading: 'Cargando…',
btn: 'Imprimir',
back: '← Volver',
n_cards_one: '1 tarjeta',
n_cards: '{n} tarjetas',
front: 'Anverso',
back_side: 'Reverso',
},
inbox_banner: {
label: '📥 Bandeja',

View file

@ -50,6 +50,8 @@ export const fr: TranslationNode = {
card_delete_confirm: 'Supprimer la carte ? Les révisions seront supprimées avec elle.',
fan_aria: 'Cartes étalées de la pile « {name} »',
card_open: 'Ouvrir la carte — {type}',
export_csv: 'CSV',
print_cards: 'Imprimer',
},
deck_stack: {
aria_label: 'Pile « {name} » — {cards} cartes, {due} à réviser',
@ -197,6 +199,43 @@ export const fr: TranslationNode = {
done_more: 'Autre fichier',
error_label: 'Erreur : {msg}',
retry: 'Réessayer',
tab_anki: 'Anki',
tab_csv: 'CSV',
tab_quizlet: 'Format tab',
csv_label: 'Importer un fichier CSV',
csv_dropzone: '📄 Déposez un fichier CSV ici ou cliquez',
csv_dropzone_hint:
'Format : type,front,back — en-tête facultatif. Pris en charge : basic, basic-reverse, cloze.',
csv_deck_label: 'Nom du paquet',
csv_deck_placeholder: 'Mon paquet importé',
csv_done_summary: '✓ {cards} cartes ajoutées dans « {deck} ».',
csv_format_title: 'Format CSV',
csv_format_example:
'type,front,back\nbasic,What is 2+2?,4\nbasic-reverse,Paris,Capital of France\ncloze,"The {{c1::mitochondria}} are the powerhouse",',
csv_format_note: 'Sans colonne type, toutes les cartes sont créées en basic.',
quizlet_label: 'Importer le format tabulé',
quizlet_hint:
'Fonctionne avec Quizlet (via extension), Excel, Notion, Google Sheets ou tout outil copiant des lignes séparées par des tabulations.',
quizlet_paste_label: 'Coller le texte (terme Tabulation définition)',
quizlet_placeholder: 'Terme\tDéfinition',
quizlet_deck_label: 'Nom du paquet',
quizlet_deck_placeholder: 'Mon paquet importé',
quizlet_preview: 'Aperçu',
quizlet_how_title: "D'où vient ce format ?",
quizlet_how_1: 'Excel / Google Sheets : sélectionner deux colonnes, copier → coller ici.',
quizlet_how_2: 'Tableau Notion : sélectionner les colonnes, copier → coller ici.',
quizlet_how_3:
'Quizlet : export payant. Solution gratuite : installer l\'extension « Quizlet Exporter » → exporter en CSV tabulé → coller ici.',
},
print: {
title: 'Imprimer {deck}',
loading: 'Chargement…',
btn: 'Imprimer',
back: '← Retour',
n_cards_one: '1 carte',
n_cards: '{n} cartes',
front: 'Recto',
back_side: 'Verso',
},
inbox_banner: {
label: '📥 Boîte de réception',

View file

@ -50,6 +50,8 @@ export const it: TranslationNode = {
card_delete_confirm: 'Eliminare la carta? I ripassi verranno eliminati insieme ad essa.',
fan_aria: 'Carte aperte a ventaglio dal mazzo "{name}"',
card_open: 'Apri carta — {type}',
export_csv: 'CSV',
print_cards: 'Stampa',
},
deck_stack: {
aria_label: 'Mazzo "{name}" — {cards} carte, {due} da ripassare',
@ -197,6 +199,43 @@ export const it: TranslationNode = {
done_more: 'Un altro file',
error_label: 'Errore: {msg}',
retry: 'Riprova',
tab_anki: 'Anki',
tab_csv: 'CSV',
tab_quizlet: 'Formato tab',
csv_label: 'Importa file CSV',
csv_dropzone: '📄 Trascina un file CSV qui o clicca',
csv_dropzone_hint:
'Formato: type,front,back — intestazione facoltativa. Supporta: basic, basic-reverse, cloze.',
csv_deck_label: 'Nome del mazzo',
csv_deck_placeholder: 'Il mio mazzo importato',
csv_done_summary: '✓ {cards} carte aggiunte a "{deck}".',
csv_format_title: 'Formato CSV',
csv_format_example:
'type,front,back\nbasic,What is 2+2?,4\nbasic-reverse,Paris,Capital of France\ncloze,"The {{c1::mitochondria}} are the powerhouse",',
csv_format_note: 'Senza colonna type, tutte le carte vengono create come basic.',
quizlet_label: 'Importa formato tabulato',
quizlet_hint:
'Funziona con Quizlet (tramite estensione), Excel, Notion, Google Sheets o qualsiasi strumento che copia righe separate da tabulazioni.',
quizlet_paste_label: 'Incolla testo (termine Tab definizione)',
quizlet_placeholder: 'Termine\tDefinizione',
quizlet_deck_label: 'Nome del mazzo',
quizlet_deck_placeholder: 'Il mio mazzo importato',
quizlet_preview: 'Anteprima',
quizlet_how_title: 'Da dove viene questo formato?',
quizlet_how_1: 'Excel / Google Sheets: seleziona due colonne, copia → incolla qui.',
quizlet_how_2: 'Tabella Notion: seleziona le colonne, copia → incolla qui.',
quizlet_how_3:
'Quizlet: l\'esportazione è a pagamento. Alternativa gratuita: installa l\'estensione "Quizlet Exporter" → esporta come CSV tabulato → incolla qui.',
},
print: {
title: 'Stampa {deck}',
loading: 'Caricamento…',
btn: 'Stampa',
back: '← Indietro',
n_cards_one: '1 carta',
n_cards: '{n} carte',
front: 'Fronte',
back_side: 'Retro',
},
inbox_banner: {
label: '📥 In arrivo',

View file

@ -0,0 +1,40 @@
import type { ParsedCsvCard } from '$lib/csv/parse.ts';
export interface ParsedQuizlet {
cards: ParsedCsvCard[];
skipped: number;
warnings: string[];
}
/** Parses Quizlet's tab-separated export (Term \t Definition, one per line). */
export function parseQuizlet(text: string): ParsedQuizlet {
const cards: ParsedCsvCard[] = [];
let skipped = 0;
const warnings: string[] = [];
const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line.trim()) continue;
const tabIdx = line.indexOf('\t');
if (tabIdx === -1) {
warnings.push(`Zeile ${i + 1}: Kein Tab-Trennzeichen gefunden`);
skipped++;
continue;
}
const front = line.slice(0, tabIdx).trim();
const back = line.slice(tabIdx + 1).trim();
if (!front) {
skipped++;
continue;
}
cards.push({ type: 'basic', fields: { front, back } });
}
return { cards, skipped, warnings };
}

View file

@ -16,6 +16,7 @@
import { Image, CaretRight, DotsThree, PencilSimple, Trash } from '@mana/shared-icons';
import { marked } from 'marked';
import { apiErrorMessage } from '$lib/api/error.ts';
import { exportToCsv, downloadCsv } from '$lib/csv/export.ts';
function md(text: string): string {
return marked.parse(text, { async: false }) as string;
@ -102,6 +103,12 @@
if (card.type === 'basic') return f.back ?? null;
return null;
}
function onExportCsv() {
if (!deck || !cards.length) return;
const csv = exportToCsv(cards);
downloadCsv(`${deck.name}.csv`, csv);
}
</script>
<svelte:window onclick={closeMenu} />
@ -124,6 +131,14 @@
<a href="/cards/new?deck={deck.id}" class="btn-outline">
+ {t('deck_detail.new_card')}
</a>
{#if cards.length > 0}
<button type="button" class="btn-outline" onclick={onExportCsv} title={t('deck_detail.export_csv')}>
{t('deck_detail.export_csv')}
</button>
<a href="/decks/{deck.id}/print" class="btn-outline" title={t('deck_detail.print_cards')}>
{t('deck_detail.print_cards')}
</a>
{/if}
<button
type="button"
class="btn-outline"

View file

@ -0,0 +1,229 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import type { Card, Deck } from '@cards/domain';
import { getDeck } from '$lib/api/decks.ts';
import { listCards } from '$lib/api/cards.ts';
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
import { t } from '$lib/i18n/index.svelte.ts';
let deck = $state<Deck | null>(null);
let cards = $state<Card[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
const deckId = $derived(page.params.id ?? '');
onMount(async () => {
if (!devUser.id) {
goto('/');
return;
}
try {
const [d, c] = await Promise.all([getDeck(deckId), listCards(deckId)]);
deck = d;
cards = c.cards;
} catch (e) {
error = e instanceof Error ? e.message : '?';
} finally {
loading = false;
}
});
function cardFront(card: Card): string {
const f = card.fields as Record<string, string>;
if (card.type === 'cloze') return f.text ?? '';
return f.front ?? '';
}
function cardBack(card: Card): string {
const f = card.fields as Record<string, string>;
if (card.type === 'basic' || card.type === 'basic-reverse') return f.back ?? '';
if (card.type === 'cloze') return f.extra ?? '';
return '';
}
</script>
<svelte:head>
<title>{deck ? t('print.title', { deck: deck.name }) : t('print.loading')} · Cardecky</title>
</svelte:head>
<div class="screen-controls">
<a href="/decks/{deckId}" class="back-link">{t('print.back')}</a>
{#if !loading && !error}
<span class="card-count">{t('print.n_cards', { n: cards.length })}</span>
<button class="print-btn" onclick={() => window.print()}>
{t('print.btn')}
</button>
{/if}
</div>
{#if loading}
<p class="screen-only status">{t('print.loading')}</p>
{:else if error}
<p class="screen-only status error">{error}</p>
{:else if deck}
<div class="cards-list">
{#each cards as card (card.id)}
{@const front = cardFront(card)}
{@const back = cardBack(card)}
<!-- Front side -->
<div class="flash-card flash-card--front">
<div class="card-label">{t('print.front')}</div>
<div class="card-body">{front}</div>
<div class="card-footer">{deck.name} · {card.type}</div>
</div>
<!-- Back side -->
<div class="flash-card flash-card--back">
<div class="card-label">{t('print.back_side')}</div>
<div class="card-body">{back || '—'}</div>
<div class="card-footer">{deck.name}</div>
</div>
{/each}
</div>
{/if}
<style>
/* ── Screen UI ─────────────────────────────────────── */
.screen-controls {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.5rem;
border-bottom: 1px solid hsl(var(--color-border));
}
.back-link {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
text-decoration: none;
}
.back-link:hover {
color: hsl(var(--color-foreground));
}
.card-count {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
margin-right: auto;
}
.print-btn {
padding: 0.5rem 1.25rem;
border-radius: 0.375rem;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
font-size: 0.875rem;
font-family: inherit;
border: none;
cursor: pointer;
}
.print-btn:hover {
opacity: 0.9;
}
.status {
padding: 2rem 1.5rem;
color: hsl(var(--color-muted-foreground));
}
.status.error {
color: hsl(var(--color-error));
}
/* ── Cards grid (screen preview) ──────────────────── */
.cards-list {
display: grid;
grid-template-columns: repeat(auto-fill, 148mm);
gap: 0.5rem;
padding: 1rem 1.5rem;
justify-content: center;
}
.flash-card {
width: 148mm;
height: 105mm;
border: 1px solid hsl(var(--color-border));
border-radius: 4px;
display: flex;
flex-direction: column;
padding: 6mm 8mm 4mm;
box-sizing: border-box;
background: hsl(var(--color-card));
overflow: hidden;
}
.flash-card--back {
background: hsl(var(--color-surface));
}
.card-label {
font-size: 8pt;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color: hsl(var(--color-muted-foreground));
margin-bottom: 3mm;
}
.flash-card--back .card-label {
color: hsl(var(--color-primary));
}
.card-body {
flex: 1;
font-size: 13pt;
line-height: 1.4;
overflow: hidden;
display: flex;
align-items: center;
white-space: pre-wrap;
word-break: break-word;
}
.card-footer {
font-size: 7pt;
color: hsl(var(--color-muted-foreground));
margin-top: 3mm;
text-align: right;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Print styles ──────────────────────────────────── */
@media print {
.screen-controls {
display: none;
}
.screen-only {
display: none;
}
.cards-list {
display: block;
padding: 0;
margin: 0;
}
.flash-card {
width: 100%;
height: 100vh;
border: none;
border-radius: 0;
page-break-after: always;
break-after: page;
}
@page {
size: A6 landscape;
margin: 0;
}
}
</style>

View file

@ -3,8 +3,13 @@
import { goto } from '$app/navigation';
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
import AnkiImport from '$lib/components/AnkiImport.svelte';
import CsvImport from '$lib/components/CsvImport.svelte';
import QuizletImport from '$lib/components/QuizletImport.svelte';
import { t } from '$lib/i18n/index.svelte.ts';
type Tab = 'anki' | 'csv' | 'quizlet';
let activeTab = $state<Tab>('anki');
onMount(() => {
if (!devUser.id) {
goto('/');
@ -20,23 +25,75 @@
<h1 class="text-2xl font-semibold">{t('import.title')}</h1>
<p class="mt-2 text-sm text-[hsl(var(--color-muted-foreground))]">{t('import.intro')}</p>
<div class="mt-6">
<AnkiImport />
<!-- Tab bar -->
<div class="mt-5 flex gap-1 border-b border-[hsl(var(--color-border))]">
{#each (['anki', 'csv', 'quizlet'] as Tab[]) as tab (tab)}
<button
class="px-4 py-2 text-sm font-medium transition-colors"
class:tab-active={activeTab === tab}
class:tab-inactive={activeTab !== tab}
onclick={() => (activeTab = tab)}
>
{t(`import.tab_${tab}`)}
</button>
{/each}
</div>
<aside class="mt-8 rounded-lg border border-dashed border-[hsl(var(--color-border))] p-4 text-xs text-[hsl(var(--color-muted-foreground))]">
<div class="mb-1 font-medium text-[hsl(var(--color-foreground))]">{t('import.what_works_title')}</div>
<ul class="list-disc pl-4">
<li>{t('import.what_works_decks')}</li>
<li>{t('import.what_works_basic')}</li>
<li>{t('import.what_works_cloze')}</li>
<li>{t('import.what_works_media')}</li>
</ul>
<div class="mt-2 mb-1 font-medium text-[hsl(var(--color-foreground))]">{t('import.what_skipped_title')}</div>
<ul class="list-disc pl-4">
<li>{t('import.what_skipped_media')}</li>
<li>{t('import.what_skipped_history')}</li>
<li>{t('import.what_skipped_addons')}</li>
</ul>
</aside>
<div class="mt-5">
{#if activeTab === 'anki'}
<AnkiImport />
<aside class="mt-6 rounded-lg border border-dashed border-[hsl(var(--color-border))] p-4 text-xs text-[hsl(var(--color-muted-foreground))]">
<div class="mb-1 font-medium text-[hsl(var(--color-foreground))]">{t('import.what_works_title')}</div>
<ul class="list-disc pl-4">
<li>{t('import.what_works_decks')}</li>
<li>{t('import.what_works_basic')}</li>
<li>{t('import.what_works_cloze')}</li>
<li>{t('import.what_works_media')}</li>
</ul>
<div class="mt-2 mb-1 font-medium text-[hsl(var(--color-foreground))]">{t('import.what_skipped_title')}</div>
<ul class="list-disc pl-4">
<li>{t('import.what_skipped_media')}</li>
<li>{t('import.what_skipped_history')}</li>
<li>{t('import.what_skipped_addons')}</li>
</ul>
</aside>
{:else if activeTab === 'csv'}
<CsvImport />
<aside class="mt-6 rounded-lg border border-dashed border-[hsl(var(--color-border))] p-4 text-xs text-[hsl(var(--color-muted-foreground))]">
<div class="mb-1 font-medium text-[hsl(var(--color-foreground))]">{t('import.csv_format_title')}</div>
<pre class="mt-1 overflow-x-auto rounded bg-[hsl(var(--color-border))]/30 p-2 text-[10px] leading-relaxed">{t('import.csv_format_example')}</pre>
<p class="mt-2">{t('import.csv_format_note')}</p>
</aside>
{:else}
<QuizletImport />
<aside class="mt-6 rounded-lg border border-dashed border-[hsl(var(--color-border))] p-4 text-xs text-[hsl(var(--color-muted-foreground))]">
<div class="mb-1 font-medium text-[hsl(var(--color-foreground))]">{t('import.quizlet_how_title')}</div>
<ol class="list-decimal pl-4 space-y-1">
<li>{t('import.quizlet_how_1')}</li>
<li>{t('import.quizlet_how_2')}</li>
<li>{t('import.quizlet_how_3')}</li>
</ol>
</aside>
{/if}
</div>
</div>
<style>
.tab-active {
color: hsl(var(--color-foreground));
border-bottom: 2px solid hsl(var(--color-primary));
margin-bottom: -1px;
}
.tab-inactive {
color: hsl(var(--color-muted-foreground));
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.tab-inactive:hover {
color: hsl(var(--color-foreground));
}
</style>

94
docs/FEATURE_IDEAS.md Normal file
View file

@ -0,0 +1,94 @@
# Feature Ideas
Stand: 2026-05-11. Basiert auf einer Analyse des aktuellen Cardecky-Stands (Phasen 012).
---
## Lern-Erlebnis
### Schema-ready (nur UI fehlt)
| Feature | Kartentyp | Notiz |
|---------|-----------|-------|
| Hör-Verständnis | `audio-front` | Schema + Renderer vorbereitet |
| Tipp-Antwort | `typing` | Fuzzy-Matching-Logik in `domain/typing.ts` |
| Multiple Choice | `multiple-choice` | Schema vorbereitet, Form-Komponente fehlt |
### Scheduler-Verbesserungen
- **Card Burial / Suspension** — Karten temporär deaktivieren ohne Löschen; häufig angefragtes Anki-Feature
- **Geschwister-Burial** — Cloze-Cluster und basic-reverse-Seiten nicht am selben Tag wiederholen
- **Custom Study Sessions** — Gefilterte Sitzungen: nur neue Karten, nur Fehler der letzten Woche, nach Tag filtern
- **Subdeck-Unterstützung** — Hierarchische Deck-Struktur (z. B. Vokabeln → Nomen / Verben)
---
## Gamification & Motivation
- **Daily Streaks** — Tägliche Lernkette mit optionalem Freeze-Token
- **XP + Badges** — Meilensteine (erstes Deck, 100 Karten, 30-Tage-Streak)
- **Tages-Ziele** — "Heute: 20 Karten" mit Progress-Bar im Dashboard
- **Push/Email-Reminders** — "Du hast heute noch 15 fällige Karten" via mana-notify
- **Estimated Mastery Date** — "Dieses Deck beherrschst du voraussichtlich in 3 Wochen" (aus FSRS-Parametern berechenbar)
---
## KI-Features
- **Auto-Cloze-Generator** — Text markieren → `{{c1::...}}` automatisch einfügen
- **Card-Split-Vorschlag** — KI erkennt informationsreiche Karten und schlägt Aufteilung vor
- **Erklär-Modus** — Nach falscher Antwort: KI erklärt den Zusammenhang (opt-in)
- **Auto-Tagging** — Karten beim Erstellen / Importieren semantisch taggen
- **Duplicate Detection** — Semantische Ähnlichkeit über Decks hinweg erkennen
- **Card Quality Score** — Hinweis: "Diese Karte hat zu viel Text" + Verbesserungsvorschlag
---
## Analytics & Insights
- **Vergessenskurven-Visualisierung** — Pro Deck und Tag, aus FSRS-State ableitbar
- **Retention-Rate** — Aufgeschlüsselt nach Kategorie und Sprache
- **Lernzeit-Tracking** — Minuten pro Session, Wochentrend
- **Karten-Schwierigkeits-Heatmap** — Welche Karten kosten die meiste Review-Zeit
- **Wöchentliche Zusammenfassung** — In-App oder per Email via mana-notify
---
## Import / Export
- **CSV Import/Export** — Einfachste Interop, relevant für Lehrer und Nutzer-Migration
- **PDF Export** — Druckbare Karteikarten (A6-Format, vorder-/rückseitig)
- **Web Clipper** (Browser-Extension) — Markierter Text → sofort neue Karte; eigenes Projekt
- **Quizlet Import** — Größte Nutzerbasis im Markt, hohe Migrations-Relevanz
- **SuperMemo XML** — Für Power-User aus dem SM-Ecosystem
- **FSRS-State Export** — Lernstand als JSON exportieren für Backup und Migration
---
## Zusammenarbeit & Community
- **Study Spaces** — Gemeinsame Decks für Schulklassen und Lerngruppen (braucht mana-auth Gruppen-Konzept)
- **Deck-Ratings & Kommentare** — Qualitätssicherung im Marketplace durch Community
- **Study Challenges** — Mit Freunden auf demselben Deck messen
- **Kreator-Analytics** — Für Marketplace-Publisher: Views, Forks, Abonnenten-Retention
- **Collaborative Decks** — Team-Editing mit Rollen (Maintainer / Contributor)
---
## UX / Plattform
- **PWA Offline-Support** — Service Worker + lokaler Lern-Cache; erfordert Entscheidung über FSRS-State-Sync-Strategie (server-authoritative vs. lokal)
- **Keyboard Shortcuts im Study-Mode** — Space = Antwort zeigen, 14 = Rating
- **Dark Mode / Theme-Switcher** — In-App-Auswahl statt nur System-Präferenz
- **Bulk-Operationen** — Mehrere Karten auswählen, verschieben, taggen, löschen
- **Dynamic Decks (Smart Playlists)** — Automatisch gefiltert: z. B. "Alle Karten mit Tag 'Grammatik' aus 3 Decks"
- **Card History** — Lernverlauf pro Karte: wann wie bewertet
---
## Offene Punkte
- **Schnell umsetzbar / hoher ROI:** Keyboard Shortcuts, Daily Streaks, CSV-Import — geringer Aufwand, spürbare UX-Verbesserung
- **Web Clipper** ist ein separates Browser-Extension-Projekt und braucht einen eigenen Scope
- **PWA Offline** ist der größte Architektur-Trade-off: die aktuelle server-authoritative FSRS-Architektur müsste um einen lokalen Sync-Layer erweitert werden
- **Study Spaces** setzt ein Gruppen-Konzept in mana-auth voraus, das noch nicht existiert