feat(cards): image / audio attachments on cards via mana-media

Cards can now carry image, audio, and video attachments uploaded to
mana-media (the existing CAS service that already powers picture,
photos, wardrobe, etc.).

Pipeline:
  • lib/media/upload.ts wraps POST /api/v1/media/upload (multipart,
    app=cards). Returns { id, url, kind } with the right variant URL
    per kind (medium for images, full file for audio/video). 25 MB
    cap matches the website-upload pattern.
  • mediaToFieldSnippet(): drops Markdown ![]() for images; raw
    <audio>/<video controls> for the others — the user can later
    tweak attributes by hand.
  • Deck-detail card editor gains a "📎 Anhang" button next to every
    text field (front/back/cloze). Pick → upload → snippet appended
    to the field's content. Loading + error states surfaced inline.

Render:
  • @mana/cards-core/render.ts whitelists `audio`, `source`, `video`
    plus the `controls`/`preload`/`src`/`type` attrs in DOMPurify so
    inline media survives sanitization. Markdown's <img> already
    passed through the default policy.

Wiring:
  • hooks.server.ts injects __PUBLIC_MANA_MEDIA_URL__.
  • compose adds PUBLIC_MANA_MEDIA_URL_CLIENT=https://media.mana.how
    to cards-web.

Phase 2 ideas: drag-drop directly into the textarea, paste-from-
clipboard for screenshots, mana-media auth scoping per user, Anki
import bringing media files along.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-07 13:52:53 +02:00
parent 1f2206f10b
commit daa1ef0513
5 changed files with 197 additions and 8 deletions

View file

@ -19,6 +19,8 @@ const PUBLIC_MANA_SYNC_URL_CLIENT =
process.env.PUBLIC_MANA_SYNC_URL_CLIENT || process.env.PUBLIC_MANA_SYNC_URL || '';
const PUBLIC_MANA_LLM_URL_CLIENT =
process.env.PUBLIC_MANA_LLM_URL_CLIENT || process.env.PUBLIC_MANA_LLM_URL || '';
const PUBLIC_MANA_MEDIA_URL_CLIENT =
process.env.PUBLIC_MANA_MEDIA_URL_CLIENT || process.env.PUBLIC_MANA_MEDIA_URL || '';
export const handle: Handle = async ({ event, resolve }) => {
return resolve(event, {
@ -28,6 +30,7 @@ export const handle: Handle = async ({ event, resolve }) => {
`window.__PUBLIC_MANA_AUTH_URL__=${JSON.stringify(PUBLIC_MANA_AUTH_URL_CLIENT)};` +
`window.__PUBLIC_MANA_SYNC_URL__=${JSON.stringify(PUBLIC_MANA_SYNC_URL_CLIENT)};` +
`window.__PUBLIC_MANA_LLM_URL__=${JSON.stringify(PUBLIC_MANA_LLM_URL_CLIENT)};` +
`window.__PUBLIC_MANA_MEDIA_URL__=${JSON.stringify(PUBLIC_MANA_MEDIA_URL_CLIENT)};` +
`</script>`;
return html.replace('<head>', `<head>${envScript}`);
},

View file

@ -0,0 +1,90 @@
/**
* Upload an image or audio file to mana-media and get back a media id
* + a public URL ready to drop into a card field.
*
* Resolves the media base URL from window.__PUBLIC_MANA_MEDIA_URL__
* (injected by hooks.server.ts) so the same code works in dev (when
* mana-media runs on localhost) and prod (https://media.mana.how).
*
* 25 MB hard-cap mirrors the website-upload pattern in mana-web.
*/
const MAX_BYTES = 25 * 1024 * 1024;
export class MediaUploadError extends Error {
constructor(
message: string,
public status?: number
) {
super(message);
this.name = 'MediaUploadError';
}
}
function mediaBaseUrl(): string {
if (typeof window !== 'undefined') {
const fromWindow = (window as unknown as { __PUBLIC_MANA_MEDIA_URL__?: string })
.__PUBLIC_MANA_MEDIA_URL__;
if (fromWindow) return fromWindow.replace(/\/$/, '');
}
return 'http://localhost:3015';
}
export interface UploadedMedia {
id: string;
url: string;
kind: 'image' | 'audio' | 'video' | 'other';
}
function classify(mime: string): UploadedMedia['kind'] {
if (mime.startsWith('image/')) return 'image';
if (mime.startsWith('audio/')) return 'audio';
if (mime.startsWith('video/')) return 'video';
return 'other';
}
export async function uploadCardMedia(file: File): Promise<UploadedMedia> {
if (file.size > MAX_BYTES) {
throw new MediaUploadError(`Datei zu groß (max ${MAX_BYTES / 1024 / 1024} MB).`, 400);
}
const kind = classify(file.type);
if (kind === 'other') {
throw new MediaUploadError('Nur Bilder, Audio oder Video werden unterstützt.', 400);
}
const formData = new FormData();
formData.append('file', file);
formData.append('app', 'cards');
const res = await fetch(`${mediaBaseUrl()}/api/v1/media/upload`, {
method: 'POST',
body: formData,
});
if (!res.ok) {
throw new MediaUploadError(`Upload fehlgeschlagen (${res.status})`, res.status);
}
const data = (await res.json()) as { id?: string };
if (!data.id) throw new MediaUploadError('Upload-Antwort ohne Media-ID.', 500);
const variant = kind === 'image' ? '/file/medium' : '/file';
return {
id: data.id,
url: `${mediaBaseUrl()}/api/v1/media/${data.id}${variant}`,
kind,
};
}
/** Snippet to drop into a card field. Markdown for images, raw HTML for
* audio/video so the user can also tweak attributes by hand later. */
export function mediaToFieldSnippet(media: UploadedMedia, label: string): string {
switch (media.kind) {
case 'image':
return `![${label}](${media.url})`;
case 'audio':
return `<audio controls preload="metadata" src="${media.url}"></audio>`;
case 'video':
return `<video controls preload="metadata" src="${media.url}"></video>`;
default:
return media.url;
}
}

View file

@ -6,6 +6,7 @@
import { cardStore } from '$lib/stores/cards.svelte';
import { renderMarkdown, type Card, type CardType, type Deck } from '@mana/cards-core';
import AiCardGen from '$lib/components/AiCardGen.svelte';
import { uploadCardMedia, mediaToFieldSnippet } from '$lib/media/upload';
const deckId = $derived(page.params.id as string);
@ -19,6 +20,46 @@
let showNew = $state(false);
let showAi = $state(false);
let attachBusy = $state<'front' | 'back' | 'cloze' | null>(null);
let attachError = $state<string | null>(null);
let attachInputs = $state<Record<string, HTMLInputElement | null>>({
front: null,
back: null,
cloze: null,
});
async function handleAttach(target: 'front' | 'back' | 'cloze', file: File) {
attachError = null;
attachBusy = target;
try {
const media = await uploadCardMedia(file);
const snippet = mediaToFieldSnippet(media, file.name.replace(/\.[^.]+$/, ''));
if (target === 'front') {
newFront = newFront ? `${newFront}\n${snippet}` : snippet;
} else if (target === 'back') {
newBack = newBack ? `${newBack}\n${snippet}` : snippet;
} else {
newCloze = newCloze ? `${newCloze}\n${snippet}` : snippet;
}
} catch (e: any) {
attachError = e?.message ?? 'Upload fehlgeschlagen.';
} finally {
attachBusy = null;
}
}
function pickAttachment(target: 'front' | 'back' | 'cloze') {
attachInputs[target]?.click();
}
function onAttachChange(target: 'front' | 'back' | 'cloze') {
return (e: Event) => {
const input = e.currentTarget as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (file) handleAttach(target, file);
};
}
let newType = $state<CardType>('basic');
let newFront = $state('');
let newBack = $state('');
@ -190,9 +231,24 @@
<div class="space-y-3">
{#if newType === 'cloze'}
<div>
<label for="card-cloze" class="mb-1 block text-sm text-neutral-400">
Text mit Lücken
</label>
<div class="mb-1 flex items-center justify-between">
<label for="card-cloze" class="text-sm text-neutral-400">Text mit Lücken</label>
<button
type="button"
class="text-xs text-indigo-300 hover:text-indigo-200 disabled:opacity-50"
onclick={() => pickAttachment('cloze')}
disabled={attachBusy !== null}
>
{attachBusy === 'cloze' ? '⏳ lade…' : '📎 Anhang'}
</button>
<input
bind:this={attachInputs.cloze}
type="file"
accept="image/*,audio/*,video/*"
class="hidden"
onchange={onAttachChange('cloze')}
/>
</div>
<!-- svelte-ignore a11y_autofocus -->
<textarea
id="card-cloze"
@ -209,8 +265,24 @@
</div>
{:else}
<div>
<label for="card-front" class="mb-1 block text-sm text-neutral-400">Vorderseite</label
>
<div class="mb-1 flex items-center justify-between">
<label for="card-front" class="text-sm text-neutral-400">Vorderseite</label>
<button
type="button"
class="text-xs text-indigo-300 hover:text-indigo-200 disabled:opacity-50"
onclick={() => pickAttachment('front')}
disabled={attachBusy !== null}
>
{attachBusy === 'front' ? '⏳ lade…' : '📎 Anhang'}
</button>
<input
bind:this={attachInputs.front}
type="file"
accept="image/*,audio/*,video/*"
class="hidden"
onchange={onAttachChange('front')}
/>
</div>
<!-- svelte-ignore a11y_autofocus -->
<input
id="card-front"
@ -222,7 +294,24 @@
/>
</div>
<div>
<label for="card-back" class="mb-1 block text-sm text-neutral-400">Rückseite</label>
<div class="mb-1 flex items-center justify-between">
<label for="card-back" class="text-sm text-neutral-400">Rückseite</label>
<button
type="button"
class="text-xs text-indigo-300 hover:text-indigo-200 disabled:opacity-50"
onclick={() => pickAttachment('back')}
disabled={attachBusy !== null}
>
{attachBusy === 'back' ? '⏳ lade…' : '📎 Anhang'}
</button>
<input
bind:this={attachInputs.back}
type="file"
accept="image/*,audio/*,video/*"
class="hidden"
onchange={onAttachChange('back')}
/>
</div>
<textarea
id="card-back"
bind:value={newBack}
@ -231,6 +320,9 @@
></textarea>
</div>
{/if}
{#if attachError}
<p class="text-xs text-red-400">{attachError}</p>
{/if}
<div class="flex justify-end gap-2">
<button
class="rounded-lg px-3 py-1.5 text-sm text-neutral-400 hover:text-neutral-100"

View file

@ -1033,6 +1033,7 @@ services:
PUBLIC_MANA_SYNC_URL: http://mana-sync:3050
PUBLIC_MANA_SYNC_URL_CLIENT: https://sync.mana.how
PUBLIC_MANA_LLM_URL_CLIENT: https://llm.mana.how
PUBLIC_MANA_MEDIA_URL_CLIENT: https://media.mana.how
ports:
- "5180:5180"
healthcheck:

View file

@ -21,8 +21,11 @@ export function renderMarkdown(source: string, opts: RenderOptions = {}): string
if (!source) return '';
const raw = marked.parse(source, { async: false }) as string;
let html = DOMPurify.sanitize(raw, {
ADD_TAGS: ['mark'],
ADD_ATTR: ['class'],
// `mark` for cloze highlights; `audio`/`source`/`video` for media
// attachments inserted via the editor (the Markdown renderer
// passes inline HTML through, sanitizer is the gate).
ADD_TAGS: ['mark', 'audio', 'source', 'video'],
ADD_ATTR: ['class', 'controls', 'preload', 'src', 'type'],
});
if (opts.skipParagraph) {
html = html.replace(/^\s*<p>/, '').replace(/<\/p>\s*$/, '');