feat(publish): Deck direkt aus der Detail-Seite veröffentlichen

- PublishDeckModal: Author-Check, Slug-Eingabe mit Live-Exists-Check,
  Titel/Beschreibung/Lizenz (nur für neue Decks), Semver auto-gebumpt,
  Karten automatisch aus privatem Deck übernommen (kein JSON-Paste)
- Deck-Detail-Seite: "↑ Veröffentlichen"-Button im Header, öffnet Modal,
  leitet nach Erfolg auf /d/:slug (Marketplace-Seite) weiter

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-10 16:07:55 +02:00
parent b761cd52c9
commit c1a87a4f88
2 changed files with 605 additions and 0 deletions

View file

@ -0,0 +1,586 @@
<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 { 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 = (e as Error).message;
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>

View file

@ -12,6 +12,7 @@
import { t, tn } from '$lib/i18n/index.svelte.ts';
import CardSurface from '$lib/components/CardSurface.svelte';
import DeckCategoryIcon from '$lib/components/DeckCategoryIcon.svelte';
import PublishDeckModal from '$lib/components/marketplace/PublishDeckModal.svelte';
import { Image, CaretRight, DotsThree, PencilSimple, Trash } from '@mana/shared-icons';
import { marked } from 'marked';
@ -26,6 +27,7 @@
let error = $state<string | null>(null);
let categoryOpen = $state(false);
let openMenuId = $state<string | null>(null);
let publishModalOpen = $state(false);
function toggleMenu(cardId: string, e: MouseEvent) {
e.preventDefault();
@ -121,6 +123,14 @@
<a href="/cards/new?deck={deck.id}" class="btn-outline">
+ {t('deck_detail.new_card')}
</a>
<button
type="button"
class="btn-outline"
onclick={() => { publishModalOpen = true; }}
title="Deck zum Marketplace veröffentlichen"
>
↑ Veröffentlichen
</button>
{#if dueCount > 0}
<a href="/study/{deck.id}" class="btn-primary">
{t('deck_detail.study_button')} ({t('study.due_count', { n: dueCount })})
@ -265,6 +275,15 @@
{/each}
</ul>
{/if}
{#if publishModalOpen && deck}
<PublishDeckModal
{deck}
{cards}
onClose={() => { publishModalOpen = false; }}
onPublished={(slug) => { publishModalOpen = false; goto(`/d/${slug}`); }}
/>
{/if}
{/if}
<style>