feat(web): audio-front Upload-Widget + typing Aliases-Feld + Edit-Fixes

audio-front:
- AudioUploadField.svelte: Datei-Upload statt rohem media_ref-Textfeld;
  ruft uploadMedia() auf, zeigt Dateiname nach Upload + Replace-Button
- Karten-Erstellungsseite: AudioUploadField ersetzt das unbrauchbare Textfeld
- Edit-Seite: audio-front wird jetzt korrekt geladen (audio_ref + back statt
  dem falschen basic-else-Zweig) und gespeichert

typing:
- Aliases-Feld in Erstellungs- und Edit-Seite; kommagetrennte Alternativ-
  antworten werden in fields.aliases gespeichert und von checkTypingAnswer
  ausgewertet
- Edit-Seite: typing wird jetzt korrekt geladen (front + answer + aliases)

i18n: alle 5 Sprachen mit audio_upload_btn/uploading/failed/replace,
typing_aliases_label/hint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-11 18:36:28 +02:00
parent 926ff685c7
commit 3669a86599
8 changed files with 254 additions and 21 deletions

View file

@ -0,0 +1,143 @@
<script lang="ts">
import { uploadMedia } from '$lib/api/media.ts';
import { t } from '$lib/i18n/index.svelte.ts';
let { mediaRef = $bindable('') }: { mediaRef: string } = $props();
let fileInput = $state<HTMLInputElement | null>(null);
let uploading = $state(false);
let uploadError = $state<string | null>(null);
let uploadedName = $state('');
async function handleFile(file: File) {
uploading = true;
uploadError = null;
try {
const result = await uploadMedia(file);
mediaRef = result.id;
uploadedName = file.name;
} catch (e) {
uploadError = e instanceof Error ? e.message : '?';
} finally {
uploading = false;
}
}
function onPick(e: Event) {
const f = (e.currentTarget as HTMLInputElement).files?.[0];
if (f) handleFile(f);
(e.currentTarget as HTMLInputElement).value = '';
}
</script>
<div class="upload-field">
{#if uploading}
<div class="status uploading">{t('card_new.audio_uploading')}</div>
{:else if mediaRef}
<div class="status done">
<span class="filename">{uploadedName || mediaRef}</span>
<button type="button" class="replace-btn" onclick={() => fileInput?.click()}>
{t('card_new.audio_replace')}
</button>
</div>
{:else}
<button type="button" class="upload-btn" onclick={() => fileInput?.click()}>
{t('card_new.audio_upload_btn')}
</button>
{/if}
{#if uploadError}
<p class="error">{t('card_new.audio_upload_failed', { msg: uploadError })}</p>
{/if}
<input
bind:this={fileInput}
type="file"
accept="audio/*,.mp3,.ogg,.wav,.m4a,.webm,.aac"
class="hidden"
onchange={onPick}
/>
</div>
<style>
.upload-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.upload-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
border: 2px dashed hsl(var(--color-border));
border-radius: 0.5rem;
background: transparent;
color: hsl(var(--color-muted-foreground));
font: inherit;
font-size: 0.875rem;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
width: 100%;
justify-content: center;
}
.upload-btn:hover {
border-color: hsl(var(--color-primary));
color: hsl(var(--color-foreground));
}
.status {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
font-size: 0.875rem;
}
.uploading {
color: hsl(var(--color-muted-foreground));
background: hsl(var(--color-border) / 0.3);
}
.done {
background: hsl(var(--color-success) / 0.08);
border: 1px solid hsl(var(--color-success) / 0.3);
color: hsl(var(--color-foreground));
}
.filename {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: ui-monospace, monospace;
font-size: 0.8125rem;
}
.replace-btn {
flex-shrink: 0;
padding: 0.25rem 0.625rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: transparent;
color: hsl(var(--color-muted-foreground));
font: inherit;
font-size: 0.75rem;
cursor: pointer;
}
.replace-btn:hover {
color: hsl(var(--color-foreground));
}
.error {
font-size: 0.8125rem;
color: hsl(var(--color-error));
margin: 0;
}
.hidden {
display: none;
}
</style>

View file

@ -120,6 +120,12 @@ export const de: TranslationNode = {
distractor_pool_placeholder: 'Ein Eintrag pro Zeile — Fallback wenn das Deck zu klein für KI-Distractors ist', distractor_pool_placeholder: 'Ein Eintrag pro Zeile — Fallback wenn das Deck zu klein für KI-Distractors ist',
audio_ref_label: 'Audio-Referenz (media_ref)', audio_ref_label: 'Audio-Referenz (media_ref)',
audio_ref_placeholder: 'z. B. abc123.mp3', audio_ref_placeholder: 'z. B. abc123.mp3',
audio_upload_btn: '🎵 Audio-Datei hochladen',
audio_uploading: 'Lade hoch…',
audio_upload_failed: 'Upload fehlgeschlagen: {msg}',
audio_replace: 'Austauschen',
typing_aliases_label: 'Aliase (optional)',
typing_aliases_hint: 'Kommagetrennte Alternativantworten — z. B. Paris, Paris (Frankreich)',
back_audio_label: 'Antworttext (Markdown)', back_audio_label: 'Antworttext (Markdown)',
toast_typing: 'Typing-Karte angelegt', toast_typing: 'Typing-Karte angelegt',
toast_multiple_choice: 'Multiple-Choice-Karte angelegt', toast_multiple_choice: 'Multiple-Choice-Karte angelegt',

View file

@ -117,6 +117,12 @@ export const en: TranslationNode = {
distractor_pool_placeholder: 'One entry per line — fallback when the deck is too small for AI distractors', distractor_pool_placeholder: 'One entry per line — fallback when the deck is too small for AI distractors',
audio_ref_label: 'Audio reference (media_ref)', audio_ref_label: 'Audio reference (media_ref)',
audio_ref_placeholder: 'e.g. abc123.mp3', audio_ref_placeholder: 'e.g. abc123.mp3',
audio_upload_btn: '🎵 Upload audio file',
audio_uploading: 'Uploading…',
audio_upload_failed: 'Upload failed: {msg}',
audio_replace: 'Replace',
typing_aliases_label: 'Aliases (optional)',
typing_aliases_hint: 'Comma-separated alternative answers — e.g. Paris, Paris (France)',
back_audio_label: 'Answer text (Markdown)', back_audio_label: 'Answer text (Markdown)',
toast_typing: 'Typing card created', toast_typing: 'Typing card created',
toast_multiple_choice: 'Multiple-choice card created', toast_multiple_choice: 'Multiple-choice card created',

View file

@ -117,6 +117,12 @@ export const es: TranslationNode = {
distractor_pool_placeholder: 'Un elemento por línea — usado si el mazo es demasiado pequeño para distractores IA', distractor_pool_placeholder: 'Un elemento por línea — usado si el mazo es demasiado pequeño para distractores IA',
audio_ref_label: 'Referencia de audio (media_ref)', audio_ref_label: 'Referencia de audio (media_ref)',
audio_ref_placeholder: 'ej. abc123.mp3', audio_ref_placeholder: 'ej. abc123.mp3',
audio_upload_btn: '🎵 Subir archivo de audio',
audio_uploading: 'Subiendo…',
audio_upload_failed: 'Error al subir: {msg}',
audio_replace: 'Reemplazar',
typing_aliases_label: 'Alias (opcional)',
typing_aliases_hint: 'Respuestas alternativas separadas por comas — ej. París, París (Francia)',
back_audio_label: 'Texto de respuesta (Markdown)', back_audio_label: 'Texto de respuesta (Markdown)',
toast_typing: 'Tarjeta de escritura creada', toast_typing: 'Tarjeta de escritura creada',
toast_multiple_choice: 'Tarjeta de opción múltiple creada', toast_multiple_choice: 'Tarjeta de opción múltiple creada',

View file

@ -117,6 +117,12 @@ export const fr: TranslationNode = {
distractor_pool_placeholder: 'Un élément par ligne — utilisé si la collection est trop petite pour les distracteurs IA', distractor_pool_placeholder: 'Un élément par ligne — utilisé si la collection est trop petite pour les distracteurs IA',
audio_ref_label: 'Référence audio (media_ref)', audio_ref_label: 'Référence audio (media_ref)',
audio_ref_placeholder: 'ex. abc123.mp3', audio_ref_placeholder: 'ex. abc123.mp3',
audio_upload_btn: '🎵 Téléverser un fichier audio',
audio_uploading: 'Téléversement…',
audio_upload_failed: 'Échec du téléversement : {msg}',
audio_replace: 'Remplacer',
typing_aliases_label: 'Alias (facultatif)',
typing_aliases_hint: 'Réponses alternatives séparées par des virgules — ex. Paris, Paris (France)',
back_audio_label: 'Texte de réponse (Markdown)', back_audio_label: 'Texte de réponse (Markdown)',
toast_typing: 'Carte de saisie créée', toast_typing: 'Carte de saisie créée',
toast_multiple_choice: 'Carte à choix multiple créée', toast_multiple_choice: 'Carte à choix multiple créée',

View file

@ -117,6 +117,12 @@ export const it: TranslationNode = {
distractor_pool_placeholder: 'Un elemento per riga — usato se il mazzo è troppo piccolo per i distrattori IA', distractor_pool_placeholder: 'Un elemento per riga — usato se il mazzo è troppo piccolo per i distrattori IA',
audio_ref_label: 'Riferimento audio (media_ref)', audio_ref_label: 'Riferimento audio (media_ref)',
audio_ref_placeholder: 'es. abc123.mp3', audio_ref_placeholder: 'es. abc123.mp3',
audio_upload_btn: '🎵 Carica file audio',
audio_uploading: 'Caricamento…',
audio_upload_failed: 'Caricamento fallito: {msg}',
audio_replace: 'Sostituisci',
typing_aliases_label: 'Alias (facoltativo)',
typing_aliases_hint: 'Risposte alternative separate da virgola — es. Parigi, Parigi (Francia)',
back_audio_label: 'Testo risposta (Markdown)', back_audio_label: 'Testo risposta (Markdown)',
toast_typing: 'Carta di digitazione creata', toast_typing: 'Carta di digitazione creata',
toast_multiple_choice: 'Carta a scelta multipla creata', toast_multiple_choice: 'Carta a scelta multipla creata',

View file

@ -17,6 +17,7 @@
import { t } from '$lib/i18n/index.svelte.ts'; import { t } from '$lib/i18n/index.svelte.ts';
import ImageOcclusionEditor from '$lib/components/ImageOcclusionEditor.svelte'; import ImageOcclusionEditor from '$lib/components/ImageOcclusionEditor.svelte';
import MultipleChoiceCardForm from '$lib/components/MultipleChoiceCardForm.svelte'; import MultipleChoiceCardForm from '$lib/components/MultipleChoiceCardForm.svelte';
import AudioUploadField from '$lib/components/AudioUploadField.svelte';
let card = $state<Card | null>(null); let card = $state<Card | null>(null);
let cardType = $state<CardType>('basic'); let cardType = $state<CardType>('basic');
@ -26,6 +27,9 @@
let extra = $state(''); let extra = $state('');
let imageRef = $state(''); let imageRef = $state('');
let maskRegionsJson = $state('[]'); let maskRegionsJson = $state('[]');
let answer = $state('');
let aliases = $state('');
let audioFileRef = $state('');
let mcOptions = $state(['', '', '', '']); let mcOptions = $state(['', '', '', '']);
let mcCorrectIdx = $state(0); let mcCorrectIdx = $state(0);
let mcExplanation = $state(''); let mcExplanation = $state('');
@ -68,6 +72,13 @@
mcOptions = [answer, ...distractors, '', '', ''].slice(0, 4); mcOptions = [answer, ...distractors, '', '', ''].slice(0, 4);
mcCorrectIdx = 0; mcCorrectIdx = 0;
mcExplanation = fields.explanation ?? ''; mcExplanation = fields.explanation ?? '';
} else if (c.type === 'typing') {
front = fields.front ?? '';
answer = fields.answer ?? '';
aliases = fields.aliases ?? '';
} else if (c.type === 'audio-front') {
audioFileRef = fields.audio_ref ?? '';
back = fields.back ?? '';
} else { } else {
front = fields.front ?? ''; front = fields.front ?? '';
back = fields.back ?? ''; back = fields.back ?? '';
@ -89,6 +100,8 @@
const filledCount = mcOptions.filter((o) => o.trim()).length; const filledCount = mcOptions.filter((o) => o.trim()).length;
return front.trim().length > 0 && mcOptions[mcCorrectIdx].trim().length > 0 && filledCount >= 2; return front.trim().length > 0 && mcOptions[mcCorrectIdx].trim().length > 0 && filledCount >= 2;
} }
if (cardType === 'typing') return front.trim().length > 0 && answer.trim().length > 0;
if (cardType === 'audio-front') return audioFileRef.trim().length > 0 && back.trim().length > 0;
return front.trim().length > 0 && back.trim().length > 0; return front.trim().length > 0 && back.trim().length > 0;
}); });
@ -112,6 +125,11 @@
fields = { front: front.trim(), answer: mcOptions[mcCorrectIdx].trim() }; fields = { front: front.trim(), answer: mcOptions[mcCorrectIdx].trim() };
if (distractors) fields.distractor_pool = distractors; if (distractors) fields.distractor_pool = distractors;
if (mcExplanation.trim()) fields.explanation = mcExplanation.trim(); if (mcExplanation.trim()) fields.explanation = mcExplanation.trim();
} else if (cardType === 'typing') {
fields = { front: front.trim(), answer: answer.trim() };
if (aliases.trim()) fields.aliases = aliases.trim();
} else if (cardType === 'audio-front') {
fields = { audio_ref: audioFileRef.trim(), back: back.trim() };
} else { } else {
fields = { front: front.trim(), back: back.trim() }; fields = { front: front.trim(), back: back.trim() };
} }
@ -207,6 +225,55 @@
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 font-mono text-sm" class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 font-mono text-sm"
></textarea> ></textarea>
</label> </label>
{:else if cardType === 'typing'}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<label class="block">
<span class="text-sm font-medium">{t('card_new.front_label')}</span>
<textarea
bind:value={front}
required
rows="8"
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 font-mono text-sm"
></textarea>
</label>
<label class="block">
<span class="text-sm font-medium">{t('card_new.answer_label')}</span>
<textarea
bind:value={answer}
required
rows="8"
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 font-mono text-sm"
></textarea>
</label>
</div>
<label class="block">
<span class="text-sm font-medium">{t('card_new.typing_aliases_label')}</span>
<input
type="text"
bind:value={aliases}
placeholder="Paris, Paris (Frankreich)"
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 font-mono text-sm"
/>
<p class="mt-1 text-xs text-[hsl(var(--color-muted-foreground))]">{t('card_new.typing_aliases_hint')}</p>
</label>
{:else if cardType === 'audio-front'}
<label class="block">
<span class="text-sm font-medium">{t('card_new.audio_ref_label')}</span>
<div class="mt-1">
<AudioUploadField bind:mediaRef={audioFileRef} />
</div>
</label>
<label class="block">
<span class="text-sm font-medium">{t('card_new.back_audio_label')}</span>
<textarea
bind:value={back}
required
rows="6"
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 font-mono text-sm"
></textarea>
</label>
{:else} {:else}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div> <div>

View file

@ -19,6 +19,7 @@
import ImageOcclusionEditor from '$lib/components/ImageOcclusionEditor.svelte'; import ImageOcclusionEditor from '$lib/components/ImageOcclusionEditor.svelte';
import ClozeCardForm from '$lib/components/ClozeCardForm.svelte'; import ClozeCardForm from '$lib/components/ClozeCardForm.svelte';
import MultipleChoiceCardForm from '$lib/components/MultipleChoiceCardForm.svelte'; import MultipleChoiceCardForm from '$lib/components/MultipleChoiceCardForm.svelte';
import AudioUploadField from '$lib/components/AudioUploadField.svelte';
import CardSurface from '$lib/components/CardSurface.svelte'; import CardSurface from '$lib/components/CardSurface.svelte';
type DeckLite = { id: string; name: string }; type DeckLite = { id: string; name: string };
@ -33,6 +34,7 @@
let imageRef = $state(''); let imageRef = $state('');
let maskRegionsJson = $state('[]'); let maskRegionsJson = $state('[]');
let answer = $state(''); let answer = $state('');
let aliases = $state('');
let audioFileRef = $state(''); let audioFileRef = $state('');
let saving = $state(false); let saving = $state(false);
let previewFlipped = $state(false); let previewFlipped = $state(false);
@ -119,6 +121,7 @@
fields = { image_ref: imageRef, mask_regions: maskRegionsJson }; fields = { image_ref: imageRef, mask_regions: maskRegionsJson };
} else if (cardType === 'typing') { } else if (cardType === 'typing') {
fields = { front: front.trim(), answer: answer.trim() }; fields = { front: front.trim(), answer: answer.trim() };
if (aliases.trim()) fields.aliases = aliases.trim();
} else if (cardType === 'multiple-choice') { } else if (cardType === 'multiple-choice') {
const distractors = mcOptions const distractors = mcOptions
.filter((_, i) => i !== mcCorrectIdx) .filter((_, i) => i !== mcCorrectIdx)
@ -223,21 +226,23 @@
placeholder={t('card_new.answer_placeholder')} placeholder={t('card_new.answer_placeholder')}
class="input mono" class="input mono"
></textarea> ></textarea>
<span class="field-hint">Kommagetrennte Aliase erlaubt: <code>Paris, Paris (Frankreich)</code></span>
</label> </label>
</div> </div>
<label class="field">
<span class="field-label">{t('card_new.typing_aliases_label')}</span>
<input
type="text"
bind:value={aliases}
placeholder="Paris, Paris (Frankreich)"
class="input mono"
/>
<span class="field-hint">{t('card_new.typing_aliases_hint')}</span>
</label>
{:else if cardType === 'audio-front'} {:else if cardType === 'audio-front'}
<label class="field"> <label class="field">
<span class="field-label">{t('card_new.audio_ref_label')}</span> <span class="field-label">{t('card_new.audio_ref_label')}</span>
<input <AudioUploadField bind:mediaRef={audioFileRef} />
type="text"
bind:value={audioFileRef}
required
placeholder={t('card_new.audio_ref_placeholder')}
class="input mono"
/>
<span class="field-hint">Die media_ref-ID aus dem Media-Upload-Endpoint.</span>
</label> </label>
<label class="field"> <label class="field">
<span class="field-label">{t('card_new.back_audio_label')}</span> <span class="field-label">{t('card_new.back_audio_label')}</span>
@ -524,18 +529,6 @@
color: hsl(var(--color-muted-foreground)); color: hsl(var(--color-muted-foreground));
line-height: 1.4; line-height: 1.4;
} }
.field-hint code {
font-size: 0.75rem;
background: hsl(var(--color-muted) / 0.5);
padding: 0.1em 0.3em;
border-radius: 0.2rem;
}
.field-error {
font-size: 0.75rem;
color: hsl(var(--color-error));
}
.input { .input {
display: block; display: block;
width: 100%; width: 100%;