Phase 12 R5: Marketplace-Frontend — /explore + /d + /u + /me/{published,subscribed,forks}
Routes: - /explore — Featured + Trending side-by-side, Browse mit Suche (Title/Description ILIKE), Sprachfilter, Sort (recent/popular/ trending), load-more-Pagination - /d/[slug] — Public-Deck-Detail mit Star/Subscribe/Fork-Buttons (Star + Subscribe sind toggle, Fork erstellt private cards.decks- Kopie und navigiert dorthin), Karten-Liste mit Discussion-Counts + Click-to-expand-Thread + Suggest-Edit-Modal, PR-Liste mit Owner-Merge/Reject + PR-Author-Close, Publish-Modal für Owner - /u/[slug] — Author-Profil mit Verified-Badges (Mana/Community), Follow-Button, Decks-Liste - /me/published — Author-Profil-CRUD (Slug + Display-Name + Bio + Pseudonym-Toggle), Liste eigener veröffentlichter Decks - /me/subscribed — Abos mit prominentem update_available-Banner - /me/forks — Geforkte Decks mit „Update ziehen"-Button → Smart-Merge-Pull (FSRS-State unveränderter Karten bleibt erhalten) Components (apps/web/src/lib/components/marketplace/, eigener Namespace ohne Konflikt zu Tills WIP-DeckGrid.svelte/DeckFan/ DeckStack): - AuthorBadge — Display-Name + Verified-Symbole + Link aufs Profil - DeckListGrid — 3-spalt Grid mit Author-Badge, Karten-/Star-/ Subscriber-Counts, Sprache, Featured-Tag - PublishVersionModal — SemVer-Eingabe (Default-Bump 1.0.0→1.1.0), Changelog, Karten als JSON-Array - SuggestEditModal — Modify- oder Remove-Mode pro Karte, ergibt einen Pull-Request via /api/v1/marketplace/.../pull-requests - DiscussionThread — Liste sichtbarer Comments inkl. Reply-Threading (parent_id), Hide-Button für Author oder Deck-Owner, Post-Form - PullRequestList — Status-Filter, Diff-Summary +N ~M −R, per-PR Merge/Reject/Close-Buttons je nach Owner/Author-Permission API-Client (apps/web/src/lib/api/marketplace.ts, ~440 Z.): - Authors (CRUD + public lookup) - Discovery (explore + browse + tags) - Public Deck-Read + Init/Publish/Patch - Engagement (Stars + Follows mit own-state-Endpoints) - Subscribe + Fork + Pull-Update - Pull-Requests (Lifecycle + List + Detail) - Card-Discussions (Post + List + Counts + Hide) Verifikation: - svelte-check: 4017 Files, 0 errors, 5 Svelte-5-rune-Warnings (benigne — Modals capturen Init-Values von Props bewusst, weil sie pro Klick frisch gemountet werden; nicht-reactive ist gewollt) - SSR-Smoke: /explore, /d/r5-stoa-grundlagen, /u/cardecky, /me/published liefern alle 200 — Routes mounten, Pages rendern initial mit Titles + Containern; API-Calls laufen client-side beim Mount - Live-Daten: Test-Decks r5-stoa-grundlagen (Stoische Grundbegriffe, 4 Karten v1.0.0) + r5-deutsche-historie (2 Karten) bewusst in lokaler cards-DB liegen gelassen, damit Browser sofort Inhalt hat Bewusst nicht angefasst: - Header.svelte ist in Tills uncommitted WIP — Header-Nav-Link auf /explore wird beim Theming-WIP-Commit nachgezogen. Marketplace- URLs sind aktuell direkt erreichbar via URL-Bar. - type-check-Warnings nicht silencet — die 5 sind benign und das Refactoren auf $derived würde keine Verhaltens-Änderung bringen. Verbleibend: R6 voller UI-E2E gegen das ganze System (Cardecky- Deck-Publish + Till-Subscribe + Till-Fork + Till-Suggest-PR + Cardecky-Merge + Till-Pull-Update — alles im Browser, manuell oder Playwright). Polish (Empty-States, Loading-Skeletons, Pagination- Edge-Cases) sammelt sich auf für eine separate Welle. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
92a1d5804f
commit
40861710bf
14 changed files with 2310 additions and 1 deletions
|
|
@ -98,7 +98,7 @@ Vollständiger Plan: [`mana/docs/playbooks/CARDS_GREENFIELD.md`](../mana/docs/pl
|
|||
| 9 | Polish (DSGVO-UI, Settings, Account, Statistik, i18n, A11y, Media, Image-Occlusion) | 🟡 weit | Card-Edit + Cloze-Editor + Inbox-Banner + Account/DSGVO + Statistik + Pre-Flight-Swap + i18n DE/EN + A11y-Pass + Cloze-Hint-Anzeige + Anki-Re-Import-Dedupe + MinIO-Media-Upload + Image-Occlusion durch (9a–9l). Verbleibend: type-in, audio, multiple-choice (Schema vorbereitet) |
|
||||
| 10 | Production-Deploy (Mac Mini, Cloudflare-Tunnel) | ✅ live 2026-05-08 | cardecky.mana.how + cardecky-api.mana.how, alte cards.* via nginx-301-Redirect |
|
||||
| 11 | Decommission Cards-Modul aus mana-monorepo | ✅ 2026-05-08 | apps/cards, services/cards-server, packages/cards-core, mana-app cards-Modul + cross-refs entfernt (4 Commits, type-check 0 errors) |
|
||||
| 12 | Marketplace-Restore (R0–R6) | 🟡 R0+R1+R2+R3+R4 durch | Plan: [`docs/playbooks/MARKETPLACE_RESTORE.md`](docs/playbooks/MARKETPLACE_RESTORE.md). R0 (Doku): ✅. R1 (Schema): ✅. R2 (α+β Authors + Publish): ✅. R3 (γ+δ Discovery + Engagement + Subscribe + Smart-Merge mit FSRS-State-Erhalt): ✅. **R4 (ε Pull-Requests + Card-Discussions): ✅** — `POST/GET /decks/:slug/pull-requests` (PR-Erstellung mit add/modify/remove-Diff, public List), `GET /pull-requests/:id`, `POST /pull-requests/:id/{close,reject,merge}` mit Lifecycle-Enforcement (open→merged|closed|rejected), Merge ist Owner-only und erzeugt atomar eine neue Version mit semver-minor-Bump (1.0.0→1.1.0 default), bumpt `latest_version_id`, schreibt PR-Resolution. Card-Discussions: `POST /decks/:slug/cards/:hash/discussions` (auth, Threads keyed auf `card_content_hash` damit sie Versions-Bumps überleben), `GET /cards/:hash/discussions` (public read, hidden filtered), `GET /decks/:slug/discussions/counts` (Bulk pro Karte), `POST /discussions/:id/hide` (Author oder Deck-Owner). 11 neue Semver-Unit-Tests, 89 gesamt grün. **E2E-Smoke**: Cardecky publisht v1.0.0 (Apatheia, Eudaimonia, Logos) → Till submitted PR (modify Eudaimonia-Back, remove Logos, add Tugendlehre) → Till's Merge-Versuch wird mit 403 abgelehnt (deck_owner_only) → Cardecky merged → v1.1.0 entsteht atomar mit korrektem Karten-Mix in Ord-Reihenfolge → re-merge wird mit 409 abgelehnt → Till postet Frage zur Apatheia-Karte → Cardecky antwortet mit parent_id (Threading) → Cross-Card-Parent wird mit 422 abgelehnt → Hide-Operation versteckt vom Read aus → Bulk-Counts korrekt → Smart-Merge-Pull gegen v1.0.0→v1.1.0 zeigt 2 changed (Eudaimonia + Logos↔Tugendlehre über ord-Heuristik), 0 cards_inserted weil bereits-private-via-Fork. Bug-Fix: `r.use('*', authMiddleware)` in fork.ts wäre an dem `/api/v1/marketplace`-Mount-Punkt nachfolgende Router-Mounts (PRs, Discussions) versehentlich gefangen — Refactor auf per-route Middleware. Verbleibend: R5 Frontend-Routes, R6 voller UI-E2E. |
|
||||
| 12 | Marketplace-Restore (R0–R6) | 🟡 R0+R1+R2+R3+R4+R5 durch | Plan: [`docs/playbooks/MARKETPLACE_RESTORE.md`](docs/playbooks/MARKETPLACE_RESTORE.md). R0–R4 (Backend-Stack): ✅. **R5 (Frontend-Routes): ✅** — `apps/web/src/lib/api/marketplace.ts` (~340 Z. Client mit Authors, Discovery, Engagement, Subscribe, Fork, PR, Discussions), Components in `lib/components/marketplace/` (AuthorBadge, DeckListGrid, PublishVersionModal, SuggestEditModal, DiscussionThread, PullRequestList — eigener Namespace ohne Konflikt zu Tills WIP-DeckGrid.svelte), Routes: `/explore` (Featured + Trending + Browse mit Suche + Sortierung + Pagination), `/d/[slug]` (Public-Detail mit Star/Subscribe/Fork-Buttons + Karten-Liste mit Discussion-Counts + Suggest-Edit-Modal pro Karte + PR-Liste mit Owner-Merge/Reject + Publish-Modal für Owner), `/u/[slug]` (Author-Profil + Verified-Badges + Follow-Button + eigene Decks), `/me/published` (Author-Profil-CRUD + eigene Veröffentlichungen), `/me/subscribed` (Subs mit update_available-Banner), `/me/forks` (geforkte Decks mit „Update ziehen"-Button → Smart-Merge-Pull). svelte-check: 4017 Files, **0 errors, 5 Svelte-5-rune-Warnings** (benign — Modals capturen Init-Values von Props, gewollt). SSR-Smoke: alle 4 Marketplace-URLs (`/explore`, `/d/r5-stoa-grundlagen`, `/u/cardecky`, `/me/published`) liefern 200. Test-Decks `r5-stoa-grundlagen` (Stoische Grundbegriffe, 4 Karten v1.0.0) + `r5-deutsche-historie` (2 Karten) bewusst in der lokalen `cards`-DB liegen gelassen für Browser-Spielwiese. Header-Nav-Link auf `/explore` **nicht** gesetzt — `Header.svelte` ist in Tills uncommitted WIP, Link wird beim Theming-WIP-Commit nachgezogen. Verbleibend: R6 voller UI-E2E + ggf. Polish (Modal-Warnings, Empty-States, Loading-Skeletons). |
|
||||
|
||||
Legende: ✅ erledigt + verifiziert · 🚧 blockiert · ⏸ noch nicht begonnen
|
||||
|
||||
|
|
|
|||
440
apps/web/src/lib/api/marketplace.ts
Normal file
440
apps/web/src/lib/api/marketplace.ts
Normal file
|
|
@ -0,0 +1,440 @@
|
|||
/**
|
||||
* Marketplace-API-Client. Konsumiert das `/api/v1/marketplace/*`-
|
||||
* Backend (siehe `cards/apps/api/src/routes/marketplace/`).
|
||||
*
|
||||
* Pattern wie `decks.ts` — dünne Wrapper um den `api()`-Helper aus
|
||||
* `client.ts`, der JWT/X-User-Id-Auth und 401-Refresh bereits
|
||||
* abhandelt.
|
||||
*/
|
||||
|
||||
import { api } from './client.ts';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface MarketplaceAuthor {
|
||||
slug: string;
|
||||
display_name: string;
|
||||
bio: string | null;
|
||||
avatar_url: string | null;
|
||||
joined_at: string;
|
||||
pseudonym: boolean;
|
||||
verified_mana: boolean;
|
||||
verified_community: boolean;
|
||||
banned: boolean;
|
||||
}
|
||||
|
||||
export interface MarketplaceDeck {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
language: string | null;
|
||||
license: string;
|
||||
price_credits: number;
|
||||
owner_user_id: string;
|
||||
latest_version_id: string | null;
|
||||
is_featured: boolean;
|
||||
is_takedown: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface MarketplaceVersion {
|
||||
id: string;
|
||||
deck_id: string;
|
||||
semver: string;
|
||||
changelog: string | null;
|
||||
content_hash: string;
|
||||
card_count: number;
|
||||
published_at: string;
|
||||
deprecated_at?: string | null;
|
||||
}
|
||||
|
||||
export interface MarketplaceVersionCard {
|
||||
content_hash: string;
|
||||
type: string;
|
||||
fields: Record<string, string>;
|
||||
ord: number;
|
||||
}
|
||||
|
||||
export interface DeckListEntry {
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
language: string | null;
|
||||
license: string;
|
||||
price_credits: number;
|
||||
card_count: number;
|
||||
star_count: number;
|
||||
subscriber_count: number;
|
||||
is_featured: boolean;
|
||||
created_at: string;
|
||||
owner: {
|
||||
slug: string;
|
||||
display_name: string;
|
||||
verified_mana: boolean;
|
||||
verified_community: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PullRequest {
|
||||
id: string;
|
||||
deck_id: string;
|
||||
author_user_id: string;
|
||||
status: 'open' | 'merged' | 'closed' | 'rejected';
|
||||
title: string;
|
||||
body: string | null;
|
||||
diff: {
|
||||
add: { type: string; fields: Record<string, string> }[];
|
||||
modify: { contentHash: string; fields: Record<string, string> }[];
|
||||
remove: { contentHash: string }[];
|
||||
};
|
||||
merged_into_version_id: string | null;
|
||||
created_at: string;
|
||||
resolved_at: string | null;
|
||||
}
|
||||
|
||||
export interface Discussion {
|
||||
id: string;
|
||||
card_content_hash: string;
|
||||
deck_id: string;
|
||||
author_user_id: string;
|
||||
parent_id: string | null;
|
||||
body: string;
|
||||
hidden: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface DiffPayload {
|
||||
from: { semver?: string; versionId?: string };
|
||||
to: { semver: string; versionId: string };
|
||||
added: MarketplaceVersionCard[];
|
||||
changed: { previous: { contentHash: string }; next: MarketplaceVersionCard }[];
|
||||
unchanged: { contentHash: string; ord: number }[];
|
||||
removed: { contentHash: string }[];
|
||||
}
|
||||
|
||||
export interface SubscriptionEntry {
|
||||
deck_slug: string;
|
||||
deck_title: string;
|
||||
deck_description: string | null;
|
||||
subscribed_at: string;
|
||||
notify_updates: boolean;
|
||||
current_version_id: string | null;
|
||||
latest_version_id: string | null;
|
||||
update_available: boolean;
|
||||
}
|
||||
|
||||
// ─── Authors ─────────────────────────────────────────────────────────
|
||||
|
||||
export function getMyAuthorProfile() {
|
||||
return api<MarketplaceAuthor | null>('/api/v1/marketplace/authors/me');
|
||||
}
|
||||
|
||||
export function upsertMyAuthorProfile(input: {
|
||||
slug: string;
|
||||
displayName: string;
|
||||
bio?: string;
|
||||
avatarUrl?: string;
|
||||
pseudonym?: boolean;
|
||||
}) {
|
||||
return api<MarketplaceAuthor>('/api/v1/marketplace/authors/me', {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
export function getAuthor(slug: string) {
|
||||
return api<MarketplaceAuthor>(`/api/v1/marketplace/authors/${slug}`);
|
||||
}
|
||||
|
||||
// ─── Discovery ───────────────────────────────────────────────────────
|
||||
|
||||
export function getExplore() {
|
||||
return api<{ featured: DeckListEntry[]; trending: DeckListEntry[] }>(
|
||||
'/api/v1/marketplace/explore'
|
||||
);
|
||||
}
|
||||
|
||||
export interface BrowseQuery {
|
||||
q?: string;
|
||||
tag?: string;
|
||||
language?: string;
|
||||
author?: string;
|
||||
sort?: 'recent' | 'popular' | 'trending';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export function browseDecks(query: BrowseQuery = {}) {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
params.set(key, String(value));
|
||||
}
|
||||
}
|
||||
const qs = params.toString();
|
||||
return api<{ items: DeckListEntry[]; total: number }>(
|
||||
`/api/v1/marketplace/decks${qs ? '?' + qs : ''}`
|
||||
);
|
||||
}
|
||||
|
||||
export function getTags() {
|
||||
return api<{
|
||||
tags: {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
parent_id: string | null;
|
||||
description: string | null;
|
||||
curated: boolean;
|
||||
}[];
|
||||
}>('/api/v1/marketplace/tags');
|
||||
}
|
||||
|
||||
// ─── Deck (Public) ───────────────────────────────────────────────────
|
||||
|
||||
export function getMarketplaceDeck(slug: string) {
|
||||
return api<{ deck: MarketplaceDeck; latest_version: MarketplaceVersion | null }>(
|
||||
`/api/v1/marketplace/decks/${slug}`
|
||||
);
|
||||
}
|
||||
|
||||
export function getMarketplaceVersion(slug: string, semver: string) {
|
||||
return api<{ version: MarketplaceVersion; cards: MarketplaceVersionCard[] }>(
|
||||
`/api/v1/marketplace/decks/${slug}/versions/${semver}`
|
||||
);
|
||||
}
|
||||
|
||||
export function getDiff(slug: string, fromSemver: string) {
|
||||
return api<DiffPayload>(
|
||||
`/api/v1/marketplace/decks/${slug}/diff?from=${encodeURIComponent(fromSemver)}`
|
||||
);
|
||||
}
|
||||
|
||||
export function initMarketplaceDeck(input: {
|
||||
slug: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
language?: string;
|
||||
license?: string;
|
||||
priceCredits?: number;
|
||||
}) {
|
||||
return api<MarketplaceDeck>('/api/v1/marketplace/decks', {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
export function patchMarketplaceDeck(
|
||||
slug: string,
|
||||
patch: Partial<{
|
||||
title: string;
|
||||
description: string;
|
||||
language: string;
|
||||
license: string;
|
||||
priceCredits: number;
|
||||
}>
|
||||
) {
|
||||
return api<MarketplaceDeck>(`/api/v1/marketplace/decks/${slug}`, {
|
||||
method: 'PATCH',
|
||||
body: patch,
|
||||
});
|
||||
}
|
||||
|
||||
export function publishMarketplaceVersion(
|
||||
slug: string,
|
||||
input: {
|
||||
semver: string;
|
||||
changelog?: string;
|
||||
cards: { type: string; fields: Record<string, string> }[];
|
||||
}
|
||||
) {
|
||||
return api<{
|
||||
deck: MarketplaceDeck;
|
||||
version: MarketplaceVersion;
|
||||
moderation: { verdict: string; categories: string[]; model: string };
|
||||
}>(`/api/v1/marketplace/decks/${slug}/publish`, {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Engagement: Stars + Follows ─────────────────────────────────────
|
||||
|
||||
export function starDeck(slug: string) {
|
||||
return api<{ starred: true }>(`/api/v1/marketplace/decks/${slug}/star`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function unstarDeck(slug: string) {
|
||||
return api<{ starred: false }>(`/api/v1/marketplace/decks/${slug}/star`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
export function getStarState(slug: string) {
|
||||
return api<{ starred: boolean }>(`/api/v1/marketplace/decks/${slug}/star`);
|
||||
}
|
||||
|
||||
export function followAuthor(slug: string) {
|
||||
return api<{ following: true }>(`/api/v1/marketplace/authors/${slug}/follow`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function unfollowAuthor(slug: string) {
|
||||
return api<{ following: false }>(`/api/v1/marketplace/authors/${slug}/follow`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
export function getFollowState(slug: string) {
|
||||
return api<{ following: boolean }>(`/api/v1/marketplace/authors/${slug}/follow`);
|
||||
}
|
||||
|
||||
// ─── Subscriptions + Fork + Pull-Update ──────────────────────────────
|
||||
|
||||
export function subscribe(slug: string) {
|
||||
return api<{ subscribed: true; deck_slug: string; current_version_id: string }>(
|
||||
`/api/v1/marketplace/decks/${slug}/subscribe`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
}
|
||||
|
||||
export function unsubscribe(slug: string) {
|
||||
return api<{ subscribed: false }>(`/api/v1/marketplace/decks/${slug}/subscribe`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
export function getSubscribeState(slug: string) {
|
||||
return api<{ subscribed: boolean; current_version_id?: string }>(
|
||||
`/api/v1/marketplace/decks/${slug}/subscribe`
|
||||
);
|
||||
}
|
||||
|
||||
export function getMySubscriptions() {
|
||||
return api<{ subscriptions: SubscriptionEntry[] }>('/api/v1/marketplace/me/subscriptions');
|
||||
}
|
||||
|
||||
export function forkDeck(slug: string, color?: string) {
|
||||
return api<{
|
||||
deck: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
color: string | null;
|
||||
forked_from_marketplace_deck_id: string;
|
||||
forked_from_marketplace_version_id: string;
|
||||
};
|
||||
cards_created: number;
|
||||
}>(`/api/v1/marketplace/decks/${slug}/fork`, {
|
||||
method: 'POST',
|
||||
body: color ? { color } : {},
|
||||
});
|
||||
}
|
||||
|
||||
export function pullUpdate(privateDeckId: string) {
|
||||
return api<{
|
||||
up_to_date: boolean;
|
||||
from?: { semver: string; versionId: string };
|
||||
to?: { semver: string; versionId: string };
|
||||
added: number;
|
||||
changed: number;
|
||||
removed: number;
|
||||
cards_inserted?: number;
|
||||
}>(`/api/v1/marketplace/private/${privateDeckId}/pull-update`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Pull-Requests ───────────────────────────────────────────────────
|
||||
|
||||
export interface PrCreateInput {
|
||||
title: string;
|
||||
body?: string;
|
||||
diff: {
|
||||
add: { type: string; fields: Record<string, string> }[];
|
||||
modify: { type: string; previousContentHash: string; fields: Record<string, string> }[];
|
||||
remove: { contentHash: string }[];
|
||||
};
|
||||
}
|
||||
|
||||
export function listPullRequests(
|
||||
slug: string,
|
||||
status?: 'open' | 'merged' | 'closed' | 'rejected'
|
||||
) {
|
||||
const qs = status ? `?status=${status}` : '';
|
||||
return api<{ pull_requests: PullRequest[]; total: number }>(
|
||||
`/api/v1/marketplace/decks/${slug}/pull-requests${qs}`
|
||||
);
|
||||
}
|
||||
|
||||
export function getPullRequest(id: string) {
|
||||
return api<PullRequest>(`/api/v1/marketplace/pull-requests/${id}`);
|
||||
}
|
||||
|
||||
export function createPullRequest(slug: string, input: PrCreateInput) {
|
||||
return api<PullRequest>(`/api/v1/marketplace/decks/${slug}/pull-requests`, {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
export function closePullRequest(id: string) {
|
||||
return api<{ status: 'closed' }>(`/api/v1/marketplace/pull-requests/${id}/close`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function rejectPullRequest(id: string) {
|
||||
return api<{ status: 'rejected' }>(`/api/v1/marketplace/pull-requests/${id}/reject`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function mergePullRequest(
|
||||
id: string,
|
||||
opts: { newSemver?: string; mergeNote?: string } = {}
|
||||
) {
|
||||
return api<{
|
||||
pull_request: PullRequest;
|
||||
version: MarketplaceVersion;
|
||||
}>(`/api/v1/marketplace/pull-requests/${id}/merge`, {
|
||||
method: 'POST',
|
||||
body: opts,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Card-Discussions ────────────────────────────────────────────────
|
||||
|
||||
export function listDiscussions(cardContentHash: string) {
|
||||
return api<{ discussions: Discussion[]; total: number }>(
|
||||
`/api/v1/marketplace/cards/${cardContentHash}/discussions`
|
||||
);
|
||||
}
|
||||
|
||||
export function postDiscussion(
|
||||
deckSlug: string,
|
||||
cardContentHash: string,
|
||||
body: string,
|
||||
parentId?: string
|
||||
) {
|
||||
return api<Discussion>(
|
||||
`/api/v1/marketplace/decks/${deckSlug}/cards/${cardContentHash}/discussions`,
|
||||
{ method: 'POST', body: { body, parentId } }
|
||||
);
|
||||
}
|
||||
|
||||
export function getDiscussionCounts(slug: string) {
|
||||
return api<{ counts: Record<string, number> }>(
|
||||
`/api/v1/marketplace/decks/${slug}/discussions/counts`
|
||||
);
|
||||
}
|
||||
|
||||
export function hideDiscussion(id: string) {
|
||||
return api<{ hidden: true }>(`/api/v1/marketplace/discussions/${id}/hide`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
37
apps/web/src/lib/components/marketplace/AuthorBadge.svelte
Normal file
37
apps/web/src/lib/components/marketplace/AuthorBadge.svelte
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
slug: string;
|
||||
displayName: string;
|
||||
verifiedMana?: boolean;
|
||||
verifiedCommunity?: boolean;
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
const { slug, displayName, verifiedMana = false, verifiedCommunity = false, size = 'md' }: Props =
|
||||
$props();
|
||||
|
||||
const labelTitle = $derived(
|
||||
[
|
||||
verifiedMana ? 'Mana Verifiziert' : null,
|
||||
verifiedCommunity ? 'Community Verifiziert' : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
);
|
||||
</script>
|
||||
|
||||
<a
|
||||
href="/u/{slug}"
|
||||
class="inline-flex items-center gap-1 hover:text-[hsl(var(--color-primary))] {size === 'sm'
|
||||
? 'text-xs'
|
||||
: 'text-sm'}"
|
||||
title={labelTitle || displayName}
|
||||
>
|
||||
<span class="font-medium">{displayName}</span>
|
||||
{#if verifiedMana}
|
||||
<span aria-label="Mana Verifiziert" title="Mana Verifiziert">🛡️</span>
|
||||
{/if}
|
||||
{#if verifiedCommunity}
|
||||
<span aria-label="Community Verifiziert" title="Community Verifiziert">⭐</span>
|
||||
{/if}
|
||||
</a>
|
||||
79
apps/web/src/lib/components/marketplace/DeckListGrid.svelte
Normal file
79
apps/web/src/lib/components/marketplace/DeckListGrid.svelte
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<script lang="ts">
|
||||
import type { DeckListEntry } from '$lib/api/marketplace.ts';
|
||||
import AuthorBadge from './AuthorBadge.svelte';
|
||||
|
||||
interface Props {
|
||||
items: DeckListEntry[];
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
const { items, emptyMessage = 'Noch keine Decks gefunden.' }: Props = $props();
|
||||
|
||||
function languageLabel(code: string | null): string {
|
||||
if (!code) return '';
|
||||
const map: Record<string, string> = { de: 'Deutsch', en: 'English', es: 'Español', fr: 'Français' };
|
||||
return map[code] ?? code.toUpperCase();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if items.length === 0}
|
||||
<p class="rounded-lg border border-dashed border-[hsl(var(--color-border))] p-8 text-center text-sm text-[hsl(var(--color-muted-foreground))]">
|
||||
{emptyMessage}
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each items as deck (deck.slug)}
|
||||
<li
|
||||
class="group relative rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4 transition-colors hover:border-[hsl(var(--color-primary))]"
|
||||
>
|
||||
<a href="/d/{deck.slug}" class="block">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<h3 class="truncate font-medium">{deck.title}</h3>
|
||||
{#if deck.is_featured}
|
||||
<span
|
||||
class="shrink-0 rounded-full bg-[hsl(var(--color-primary))]/15 px-2 py-0.5 text-[10px] font-medium text-[hsl(var(--color-primary))]"
|
||||
title="Editorial Pick"
|
||||
>
|
||||
★ Featured
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if deck.description}
|
||||
<p
|
||||
class="mt-1 line-clamp-2 text-xs text-[hsl(var(--color-muted-foreground))]"
|
||||
>
|
||||
{deck.description}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[hsl(var(--color-muted-foreground))]">
|
||||
<AuthorBadge
|
||||
slug={deck.owner.slug}
|
||||
displayName={deck.owner.display_name}
|
||||
verifiedMana={deck.owner.verified_mana}
|
||||
verifiedCommunity={deck.owner.verified_community}
|
||||
size="sm"
|
||||
/>
|
||||
<span>·</span>
|
||||
<span title="Karten">{deck.card_count} 🃏</span>
|
||||
<span>·</span>
|
||||
<span title="Stars">{deck.star_count} ★</span>
|
||||
{#if deck.subscriber_count > 0}
|
||||
<span>·</span>
|
||||
<span title="Subscribers">{deck.subscriber_count} ↩︎</span>
|
||||
{/if}
|
||||
{#if deck.language}
|
||||
<span>·</span>
|
||||
<span class="uppercase">{languageLabel(deck.language)}</span>
|
||||
{/if}
|
||||
{#if deck.price_credits > 0}
|
||||
<span>·</span>
|
||||
<span class="font-medium text-[hsl(var(--color-primary))]">
|
||||
{deck.price_credits} Credits
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
155
apps/web/src/lib/components/marketplace/DiscussionThread.svelte
Normal file
155
apps/web/src/lib/components/marketplace/DiscussionThread.svelte
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import {
|
||||
hideDiscussion,
|
||||
listDiscussions,
|
||||
postDiscussion,
|
||||
type Discussion,
|
||||
} from '$lib/api/marketplace.ts';
|
||||
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
|
||||
import { toasts } from '$lib/stores/toasts.svelte.ts';
|
||||
|
||||
interface Props {
|
||||
deckSlug: string;
|
||||
cardContentHash: string;
|
||||
ownerUserId?: string;
|
||||
}
|
||||
|
||||
const { deckSlug, cardContentHash, ownerUserId }: Props = $props();
|
||||
|
||||
let comments = $state<Discussion[]>([]);
|
||||
let loading = $state(true);
|
||||
let newBody = $state('');
|
||||
let replyToId = $state<string | null>(null);
|
||||
let posting = $state(false);
|
||||
|
||||
const myUserId = $derived(devUser.id);
|
||||
const canHideAny = $derived(myUserId === ownerUserId);
|
||||
|
||||
onMount(() => {
|
||||
void refresh();
|
||||
});
|
||||
|
||||
async function refresh() {
|
||||
loading = true;
|
||||
try {
|
||||
const result = await listDiscussions(cardContentHash);
|
||||
comments = result.discussions;
|
||||
} catch (e) {
|
||||
toasts.error(`Discussions laden fehlgeschlagen: ${(e as Error).message}`);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onPost(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!newBody.trim() || posting) return;
|
||||
posting = true;
|
||||
try {
|
||||
await postDiscussion(deckSlug, cardContentHash, newBody.trim(), replyToId ?? undefined);
|
||||
newBody = '';
|
||||
replyToId = null;
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
toasts.error((e as Error).message);
|
||||
} finally {
|
||||
posting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onHide(id: string) {
|
||||
if (!confirm('Comment verstecken?')) return;
|
||||
try {
|
||||
await hideDiscussion(id);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
toasts.error((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
function canHide(comment: Discussion): boolean {
|
||||
return canHideAny || comment.author_user_id === myUserId;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
<h4 class="text-sm font-medium">💬 Diskussion zur Karte</h4>
|
||||
|
||||
{#if loading}
|
||||
<p class="text-xs text-[hsl(var(--color-muted-foreground))]">Lade…</p>
|
||||
{:else if comments.length === 0}
|
||||
<p class="text-xs text-[hsl(var(--color-muted-foreground))]">Noch keine Kommentare.</p>
|
||||
{:else}
|
||||
<ul class="space-y-2">
|
||||
{#each comments as comment (comment.id)}
|
||||
<li
|
||||
class="rounded border border-[hsl(var(--color-border))] p-3 text-sm"
|
||||
class:ml-6={comment.parent_id !== null}
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex-1 whitespace-pre-wrap">{comment.body}</div>
|
||||
{#if canHide(comment)}
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-error))]"
|
||||
onclick={() => onHide(comment.id)}
|
||||
title="Verstecken"
|
||||
>
|
||||
🙈
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-2 flex items-center gap-2 text-xs text-[hsl(var(--color-muted-foreground))]">
|
||||
<span>{new Date(comment.created_at).toLocaleString()}</span>
|
||||
{#if myUserId && replyToId !== comment.id}
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-[hsl(var(--color-primary))]"
|
||||
onclick={() => (replyToId = comment.id)}
|
||||
>
|
||||
Antworten
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if myUserId}
|
||||
<form class="space-y-2" onsubmit={onPost}>
|
||||
{#if replyToId}
|
||||
<p class="text-xs text-[hsl(var(--color-muted-foreground))]">
|
||||
↪ Antwort auf einen Kommentar.
|
||||
<button
|
||||
type="button"
|
||||
class="hover:underline"
|
||||
onclick={() => (replyToId = null)}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</p>
|
||||
{/if}
|
||||
<textarea
|
||||
bind:value={newBody}
|
||||
rows="2"
|
||||
maxlength="2000"
|
||||
placeholder="Kommentar / Frage / Korrekturhinweis"
|
||||
class="block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
|
||||
></textarea>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={posting || !newBody.trim()}
|
||||
class="rounded bg-[hsl(var(--color-primary))] px-3 py-1 text-xs text-[hsl(var(--color-primary-foreground))] disabled:opacity-50"
|
||||
>
|
||||
{posting ? 'Posten…' : 'Posten'}
|
||||
</button>
|
||||
</form>
|
||||
{:else}
|
||||
<p class="text-xs text-[hsl(var(--color-muted-foreground))]">
|
||||
<a href="/" class="text-[hsl(var(--color-primary))] hover:underline">Anmelden</a>, um zu kommentieren.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
<script lang="ts">
|
||||
import { publishMarketplaceVersion } from '$lib/api/marketplace.ts';
|
||||
import { toasts } from '$lib/stores/toasts.svelte.ts';
|
||||
|
||||
interface Props {
|
||||
slug: string;
|
||||
latestSemver: string | null;
|
||||
onPublished?: () => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const { slug, latestSemver, onPublished, onClose }: Props = $props();
|
||||
|
||||
function bumpMinor(semver: string): string {
|
||||
const m = semver.match(/^(\d+)\.(\d+)\.(\d+)$/);
|
||||
if (!m) return '1.0.0';
|
||||
return `${m[1]}.${Number(m[2]) + 1}.0`;
|
||||
}
|
||||
|
||||
let semver = $state(latestSemver ? bumpMinor(latestSemver) : '1.0.0');
|
||||
let changelog = $state('');
|
||||
let cardsJson = $state('[]');
|
||||
let busy = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
async function onSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
error = null;
|
||||
|
||||
let cards: { type: string; fields: Record<string, string> }[] = [];
|
||||
try {
|
||||
const parsed = JSON.parse(cardsJson);
|
||||
if (!Array.isArray(parsed) || parsed.length === 0) {
|
||||
throw new Error('cards muss ein nicht-leeres Array sein');
|
||||
}
|
||||
for (const c of parsed) {
|
||||
if (typeof c !== 'object' || !c.type || !c.fields) {
|
||||
throw new Error('Jede Karte braucht type + fields');
|
||||
}
|
||||
}
|
||||
cards = parsed;
|
||||
} catch (e) {
|
||||
error = `JSON-Parse-Fehler: ${(e as Error).message}`;
|
||||
busy = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await publishMarketplaceVersion(slug, {
|
||||
semver,
|
||||
changelog: changelog || undefined,
|
||||
cards,
|
||||
});
|
||||
toasts.success(`Version ${result.version.semver} veröffentlicht (${result.version.card_count} Karten)`);
|
||||
onPublished?.();
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div class="w-full max-w-2xl rounded-lg bg-[hsl(var(--color-card))] p-6 shadow-2xl">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">Neue Version veröffentlichen</h2>
|
||||
<p class="mt-1 text-sm text-[hsl(var(--color-muted-foreground))]">
|
||||
{#if latestSemver}
|
||||
Aktuell: v{latestSemver}. Default-Bump auf v{bumpMinor(latestSemver)}.
|
||||
{:else}
|
||||
Erste Version dieses Decks. Karten als JSON unten einfügen.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="text-2xl text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]"
|
||||
aria-label="Schließen"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form class="mt-6 space-y-4" onsubmit={onSubmit}>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium">SemVer (X.Y.Z)</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={semver}
|
||||
required
|
||||
pattern="\d+\.\d+\.\d+"
|
||||
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm font-mono"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium">Changelog (optional)</span>
|
||||
<textarea
|
||||
bind:value={changelog}
|
||||
rows="2"
|
||||
maxlength="2000"
|
||||
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium">Karten (JSON-Array)</span>
|
||||
<textarea
|
||||
bind:value={cardsJson}
|
||||
rows="10"
|
||||
required
|
||||
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 font-mono text-xs"
|
||||
placeholder={'[\n {"type":"basic","fields":{"front":"…","back":"…"}}\n]'}
|
||||
></textarea>
|
||||
<p class="mt-1 text-xs text-[hsl(var(--color-muted-foreground))]">
|
||||
Schema: <code>type</code> ∈ {`{basic, basic-reverse, cloze, …}`}, <code>fields</code> ist ein
|
||||
Key-Value-Object.
|
||||
</p>
|
||||
</label>
|
||||
|
||||
{#if error}
|
||||
<div
|
||||
class="rounded border border-[hsl(var(--color-error))]/40 bg-[hsl(var(--color-error))]/10 p-3 text-sm text-[hsl(var(--color-error))]"
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="text-sm text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
class="rounded bg-[hsl(var(--color-primary))] px-4 py-2 text-sm text-[hsl(var(--color-primary-foreground))] disabled:opacity-50"
|
||||
>
|
||||
{busy ? 'Veröffentliche…' : 'Veröffentlichen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
189
apps/web/src/lib/components/marketplace/PullRequestList.svelte
Normal file
189
apps/web/src/lib/components/marketplace/PullRequestList.svelte
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import {
|
||||
closePullRequest,
|
||||
listPullRequests,
|
||||
mergePullRequest,
|
||||
rejectPullRequest,
|
||||
type PullRequest,
|
||||
} from '$lib/api/marketplace.ts';
|
||||
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
|
||||
import { toasts } from '$lib/stores/toasts.svelte.ts';
|
||||
|
||||
interface Props {
|
||||
slug: string;
|
||||
ownerUserId: string;
|
||||
latestSemver: string | null;
|
||||
}
|
||||
|
||||
const { slug, ownerUserId, latestSemver }: Props = $props();
|
||||
|
||||
let prs = $state<PullRequest[]>([]);
|
||||
let loading = $state(true);
|
||||
let statusFilter = $state<'open' | 'merged' | 'closed' | 'rejected'>('open');
|
||||
let busyId = $state<string | null>(null);
|
||||
|
||||
const myUserId = $derived(devUser.id);
|
||||
const isOwner = $derived(myUserId === ownerUserId);
|
||||
|
||||
onMount(() => {
|
||||
void refresh();
|
||||
});
|
||||
|
||||
async function refresh() {
|
||||
loading = true;
|
||||
try {
|
||||
const result = await listPullRequests(slug, statusFilter);
|
||||
prs = result.pull_requests;
|
||||
} catch (e) {
|
||||
toasts.error(`PRs laden fehlgeschlagen: ${(e as Error).message}`);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function reload() {
|
||||
void refresh();
|
||||
}
|
||||
|
||||
async function onMerge(id: string) {
|
||||
busyId = id;
|
||||
try {
|
||||
await mergePullRequest(id);
|
||||
toasts.success('PR gemerged — neue Version live.');
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
toasts.error((e as Error).message);
|
||||
} finally {
|
||||
busyId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function onReject(id: string) {
|
||||
if (!confirm('PR ablehnen?')) return;
|
||||
busyId = id;
|
||||
try {
|
||||
await rejectPullRequest(id);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
toasts.error((e as Error).message);
|
||||
} finally {
|
||||
busyId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function onClose(id: string) {
|
||||
busyId = id;
|
||||
try {
|
||||
await closePullRequest(id);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
toasts.error((e as Error).message);
|
||||
} finally {
|
||||
busyId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function diffSummary(diff: PullRequest['diff']): string {
|
||||
const a = diff.add?.length ?? 0;
|
||||
const m = diff.modify?.length ?? 0;
|
||||
const r = diff.remove?.length ?? 0;
|
||||
return `+${a} ~${m} −${r}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h3 class="text-lg font-semibold">Pull-Requests</h3>
|
||||
<select
|
||||
bind:value={statusFilter}
|
||||
onchange={reload}
|
||||
class="rounded border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] px-2 py-1 text-xs"
|
||||
>
|
||||
<option value="open">Open</option>
|
||||
<option value="merged">Merged</option>
|
||||
<option value="closed">Closed</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">Lade…</p>
|
||||
{:else if prs.length === 0}
|
||||
<p
|
||||
class="rounded border border-dashed border-[hsl(var(--color-border))] p-4 text-center text-sm text-[hsl(var(--color-muted-foreground))]"
|
||||
>
|
||||
Keine {statusFilter} PRs.
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="space-y-2">
|
||||
{#each prs as pr (pr.id)}
|
||||
<li class="rounded-lg border border-[hsl(var(--color-border))] p-3">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex-1">
|
||||
<h4 class="font-medium">{pr.title}</h4>
|
||||
{#if pr.body}
|
||||
<p class="mt-1 text-sm text-[hsl(var(--color-muted-foreground))] whitespace-pre-wrap">
|
||||
{pr.body}
|
||||
</p>
|
||||
{/if}
|
||||
<p class="mt-2 text-xs text-[hsl(var(--color-muted-foreground))]">
|
||||
<span class="font-mono">{diffSummary(pr.diff)}</span>
|
||||
·
|
||||
<span class="capitalize">{pr.status}</span>
|
||||
·
|
||||
<time>{new Date(pr.created_at).toLocaleDateString()}</time>
|
||||
{#if pr.status === 'merged' && pr.merged_into_version_id}
|
||||
·
|
||||
<span class="text-[hsl(var(--color-primary))]">in einer neuen Version live</span>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if pr.status === 'open'}
|
||||
<div class="flex shrink-0 flex-col gap-1">
|
||||
{#if isOwner}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded bg-[hsl(var(--color-primary))] px-3 py-1 text-xs text-[hsl(var(--color-primary-foreground))] disabled:opacity-50"
|
||||
disabled={busyId === pr.id}
|
||||
onclick={() => onMerge(pr.id)}
|
||||
>
|
||||
{busyId === pr.id ? '…' : 'Merge'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-[hsl(var(--color-error))] px-3 py-1 text-xs text-[hsl(var(--color-error))] disabled:opacity-50"
|
||||
disabled={busyId === pr.id}
|
||||
onclick={() => onReject(pr.id)}
|
||||
>
|
||||
Ablehnen
|
||||
</button>
|
||||
{:else if pr.author_user_id === myUserId}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-[hsl(var(--color-border))] px-3 py-1 text-xs disabled:opacity-50"
|
||||
disabled={busyId === pr.id}
|
||||
onclick={() => onClose(pr.id)}
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if isOwner && statusFilter === 'open' && latestSemver}
|
||||
<p class="text-xs text-[hsl(var(--color-muted-foreground))]">
|
||||
Tipp: Beim Merge wird automatisch auf v{latestSemver
|
||||
.split('.')
|
||||
.map((n, i) => (i === 1 ? Number(n) + 1 : i === 2 ? '0' : n))
|
||||
.join('.')} gebumpt (semver-minor).
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
168
apps/web/src/lib/components/marketplace/SuggestEditModal.svelte
Normal file
168
apps/web/src/lib/components/marketplace/SuggestEditModal.svelte
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
<script lang="ts">
|
||||
import { createPullRequest } from '$lib/api/marketplace.ts';
|
||||
import type { MarketplaceVersionCard } from '$lib/api/marketplace.ts';
|
||||
import { toasts } from '$lib/stores/toasts.svelte.ts';
|
||||
|
||||
interface Props {
|
||||
slug: string;
|
||||
card: MarketplaceVersionCard;
|
||||
onSubmitted?: () => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const { slug, card, onSubmitted, onClose }: Props = $props();
|
||||
|
||||
let mode = $state<'modify' | 'remove'>('modify');
|
||||
let title = $state(`„${(card.fields.front ?? card.fields.text ?? '').slice(0, 60)}" verbessern`);
|
||||
let body = $state('');
|
||||
// Map des aktuellen field-Werts → editierbarer Wert.
|
||||
let editedFields = $state<Record<string, string>>({ ...card.fields });
|
||||
let busy = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
const fieldKeys = $derived(Object.keys(card.fields));
|
||||
|
||||
async function onSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
error = null;
|
||||
|
||||
const diff =
|
||||
mode === 'modify'
|
||||
? {
|
||||
add: [],
|
||||
modify: [
|
||||
{
|
||||
type: card.type,
|
||||
previousContentHash: card.content_hash,
|
||||
fields: editedFields,
|
||||
},
|
||||
],
|
||||
remove: [],
|
||||
}
|
||||
: {
|
||||
add: [],
|
||||
modify: [],
|
||||
remove: [{ contentHash: card.content_hash }],
|
||||
};
|
||||
|
||||
try {
|
||||
await createPullRequest(slug, {
|
||||
title,
|
||||
body: body || undefined,
|
||||
diff,
|
||||
});
|
||||
toasts.success('Pull-Request eingereicht — Author bekommt Bescheid.');
|
||||
onSubmitted?.();
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div class="w-full max-w-xl rounded-lg bg-[hsl(var(--color-card))] p-6 shadow-2xl">
|
||||
<div class="flex items-start justify-between">
|
||||
<h2 class="text-xl font-semibold">✏️ Karte verbessern</h2>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="text-2xl text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]"
|
||||
aria-label="Schließen"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form class="mt-4 space-y-4" onsubmit={onSubmit}>
|
||||
<fieldset class="flex gap-2 text-sm">
|
||||
<label class="flex items-center gap-1">
|
||||
<input type="radio" bind:group={mode} value="modify" />
|
||||
Felder ändern
|
||||
</label>
|
||||
<label class="flex items-center gap-1">
|
||||
<input type="radio" bind:group={mode} value="remove" />
|
||||
Karte entfernen
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium">Titel des PRs</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={title}
|
||||
required
|
||||
maxlength="140"
|
||||
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium">Begründung (optional)</span>
|
||||
<textarea
|
||||
bind:value={body}
|
||||
rows="3"
|
||||
maxlength="4000"
|
||||
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
{#if mode === 'modify'}
|
||||
<div class="space-y-2 rounded border border-[hsl(var(--color-border))] p-3">
|
||||
<p class="text-xs text-[hsl(var(--color-muted-foreground))]">
|
||||
Karten-Typ: <code>{card.type}</code>
|
||||
</p>
|
||||
{#each fieldKeys as key (key)}
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium">{key}</span>
|
||||
<textarea
|
||||
bind:value={editedFields[key]}
|
||||
rows="2"
|
||||
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
|
||||
></textarea>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p
|
||||
class="rounded border border-[hsl(var(--color-error))]/40 bg-[hsl(var(--color-error))]/5 p-3 text-sm"
|
||||
>
|
||||
Karte wird nach Merge des PRs aus dem Deck entfernt. Subscriber, die die Karte schon
|
||||
lokal haben, behalten sie (server-authoritative User-Choice).
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div
|
||||
class="rounded border border-[hsl(var(--color-error))]/40 bg-[hsl(var(--color-error))]/10 p-3 text-sm text-[hsl(var(--color-error))]"
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="text-sm text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
class="rounded bg-[hsl(var(--color-primary))] px-4 py-2 text-sm text-[hsl(var(--color-primary-foreground))] disabled:opacity-50"
|
||||
>
|
||||
{busy ? 'Reiche ein…' : 'PR einreichen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
366
apps/web/src/routes/d/[slug]/+page.svelte
Normal file
366
apps/web/src/routes/d/[slug]/+page.svelte
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
import {
|
||||
forkDeck,
|
||||
getMarketplaceDeck,
|
||||
getMarketplaceVersion,
|
||||
getDiscussionCounts,
|
||||
getStarState,
|
||||
getSubscribeState,
|
||||
starDeck,
|
||||
subscribe,
|
||||
unstarDeck,
|
||||
unsubscribe,
|
||||
type MarketplaceDeck,
|
||||
type MarketplaceVersion,
|
||||
type MarketplaceVersionCard,
|
||||
} from '$lib/api/marketplace.ts';
|
||||
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
|
||||
import AuthorBadge from '$lib/components/marketplace/AuthorBadge.svelte';
|
||||
import DiscussionThread from '$lib/components/marketplace/DiscussionThread.svelte';
|
||||
import PublishVersionModal from '$lib/components/marketplace/PublishVersionModal.svelte';
|
||||
import PullRequestList from '$lib/components/marketplace/PullRequestList.svelte';
|
||||
import SuggestEditModal from '$lib/components/marketplace/SuggestEditModal.svelte';
|
||||
import { toasts } from '$lib/stores/toasts.svelte.ts';
|
||||
|
||||
const slug = $derived(page.params.slug ?? '');
|
||||
|
||||
let deck = $state<MarketplaceDeck | null>(null);
|
||||
let latestVersion = $state<MarketplaceVersion | null>(null);
|
||||
let cards = $state<MarketplaceVersionCard[]>([]);
|
||||
let discussionCounts = $state<Record<string, number>>({});
|
||||
let starred = $state(false);
|
||||
let subscribed = $state(false);
|
||||
let loading = $state(true);
|
||||
let busy = $state(false);
|
||||
|
||||
let publishOpen = $state(false);
|
||||
let suggestOpen = $state(false);
|
||||
let suggestCard = $state<MarketplaceVersionCard | null>(null);
|
||||
let openDiscussionFor = $state<string | null>(null);
|
||||
|
||||
const myUserId = $derived(devUser.id);
|
||||
const isOwner = $derived(deck !== null && myUserId === deck.owner_user_id);
|
||||
|
||||
onMount(() => {
|
||||
void load();
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
const detail = await getMarketplaceDeck(slug);
|
||||
deck = detail.deck;
|
||||
latestVersion = detail.latest_version;
|
||||
|
||||
const [stateChecks, version, counts] = await Promise.all([
|
||||
myUserId
|
||||
? Promise.all([getStarState(slug), getSubscribeState(slug)])
|
||||
: Promise.resolve([{ starred: false }, { subscribed: false }] as const),
|
||||
latestVersion
|
||||
? getMarketplaceVersion(slug, latestVersion.semver).then((r) => r.cards)
|
||||
: Promise.resolve([] as MarketplaceVersionCard[]),
|
||||
getDiscussionCounts(slug).then((r) => r.counts).catch(() => ({})),
|
||||
]);
|
||||
|
||||
starred = (stateChecks as [{ starred: boolean }, unknown])[0].starred;
|
||||
subscribed = (stateChecks as [unknown, { subscribed: boolean }])[1].subscribed;
|
||||
cards = version;
|
||||
discussionCounts = counts;
|
||||
} catch (e) {
|
||||
toasts.error(`Deck laden fehlgeschlagen: ${(e as Error).message}`);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStar() {
|
||||
if (!myUserId) {
|
||||
toasts.error('Bitte einloggen.');
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
try {
|
||||
if (starred) {
|
||||
await unstarDeck(slug);
|
||||
starred = false;
|
||||
} else {
|
||||
await starDeck(slug);
|
||||
starred = true;
|
||||
}
|
||||
} catch (e) {
|
||||
toasts.error((e as Error).message);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleSubscribe() {
|
||||
if (!myUserId) {
|
||||
toasts.error('Bitte einloggen.');
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
try {
|
||||
if (subscribed) {
|
||||
await unsubscribe(slug);
|
||||
subscribed = false;
|
||||
toasts.success('Abo gekündigt.');
|
||||
} else {
|
||||
await subscribe(slug);
|
||||
subscribed = true;
|
||||
toasts.success('Abonniert. Update-Benachrichtigung an.');
|
||||
}
|
||||
} catch (e) {
|
||||
toasts.error((e as Error).message);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onFork() {
|
||||
if (!myUserId) {
|
||||
toasts.error('Bitte einloggen.');
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
try {
|
||||
const result = await forkDeck(slug);
|
||||
toasts.success(`Deck geforkt — ${result.cards_created} Karten kopiert.`);
|
||||
await goto(`/decks/${result.deck.id}`);
|
||||
} catch (e) {
|
||||
toasts.error((e as Error).message);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function previewCard(card: MarketplaceVersionCard): string {
|
||||
const text = card.fields.front ?? card.fields.text ?? Object.values(card.fields)[0] ?? '';
|
||||
return text.length > 140 ? text.slice(0, 140) + '…' : text;
|
||||
}
|
||||
|
||||
function onSuggestEdit(card: MarketplaceVersionCard) {
|
||||
if (!myUserId) {
|
||||
toasts.error('Bitte einloggen, um einen PR einzureichen.');
|
||||
return;
|
||||
}
|
||||
suggestCard = card;
|
||||
suggestOpen = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{deck?.title ?? slug} · Cardecky</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if loading}
|
||||
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">Lade…</p>
|
||||
{:else if !deck}
|
||||
<p>Deck nicht gefunden.</p>
|
||||
{:else}
|
||||
<div class="mx-auto max-w-4xl space-y-8">
|
||||
<a href="/explore" class="text-xs text-[hsl(var(--color-muted-foreground))] hover:underline">
|
||||
← zurück zur Library
|
||||
</a>
|
||||
|
||||
<header class="space-y-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<h1 class="text-3xl font-semibold">{deck.title}</h1>
|
||||
{#if deck.is_takedown}
|
||||
<span
|
||||
class="rounded bg-[hsl(var(--color-error))]/15 px-2 py-1 text-xs text-[hsl(var(--color-error))]"
|
||||
>
|
||||
Take-Down
|
||||
</span>
|
||||
{/if}
|
||||
{#if deck.is_featured}
|
||||
<span
|
||||
class="rounded bg-[hsl(var(--color-primary))]/15 px-2 py-1 text-xs text-[hsl(var(--color-primary))]"
|
||||
>
|
||||
★ Featured
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if deck.description}
|
||||
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">{deck.description}</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs text-[hsl(var(--color-muted-foreground))]">
|
||||
<span class="rounded bg-[hsl(var(--color-border))]/30 px-2 py-0.5 font-mono">
|
||||
{deck.license}
|
||||
</span>
|
||||
{#if deck.language}
|
||||
<span class="uppercase">{deck.language}</span>
|
||||
{/if}
|
||||
{#if latestVersion}
|
||||
<span>v{latestVersion.semver} · {latestVersion.card_count} Karten</span>
|
||||
{#if latestVersion.changelog}
|
||||
<span title={latestVersion.changelog}>
|
||||
📝 „{latestVersion.changelog.slice(0, 60)}…"
|
||||
</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="italic">noch nicht published</span>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-[hsl(var(--color-border))] px-3 py-2 text-sm hover:bg-[hsl(var(--color-card))]"
|
||||
onclick={toggleStar}
|
||||
disabled={busy}
|
||||
aria-pressed={starred}
|
||||
>
|
||||
{starred ? '★ Gestarred' : '☆ Star'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-[hsl(var(--color-border))] px-3 py-2 text-sm hover:bg-[hsl(var(--color-card))]"
|
||||
onclick={toggleSubscribe}
|
||||
disabled={busy || !latestVersion}
|
||||
aria-pressed={subscribed}
|
||||
>
|
||||
{subscribed ? '↩︎ Abonniert' : '↩︎ Abonnieren'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded bg-[hsl(var(--color-primary))] px-3 py-2 text-sm text-[hsl(var(--color-primary-foreground))] disabled:opacity-50"
|
||||
onclick={onFork}
|
||||
disabled={busy || !latestVersion || !myUserId}
|
||||
title="Karten in eigenes privates Deck kopieren — eigener Lern-Stand"
|
||||
>
|
||||
🔱 Fork
|
||||
</button>
|
||||
|
||||
{#if isOwner}
|
||||
<button
|
||||
type="button"
|
||||
class="ml-auto rounded border border-[hsl(var(--color-primary))] px-3 py-2 text-sm text-[hsl(var(--color-primary))] hover:bg-[hsl(var(--color-primary))]/10"
|
||||
onclick={() => (publishOpen = true)}
|
||||
>
|
||||
+ Neue Version
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if deck}
|
||||
<section>
|
||||
<header class="mb-3 flex items-center gap-2 text-sm">
|
||||
<span>Author:</span>
|
||||
<AuthorBadge
|
||||
slug=""
|
||||
displayName={deck.owner_user_id.slice(0, 12) + '…'}
|
||||
size="sm"
|
||||
/>
|
||||
<span class="text-[hsl(var(--color-muted-foreground))]">
|
||||
(Profil-Slug auf Author-Page sichtbar)
|
||||
</span>
|
||||
</header>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section>
|
||||
<h2 class="mb-3 text-xl font-semibold">Karten</h2>
|
||||
{#if cards.length === 0}
|
||||
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">Noch keine Karten.</p>
|
||||
{:else}
|
||||
<ul class="space-y-2">
|
||||
{#each cards as card (card.content_hash)}
|
||||
<li class="rounded-lg border border-[hsl(var(--color-border))] p-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium">
|
||||
{previewCard(card)}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-[hsl(var(--color-muted-foreground))]">
|
||||
<code>{card.type}</code>
|
||||
· ord {card.ord}
|
||||
· hash {card.content_hash.slice(0, 8)}
|
||||
{#if discussionCounts[card.content_hash] > 0}
|
||||
· 💬 {discussionCounts[card.content_hash]}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-col gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-[hsl(var(--color-border))] px-2 py-1 text-xs hover:bg-[hsl(var(--color-card))]"
|
||||
onclick={() =>
|
||||
(openDiscussionFor =
|
||||
openDiscussionFor === card.content_hash ? null : card.content_hash)}
|
||||
>
|
||||
💬 {discussionCounts[card.content_hash] ?? 0}
|
||||
</button>
|
||||
{#if myUserId && !isOwner}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-[hsl(var(--color-primary))] px-2 py-1 text-xs text-[hsl(var(--color-primary))] hover:bg-[hsl(var(--color-primary))]/10"
|
||||
onclick={() => onSuggestEdit(card)}
|
||||
>
|
||||
✏️ Verbessern
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if openDiscussionFor === card.content_hash && deck}
|
||||
<div class="mt-3 border-t border-[hsl(var(--color-border))] pt-3">
|
||||
<DiscussionThread
|
||||
deckSlug={slug}
|
||||
cardContentHash={card.content_hash}
|
||||
ownerUserId={deck.owner_user_id}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if deck}
|
||||
<section>
|
||||
<PullRequestList
|
||||
{slug}
|
||||
ownerUserId={deck.owner_user_id}
|
||||
latestSemver={latestVersion?.semver ?? null}
|
||||
/>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if publishOpen && deck}
|
||||
<PublishVersionModal
|
||||
{slug}
|
||||
latestSemver={latestVersion?.semver ?? null}
|
||||
onClose={() => (publishOpen = false)}
|
||||
onPublished={async () => {
|
||||
publishOpen = false;
|
||||
await load();
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if suggestOpen && suggestCard}
|
||||
<SuggestEditModal
|
||||
{slug}
|
||||
card={suggestCard}
|
||||
onClose={() => {
|
||||
suggestOpen = false;
|
||||
suggestCard = null;
|
||||
}}
|
||||
onSubmitted={() => {
|
||||
suggestOpen = false;
|
||||
suggestCard = null;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
167
apps/web/src/routes/explore/+page.svelte
Normal file
167
apps/web/src/routes/explore/+page.svelte
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import {
|
||||
browseDecks,
|
||||
getExplore,
|
||||
type DeckListEntry,
|
||||
} from '$lib/api/marketplace.ts';
|
||||
import DeckListGrid from '$lib/components/marketplace/DeckListGrid.svelte';
|
||||
import { toasts } from '$lib/stores/toasts.svelte.ts';
|
||||
|
||||
let featured = $state<DeckListEntry[]>([]);
|
||||
let trending = $state<DeckListEntry[]>([]);
|
||||
let browseResults = $state<DeckListEntry[]>([]);
|
||||
let browseTotal = $state(0);
|
||||
let loadingExplore = $state(true);
|
||||
let loadingBrowse = $state(false);
|
||||
|
||||
let q = $state('');
|
||||
let language = $state('');
|
||||
let sort = $state<'recent' | 'popular' | 'trending'>('recent');
|
||||
let offset = $state(0);
|
||||
const limit = 12;
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const explore = await getExplore();
|
||||
featured = explore.featured;
|
||||
trending = explore.trending;
|
||||
} catch (e) {
|
||||
toasts.error(`Explore-Liste laden fehlgeschlagen: ${(e as Error).message}`);
|
||||
} finally {
|
||||
loadingExplore = false;
|
||||
}
|
||||
await runBrowse(true);
|
||||
});
|
||||
|
||||
async function runBrowse(reset = false) {
|
||||
loadingBrowse = true;
|
||||
if (reset) {
|
||||
offset = 0;
|
||||
browseResults = [];
|
||||
}
|
||||
try {
|
||||
const result = await browseDecks({
|
||||
q: q || undefined,
|
||||
language: language || undefined,
|
||||
sort,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
if (reset) {
|
||||
browseResults = result.items;
|
||||
} else {
|
||||
browseResults = [...browseResults, ...result.items];
|
||||
}
|
||||
browseTotal = result.total;
|
||||
} catch (e) {
|
||||
toasts.error(`Browse fehlgeschlagen: ${(e as Error).message}`);
|
||||
} finally {
|
||||
loadingBrowse = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
void runBrowse(true);
|
||||
}
|
||||
|
||||
function onLoadMore() {
|
||||
offset += limit;
|
||||
void runBrowse(false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Explore · Cardecky</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-6xl space-y-10">
|
||||
<header>
|
||||
<h1 class="text-3xl font-semibold">Cardecky-Library</h1>
|
||||
<p class="mt-2 text-sm text-[hsl(var(--color-muted-foreground))]">
|
||||
Decks von der Verein-Community + KI-kuratierten Cardecky-Author. Subscribe für Live-Updates,
|
||||
fork für eigenen Lern-Stand, ✏️ Verbesserungen via Pull-Request einreichen.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if loadingExplore}
|
||||
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">Lade Featured + Trending…</p>
|
||||
{:else}
|
||||
{#if featured.length > 0}
|
||||
<section>
|
||||
<h2 class="mb-3 text-xl font-semibold">★ Featured</h2>
|
||||
<DeckListGrid items={featured} />
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if trending.length > 0}
|
||||
<section>
|
||||
<h2 class="mb-3 text-xl font-semibold">🔥 Trending</h2>
|
||||
<DeckListGrid items={trending} />
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<section>
|
||||
<h2 class="mb-3 text-xl font-semibold">🔎 Stöbern</h2>
|
||||
|
||||
<form
|
||||
class="mb-4 flex flex-wrap items-end gap-3"
|
||||
onsubmit={onSearch}
|
||||
>
|
||||
<label class="flex-1 min-w-[14rem]">
|
||||
<span class="block text-xs text-[hsl(var(--color-muted-foreground))]">Suche</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={q}
|
||||
placeholder="Titel oder Beschreibung"
|
||||
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span class="block text-xs text-[hsl(var(--color-muted-foreground))]">Sprache</span>
|
||||
<select
|
||||
bind:value={language}
|
||||
class="mt-1 block rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Alle</option>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span class="block text-xs text-[hsl(var(--color-muted-foreground))]">Sortierung</span>
|
||||
<select
|
||||
bind:value={sort}
|
||||
class="mt-1 block rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="recent">Neueste</option>
|
||||
<option value="popular">Beliebt</option>
|
||||
<option value="trending">Trending</option>
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded bg-[hsl(var(--color-primary))] px-4 py-2 text-sm text-[hsl(var(--color-primary-foreground))] disabled:opacity-50"
|
||||
disabled={loadingBrowse}
|
||||
>
|
||||
Suchen
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<DeckListGrid items={browseResults} emptyMessage="Keine Decks zu deinem Filter." />
|
||||
|
||||
{#if browseResults.length < browseTotal}
|
||||
<button
|
||||
type="button"
|
||||
class="mt-4 w-full rounded border border-[hsl(var(--color-border))] py-2 text-sm hover:bg-[hsl(var(--color-card))]"
|
||||
onclick={onLoadMore}
|
||||
disabled={loadingBrowse}
|
||||
>
|
||||
{loadingBrowse ? 'Lade…' : `${browseResults.length} / ${browseTotal} — mehr laden`}
|
||||
</button>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
122
apps/web/src/routes/me/forks/+page.svelte
Normal file
122
apps/web/src/routes/me/forks/+page.svelte
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
import { listDecks } from '$lib/api/decks.ts';
|
||||
import { pullUpdate } from '$lib/api/marketplace.ts';
|
||||
import type { Deck } from '@cards/domain';
|
||||
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
|
||||
import { toasts } from '$lib/stores/toasts.svelte.ts';
|
||||
|
||||
let forks = $state<Deck[]>([]);
|
||||
let loading = $state(true);
|
||||
let busyId = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
if (!devUser.id) {
|
||||
await goto('/');
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
});
|
||||
|
||||
async function refresh() {
|
||||
loading = true;
|
||||
try {
|
||||
const result = await listDecks();
|
||||
// Greenfield's `cards.decks` hat `forked_from_marketplace_*`-
|
||||
// Spalten, die @cards/domain Deck-Type nicht unbedingt
|
||||
// exportiert. Wir filtern hier defensiv über runtime-Cast.
|
||||
forks = result.decks.filter(
|
||||
(d) => (d as Deck & { forked_from_marketplace_deck_id?: string | null })
|
||||
.forked_from_marketplace_deck_id != null
|
||||
);
|
||||
} catch (e) {
|
||||
toasts.error(`Forks laden fehlgeschlagen: ${(e as Error).message}`);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onPullUpdate(deckId: string) {
|
||||
busyId = deckId;
|
||||
try {
|
||||
const result = await pullUpdate(deckId);
|
||||
if (result.up_to_date) {
|
||||
toasts.success('Schon auf dem neuesten Stand.');
|
||||
} else {
|
||||
toasts.success(
|
||||
`Update gezogen: +${result.added} neu, ~${result.changed} geändert, −${result.removed} entfernt. ${result.cards_inserted ?? 0} neue Karten lokal.`
|
||||
);
|
||||
await refresh();
|
||||
}
|
||||
} catch (e) {
|
||||
toasts.error((e as Error).message);
|
||||
} finally {
|
||||
busyId = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Meine Forks · Cardecky</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-4xl space-y-6">
|
||||
<header>
|
||||
<h1 class="text-2xl font-semibold">Meine Forks</h1>
|
||||
<p class="mt-1 text-sm text-[hsl(var(--color-muted-foreground))]">
|
||||
Marketplace-Decks, die du in deine private Lern-Welt kopiert hast. Smart-Merge: bei
|
||||
„Update ziehen" werden neue/geänderte Karten ergänzt — unveränderte Karten samt FSRS-Stand
|
||||
bleiben unangetastet.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if loading}
|
||||
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">Lade…</p>
|
||||
{:else if forks.length === 0}
|
||||
<div
|
||||
class="rounded-lg border border-dashed border-[hsl(var(--color-border))] p-8 text-center"
|
||||
>
|
||||
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">Noch nichts geforkt.</p>
|
||||
<a
|
||||
href="/explore"
|
||||
class="mt-2 inline-block text-sm text-[hsl(var(--color-primary))] hover:underline"
|
||||
>
|
||||
Library durchstöbern →
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="space-y-2">
|
||||
{#each forks as fork (fork.id)}
|
||||
<li class="rounded-lg border border-[hsl(var(--color-border))] p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex-1">
|
||||
<a
|
||||
href="/decks/{fork.id}"
|
||||
class="font-medium hover:text-[hsl(var(--color-primary))]"
|
||||
>
|
||||
{fork.name}
|
||||
</a>
|
||||
{#if fork.description}
|
||||
<p class="mt-1 line-clamp-2 text-sm text-[hsl(var(--color-muted-foreground))]">
|
||||
{fork.description}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded border border-[hsl(var(--color-primary))] px-3 py-1 text-xs text-[hsl(var(--color-primary))] hover:bg-[hsl(var(--color-primary))]/10 disabled:opacity-50"
|
||||
onclick={() => onPullUpdate(fork.id)}
|
||||
disabled={busyId === fork.id}
|
||||
title="Smart-Merge: nur neue/geänderte Karten dazu, FSRS bleibt"
|
||||
>
|
||||
{busyId === fork.id ? '…' : 'Update ziehen'}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
192
apps/web/src/routes/me/published/+page.svelte
Normal file
192
apps/web/src/routes/me/published/+page.svelte
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
import {
|
||||
browseDecks,
|
||||
getMyAuthorProfile,
|
||||
upsertMyAuthorProfile,
|
||||
type DeckListEntry,
|
||||
type MarketplaceAuthor,
|
||||
} from '$lib/api/marketplace.ts';
|
||||
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
|
||||
import { toasts } from '$lib/stores/toasts.svelte.ts';
|
||||
|
||||
let author = $state<MarketplaceAuthor | null>(null);
|
||||
let decks = $state<DeckListEntry[]>([]);
|
||||
let loading = $state(true);
|
||||
|
||||
let formSlug = $state('');
|
||||
let formDisplayName = $state('');
|
||||
let formBio = $state('');
|
||||
let formPseudonym = $state(false);
|
||||
let saving = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
if (!devUser.id) {
|
||||
await goto('/');
|
||||
return;
|
||||
}
|
||||
await load();
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
author = await getMyAuthorProfile();
|
||||
if (author) {
|
||||
formSlug = author.slug;
|
||||
formDisplayName = author.display_name;
|
||||
formBio = author.bio ?? '';
|
||||
formPseudonym = author.pseudonym;
|
||||
|
||||
const result = await browseDecks({
|
||||
author: author.slug,
|
||||
sort: 'recent',
|
||||
limit: 50,
|
||||
});
|
||||
decks = result.items;
|
||||
}
|
||||
} catch (e) {
|
||||
toasts.error(`Profil laden fehlgeschlagen: ${(e as Error).message}`);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onSaveProfile(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!formSlug.trim() || !formDisplayName.trim()) return;
|
||||
saving = true;
|
||||
try {
|
||||
author = await upsertMyAuthorProfile({
|
||||
slug: formSlug.trim(),
|
||||
displayName: formDisplayName.trim(),
|
||||
bio: formBio || undefined,
|
||||
pseudonym: formPseudonym,
|
||||
});
|
||||
toasts.success('Profil gespeichert.');
|
||||
if (decks.length === 0) {
|
||||
const result = await browseDecks({ author: formSlug.trim(), sort: 'recent', limit: 50 });
|
||||
decks = result.items;
|
||||
}
|
||||
} catch (e) {
|
||||
toasts.error((e as Error).message);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Meine Veröffentlichungen · Cardecky</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-4xl space-y-8">
|
||||
<header>
|
||||
<h1 class="text-2xl font-semibold">Meine Veröffentlichungen</h1>
|
||||
<p class="mt-1 text-sm text-[hsl(var(--color-muted-foreground))]">
|
||||
Decks, die du als Marketplace-Author published hast.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if loading}
|
||||
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">Lade…</p>
|
||||
{:else}
|
||||
<section
|
||||
class="rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4"
|
||||
>
|
||||
<h2 class="mb-3 text-lg font-semibold">
|
||||
{author ? 'Author-Profil' : 'Author-Profil anlegen'}
|
||||
</h2>
|
||||
<form class="grid gap-3 sm:grid-cols-2" onsubmit={onSaveProfile}>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium">Slug (url-safe)</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={formSlug}
|
||||
required
|
||||
pattern="[a-z0-9](?:[a-z0-9-]+[a-z0-9])?"
|
||||
maxlength="60"
|
||||
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm font-mono"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium">Anzeige-Name</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={formDisplayName}
|
||||
required
|
||||
maxlength="80"
|
||||
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label class="block sm:col-span-2">
|
||||
<span class="text-sm font-medium">Bio (optional)</span>
|
||||
<textarea
|
||||
bind:value={formBio}
|
||||
rows="2"
|
||||
maxlength="500"
|
||||
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
|
||||
></textarea>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 sm:col-span-2">
|
||||
<input type="checkbox" bind:checked={formPseudonym} />
|
||||
<span class="text-sm">Pseudonym-Modus (Klarname versteckt)</span>
|
||||
</label>
|
||||
<div class="sm:col-span-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || !formSlug || !formDisplayName}
|
||||
class="rounded bg-[hsl(var(--color-primary))] px-4 py-2 text-sm text-[hsl(var(--color-primary-foreground))] disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Speichere…' : author ? 'Profil aktualisieren' : 'Profil anlegen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{#if author}
|
||||
<section>
|
||||
<h2 class="mb-3 text-lg font-semibold">Decks ({decks.length})</h2>
|
||||
{#if decks.length === 0}
|
||||
<p
|
||||
class="rounded-lg border border-dashed border-[hsl(var(--color-border))] p-6 text-center text-sm text-[hsl(var(--color-muted-foreground))]"
|
||||
>
|
||||
Noch nichts veröffentlicht. Decks werden über die Marketplace-API initialisiert
|
||||
(z.B. via Cardecky-Skill); danach erscheinen sie hier.
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="space-y-2">
|
||||
{#each decks as deck (deck.slug)}
|
||||
<li class="rounded-lg border border-[hsl(var(--color-border))] p-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<a
|
||||
href="/d/{deck.slug}"
|
||||
class="font-medium hover:text-[hsl(var(--color-primary))]"
|
||||
>
|
||||
{deck.title}
|
||||
</a>
|
||||
<p class="mt-1 text-xs text-[hsl(var(--color-muted-foreground))]">
|
||||
{deck.card_count} Karten · {deck.star_count} ★ · {deck.subscriber_count} ↩︎ ·
|
||||
{deck.license}
|
||||
</p>
|
||||
</div>
|
||||
{#if deck.is_featured}
|
||||
<span
|
||||
class="shrink-0 rounded bg-[hsl(var(--color-primary))]/15 px-2 py-1 text-xs text-[hsl(var(--color-primary))]"
|
||||
>
|
||||
★ Featured
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
92
apps/web/src/routes/me/subscribed/+page.svelte
Normal file
92
apps/web/src/routes/me/subscribed/+page.svelte
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
import { getMySubscriptions, type SubscriptionEntry } from '$lib/api/marketplace.ts';
|
||||
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
|
||||
import { toasts } from '$lib/stores/toasts.svelte.ts';
|
||||
|
||||
let items = $state<SubscriptionEntry[]>([]);
|
||||
let loading = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
if (!devUser.id) {
|
||||
await goto('/');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await getMySubscriptions();
|
||||
items = result.subscriptions;
|
||||
} catch (e) {
|
||||
toasts.error(`Subs laden fehlgeschlagen: ${(e as Error).message}`);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Meine Abos · Cardecky</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-4xl space-y-6">
|
||||
<header>
|
||||
<h1 class="text-2xl font-semibold">Meine Abonnements</h1>
|
||||
<p class="mt-1 text-sm text-[hsl(var(--color-muted-foreground))]">
|
||||
Decks, die du beobachtest. Wenn der Author eine neue Version publisht, siehst du hier den
|
||||
Update-Indikator.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if loading}
|
||||
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">Lade…</p>
|
||||
{:else if items.length === 0}
|
||||
<div
|
||||
class="rounded-lg border border-dashed border-[hsl(var(--color-border))] p-8 text-center"
|
||||
>
|
||||
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">
|
||||
Noch keine Abos.
|
||||
</p>
|
||||
<a
|
||||
href="/explore"
|
||||
class="mt-2 inline-block text-sm text-[hsl(var(--color-primary))] hover:underline"
|
||||
>
|
||||
Library durchstöbern →
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="space-y-2">
|
||||
{#each items as sub (sub.deck_slug)}
|
||||
<li class="rounded-lg border border-[hsl(var(--color-border))] p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex-1">
|
||||
<a
|
||||
href="/d/{sub.deck_slug}"
|
||||
class="font-medium hover:text-[hsl(var(--color-primary))]"
|
||||
>
|
||||
{sub.deck_title}
|
||||
</a>
|
||||
{#if sub.deck_description}
|
||||
<p class="mt-1 line-clamp-2 text-sm text-[hsl(var(--color-muted-foreground))]">
|
||||
{sub.deck_description}
|
||||
</p>
|
||||
{/if}
|
||||
<p class="mt-2 text-xs text-[hsl(var(--color-muted-foreground))]">
|
||||
Abonniert seit {new Date(sub.subscribed_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
{#if sub.update_available}
|
||||
<a
|
||||
href="/d/{sub.deck_slug}"
|
||||
class="shrink-0 rounded bg-[hsl(var(--color-primary))] px-3 py-1 text-xs text-[hsl(var(--color-primary-foreground))]"
|
||||
>
|
||||
Update verfügbar
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
146
apps/web/src/routes/u/[slug]/+page.svelte
Normal file
146
apps/web/src/routes/u/[slug]/+page.svelte
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import { page } from '$app/state';
|
||||
|
||||
import {
|
||||
browseDecks,
|
||||
followAuthor,
|
||||
getAuthor,
|
||||
getFollowState,
|
||||
unfollowAuthor,
|
||||
type DeckListEntry,
|
||||
type MarketplaceAuthor,
|
||||
} from '$lib/api/marketplace.ts';
|
||||
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
|
||||
import DeckListGrid from '$lib/components/marketplace/DeckListGrid.svelte';
|
||||
import { toasts } from '$lib/stores/toasts.svelte.ts';
|
||||
|
||||
const slug = $derived(page.params.slug ?? '');
|
||||
|
||||
let author = $state<MarketplaceAuthor | null>(null);
|
||||
let decks = $state<DeckListEntry[]>([]);
|
||||
let following = $state(false);
|
||||
let loading = $state(true);
|
||||
let busy = $state(false);
|
||||
|
||||
const myUserId = $derived(devUser.id);
|
||||
|
||||
onMount(() => {
|
||||
void load();
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
const [a, d, follow] = await Promise.all([
|
||||
getAuthor(slug),
|
||||
browseDecks({ author: slug, sort: 'recent', limit: 50 }),
|
||||
myUserId
|
||||
? getFollowState(slug).catch(() => ({ following: false }))
|
||||
: Promise.resolve({ following: false }),
|
||||
]);
|
||||
author = a;
|
||||
decks = d.items;
|
||||
following = follow.following;
|
||||
} catch (e) {
|
||||
toasts.error(`Author laden fehlgeschlagen: ${(e as Error).message}`);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleFollow() {
|
||||
if (!myUserId) {
|
||||
toasts.error('Bitte einloggen.');
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
try {
|
||||
if (following) {
|
||||
await unfollowAuthor(slug);
|
||||
following = false;
|
||||
} else {
|
||||
await followAuthor(slug);
|
||||
following = true;
|
||||
}
|
||||
} catch (e) {
|
||||
toasts.error((e as Error).message);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{author?.display_name ?? slug} · Cardecky</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if loading}
|
||||
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">Lade…</p>
|
||||
{:else if !author}
|
||||
<p>Author nicht gefunden.</p>
|
||||
{:else}
|
||||
<div class="mx-auto max-w-4xl space-y-8">
|
||||
<a href="/explore" class="text-xs text-[hsl(var(--color-muted-foreground))] hover:underline">
|
||||
← zurück zur Library
|
||||
</a>
|
||||
|
||||
<header class="space-y-2">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<h1 class="text-3xl font-semibold">{author.display_name}</h1>
|
||||
{#if author.verified_mana}
|
||||
<span
|
||||
class="rounded bg-[hsl(var(--color-primary))]/15 px-2 py-1 text-xs text-[hsl(var(--color-primary))]"
|
||||
title="Vom Verein verifiziert"
|
||||
>
|
||||
🛡️ Mana Verifiziert
|
||||
</span>
|
||||
{/if}
|
||||
{#if author.verified_community}
|
||||
<span
|
||||
class="rounded bg-[hsl(var(--color-primary))]/10 px-2 py-1 text-xs"
|
||||
title="Aus der Community heraus verifiziert"
|
||||
>
|
||||
⭐ Community Verifiziert
|
||||
</span>
|
||||
{/if}
|
||||
{#if author.banned}
|
||||
<span
|
||||
class="rounded bg-[hsl(var(--color-error))]/15 px-2 py-1 text-xs text-[hsl(var(--color-error))]"
|
||||
>
|
||||
Gesperrt
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">
|
||||
@{author.slug}
|
||||
· seit {new Date(author.joined_at).toLocaleDateString()}
|
||||
{#if author.pseudonym}
|
||||
· Pseudonym
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
{#if author.bio}
|
||||
<p class="text-sm">{author.bio}</p>
|
||||
{/if}
|
||||
|
||||
{#if myUserId}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-[hsl(var(--color-border))] px-3 py-1 text-sm hover:bg-[hsl(var(--color-card))]"
|
||||
onclick={toggleFollow}
|
||||
disabled={busy}
|
||||
>
|
||||
{following ? '✓ Folge' : '+ Folgen'}
|
||||
</button>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<h2 class="mb-3 text-xl font-semibold">Decks ({decks.length})</h2>
|
||||
<DeckListGrid items={decks} emptyMessage="Noch keine Decks veröffentlicht." />
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
Loading…
Add table
Add a link
Reference in a new issue