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 `![${label}](${media.url})`; + 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'}
- +
+ + + +
{/if} + {#if attachError} +

{attachError}

+ {/if}