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 { toasts } from '$lib/stores/toasts.svelte.ts';
|
||||||
import { t } from '$lib/i18n/index.svelte.ts';
|
import { t } from '$lib/i18n/index.svelte.ts';
|
||||||
import ImageOcclusionView from '$lib/components/ImageOcclusionView.svelte';
|
import ImageOcclusionView from '$lib/components/ImageOcclusionView.svelte';
|
||||||
|
import AudioFrontView from '$lib/components/AudioFrontView.svelte';
|
||||||
import CardSurface from '$lib/components/CardSurface.svelte';
|
import CardSurface from '$lib/components/CardSurface.svelte';
|
||||||
|
|
||||||
const deckId = $derived(page.params.deckId ?? '');
|
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 () => {
|
onMount(async () => {
|
||||||
if (!devUser.id) {
|
if (!devUser.id) {
|
||||||
goto('/');
|
goto('/');
|
||||||
|
|
@ -216,8 +228,15 @@
|
||||||
activeMaskId={imageOcclusionData.activeMaskId}
|
activeMaskId={imageOcclusionData.activeMaskId}
|
||||||
{revealed}
|
{revealed}
|
||||||
/>
|
/>
|
||||||
|
{:else}
|
||||||
|
{#if isAudioFront && audioFrontData}
|
||||||
|
<AudioFrontView
|
||||||
|
audioRef={audioFrontData.audioRef}
|
||||||
|
frontText={audioFrontData.frontText}
|
||||||
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="prose">{@html promptHtml}</div>
|
<div class="prose">{@html promptHtml}</div>
|
||||||
|
{/if}
|
||||||
{#if revealed}
|
{#if revealed}
|
||||||
<hr class="divider" />
|
<hr class="divider" />
|
||||||
<div class="prose answer">{@html answerHtml}</div>
|
<div class="prose answer">{@html answerHtml}</div>
|
||||||
|
|
|
||||||
|
|
@ -147,7 +147,7 @@ export function subIndexCount(type: string): number {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'subIndexCount("image-occlusion") not supported — use maskRegionCount(fields.mask_regions) from @cards/domain'
|
'subIndexCount("image-occlusion") not supported — use maskRegionCount(fields.mask_regions) from @cards/domain'
|
||||||
);
|
);
|
||||||
case 'audio':
|
case 'audio-front':
|
||||||
return 1;
|
return 1;
|
||||||
case 'multiple-choice':
|
case 'multiple-choice':
|
||||||
return 1;
|
return 1;
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ export const CardTypeSchema = z.enum([
|
||||||
'basic-reverse',
|
'basic-reverse',
|
||||||
'cloze',
|
'cloze',
|
||||||
'image-occlusion',
|
'image-occlusion',
|
||||||
|
'audio-front',
|
||||||
]);
|
]);
|
||||||
export type CardType = z.infer<typeof CardTypeSchema>;
|
export type CardType = z.infer<typeof CardTypeSchema>;
|
||||||
|
|
||||||
|
|
@ -20,7 +21,7 @@ export const CardTypeFutureSchema = z.enum([
|
||||||
'cloze',
|
'cloze',
|
||||||
'type-in',
|
'type-in',
|
||||||
'image-occlusion',
|
'image-occlusion',
|
||||||
'audio',
|
'audio-front',
|
||||||
'multiple-choice',
|
'multiple-choice',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -46,7 +47,7 @@ export function validateFieldsForType(
|
||||||
cloze: ['text'],
|
cloze: ['text'],
|
||||||
'type-in': ['question', 'expected'],
|
'type-in': ['question', 'expected'],
|
||||||
'image-occlusion': ['image_ref', 'mask_regions'],
|
'image-occlusion': ['image_ref', 'mask_regions'],
|
||||||
audio: ['audio_ref'],
|
'audio-front': ['audio_ref', 'back'],
|
||||||
'multiple-choice': ['question', 'options', 'correct_index'],
|
'multiple-choice': ['question', 'options', 'correct_index'],
|
||||||
};
|
};
|
||||||
const need = required[type] ?? [];
|
const need = required[type] ?? [];
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue