diff --git a/apps/cards/apps/web/src/hooks.server.ts b/apps/cards/apps/web/src/hooks.server.ts
index b4ba7b0da..fe2b663b9 100644
--- a/apps/cards/apps/web/src/hooks.server.ts
+++ b/apps/cards/apps/web/src/hooks.server.ts
@@ -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)};` +
``;
return html.replace('
', `${envScript}`);
},
diff --git a/apps/cards/apps/web/src/lib/media/upload.ts b/apps/cards/apps/web/src/lib/media/upload.ts
new file mode 100644
index 000000000..2a28d01e1
--- /dev/null
+++ b/apps/cards/apps/web/src/lib/media/upload.ts
@@ -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 {
+ 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 ``;
+ case 'video':
+ return ``;
+ default:
+ return media.url;
+ }
+}
diff --git a/apps/cards/apps/web/src/routes/decks/[id]/+page.svelte b/apps/cards/apps/web/src/routes/decks/[id]/+page.svelte
index b1fa6fc29..3180c49d6 100644
--- a/apps/cards/apps/web/src/routes/decks/[id]/+page.svelte
+++ b/apps/cards/apps/web/src/routes/decks/[id]/+page.svelte
@@ -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(null);
+ let attachInputs = $state>({
+ 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('basic');
let newFront = $state('');
let newBack = $state('');
@@ -190,9 +231,24 @@
{#if newType === 'cloze'}
-
+
+
+
+
+