feat(decks/from-image): URL-Input als Alternative zu Datei-Upload

- API: fetchUrlContent() via mana-search /api/v1/extract (Fallback: direktes Fetch)
- URL-Inhalt wird als Kontext an die LLM-Karten-Generierung übergeben
- Client: url-only Flow sendet JSON statt FormData (Bun-Kompatibilität)
- Deck-Neu-Seite: URL-Eingabefeld neben dem Datei-Upload

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-10 16:00:04 +02:00
parent 731481ffe3
commit 1f1abf3c4f
3 changed files with 180 additions and 54 deletions

View file

@ -43,12 +43,22 @@ export function fetchDistractors(
export function generateDeckFromImage(
files: File | File[],
opts: { language?: 'de' | 'en'; count?: number },
opts: { language?: 'de' | 'en'; count?: number; url?: string },
) {
const form = new FormData();
const arr = Array.isArray(files) ? files : [files];
// URL-only (no files): send as JSON — FormData without file parts fails in Bun
if (arr.length === 0) {
return api<{ deck: Deck; cards_created: number }>('/api/v1/decks/from-image', {
method: 'POST',
body: { language: opts.language, count: opts.count, url: opts.url },
});
}
const form = new FormData();
for (const f of arr) form.append('file', f);
if (opts.language) form.append('language', opts.language);
if (opts.count != null) form.append('count', String(opts.count));
if (opts.url) form.append('url', opts.url);
return apiForm<{ deck: Deck; cards_created: number }>('/api/v1/decks/from-image', form);
}

View file

@ -22,6 +22,7 @@
let imageFiles = $state<File[]>([]);
let imagePreviews = $state<string[]>([]);
let imageUrl = $state('');
let imageGenerating = $state(false);
let imageError = $state<string | null>(null);
let fileInput = $state<HTMLInputElement | null>(null);
@ -55,11 +56,15 @@
}
async function onFromImage() {
if (imageFiles.length === 0 || imageGenerating) return;
if ((imageFiles.length === 0 && !imageUrl.trim()) || imageGenerating) return;
imageError = null;
imageGenerating = true;
try {
const result = await generateDeckFromImage(imageFiles, { count, language });
const result = await generateDeckFromImage(imageFiles, {
count,
language,
url: imageUrl.trim() || undefined,
});
toasts.success(`🖼 "${result.deck.name}" mit ${result.cards_created} Karten erstellt`);
goto(`/decks/${result.deck.id}`);
} catch (err) {
@ -151,7 +156,7 @@
>
<DeckCategoryIcon
category={id}
size={22}
size={16}
color={category === id ? color : null}
weight={category === id ? 'fill' : 'regular'}
/>
@ -285,6 +290,16 @@
/>
</div>
<label class="mt-3 block">
<span class="text-xs text-[hsl(var(--color-muted-foreground))]">URL als Kontext (optional)</span>
<input
bind:value={imageUrl}
type="url"
placeholder="https://de.wikipedia.org/wiki/…"
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
/>
</label>
{#if imageError}
<div
class="mt-2 rounded border border-[hsl(var(--color-error))]/40 bg-[hsl(var(--color-error))]/10 p-3 text-sm text-[hsl(var(--color-error))]"
@ -302,29 +317,36 @@
<button
type="button"
disabled={imageFiles.length === 0 || imageGenerating || saving || generating}
disabled={(imageFiles.length === 0 && !imageUrl.trim()) || imageGenerating || saving || generating}
onclick={onFromImage}
class="mt-3 rounded border border-[hsl(var(--color-primary))] px-4 py-2 text-sm text-[hsl(var(--color-primary))] disabled:opacity-50 hover:bg-[hsl(var(--color-primary)/0.08)]"
>
{imageGenerating ? '🖼 Analysiere…' : `🖼 Aus ${imagePreviews.length > 1 ? `${imagePreviews.length} Bildern` : 'Bild'} generieren`}
{#if imageGenerating}
🖼 Analysiere…
{:else if imageFiles.length > 0 && imageUrl.trim()}
🖼 Aus {imagePreviews.length > 1 ? `${imagePreviews.length} Bildern` : 'Bild'} + URL generieren
{:else if imageUrl.trim()}
🖼 Aus URL generieren
{:else}
🖼 Aus {imagePreviews.length > 1 ? `${imagePreviews.length} Bildern` : 'Bild'} generieren
{/if}
</button>
</div>
</div>
<style>
.category-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(4.5rem, 1fr));
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.category-btn {
display: flex;
flex-direction: column;
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.5rem 0.25rem;
border-radius: 0.5rem;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: 9999px;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-surface));
color: hsl(var(--color-muted-foreground));
@ -342,10 +364,7 @@
}
.category-label {
font-size: 0.625rem;
font-size: 0.8125rem;
font-weight: 500;
letter-spacing: 0.03em;
text-align: center;
line-height: 1.2;
}
</style>