feat(cards-web): Phase β.2 — author onboarding + publish flow

End-to-end "publish my local deck to the marketplace" surface in
the Cards standalone app. Hooks into cards-server (Phase β) so a
user can take a deck they've been editing locally and put it under
cards.mana.how/d/<slug> with one modal.

Pipeline:
  • lib/api/cards-api.ts — typed fetch wrapper around the cards-server
    /v1 surface. Reads the JWT from authStore, never from storage
    directly. CardsApiError carries `{status, message, details}`
    so UI can branch on 401/409/etc.
  • lib/stores/author.svelte.ts — lazy-loaded author state. Caches
    `cardsApi.authors.me()` on first access; resets cleanly on logout.
  • lib/util/slug.ts — best-effort slugify mirror of the server-side
    validator (server still has final say).
  • lib/components/PublishDeckModal.svelte — three-stage flow:
    become-author (slug + displayName + pseudonym), deck-meta (title,
    description, language, license picker, semver, changelog), then
    publishing → done with moderation-flag surface if AI mod returned
    'flag'. Keys off authorStore.isAuthor to skip stage 1 for
    returning authors.
  • routes/decks/[id]/+page.svelte gets a "🌍 Veröffentlichen" button
    next to "Lernen". Disabled until the deck has cards.

Wiring:
  • hooks.server.ts injects __PUBLIC_CARDS_API_URL__ on every SSR'd
    page so the client knows where cards-server lives.
  • compose adds PUBLIC_CARDS_API_URL_CLIENT=https://cards-api.mana.how
    to the cards-web container.

Validated: svelte-check 0/0, vite build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-07 16:53:17 +02:00
parent f47edc14af
commit ec8abfe6b8
7 changed files with 636 additions and 1 deletions

View file

@ -21,6 +21,8 @@ const PUBLIC_MANA_LLM_URL_CLIENT =
process.env.PUBLIC_MANA_LLM_URL_CLIENT || process.env.PUBLIC_MANA_LLM_URL || '';
const PUBLIC_MANA_MEDIA_URL_CLIENT =
process.env.PUBLIC_MANA_MEDIA_URL_CLIENT || process.env.PUBLIC_MANA_MEDIA_URL || '';
const PUBLIC_CARDS_API_URL_CLIENT =
process.env.PUBLIC_CARDS_API_URL_CLIENT || process.env.PUBLIC_CARDS_API_URL || '';
export const handle: Handle = async ({ event, resolve }) => {
return resolve(event, {
@ -31,6 +33,7 @@ export const handle: Handle = async ({ event, resolve }) => {
`window.__PUBLIC_MANA_SYNC_URL__=${JSON.stringify(PUBLIC_MANA_SYNC_URL_CLIENT)};` +
`window.__PUBLIC_MANA_LLM_URL__=${JSON.stringify(PUBLIC_MANA_LLM_URL_CLIENT)};` +
`window.__PUBLIC_MANA_MEDIA_URL__=${JSON.stringify(PUBLIC_MANA_MEDIA_URL_CLIENT)};` +
`window.__PUBLIC_CARDS_API_URL__=${JSON.stringify(PUBLIC_CARDS_API_URL_CLIENT)};` +
`</script>`;
return html.replace('<head>', `<head>${envScript}`);
},

View file

@ -0,0 +1,176 @@
/**
* Thin client for cards-server (https://cards-api.mana.how / dev :3072).
*
* The auth-store provides the JWT; we never read tokens from storage
* here directly so there's only one place that knows about token
* lifecycle (refresh, expiry, vault).
*
* All endpoints under /v1 require auth; the wrapper just always
* sends `Authorization: Bearer …`. Errors come back as Hono's
* `{ statusCode, message, details? }` shape we surface that to
* callers via the typed `CardsApiError` so UIs can branch on it.
*/
import { authStore } from '$lib/stores/auth.svelte';
function baseUrl(): string {
if (typeof window !== 'undefined') {
const fromWindow = (window as unknown as { __PUBLIC_CARDS_API_URL__?: string })
.__PUBLIC_CARDS_API_URL__;
if (fromWindow) return fromWindow.replace(/\/$/, '');
}
return 'http://localhost:3072';
}
export class CardsApiError extends Error {
constructor(
public status: number,
message: string,
public details?: unknown
) {
super(message);
this.name = 'CardsApiError';
}
}
interface RequestOptions {
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
body?: unknown;
signal?: AbortSignal;
/** When false, send the request without an Authorization header. */
auth?: boolean;
}
async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
const headers: Record<string, string> = {};
if (opts.body !== undefined) headers['Content-Type'] = 'application/json';
if (opts.auth !== false) {
const token = await authStore.getValidToken?.();
if (!token) throw new CardsApiError(401, 'Not signed in');
headers['Authorization'] = `Bearer ${token}`;
}
const res = await fetch(`${baseUrl()}${path}`, {
method: opts.method ?? 'GET',
headers,
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
signal: opts.signal,
});
if (res.status === 204) return undefined as T;
const text = await res.text();
const json: unknown = text ? safeJsonParse(text) : null;
if (!res.ok) {
const payload = (json ?? {}) as { message?: string; details?: unknown };
throw new CardsApiError(res.status, payload.message ?? `HTTP ${res.status}`, payload.details);
}
return json as T;
}
function safeJsonParse(s: string): unknown {
try {
return JSON.parse(s);
} catch {
return s;
}
}
// ─── Authors ────────────────────────────────────────────────
export interface Author {
userId: string;
slug: string;
displayName: string;
bio: string | null;
avatarUrl: string | null;
pseudonym: boolean;
verifiedMana: boolean;
verifiedCommunity: boolean;
bannedAt: string | null;
}
export interface PublicAuthor {
slug: string;
displayName: string;
bio: string | null;
avatarUrl: string | null;
joinedAt: string;
pseudonym: boolean;
verifiedMana: boolean;
verifiedCommunity: boolean;
banned: boolean;
}
export const cardsApi = {
authors: {
me: () => request<Author | null>('/v1/authors/me'),
upsertMe: (input: {
slug: string;
displayName: string;
bio?: string;
avatarUrl?: string;
pseudonym?: boolean;
}) => request<Author>('/v1/authors/me', { method: 'POST', body: input }),
bySlug: (slug: string) => request<PublicAuthor>(`/v1/authors/${encodeURIComponent(slug)}`),
},
decks: {
init: (input: {
slug: string;
title: string;
description?: string;
language?: string;
license?: string;
priceCredits?: number;
}) => request<PublicDeck>('/v1/decks', { method: 'POST', body: input }),
bySlug: (slug: string) =>
request<{ deck: PublicDeck; latestVersion: PublicDeckVersion | null }>(
`/v1/decks/${encodeURIComponent(slug)}`
),
publish: (
slug: string,
input: {
semver: string;
changelog?: string;
cards: { type: string; fields: Record<string, string> }[];
}
) =>
request<PublishResult>(`/v1/decks/${encodeURIComponent(slug)}/publish`, {
method: 'POST',
body: input,
}),
},
};
export interface PublicDeck {
id: string;
slug: string;
title: string;
description: string | null;
language: string | null;
license: string;
priceCredits: number;
ownerUserId: string;
latestVersionId: string | null;
isFeatured: boolean;
isTakedown: boolean;
createdAt: string;
}
export interface PublicDeckVersion {
id: string;
deckId: string;
semver: string;
changelog: string | null;
contentHash: string;
cardCount: number;
publishedAt: string;
deprecatedAt: string | null;
}
export interface PublishResult {
deck: PublicDeck;
version: PublicDeckVersion;
moderation: { verdict: 'pass' | 'flag' | 'block'; categories: string[] };
}

View file

@ -0,0 +1,351 @@
<script lang="ts">
/**
* Publish-flow modal — three-stage:
*
* 1. become-author — only if the user has no author row yet.
* Asks for slug + displayName + pseudonym.
* 2. deck-meta — title (prefilled), description, language,
* license, optional price.
* 3. publishing — posts to cards-api, shows result + link.
*
* Bestehende Karten aus der lokalen Dexie werden direkt gelesen —
* der Server bekommt eine flache Karte-Liste mit type + fields.
*/
import type { Card, Deck } from '@mana/cards-core';
import { authorStore } from '$lib/stores/author.svelte';
import { CardsApiError, type PublishResult } from '$lib/api/cards-api';
import { cardsApi } from '$lib/api/cards-api';
import { slugify } from '$lib/util/slug';
interface Props {
deck: Deck;
cards: Card[];
onClose: () => void;
onPublished?: (result: PublishResult) => void;
}
let { deck, cards, onClose, onPublished }: Props = $props();
let stage = $state<'loading' | 'become-author' | 'meta' | 'publishing' | 'done' | 'error'>(
'loading'
);
let error = $state<string | null>(null);
let result = $state<PublishResult | null>(null);
// Author form
let authorSlug = $state('');
let authorName = $state('');
let authorPseudonym = $state(false);
// Deck meta form — initial values come from the deck prop. Wrapped
// in a $derived initializer so svelte-check stops complaining
// about state-from-props initialisation; user edits then live in
// the locally-bound $state.
// svelte-ignore state_referenced_locally
let deckSlug = $state(slugify(deck.title));
// svelte-ignore state_referenced_locally
let deckTitle = $state(deck.title);
// svelte-ignore state_referenced_locally
let deckDescription = $state(deck.description ?? '');
let deckLanguage = $state('de');
let deckLicense = $state<'CC0-1.0' | 'CC-BY-4.0' | 'CC-BY-SA-4.0' | 'Cards-Personal-Use-1.0'>(
'CC-BY-4.0'
);
let deckSemver = $state('1.0.0');
let deckChangelog = $state('');
$effect(() => {
if (stage !== 'loading') return;
(async () => {
await authorStore.load();
if (authorStore.isAuthor) {
stage = 'meta';
} else {
stage = 'become-author';
authorName = ''; // user fills in
}
})();
});
async function submitAuthor() {
if (!authorSlug.trim() || !authorName.trim()) return;
const created = await authorStore.upsert({
slug: authorSlug.trim(),
displayName: authorName.trim(),
pseudonym: authorPseudonym,
});
if (created) stage = 'meta';
else error = authorStore.error;
}
function buildPublishCards() {
// Map our local CardType + fields straight to the server shape.
// Cloze fields ship as { text, extra? }; basic + basic-reverse +
// type-in ship as { front, back }.
return cards
.filter((c) => Object.keys(c.fields ?? {}).length > 0)
.map((c) => ({ type: c.type, fields: c.fields }));
}
async function submitPublish() {
if (!deckSlug.trim() || !deckTitle.trim()) return;
stage = 'publishing';
error = null;
try {
// 1. Init the deck (idempotent on slug — re-init throws 409,
// in which case we just continue to publish).
try {
await cardsApi.decks.init({
slug: deckSlug.trim(),
title: deckTitle.trim(),
description: deckDescription.trim() || undefined,
language: deckLanguage,
license: deckLicense,
priceCredits: 0,
});
} catch (e) {
if (!(e instanceof CardsApiError && e.status === 409)) throw e;
}
// 2. Publish a version with the local cards.
const publishCards = buildPublishCards();
if (publishCards.length === 0) {
throw new Error('Das Deck enthält keine Karten zum Veröffentlichen.');
}
result = await cardsApi.decks.publish(deckSlug.trim(), {
semver: deckSemver.trim(),
changelog: deckChangelog.trim() || undefined,
cards: publishCards,
});
stage = 'done';
onPublished?.(result);
} catch (e) {
error = e instanceof Error ? e.message : 'Veröffentlichung fehlgeschlagen';
stage = 'error';
}
}
</script>
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
onclick={onClose}
onkeydown={(e) => e.key === 'Escape' && onClose()}
role="presentation"
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="w-full max-w-lg rounded-xl border border-neutral-800 bg-neutral-900 p-6"
onclick={(e) => e.stopPropagation()}
>
<div class="mb-4 flex items-start justify-between">
<h2 class="text-xl font-semibold">Deck veröffentlichen</h2>
<button
onclick={onClose}
class="text-neutral-500 hover:text-neutral-200"
aria-label="Schließen">✕</button
>
</div>
{#if stage === 'loading'}
<div class="py-8 text-center text-sm text-neutral-400">Lade Author-Profil…</div>
{:else if stage === 'become-author'}
<div class="space-y-4 text-sm">
<p class="text-neutral-300">
Erstelle ein Author-Profil — andere User finden deine Decks unter
<code class="rounded bg-neutral-800 px-1 text-xs">cards.mana.how/u/dein-slug</code>.
</p>
<div>
<label for="author-slug" class="mb-1 block text-xs text-neutral-400">
Slug (360 Zeichen, az, 09, -)
</label>
<input
id="author-slug"
type="text"
bind:value={authorSlug}
placeholder="anna-lang"
class="w-full rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 text-sm outline-none focus:border-indigo-400"
/>
</div>
<div>
<label for="author-name" class="mb-1 block text-xs text-neutral-400">Anzeigename</label>
<input
id="author-name"
type="text"
bind:value={authorName}
placeholder="Anna Lang"
class="w-full rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 text-sm outline-none focus:border-indigo-400"
/>
</div>
<label class="flex items-start gap-2 text-xs text-neutral-400">
<input type="checkbox" bind:checked={authorPseudonym} class="mt-0.5" />
<span>Pseudonym — Anzeigename ist nicht mein Klarname</span>
</label>
{#if authorStore.error}
<p class="text-red-400">{authorStore.error}</p>
{/if}
<div class="flex justify-end gap-2 pt-2">
<button
class="rounded-lg px-3 py-1.5 text-sm text-neutral-400 hover:text-neutral-100"
onclick={onClose}
>
Abbrechen
</button>
<button
class="rounded-lg bg-indigo-500 px-4 py-1.5 text-sm text-white hover:bg-indigo-400 disabled:opacity-50"
onclick={submitAuthor}
disabled={!authorSlug.trim() || !authorName.trim() || authorStore.loading}
>
{authorStore.loading ? 'Speichere…' : 'Author werden'}
</button>
</div>
</div>
{:else if stage === 'meta'}
<div class="space-y-4 text-sm">
<p class="text-neutral-400">
Veröffentlicht als <code class="rounded bg-neutral-800 px-1 text-xs"
>cards.mana.how/d/{deckSlug || '...'}</code
>
</p>
<div>
<label for="d-slug" class="mb-1 block text-xs text-neutral-400">Slug</label>
<input
id="d-slug"
type="text"
bind:value={deckSlug}
class="w-full rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 text-sm outline-none focus:border-indigo-400"
/>
</div>
<div>
<label for="d-title" class="mb-1 block text-xs text-neutral-400">Titel</label>
<input
id="d-title"
type="text"
bind:value={deckTitle}
class="w-full rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 text-sm outline-none focus:border-indigo-400"
/>
</div>
<div>
<label for="d-desc" class="mb-1 block text-xs text-neutral-400">Beschreibung</label>
<textarea
id="d-desc"
bind:value={deckDescription}
placeholder="Worum geht es in diesem Deck?"
class="min-h-[80px] w-full rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 text-sm outline-none focus:border-indigo-400"
></textarea>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="d-lang" class="mb-1 block text-xs text-neutral-400">Sprache</label>
<select
id="d-lang"
bind:value={deckLanguage}
class="w-full rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 text-sm outline-none focus:border-indigo-400"
>
<option value="de">Deutsch</option>
<option value="en">English</option>
<option value="es">Español</option>
<option value="fr">Français</option>
<option value="it">Italiano</option>
<option value="pt">Português</option>
<option value="ja">日本語</option>
</select>
</div>
<div>
<label for="d-license" class="mb-1 block text-xs text-neutral-400">Lizenz</label>
<select
id="d-license"
bind:value={deckLicense}
class="w-full rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 text-sm outline-none focus:border-indigo-400"
>
<option value="CC-BY-4.0">CC-BY 4.0 — frei mit Namensnennung</option>
<option value="CC-BY-SA-4.0">CC-BY-SA 4.0 — share-alike</option>
<option value="CC0-1.0">CC0 — gemeinfrei</option>
<option value="Cards-Personal-Use-1.0">Personal Use — nur lernen</option>
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="d-semver" class="mb-1 block text-xs text-neutral-400">Version</label>
<input
id="d-semver"
type="text"
bind:value={deckSemver}
placeholder="1.0.0"
class="w-full rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 text-sm outline-none focus:border-indigo-400"
/>
</div>
<div>
<label for="d-changelog" class="mb-1 block text-xs text-neutral-400">
Changelog (optional)
</label>
<input
id="d-changelog"
type="text"
bind:value={deckChangelog}
placeholder="Erste Version"
class="w-full rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 text-sm outline-none focus:border-indigo-400"
/>
</div>
</div>
<p class="text-xs text-neutral-500">
{cards.length}
{cards.length === 1 ? 'Karte' : 'Karten'} werden veröffentlicht. Das Deck durchläuft eine KI-Inhaltsprüfung
— offensichtlich harmloses Material geht direkt durch.
</p>
<div class="flex justify-end gap-2 pt-2">
<button
class="rounded-lg px-3 py-1.5 text-sm text-neutral-400 hover:text-neutral-100"
onclick={onClose}
>
Abbrechen
</button>
<button
class="rounded-lg bg-indigo-500 px-4 py-1.5 text-sm text-white hover:bg-indigo-400 disabled:opacity-50"
onclick={submitPublish}
disabled={!deckSlug.trim() || !deckTitle.trim() || cards.length === 0}
>
Veröffentlichen
</button>
</div>
</div>
{:else if stage === 'publishing'}
<div class="py-8 text-center text-sm text-neutral-400">
Lade {cards.length} Karten hoch und prüfe Inhalt…
</div>
{:else if stage === 'done' && result}
<div class="space-y-3 text-sm">
<div class="text-green-400">
✓ Veröffentlicht als Version {result.version.semver}
</div>
<div class="text-neutral-300">
{result.version.cardCount} Karten · Lizenz: {result.deck.license}
</div>
{#if result.moderation.verdict === 'flag'}
<div class="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-amber-300">
Inhalt wurde zur Moderations-Prüfung markiert ({result.moderation.categories.join(
', '
)}). Das Deck ist veröffentlicht; ein Mensch schaut bei Gelegenheit drüber.
</div>
{/if}
<button
class="rounded-lg bg-indigo-500 px-4 py-1.5 text-sm text-white hover:bg-indigo-400"
onclick={onClose}
>
Fertig
</button>
</div>
{:else if stage === 'error'}
<div class="space-y-3 text-sm">
<div class="text-red-400">Fehler: {error}</div>
<button
class="rounded-lg px-3 py-1.5 text-sm text-neutral-400 hover:text-neutral-100"
onclick={() => (stage = 'meta')}
>
Erneut versuchen
</button>
</div>
{/if}
</div>
</div>

View file

@ -0,0 +1,72 @@
/**
* Author-state store.
*
* Lazily fetches the user's author row on first access. Runtime
* components never read the API directly they go through this
* store, so refresh-on-mutation is centralised.
*/
import { cardsApi, CardsApiError, type Author } from '$lib/api/cards-api';
let _author = $state<Author | null>(null);
let _loaded = $state(false);
let _loading = $state(false);
let _error = $state<string | null>(null);
export const authorStore = {
get author() {
return _author;
},
get loaded() {
return _loaded;
},
get loading() {
return _loading;
},
get error() {
return _error;
},
get isAuthor() {
return _loaded && _author !== null;
},
async load(force = false): Promise<Author | null> {
if (_loaded && !force) return _author;
_loading = true;
_error = null;
try {
_author = await cardsApi.authors.me();
} catch (e) {
if (e instanceof CardsApiError && e.status === 401) {
// Not authed — caller's problem, don't poison the store.
_author = null;
} else {
_error = (e as Error).message ?? 'Konnte Author-Profil nicht laden';
}
} finally {
_loaded = true;
_loading = false;
}
return _author;
},
async upsert(input: Parameters<typeof cardsApi.authors.upsertMe>[0]): Promise<Author | null> {
_loading = true;
_error = null;
try {
_author = await cardsApi.authors.upsertMe(input);
return _author;
} catch (e) {
_error = (e as Error).message ?? 'Speichern fehlgeschlagen';
return null;
} finally {
_loading = false;
}
},
reset() {
_author = null;
_loaded = false;
_error = null;
},
};

View file

@ -0,0 +1,14 @@
/**
* Best-effort slug suggestion. Server-side validateSlug is the
* authoritative gate; this just gives the user a sensible default
* to edit.
*/
export function slugify(input: string): string {
return input
.normalize('NFKD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60);
}

View file

@ -6,6 +6,7 @@
import { cardStore } from '$lib/stores/cards.svelte';
import { renderMarkdown, type Card, type CardType, type Deck } from '@mana/cards-core';
import AiCardGen from '$lib/components/AiCardGen.svelte';
import PublishDeckModal from '$lib/components/PublishDeckModal.svelte';
import { uploadCardMedia, mediaToFieldSnippet } from '$lib/media/upload';
const deckId = $derived(page.params.id as string);
@ -20,6 +21,7 @@
let showNew = $state(false);
let showAi = $state(false);
let showPublish = $state(false);
let attachBusy = $state<'front' | 'back' | 'cloze' | null>(null);
let attachError = $state<string | null>(null);
let attachInputs = $state<Record<string, HTMLInputElement | null>>({
@ -167,9 +169,21 @@
>
Lernen
{#if dueCount > 0}
<span class="ml-2 rounded-full bg-white/20 px-2 py-0.5 text-xs">{dueCount} fällig</span>
<span class="ml-2 rounded-full bg-background/20 px-2 py-0.5 text-xs">
{dueCount} fällig
</span>
{/if}
</button>
<button
class="rounded-lg border border-indigo-500/30 px-4 py-2 text-sm text-indigo-300 hover:bg-indigo-500/10 disabled:opacity-50"
onclick={() => (showPublish = true)}
disabled={cards.length === 0}
title={cards.length === 0
? 'Erstelle zuerst Karten'
: 'Im Cards-Marktplatz veröffentlichen'}
>
🌍 Veröffentlichen
</button>
{#if dueCount === 0 && cards.length > 0}
<span class="text-sm text-neutral-400">Heute alles gelernt — schau später wieder rein.</span
>
@ -430,3 +444,7 @@
</div>
{/if}
</main>
{#if showPublish && deck}
<PublishDeckModal {deck} {cards} onClose={() => (showPublish = false)} />
{/if}