mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 17:41:09 +02:00
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:
parent
1f2206f10b
commit
daa1ef0513
5 changed files with 197 additions and 8 deletions
|
|
@ -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}`);
|
||||
},
|
||||
|
|
|
|||
90
apps/cards/apps/web/src/lib/media/upload.ts
Normal file
90
apps/cards/apps/web/src/lib/media/upload.ts
Normal 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 ``;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue