diff --git a/apps/web/src/lib/components/AudioUploadField.svelte b/apps/web/src/lib/components/AudioUploadField.svelte new file mode 100644 index 0000000..c7b0d8e --- /dev/null +++ b/apps/web/src/lib/components/AudioUploadField.svelte @@ -0,0 +1,143 @@ + + +
+ {#if uploading} +
{t('card_new.audio_uploading')}
+ {:else if mediaRef} +
+ {uploadedName || mediaRef} + +
+ {:else} + + {/if} + + {#if uploadError} +

{t('card_new.audio_upload_failed', { msg: uploadError })}

+ {/if} + + +
+ + diff --git a/apps/web/src/lib/i18n/de.ts b/apps/web/src/lib/i18n/de.ts index 6696a1f..136126d 100644 --- a/apps/web/src/lib/i18n/de.ts +++ b/apps/web/src/lib/i18n/de.ts @@ -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', audio_ref_label: 'Audio-Referenz (media_ref)', 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)', toast_typing: 'Typing-Karte angelegt', toast_multiple_choice: 'Multiple-Choice-Karte angelegt', diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 81744cf..6816a5c 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -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', audio_ref_label: 'Audio reference (media_ref)', 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)', toast_typing: 'Typing card created', toast_multiple_choice: 'Multiple-choice card created', diff --git a/apps/web/src/lib/i18n/es.ts b/apps/web/src/lib/i18n/es.ts index 65cdfd3..e2580cd 100644 --- a/apps/web/src/lib/i18n/es.ts +++ b/apps/web/src/lib/i18n/es.ts @@ -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', audio_ref_label: 'Referencia de audio (media_ref)', 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)', toast_typing: 'Tarjeta de escritura creada', toast_multiple_choice: 'Tarjeta de opción múltiple creada', diff --git a/apps/web/src/lib/i18n/fr.ts b/apps/web/src/lib/i18n/fr.ts index 6f38d83..af64913 100644 --- a/apps/web/src/lib/i18n/fr.ts +++ b/apps/web/src/lib/i18n/fr.ts @@ -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', audio_ref_label: 'Référence audio (media_ref)', 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)', toast_typing: 'Carte de saisie créée', toast_multiple_choice: 'Carte à choix multiple créée', diff --git a/apps/web/src/lib/i18n/it.ts b/apps/web/src/lib/i18n/it.ts index 9240ab6..434deda 100644 --- a/apps/web/src/lib/i18n/it.ts +++ b/apps/web/src/lib/i18n/it.ts @@ -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', audio_ref_label: 'Riferimento audio (media_ref)', 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)', toast_typing: 'Carta di digitazione creata', toast_multiple_choice: 'Carta a scelta multipla creata', diff --git a/apps/web/src/routes/cards/[id]/edit/+page.svelte b/apps/web/src/routes/cards/[id]/edit/+page.svelte index f841d2d..1ed1daa 100644 --- a/apps/web/src/routes/cards/[id]/edit/+page.svelte +++ b/apps/web/src/routes/cards/[id]/edit/+page.svelte @@ -17,6 +17,7 @@ import { t } from '$lib/i18n/index.svelte.ts'; import ImageOcclusionEditor from '$lib/components/ImageOcclusionEditor.svelte'; import MultipleChoiceCardForm from '$lib/components/MultipleChoiceCardForm.svelte'; + import AudioUploadField from '$lib/components/AudioUploadField.svelte'; let card = $state(null); let cardType = $state('basic'); @@ -26,6 +27,9 @@ let extra = $state(''); let imageRef = $state(''); let maskRegionsJson = $state('[]'); + let answer = $state(''); + let aliases = $state(''); + let audioFileRef = $state(''); let mcOptions = $state(['', '', '', '']); let mcCorrectIdx = $state(0); let mcExplanation = $state(''); @@ -68,6 +72,13 @@ mcOptions = [answer, ...distractors, '', '', ''].slice(0, 4); mcCorrectIdx = 0; 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 { front = fields.front ?? ''; back = fields.back ?? ''; @@ -89,6 +100,8 @@ const filledCount = mcOptions.filter((o) => o.trim()).length; 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; }); @@ -112,6 +125,11 @@ fields = { front: front.trim(), answer: mcOptions[mcCorrectIdx].trim() }; if (distractors) fields.distractor_pool = distractors; 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 { 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" > + {:else if cardType === 'typing'} +
+ + +
+ + + {:else if cardType === 'audio-front'} + + + {:else}
diff --git a/apps/web/src/routes/cards/new/+page.svelte b/apps/web/src/routes/cards/new/+page.svelte index e6973bc..159a1ca 100644 --- a/apps/web/src/routes/cards/new/+page.svelte +++ b/apps/web/src/routes/cards/new/+page.svelte @@ -19,6 +19,7 @@ import ImageOcclusionEditor from '$lib/components/ImageOcclusionEditor.svelte'; import ClozeCardForm from '$lib/components/ClozeCardForm.svelte'; import MultipleChoiceCardForm from '$lib/components/MultipleChoiceCardForm.svelte'; + import AudioUploadField from '$lib/components/AudioUploadField.svelte'; import CardSurface from '$lib/components/CardSurface.svelte'; type DeckLite = { id: string; name: string }; @@ -33,6 +34,7 @@ let imageRef = $state(''); let maskRegionsJson = $state('[]'); let answer = $state(''); + let aliases = $state(''); let audioFileRef = $state(''); let saving = $state(false); let previewFlipped = $state(false); @@ -119,6 +121,7 @@ fields = { image_ref: imageRef, mask_regions: maskRegionsJson }; } else if (cardType === 'typing') { fields = { front: front.trim(), answer: answer.trim() }; + if (aliases.trim()) fields.aliases = aliases.trim(); } else if (cardType === 'multiple-choice') { const distractors = mcOptions .filter((_, i) => i !== mcCorrectIdx) @@ -223,21 +226,23 @@ placeholder={t('card_new.answer_placeholder')} class="input mono" > - Kommagetrennte Aliase erlaubt: Paris, Paris (Frankreich)
+ {:else if cardType === 'audio-front'}