Some checks are pending
CI / validate (push) Waiting to run
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>
626 lines
16 KiB
Svelte
626 lines
16 KiB
Svelte
<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. 10–60 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. 15–60 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>
|