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:
parent
b761cd52c9
commit
c1a87a4f88
2 changed files with 605 additions and 0 deletions
586
apps/web/src/lib/components/marketplace/PublishDeckModal.svelte
Normal file
586
apps/web/src/lib/components/marketplace/PublishDeckModal.svelte
Normal 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>
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
import { t, tn } from '$lib/i18n/index.svelte.ts';
|
import { t, tn } from '$lib/i18n/index.svelte.ts';
|
||||||
import CardSurface from '$lib/components/CardSurface.svelte';
|
import CardSurface from '$lib/components/CardSurface.svelte';
|
||||||
import DeckCategoryIcon from '$lib/components/DeckCategoryIcon.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 { Image, CaretRight, DotsThree, PencilSimple, Trash } from '@mana/shared-icons';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
|
|
||||||
|
|
@ -26,6 +27,7 @@
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
let categoryOpen = $state(false);
|
let categoryOpen = $state(false);
|
||||||
let openMenuId = $state<string | null>(null);
|
let openMenuId = $state<string | null>(null);
|
||||||
|
let publishModalOpen = $state(false);
|
||||||
|
|
||||||
function toggleMenu(cardId: string, e: MouseEvent) {
|
function toggleMenu(cardId: string, e: MouseEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -121,6 +123,14 @@
|
||||||
<a href="/cards/new?deck={deck.id}" class="btn-outline">
|
<a href="/cards/new?deck={deck.id}" class="btn-outline">
|
||||||
+ {t('deck_detail.new_card')}
|
+ {t('deck_detail.new_card')}
|
||||||
</a>
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
onclick={() => { publishModalOpen = true; }}
|
||||||
|
title="Deck zum Marketplace veröffentlichen"
|
||||||
|
>
|
||||||
|
↑ Veröffentlichen
|
||||||
|
</button>
|
||||||
{#if dueCount > 0}
|
{#if dueCount > 0}
|
||||||
<a href="/study/{deck.id}" class="btn-primary">
|
<a href="/study/{deck.id}" class="btn-primary">
|
||||||
{t('deck_detail.study_button')} ({t('study.due_count', { n: dueCount })})
|
{t('deck_detail.study_button')} ({t('study.due_count', { n: dueCount })})
|
||||||
|
|
@ -265,6 +275,15 @@
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if publishModalOpen && deck}
|
||||||
|
<PublishDeckModal
|
||||||
|
{deck}
|
||||||
|
{cards}
|
||||||
|
onClose={() => { publishModalOpen = false; }}
|
||||||
|
onPublished={(slug) => { publishModalOpen = false; goto(`/d/${slug}`); }}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue