From ec8abfe6b8e43b4067843a7ed82b096cc7b69252 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 7 May 2026 16:53:17 +0200 Subject: [PATCH] =?UTF-8?q?feat(cards-web):=20Phase=20=CE=B2.2=20=E2=80=94?= =?UTF-8?q?=20author=20onboarding=20+=20publish=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ 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) --- apps/cards/apps/web/src/hooks.server.ts | 3 + apps/cards/apps/web/src/lib/api/cards-api.ts | 176 +++++++++ .../lib/components/PublishDeckModal.svelte | 351 ++++++++++++++++++ .../apps/web/src/lib/stores/author.svelte.ts | 72 ++++ apps/cards/apps/web/src/lib/util/slug.ts | 14 + .../web/src/routes/decks/[id]/+page.svelte | 20 +- docker-compose.macmini.yml | 1 + 7 files changed, 636 insertions(+), 1 deletion(-) create mode 100644 apps/cards/apps/web/src/lib/api/cards-api.ts create mode 100644 apps/cards/apps/web/src/lib/components/PublishDeckModal.svelte create mode 100644 apps/cards/apps/web/src/lib/stores/author.svelte.ts create mode 100644 apps/cards/apps/web/src/lib/util/slug.ts diff --git a/apps/cards/apps/web/src/hooks.server.ts b/apps/cards/apps/web/src/hooks.server.ts index fe2b663b9..1bef6e03a 100644 --- a/apps/cards/apps/web/src/hooks.server.ts +++ b/apps/cards/apps/web/src/hooks.server.ts @@ -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)};` + ``; return html.replace('', `${envScript}`); }, diff --git a/apps/cards/apps/web/src/lib/api/cards-api.ts b/apps/cards/apps/web/src/lib/api/cards-api.ts new file mode 100644 index 000000000..3062a61cc --- /dev/null +++ b/apps/cards/apps/web/src/lib/api/cards-api.ts @@ -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(path: string, opts: RequestOptions = {}): Promise { + const headers: Record = {}; + 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('/v1/authors/me'), + upsertMe: (input: { + slug: string; + displayName: string; + bio?: string; + avatarUrl?: string; + pseudonym?: boolean; + }) => request('/v1/authors/me', { method: 'POST', body: input }), + bySlug: (slug: string) => request(`/v1/authors/${encodeURIComponent(slug)}`), + }, + decks: { + init: (input: { + slug: string; + title: string; + description?: string; + language?: string; + license?: string; + priceCredits?: number; + }) => request('/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 }[]; + } + ) => + request(`/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[] }; +} diff --git a/apps/cards/apps/web/src/lib/components/PublishDeckModal.svelte b/apps/cards/apps/web/src/lib/components/PublishDeckModal.svelte new file mode 100644 index 000000000..7ec63cbe1 --- /dev/null +++ b/apps/cards/apps/web/src/lib/components/PublishDeckModal.svelte @@ -0,0 +1,351 @@ + + +
e.key === 'Escape' && onClose()} + role="presentation" +> + + +
e.stopPropagation()} + > +
+

Deck veröffentlichen

+ +
+ + {#if stage === 'loading'} +
Lade Author-Profil…
+ {:else if stage === 'become-author'} +
+

+ Erstelle ein Author-Profil — andere User finden deine Decks unter + cards.mana.how/u/dein-slug. +

+
+ + +
+
+ + +
+ + {#if authorStore.error} +

{authorStore.error}

+ {/if} +
+ + +
+
+ {:else if stage === 'meta'} +
+

+ Veröffentlicht als cards.mana.how/d/{deckSlug || '...'} +

+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+

+ {cards.length} + {cards.length === 1 ? 'Karte' : 'Karten'} werden veröffentlicht. Das Deck durchläuft eine KI-Inhaltsprüfung + — offensichtlich harmloses Material geht direkt durch. +

+
+ + +
+
+ {:else if stage === 'publishing'} +
+ Lade {cards.length} Karten hoch und prüfe Inhalt… +
+ {:else if stage === 'done' && result} +
+
+ ✓ Veröffentlicht als Version {result.version.semver} +
+
+ {result.version.cardCount} Karten · Lizenz: {result.deck.license} +
+ {#if result.moderation.verdict === 'flag'} +
+ Inhalt wurde zur Moderations-Prüfung markiert ({result.moderation.categories.join( + ', ' + )}). Das Deck ist veröffentlicht; ein Mensch schaut bei Gelegenheit drüber. +
+ {/if} + +
+ {:else if stage === 'error'} +
+
Fehler: {error}
+ +
+ {/if} +
+
diff --git a/apps/cards/apps/web/src/lib/stores/author.svelte.ts b/apps/cards/apps/web/src/lib/stores/author.svelte.ts new file mode 100644 index 000000000..84b94f289 --- /dev/null +++ b/apps/cards/apps/web/src/lib/stores/author.svelte.ts @@ -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(null); +let _loaded = $state(false); +let _loading = $state(false); +let _error = $state(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 { + 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[0]): Promise { + _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; + }, +}; diff --git a/apps/cards/apps/web/src/lib/util/slug.ts b/apps/cards/apps/web/src/lib/util/slug.ts new file mode 100644 index 000000000..677d94af7 --- /dev/null +++ b/apps/cards/apps/web/src/lib/util/slug.ts @@ -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); +} diff --git a/apps/cards/apps/web/src/routes/decks/[id]/+page.svelte b/apps/cards/apps/web/src/routes/decks/[id]/+page.svelte index 3180c49d6..b6183ef4d 100644 --- a/apps/cards/apps/web/src/routes/decks/[id]/+page.svelte +++ b/apps/cards/apps/web/src/routes/decks/[id]/+page.svelte @@ -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(null); let attachInputs = $state>({ @@ -167,9 +169,21 @@ > Lernen {#if dueCount > 0} - {dueCount} fällig + + {dueCount} fällig + {/if} + {#if dueCount === 0 && cards.length > 0} Heute alles gelernt — schau später wieder rein. @@ -430,3 +444,7 @@ {/if} + +{#if showPublish && deck} + (showPublish = false)} /> +{/if} diff --git a/docker-compose.macmini.yml b/docker-compose.macmini.yml index b3cc0a47b..7d8bc7e6d 100644 --- a/docker-compose.macmini.yml +++ b/docker-compose.macmini.yml @@ -1032,6 +1032,7 @@ services: PUBLIC_MANA_SYNC_URL_CLIENT: https://sync.mana.how PUBLIC_MANA_LLM_URL_CLIENT: https://llm.mana.how PUBLIC_MANA_MEDIA_URL_CLIENT: https://media.mana.how + PUBLIC_CARDS_API_URL_CLIENT: https://cards-api.mana.how ports: - "5180:5180" healthcheck: