feat(cards): audio-front Card-Type

- CardTypeSchema: 'audio-front' als vollwertiger Type (fields: audio_ref + back + front?)
- subIndexCount: 'audio-front' → 1
- AudioFrontView.svelte: custom Play/Pause-Button, audio via /api/v1/media/:id,
  optionaler Hint-Text; Antwort-Markdown läuft über bestehenden answerHtml-Pfad
- Study-Page: isAudioFront + audioFrontData derived, AudioFrontView eingebunden

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-10 15:18:41 +02:00
parent 598acb410d
commit 170a2825a4
4 changed files with 122 additions and 4 deletions

View file

@ -0,0 +1,98 @@
<script lang="ts">
import { API_BASE } from '$lib/api/client.ts';
let {
audioRef,
frontText,
}: {
audioRef: string;
frontText?: string;
} = $props();
let audioEl = $state<HTMLAudioElement | null>(null);
let playing = $state(false);
const audioUrl = $derived(`${API_BASE}/api/v1/media/${audioRef}`);
function toggle() {
if (!audioEl) return;
if (playing) {
audioEl.pause();
} else {
audioEl.play();
}
}
</script>
<div class="audio-front">
<!-- svelte-ignore a11y_media_has_caption -->
<audio
bind:this={audioEl}
src={audioUrl}
preload="metadata"
onplay={() => (playing = true)}
onpause={() => (playing = false)}
onended={() => (playing = false)}
></audio>
<button class="play-btn" onclick={toggle} aria-label={playing ? 'Pause' : 'Abspielen'}>
{#if playing}
<svg viewBox="0 0 24 24" fill="currentColor" width="28" height="28" aria-hidden="true">
<rect x="6" y="4" width="4" height="16" rx="1" />
<rect x="14" y="4" width="4" height="16" rx="1" />
</svg>
{:else}
<svg viewBox="0 0 24 24" fill="currentColor" width="28" height="28" aria-hidden="true">
<polygon points="5,3 20,12 5,21" />
</svg>
{/if}
</button>
{#if frontText}
<p class="hint">{frontText}</p>
{/if}
</div>
<style>
.audio-front {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1.25rem;
padding: 1.5rem 0;
width: 100%;
}
.play-btn {
width: 5rem;
height: 5rem;
border-radius: 50%;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 16px hsl(var(--color-primary) / 0.35);
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.play-btn:hover {
transform: scale(1.06);
box-shadow: 0 6px 20px hsl(var(--color-primary) / 0.45);
}
.play-btn:active {
transform: scale(0.96);
}
.hint {
margin: 0;
font-size: 1rem;
color: hsl(var(--color-muted-foreground));
text-align: center;
line-height: 1.5;
}
</style>

View file

@ -16,6 +16,7 @@
import { toasts } from '$lib/stores/toasts.svelte.ts';
import { t } from '$lib/i18n/index.svelte.ts';
import ImageOcclusionView from '$lib/components/ImageOcclusionView.svelte';
import AudioFrontView from '$lib/components/AudioFrontView.svelte';
import CardSurface from '$lib/components/CardSurface.svelte';
const deckId = $derived(page.params.deckId ?? '');
@ -93,6 +94,17 @@
};
});
const isAudioFront = $derived(current?.card?.type === 'audio-front');
const audioFrontData = $derived.by(() => {
const c = current;
if (!c?.card || c.card.type !== 'audio-front') return null;
const fields = c.card.fields as Record<string, string>;
return {
audioRef: fields.audio_ref ?? '',
frontText: fields.front || undefined,
};
});
onMount(async () => {
if (!devUser.id) {
goto('/');
@ -216,8 +228,15 @@
activeMaskId={imageOcclusionData.activeMaskId}
{revealed}
/>
{:else}
{#if isAudioFront && audioFrontData}
<AudioFrontView
audioRef={audioFrontData.audioRef}
frontText={audioFrontData.frontText}
/>
{:else}
<div class="prose">{@html promptHtml}</div>
{/if}
{#if revealed}
<hr class="divider" />
<div class="prose answer">{@html answerHtml}</div>

View file

@ -147,7 +147,7 @@ export function subIndexCount(type: string): number {
throw new Error(
'subIndexCount("image-occlusion") not supported — use maskRegionCount(fields.mask_regions) from @cards/domain'
);
case 'audio':
case 'audio-front':
return 1;
case 'multiple-choice':
return 1;

View file

@ -10,6 +10,7 @@ export const CardTypeSchema = z.enum([
'basic-reverse',
'cloze',
'image-occlusion',
'audio-front',
]);
export type CardType = z.infer<typeof CardTypeSchema>;
@ -20,7 +21,7 @@ export const CardTypeFutureSchema = z.enum([
'cloze',
'type-in',
'image-occlusion',
'audio',
'audio-front',
'multiple-choice',
]);
@ -46,7 +47,7 @@ export function validateFieldsForType(
cloze: ['text'],
'type-in': ['question', 'expected'],
'image-occlusion': ['image_ref', 'mask_regions'],
audio: ['audio_ref'],
'audio-front': ['audio_ref', 'back'],
'multiple-choice': ['question', 'options', 'correct_index'],
};
const need = required[type] ?? [];