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:
parent
731481ffe3
commit
1f1abf3c4f
3 changed files with 180 additions and 54 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue