wordeck/apps/web/src/lib/components/NewDeckCard.svelte
Till JS 372832d266
Some checks are pending
CI / validate (push) Waiting to run
refactor(big-bang): cards → wordeck im gesamten Code-Layer
Phase 2 des cards→wordeck Big-Bang-Rebrand:

- 4 package.json: @cards/* → @wordeck/*
- packages/cards-domain/ → packages/wordeck-domain/
- 41+12 Files: from '@cards/domain' → '@wordeck/domain'
- pgSchema('cards') → pgSchema('wordeck') (Drizzle-Schema)
- 17 Files: process.env.CARDS_* → process.env.WORDECK_*
- docker-compose Service-Names: cards-* → wordeck-*
- docker-compose Volume: /Volumes/ManaData/cards → wordeck
- env-vars in compose: CARDS_DB_PASSWORD/_API_VERSION/_DSGVO_SERVICE_KEY etc. → WORDECK_*
- Log-Prefixes + Error-Strings + manifest-id 'cards' → 'wordeck'
- CORS-Origin cardecky.mana.how → wordeck.com
- .env.production.example umbenannt + S3-Key entfernt (kein MinIO mehr)

Type-Check 0 Errors in api+domain+web, 51/51 Domain-Tests grün.

DB-Rename + Container/Volume-Rename auf mana-server folgen in nächstem
Commit nach Verzeichnis-Rename.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:39:42 +02:00

626 lines
16 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import { goto } from '$app/navigation';
import { type DeckCategoryId, DECK_CATEGORY_IDS, DECK_CATEGORY_LABELS } from '@wordeck/domain';
import { createDeck, generateDeck, generateDeckFromImage } from '$lib/api/decks.ts';
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
import { toasts } from '$lib/stores/toasts.svelte.ts';
import { i18n, t, type Locale } from '$lib/i18n/index.svelte.ts';
import CardSurface from './CardSurface.svelte';
import DeckCategoryIcon from './DeckCategoryIcon.svelte';
import { apiErrorMessage } from '$lib/api/error.ts';
let open = $state(false);
let catOpen = $state(false);
let name = $state('');
let description = $state('');
let color = $state('#0088ff');
let category = $state<DeckCategoryId | undefined>(undefined);
let count = $state(15);
let language = $state<Locale>(i18n.current);
let saving = $state(false);
let generating = $state(false);
let aiError = $state<string | null>(null);
const MAX_IMAGE_FILES = 5;
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);
function addImageFiles(incoming: File[]) {
const images = incoming.filter(isAccepted);
const combined = [...imageFiles, ...images].slice(0, MAX_IMAGE_FILES);
// revoke URLs für Dateien die rausfallen (über Limit)
for (let i = combined.length; i < imagePreviews.length; i++) {
URL.revokeObjectURL(imagePreviews[i]);
}
imageFiles = combined;
imagePreviews = combined.map((f, i) => imagePreviews[i] ?? URL.createObjectURL(f));
imageError = null;
}
function removeImageFile(i: number) {
URL.revokeObjectURL(imagePreviews[i]);
imageFiles = imageFiles.filter((_, j) => j !== i);
imagePreviews = imagePreviews.filter((_, j) => j !== i);
}
function onFileChange(e: Event) {
const files = Array.from((e.target as HTMLInputElement).files ?? []);
if (files.length) addImageFiles(files);
}
function onImageDrop(e: DragEvent) {
e.preventDefault();
const files = Array.from(e.dataTransfer?.files ?? []);
if (files.length) addImageFiles(files);
}
function isAccepted(f: File) {
return f.type.startsWith('image/') || f.type === 'application/pdf';
}
function close() {
open = false;
catOpen = false;
name = '';
description = '';
color = '#0088ff';
category = undefined;
count = 15;
saving = false;
generating = false;
aiError = null;
for (const url of imagePreviews) URL.revokeObjectURL(url);
imageFiles = [];
imagePreviews = [];
imageUrl = '';
imageGenerating = false;
imageError = null;
}
async function onFromImage() {
if ((imageFiles.length === 0 && !imageUrl.trim()) || !devUser.id || imageGenerating) return;
imageError = null;
imageGenerating = true;
try {
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) {
imageError = apiErrorMessage(err);
imageGenerating = false;
}
}
function pickCategory(id: DeckCategoryId) {
category = category === id ? undefined : id;
catOpen = false;
}
async function onManual(e: SubmitEvent) {
e.preventDefault();
if (!name.trim() || !devUser.id) return;
saving = true;
try {
const deck = await createDeck({
name: name.trim(),
description: description.trim() || undefined,
color,
category,
});
toasts.success(`${deck.name} ✓`);
goto(`/decks/${deck.id}`);
} catch (err) {
toasts.error(t('deck_new.create_failed', { msg: apiErrorMessage(err) }));
saving = false;
}
}
async function onAi() {
if (!name.trim() || !devUser.id || generating) return;
aiError = null;
generating = true;
try {
const result = await generateDeck({ prompt: name.trim(), count, language, url: imageUrl.trim() || undefined });
toasts.success(`✨ "${result.deck.name}" mit ${result.cards_created} Karten erstellt`);
goto(`/decks/${result.deck.id}`);
} catch (err) {
aiError = apiErrorMessage(err);
generating = false;
}
}
</script>
<div class="new-wrap">
{#if !open}
<CardSurface size="md" as="button" onclick={() => (open = true)} ariaLabel={t('deck_new.title')} class="new-card">
<div class="new-inner">
<span class="new-plus" aria-hidden="true">+</span>
<span class="new-label">{t('deck_new.title')}</span>
</div>
</CardSurface>
{:else}
<CardSurface size="md" class="form-card">
<form class="form-scroll" onsubmit={onManual}>
<label class="field">
<span class="label">Name / Thema</span>
<input bind:value={name} required maxlength="200" autofocus
placeholder="z.B. Französische Revolution" class="input" />
</label>
<label class="field">
<span class="label">{t('deck_new.description_label')}</span>
<textarea bind:value={description} maxlength="2000" rows="2" class="input"></textarea>
</label>
<!-- Fach: einzelner Trigger-Button, klappt Picker inline auf -->
<div class="field">
<span class="label">Fach</span>
<button type="button" class="picker-trigger" onclick={() => (catOpen = !catOpen)}
aria-expanded={catOpen}>
{#if category}
<DeckCategoryIcon category={category} size={14} color={color} weight="fill" />
<span>{DECK_CATEGORY_LABELS[category]}</span>
{:else}
<span class="placeholder">Kein Fach</span>
{/if}
<span class="chevron" class:flipped={catOpen} aria-hidden="true"></span>
</button>
{#if catOpen}
<div class="cat-grid">
{#each DECK_CATEGORY_IDS as id}
<button type="button" class="cat-btn" class:selected={category === id}
onclick={() => pickCategory(id)}
aria-pressed={category === id}>
<DeckCategoryIcon category={id} size={13}
color={category === id ? color : null}
weight={category === id ? 'fill' : 'regular'} />
<span class="cat-label">{DECK_CATEGORY_LABELS[id]}</span>
</button>
{/each}
</div>
{/if}
</div>
<label class="field">
<span class="label">{t('deck_new.color_label')}</span>
<input type="color" bind:value={color} class="color-input" />
</label>
<label class="field">
<span class="label">Anzahl Karten (KI)</span>
<input type="number" bind:value={count} min="3" max="40" class="input" />
</label>
<label class="field">
<span class="label">Sprache (KI)</span>
<select bind:value={language} class="input">
<option value="de">Deutsch</option>
<option value="en">English</option>
</select>
</label>
<!-- Bild-Dropzone für Vision-Generierung (bis zu 5 Bilder) -->
<div class="field">
<div
class="img-drop"
role="button"
tabindex="0"
onclick={() => fileInput?.click()}
onkeydown={(e) => e.key === 'Enter' && fileInput?.click()}
ondrop={onImageDrop}
ondragover={(e) => e.preventDefault()}
>
{#if imagePreviews.length > 0}
<div class="img-strip">
{#each imageFiles as file, i}
<div class="img-thumb-wrap">
{#if file.type === 'application/pdf'}
<div class="img-thumb img-pdf-thumb"><span>📄</span></div>
{:else}
<img src={imagePreviews[i]} alt="Bild {i + 1}" class="img-thumb" />
{/if}
<button
type="button"
class="img-remove"
onclick={(e) => { e.stopPropagation(); removeImageFile(i); }}
aria-label="Datei entfernen"
>×</button>
</div>
{/each}
{#if imagePreviews.length < MAX_IMAGE_FILES}
<div class="img-add-hint">+ weitere</div>
{/if}
</div>
{:else}
<span class="img-placeholder">🖼 Bilder oder PDFs für KI (bis zu 5)</span>
{/if}
<input
bind:this={fileInput}
type="file"
accept="image/*,application/pdf"
multiple
class="sr-only"
onchange={onFileChange}
/>
</div>
</div>
<label class="field">
<span class="label">URL als Kontext (optional)</span>
<input
bind:value={imageUrl}
type="url"
placeholder="https://…"
class="input"
/>
</label>
{#if aiError}
<p class="ai-error" role="alert">{aiError}</p>
{/if}
{#if imageError}
<p class="ai-error" role="alert">{imageError}</p>
{/if}
{#if generating}
<p class="ai-hint" aria-live="polite">Generiere… ca. 1060 s</p>
{/if}
{#if imageGenerating}
<p class="ai-hint" aria-live="polite">
{imageFiles.length > 0 && imageUrl.trim() ? 'Bild + URL' : imageUrl.trim() ? 'URL' : 'Bild'} wird analysiert… ca. 1560 s
</p>
{/if}
<div class="actions">
<button type="submit" disabled={saving || generating || imageGenerating || !name.trim()} class="btn-primary">
{saving ? t('deck_new.creating') : t('deck_new.create')}
</button>
<button type="button" disabled={generating || saving || imageGenerating || !name.trim()} onclick={onAi} class="btn-ai">
{generating ? '✨ Generiere…' : '✨ Mit KI generieren'}
</button>
<button type="button" disabled={(imageFiles.length === 0 && !imageUrl.trim()) || imageGenerating || saving || generating} onclick={onFromImage} class="btn-ai">
{#if imageGenerating}
{imageUrl.trim() && imageFiles.length === 0 ? '🌐' : '🖼'} Analysiere…
{:else if imageFiles.length > 0 && imageUrl.trim()}
🖼 Bild + URL
{:else if imageUrl.trim()}
🖼 Aus URL
{:else}
🖼 Aus Bild
{/if}
</button>
<button type="button" onclick={close} class="btn-cancel">
{t('deck_new.cancel')}
</button>
</div>
</form>
</CardSurface>
{/if}
</div>
<style>
.new-wrap {
position: relative;
width: 100%;
max-width: 18rem;
aspect-ratio: 5 / 7;
}
.new-wrap :global(.new-card) {
border-style: dashed;
background: transparent;
transition: border-color 0.15s, background 0.15s;
width: 100%;
height: 100%;
}
.new-wrap :global(.new-card:hover) {
border-color: hsl(var(--color-primary) / 0.6);
background: hsl(var(--color-primary) / 0.04);
}
.new-inner {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.new-plus {
font-size: 2.5rem;
font-weight: 300;
line-height: 1;
}
.new-label {
font-size: 0.875rem;
font-weight: 500;
text-align: center;
}
.new-wrap :global(.form-card) {
width: 100%;
height: 100%;
}
.form-scroll {
position: absolute;
inset: 0;
overflow-y: auto;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
scrollbar-width: thin;
}
.field {
display: flex;
flex-direction: column;
gap: 0.1875rem;
}
.label {
font-size: 0.6875rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
}
.input {
width: 100%;
padding: 0.3125rem 0.5rem;
font-size: 0.8125rem;
font-family: inherit;
border-radius: 0.3125rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-card));
color: hsl(var(--color-foreground));
box-sizing: border-box;
}
.input:focus {
outline: 2px solid hsl(var(--color-primary) / 0.4);
outline-offset: 1px;
}
/* Fach-Trigger — sieht aus wie ein Select */
.picker-trigger {
display: flex;
align-items: center;
gap: 0.375rem;
width: 100%;
padding: 0.3125rem 0.5rem;
font-size: 0.8125rem;
font-family: inherit;
border-radius: 0.3125rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-card));
color: hsl(var(--color-foreground));
cursor: pointer;
text-align: left;
}
.picker-trigger:hover {
border-color: hsl(var(--color-primary) / 0.5);
}
.placeholder {
color: hsl(var(--color-muted-foreground));
}
.chevron {
margin-left: auto;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
transition: transform 0.15s;
}
.chevron.flipped {
transform: rotate(180deg);
}
/* Kategorie-Picker — Flexwrap Pills mit Icon + Label */
.cat-grid {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
padding-top: 0.25rem;
}
.cat-btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border-radius: 9999px;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-surface));
color: hsl(var(--color-muted-foreground));
cursor: pointer;
transition: border-color 0.12s, background 0.12s, color 0.12s;
}
.cat-btn:hover {
border-color: hsl(var(--color-primary) / 0.5);
color: hsl(var(--color-foreground));
}
.cat-btn.selected {
border-color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.08);
color: hsl(var(--color-foreground));
}
.cat-label {
font-size: 0.75rem;
font-weight: 500;
}
/* Farbe — volle Breite, feste Höhe wie ein Input */
.color-input {
width: 100%;
height: 2rem;
padding: 0.125rem 0.25rem;
border-radius: 0.3125rem;
border: 1px solid hsl(var(--color-border));
background: transparent;
cursor: pointer;
box-sizing: border-box;
}
.ai-error {
margin: 0;
font-size: 0.6875rem;
color: hsl(var(--color-error));
}
.ai-hint {
margin: 0;
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
}
.actions {
display: flex;
flex-direction: column;
gap: 0.375rem;
margin-top: auto;
padding-top: 0.25rem;
}
.btn-primary {
width: 100%;
padding: 0.375rem 0.625rem;
font-size: 0.8125rem;
font-family: inherit;
border-radius: 0.375rem;
border: none;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
cursor: pointer;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-ai {
width: 100%;
padding: 0.375rem 0.625rem;
font-size: 0.8125rem;
font-family: inherit;
border-radius: 0.375rem;
border: 1px solid hsl(var(--color-primary));
background: transparent;
color: hsl(var(--color-primary));
cursor: pointer;
}
.btn-ai:hover:not(:disabled) {
background: hsl(var(--color-primary) / 0.08);
}
.btn-ai:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-cancel {
width: 100%;
padding: 0.375rem 0.625rem;
font-size: 0.8125rem;
font-family: inherit;
border-radius: 0.375rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-surface));
color: hsl(var(--color-foreground));
cursor: pointer;
}
.btn-cancel:hover {
background: hsl(var(--color-surface-hover));
}
.img-drop {
display: flex;
align-items: center;
justify-content: center;
min-height: 2.75rem;
border: 1.5px dashed hsl(var(--color-border));
border-radius: 0.3125rem;
cursor: pointer;
overflow: hidden;
transition: border-color 0.12s;
}
.img-drop:hover {
border-color: hsl(var(--color-primary) / 0.5);
}
.img-placeholder {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
padding: 0.375rem 0.5rem;
text-align: center;
}
.img-strip {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem;
flex-wrap: wrap;
}
.img-thumb-wrap {
position: relative;
flex-shrink: 0;
}
.img-thumb {
width: 2.5rem;
height: 2.5rem;
object-fit: cover;
border-radius: 0.25rem;
display: block;
}
.img-pdf-thumb {
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
display: flex;
align-items: center;
justify-content: center;
font-size: 1.125rem;
}
.img-remove {
position: absolute;
top: -0.3rem;
right: -0.3rem;
width: 1rem;
height: 1rem;
font-size: 0.625rem;
line-height: 1;
border-radius: 50%;
border: none;
background: hsl(var(--color-error));
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.img-add-hint {
font-size: 0.625rem;
color: hsl(var(--color-muted-foreground));
padding: 0 0.25rem;
}
</style>