wordeck/apps/web/src/lib/components/marketplace/PublishDeckModal.svelte
Till JS 595f1f9cb6 refactor(web): ClozeCardForm + MultipleChoiceCardForm extrahieren + Import-Bug fixen
- `ClozeCardForm.svelte`: Lückentext-Formular-Sektion aus cards/new herausgezogen
- `MultipleChoiceCardForm.svelte`: MC-Options-Builder (inkl. 85 Zeilen MC-CSS)
  aus cards/new herausgezogen — cards/new: 1010 → 856 Zeilen
- Import-Bug in 9 Dateien behoben: Python-Skript hatte apiErrorMessage-Import
  in mehrzeilige import-Blöcke eingefügt (Syntaxfehler)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 16:35:01 +02:00

587 lines
14 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 { onMount } from 'svelte';
import type { Card, Deck } from '@cards/domain';
import {
getMyAuthorProfile,
getMarketplaceDeck,
initMarketplaceDeck,
publishMarketplaceVersion,
type MarketplaceAuthor,
} from '$lib/api/marketplace.ts';
import { apiErrorMessage } from '$lib/api/error.ts';
import { toasts } from '$lib/stores/toasts.svelte.ts';
interface Props {
deck: Deck;
cards: Card[];
onClose: () => void;
onPublished: (slug: string) => void;
}
let { deck, cards, onClose, onPublished }: Props = $props();
// Author + slug-check state
let author = $state<MarketplaceAuthor | null>(null);
let loadingAuthor = $state(true);
let slugExists = $state<boolean | null>(null); // null = unchecked
let existingVersion = $state<string | null>(null);
let checkingSlug = $state(false);
// Modal wird pro Klick frisch gemountet — Init-Capture aus Props ist gewollt.
// svelte-ignore state_referenced_locally
let slug = $state(slugify(deck.name));
// svelte-ignore state_referenced_locally
let title = $state(deck.name);
// svelte-ignore state_referenced_locally
let description = $state(deck.description ?? '');
let license = $state('CC BY 4.0');
let semver = $state('1.0.0');
let changelog = $state('');
let busy = $state(false);
let error = $state<string | null>(null);
const marketplaceCards = $derived(
cards.map((c) => ({ type: c.type, fields: c.fields as Record<string, string> }))
);
const suggestedSemver = $derived.by(() => {
if (!existingVersion) return '1.0.0';
const m = existingVersion.match(/^(\d+)\.(\d+)\.(\d+)$/);
if (!m) return '1.0.0';
return `${m[1]}.${Number(m[2]) + 1}.0`;
});
// Update semver suggestion when slug check completes
$effect(() => {
semver = suggestedSemver;
});
onMount(async () => {
try {
author = await getMyAuthorProfile();
} catch {
// no profile yet
}
loadingAuthor = false;
if (author) await checkSlug();
});
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60);
}
let slugCheckTimer: ReturnType<typeof setTimeout>;
function onSlugInput() {
slugExists = null;
existingVersion = null;
clearTimeout(slugCheckTimer);
slugCheckTimer = setTimeout(checkSlug, 500);
}
async function checkSlug() {
const s = slug.trim();
if (!s) return;
checkingSlug = true;
try {
const data = await getMarketplaceDeck(s);
slugExists = true;
existingVersion = data.latest_version?.semver ?? null;
} catch {
slugExists = false;
existingVersion = null;
}
checkingSlug = false;
}
async function onSubmit(e: SubmitEvent) {
e.preventDefault();
if (busy || !author) return;
busy = true;
error = null;
try {
if (!slugExists) {
await initMarketplaceDeck({
slug: slug.trim(),
title: title.trim(),
description: description.trim() || undefined,
license,
});
}
const result = await publishMarketplaceVersion(slug.trim(), {
semver: semver.trim(),
changelog: changelog.trim() || undefined,
cards: marketplaceCards,
});
toasts.success(
`${result.version.card_count} Karten als v${result.version.semver} veröffentlicht`
);
onPublished(slug.trim());
} catch (e) {
error = apiErrorMessage(e);
busy = false;
}
}
const canSubmit = $derived(
!busy &&
!!author &&
slug.trim().length > 0 &&
title.trim().length > 0 &&
/^\d+\.\d+\.\d+$/.test(semver.trim()) &&
marketplaceCards.length > 0 &&
slugExists !== null
);
const LICENSES = [
'CC BY 4.0',
'CC BY-SA 4.0',
'CC BY-NC 4.0',
'CC0 1.0',
'Alle Rechte vorbehalten',
];
</script>
<div
class="overlay"
role="dialog"
aria-modal="true"
aria-label="Deck veröffentlichen"
>
<div class="modal">
<!-- Header -->
<div class="modal-head">
<div>
<h2 class="modal-title">Zum Marketplace veröffentlichen</h2>
<p class="modal-subtitle">
{cards.length} Karte{cards.length !== 1 ? 'n' : ''} aus „{deck.name}"
</p>
</div>
<button type="button" class="close-btn" onclick={onClose} aria-label="Schließen">×</button>
</div>
{#if loadingAuthor}
<p class="loading-hint">Lade Author-Profil…</p>
{:else if !author}
<!-- No author profile -->
<div class="no-author">
<div class="no-author-icon">✍️</div>
<p class="no-author-text">Du brauchst zuerst ein Author-Profil um Decks zu veröffentlichen.</p>
<a href="/me/published" class="btn-primary" onclick={onClose}>
Author-Profil anlegen →
</a>
</div>
{:else}
<!-- Author confirmed -->
<div class="author-badge">
<span class="author-dot"></span>
<span>Author: <strong>{author.display_name}</strong> (@{author.slug})</span>
</div>
<form class="pub-form" onsubmit={onSubmit}>
<!-- Slug -->
<div class="field">
<label class="field-label" for="pub-slug">Marketplace-Slug</label>
<div class="slug-row">
<span class="slug-prefix">cardecky.mana.how/d/</span>
<input
id="pub-slug"
type="text"
bind:value={slug}
oninput={onSlugInput}
required
pattern="[a-z0-9](?:[a-z0-9-]*[a-z0-9])?"
maxlength="60"
class="input mono slug-input"
/>
{#if checkingSlug}
<span class="slug-status checking"></span>
{:else if slugExists === true}
<span class="slug-status exists">↻ Update</span>
{:else if slugExists === false}
<span class="slug-status new">+ Neu</span>
{/if}
</div>
{#if slugExists === true && existingVersion}
<span class="field-hint">Deck existiert bereits (v{existingVersion}) — neue Version wird veröffentlicht.</span>
{:else if slugExists === false}
<span class="field-hint">Neues Marketplace-Deck wird angelegt.</span>
{/if}
</div>
{#if slugExists === false}
<!-- Only shown for new decks -->
<div class="field">
<label class="field-label" for="pub-title">Titel</label>
<input
id="pub-title"
type="text"
bind:value={title}
required
maxlength="200"
class="input"
/>
</div>
<div class="field">
<label class="field-label" for="pub-desc">Beschreibung (optional)</label>
<textarea
id="pub-desc"
bind:value={description}
rows="2"
maxlength="1000"
class="input"
></textarea>
</div>
<div class="field">
<label class="field-label" for="pub-license">Lizenz</label>
<select id="pub-license" bind:value={license} class="input">
{#each LICENSES as l}
<option value={l}>{l}</option>
{/each}
</select>
</div>
{/if}
<!-- Version info — always shown -->
<div class="version-row">
<div class="field">
<label class="field-label" for="pub-semver">Version</label>
<input
id="pub-semver"
type="text"
bind:value={semver}
required
pattern="\d+\.\d+\.\d+"
class="input mono"
style="width: 8rem"
/>
</div>
<div class="field" style="flex:1">
<label class="field-label" for="pub-changelog">Changelog (optional)</label>
<input
id="pub-changelog"
type="text"
bind:value={changelog}
maxlength="500"
placeholder="Was ist neu in dieser Version?"
class="input"
/>
</div>
</div>
<!-- Cards summary -->
<div class="cards-summary">
<span class="cards-count">{marketplaceCards.length}</span>
<span class="cards-label">
Karte{marketplaceCards.length !== 1 ? 'n' : ''} werden automatisch übernommen
</span>
<span class="cards-types">
{[...new Set(marketplaceCards.map((c) => c.type))].join(', ')}
</span>
</div>
{#if error}
<div class="error-box" role="alert">{error}</div>
{/if}
<div class="form-actions">
<button type="button" class="btn-ghost" onclick={onClose}>Abbrechen</button>
<button type="submit" disabled={!canSubmit} class="btn-primary">
{busy ? 'Veröffentliche…' : slugExists ? 'Neue Version veröffentlichen' : 'Veröffentlichen'}
</button>
</div>
</form>
{/if}
</div>
</div>
<style>
.overlay {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
background: hsl(var(--color-foreground) / 0.4);
backdrop-filter: blur(2px);
padding: 1rem;
}
.modal {
width: 100%;
max-width: 36rem;
max-height: 90vh;
overflow-y: auto;
background: hsl(var(--color-card));
border: 1px solid hsl(var(--color-border));
border-radius: 1rem;
padding: 1.5rem;
box-shadow: 0 24px 48px hsl(var(--color-foreground) / 0.2);
display: flex;
flex-direction: column;
gap: 1.125rem;
}
.modal-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.modal-title {
font-size: 1.125rem;
font-weight: 600;
margin: 0;
}
.modal-subtitle {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
margin: 0.125rem 0 0;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
line-height: 1;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
padding: 0;
flex-shrink: 0;
}
.close-btn:hover { color: hsl(var(--color-foreground)); }
.loading-hint {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
margin: 0;
}
/* No-author state */
.no-author {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1.5rem 0;
text-align: center;
}
.no-author-icon { font-size: 2rem; }
.no-author-text {
font-size: 0.9375rem;
color: hsl(var(--color-muted-foreground));
max-width: 22rem;
margin: 0;
}
/* Author badge */
.author-badge {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
background: hsl(var(--color-success) / 0.08);
border: 1px solid hsl(var(--color-success) / 0.25);
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
}
.author-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: hsl(var(--color-success));
flex-shrink: 0;
}
/* Form */
.pub-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.field-label {
font-size: 0.8125rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.field-hint {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.input {
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-surface));
color: hsl(var(--color-foreground));
padding: 0.5rem 0.625rem;
font: inherit;
font-size: 0.875rem;
width: 100%;
transition: border-color 0.12s, box-shadow 0.12s;
resize: vertical;
}
.input:focus {
outline: none;
border-color: hsl(var(--color-primary) / 0.6);
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.1);
}
.input.mono { font-family: ui-monospace, 'Cascadia Code', monospace; }
/* Slug row */
.slug-row {
display: flex;
align-items: center;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-surface));
overflow: hidden;
transition: border-color 0.12s, box-shadow 0.12s;
}
.slug-row:focus-within {
border-color: hsl(var(--color-primary) / 0.6);
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.1);
}
.slug-prefix {
padding: 0.5rem 0.5rem 0.5rem 0.625rem;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
white-space: nowrap;
flex-shrink: 0;
}
.slug-input {
border: none;
border-radius: 0;
background: transparent;
padding-left: 0;
box-shadow: none;
flex: 1;
min-width: 0;
}
.slug-input:focus { box-shadow: none; }
.slug-status {
padding: 0 0.625rem;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
flex-shrink: 0;
}
.slug-status.checking { color: hsl(var(--color-muted-foreground)); }
.slug-status.exists { color: hsl(var(--color-primary)); }
.slug-status.new { color: hsl(var(--color-success)); }
/* Version row */
.version-row {
display: flex;
gap: 0.75rem;
align-items: flex-start;
}
/* Cards summary */
.cards-summary {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 0.875rem;
border-radius: 0.5rem;
background: hsl(var(--color-primary) / 0.07);
border: 1px solid hsl(var(--color-primary) / 0.2);
font-size: 0.8125rem;
}
.cards-count {
font-size: 1rem;
font-weight: 700;
color: hsl(var(--color-primary));
}
.cards-label {
color: hsl(var(--color-foreground));
}
.cards-types {
margin-left: auto;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
font-family: ui-monospace, monospace;
}
/* Error */
.error-box {
padding: 0.625rem 0.875rem;
border-radius: 0.5rem;
background: hsl(var(--color-error) / 0.1);
border: 1px solid hsl(var(--color-error) / 0.4);
color: hsl(var(--color-error));
font-size: 0.875rem;
}
/* Actions */
.form-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.75rem;
padding-top: 0.25rem;
}
.btn-primary {
display: inline-flex;
align-items: center;
padding: 0.5rem 1.125rem;
border-radius: 0.5rem;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
font: inherit;
font-size: 0.875rem;
font-weight: 500;
border: none;
cursor: pointer;
text-decoration: none;
transition: background-color 0.12s;
}
.btn-primary:hover:not(:disabled) { background: hsl(var(--color-primary) / 0.88); }
.btn-primary:disabled { opacity: 0.45; cursor: default; }
.btn-ghost {
background: none;
border: none;
padding: 0;
font: inherit;
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
text-decoration: none;
}
.btn-ghost:hover { color: hsl(var(--color-foreground)); }
</style>