mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 17:41:09 +02:00
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:
parent
f47edc14af
commit
ec8abfe6b8
7 changed files with 636 additions and 1 deletions
|
|
@ -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}`);
|
||||
},
|
||||
|
|
|
|||
176
apps/cards/apps/web/src/lib/api/cards-api.ts
Normal file
176
apps/cards/apps/web/src/lib/api/cards-api.ts
Normal 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[] };
|
||||
}
|
||||
351
apps/cards/apps/web/src/lib/components/PublishDeckModal.svelte
Normal file
351
apps/cards/apps/web/src/lib/components/PublishDeckModal.svelte
Normal 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 (3–60 Zeichen, a–z, 0–9, -)
|
||||
</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>
|
||||
72
apps/cards/apps/web/src/lib/stores/author.svelte.ts
Normal file
72
apps/cards/apps/web/src/lib/stores/author.svelte.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
14
apps/cards/apps/web/src/lib/util/slug.ts
Normal file
14
apps/cards/apps/web/src/lib/util/slug.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue