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:
parent
598acb410d
commit
170a2825a4
4 changed files with 122 additions and 4 deletions
98
apps/web/src/lib/components/AudioFrontView.svelte
Normal file
98
apps/web/src/lib/components/AudioFrontView.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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] ?? [];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue