Phase 12 G1-G4: Marketplace-Polish — svelte-ignore + Skeleton/Empty-State + Server-Filter + Owner-Info
G1 — svelte-ignore für 5 benigne Init-Capture-Warnings:
- PublishVersionModal: state(latestSemver ? bumpMinor(latestSemver) : '1.0.0')
ist intentional, weil das Modal pro Click frisch gemountet wird
- SuggestEditModal: state(card.fields.front…) + state({ ...card.fields })
gleicher Lebenszyklus
Kein Refactor auf $derived, weil das die Bind-Semantik kaputtmachen
würde — Direktive plus ein Kommentar reicht.
G2 — Loading + Empty-States:
- Neue Components SkeletonGrid + EmptyState in lib/components/marketplace/
- /explore: SkeletonGrid statt „Lade Featured + Trending…"-String,
EmptyState wenn weder Featured noch Trending da
- /me/subscribed + /me/forks: EmptyState statt inline-Box
- Konsistentes Vereins-Vokabular (icon + Title + Description + CTA)
G3 — Server-side Fork-Filter:
- GET /api/v1/decks akzeptiert ?forked_from_marketplace=true
- Drizzle isNotNull-Filter auf decks.forked_from_marketplace_deck_id
- toDeckDto exposed jetzt forked_from_marketplace_{deck,version}_id
(vorher schwiegen die Spalten, mussten client-side via Cast
rausgefischt werden)
- /me/forks ruft listDecks({ forkedFromMarketplace: true }) statt
listDecks() + client-side Filter
G4 — Owner-Author-Info im Deck-Detail-Endpoint:
- GET /api/v1/marketplace/decks/:slug returned jetzt zusätzlich
owner: { slug, display_name, verified_mana, verified_community,
pseudonym } — gejoint aus marketplace.authors via deck.owner_user_id
- toOwnerDto-Helper, identisches Shape wie in /authors/:slug
- /d/[slug] verbraucht den neuen owner-Block für AuthorBadge mit
echtem Profil-Link statt user_id-Slice (vorher: kaputter Link
/u/<empty-slug> + nur „SEAiKLkPZ…" als Display-Name)
Verifikation:
- API: type-check + 89 Tests grün
- Web: svelte-check 0 errors, 0 warnings (von 5 → 0)
- Live-Smoke: GET /marketplace/decks/r5-stoa-grundlagen liefert
owner={slug:'cardecky', display_name:'Cardecky', verified_*:false}
- ?forked_from_marketplace=true Filter mit Till's JWT liefert 0
(weil Till keine Forks hat) — 401 ohne JWT bestätigt
Bewusst nicht angefasst: Header-Nav-Link (WIP-Konflikt), Image-
Occlusion in Marketplace (Player-Side komplex), Auth-Guard im
+layout.svelte (page-level guards reichen), Anki-Import→Marketplace-
Publish-Hook (eigene Welle).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
40861710bf
commit
17871ba2a4
13 changed files with 174 additions and 63 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+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). |
|
||||
| 12 | Marketplace-Restore (R0–R6) | 🟡 R0+R1+R2+R3+R4+R5+G1-G4 durch | Plan: [`docs/playbooks/MARKETPLACE_RESTORE.md`](docs/playbooks/MARKETPLACE_RESTORE.md). R0–R4 (Backend): ✅. R5 (Frontend-Routes): ✅. **G1-G4 (Polish-Pass): ✅** — G1 svelte-ignore für 5 benigne Modal-Init-Capture-Warnings (Modals werden pro Click gemountet, nicht-reactive ist gewollt), G2 Loading-Skeleton + EmptyState als Shared Components in /explore und /me/{subscribed,forks} (statt nackter „Lade…"-Strings), G3 Server-side Filter `GET /api/v1/decks?forked_from_marketplace=true` (vorher client-side filtering — funktional bei <100 Decks egal, jetzt sauber), G4 Owner-Author-Info im Deck-Detail-Endpoint (`GET /api/v1/marketplace/decks/:slug` returned jetzt owner.{slug, display_name, verified_mana, verified_community, pseudonym}, /d/[slug] zeigt korrekt verlinkten AuthorBadge statt user-id-prefix). svelte-check: 4019 Files, 0 errors, 0 warnings. 89 API-Tests grün. Bewusst nicht angefasst: Header-Nav-Link auf `/explore` (Header.svelte ist in Tills uncommitted WIP), Image-Occlusion/Audio in Marketplace (Image-Occlusion-Schema ja, Player-Side später), Auth-Guard im +layout.svelte (page-level guards in /me/*-Pages reichen). Verbleibend: R6 voller UI-E2E im Browser (Cardecky-Publish + Till-Subscribe + Till-Fork + Till-PR + Cardecky-Merge + Till-Pull-Update mit FSRS-Erhalt-Verifikation), Anki-Import→Marketplace-Publish-Hook (eigene Welle). |
|
||||
|
||||
Legende: ✅ erledigt + verifiziert · 🚧 blockiert · ⏸ noch nicht begonnen
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { and, eq } from 'drizzle-orm';
|
||||
import { and, eq, isNotNull } from 'drizzle-orm';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import { DeckCreateSchema, DeckUpdateSchema } from '@cards/domain';
|
||||
|
|
@ -48,7 +48,15 @@ export function decksRouter(deps: DecksDeps = {}): Hono<{ Variables: AuthVars }>
|
|||
|
||||
r.get('/', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const rows = await dbOf().select().from(decks).where(eq(decks.userId, userId));
|
||||
const forkedFromMarketplace = c.req.query('forked_from_marketplace');
|
||||
const conditions = [eq(decks.userId, userId)];
|
||||
if (forkedFromMarketplace === 'true') {
|
||||
conditions.push(isNotNull(decks.forkedFromMarketplaceDeckId));
|
||||
}
|
||||
const rows = await dbOf()
|
||||
.select()
|
||||
.from(decks)
|
||||
.where(and(...conditions));
|
||||
return c.json({ decks: rows.map(toDeckDto), total: rows.length });
|
||||
});
|
||||
|
||||
|
|
@ -117,6 +125,8 @@ function toDeckDto(row: typeof decks.$inferSelect) {
|
|||
visibility: row.visibility,
|
||||
fsrs_settings: row.fsrsSettings,
|
||||
content_hash: row.contentHash,
|
||||
forked_from_marketplace_deck_id: row.forkedFromMarketplaceDeckId,
|
||||
forked_from_marketplace_version_id: row.forkedFromMarketplaceVersionId,
|
||||
created_at: row.createdAt.toISOString(),
|
||||
updated_at: row.updatedAt.toISOString(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
publicDeckVersions,
|
||||
publicDecks,
|
||||
} from '../../db/schema/index.ts';
|
||||
import type { AuthorRow } from '../../db/schema/marketplace/index.ts';
|
||||
import { authMiddleware, type AuthVars } from '../../middleware/auth.ts';
|
||||
import { optionalAuthMiddleware } from '../../middleware/marketplace/optional-auth.ts';
|
||||
import { moderateDeckContent } from '../../lib/marketplace/ai-moderation.ts';
|
||||
|
|
@ -113,6 +114,16 @@ function toDeckDto(row: typeof publicDecks.$inferSelect) {
|
|||
};
|
||||
}
|
||||
|
||||
function toOwnerDto(row: AuthorRow) {
|
||||
return {
|
||||
slug: row.slug,
|
||||
display_name: row.displayName,
|
||||
verified_mana: row.verifiedMana,
|
||||
verified_community: row.verifiedCommunity,
|
||||
pseudonym: row.pseudonym,
|
||||
};
|
||||
}
|
||||
|
||||
function toVersionDto(row: typeof publicDeckVersions.$inferSelect) {
|
||||
return {
|
||||
id: row.id,
|
||||
|
|
@ -140,19 +151,27 @@ export function marketplaceDecksRouter(
|
|||
const [deck] = await db.select().from(publicDecks).where(eq(publicDecks.slug, slug)).limit(1);
|
||||
if (!deck) return c.json({ error: 'not_found' }, 404);
|
||||
|
||||
let version: typeof publicDeckVersions.$inferSelect | null = null;
|
||||
if (deck.latestVersionId) {
|
||||
const [v] = await db
|
||||
.select()
|
||||
.from(publicDeckVersions)
|
||||
.where(eq(publicDeckVersions.id, deck.latestVersionId))
|
||||
.limit(1);
|
||||
version = v ?? null;
|
||||
}
|
||||
const [versionAndOwner] = await Promise.all([
|
||||
deck.latestVersionId
|
||||
? db
|
||||
.select()
|
||||
.from(publicDeckVersions)
|
||||
.where(eq(publicDeckVersions.id, deck.latestVersionId))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
|
||||
const [ownerRow] = await db
|
||||
.select()
|
||||
.from(authors)
|
||||
.where(eq(authors.userId, deck.ownerUserId))
|
||||
.limit(1);
|
||||
|
||||
return c.json({
|
||||
deck: toDeckDto(deck),
|
||||
latest_version: version ? toVersionDto(version) : null,
|
||||
latest_version: versionAndOwner ? toVersionDto(versionAndOwner) : null,
|
||||
owner: ownerRow ? toOwnerDto(ownerRow) : null,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import type { Deck, DeckCreate, DeckUpdate } from '@cards/domain';
|
||||
import { api } from './client.ts';
|
||||
|
||||
export function listDecks() {
|
||||
return api<{ decks: Deck[]; total: number }>('/api/v1/decks');
|
||||
export function listDecks(opts: { forkedFromMarketplace?: boolean } = {}) {
|
||||
const qs = opts.forkedFromMarketplace ? '?forked_from_marketplace=true' : '';
|
||||
return api<{ decks: Deck[]; total: number }>(`/api/v1/decks${qs}`);
|
||||
}
|
||||
|
||||
export function getDeck(id: string) {
|
||||
|
|
|
|||
|
|
@ -194,9 +194,17 @@ export function getTags() {
|
|||
// ─── Deck (Public) ───────────────────────────────────────────────────
|
||||
|
||||
export function getMarketplaceDeck(slug: string) {
|
||||
return api<{ deck: MarketplaceDeck; latest_version: MarketplaceVersion | null }>(
|
||||
`/api/v1/marketplace/decks/${slug}`
|
||||
);
|
||||
return api<{
|
||||
deck: MarketplaceDeck;
|
||||
latest_version: MarketplaceVersion | null;
|
||||
owner: {
|
||||
slug: string;
|
||||
display_name: string;
|
||||
verified_mana: boolean;
|
||||
verified_community: boolean;
|
||||
pseudonym: boolean;
|
||||
} | null;
|
||||
}>(`/api/v1/marketplace/decks/${slug}`);
|
||||
}
|
||||
|
||||
export function getMarketplaceVersion(slug: string, semver: string) {
|
||||
|
|
|
|||
33
apps/web/src/lib/components/marketplace/EmptyState.svelte
Normal file
33
apps/web/src/lib/components/marketplace/EmptyState.svelte
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
icon?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
ctaHref?: string;
|
||||
ctaLabel?: string;
|
||||
}
|
||||
|
||||
const { icon, title, description, ctaHref, ctaLabel }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="rounded-lg border border-dashed border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))]/40 p-10 text-center"
|
||||
>
|
||||
{#if icon}
|
||||
<div class="mx-auto mb-3 text-4xl" aria-hidden="true">{icon}</div>
|
||||
{/if}
|
||||
<h3 class="text-lg font-medium">{title}</h3>
|
||||
{#if description}
|
||||
<p class="mx-auto mt-1 max-w-md text-sm text-[hsl(var(--color-muted-foreground))]">
|
||||
{description}
|
||||
</p>
|
||||
{/if}
|
||||
{#if ctaHref && ctaLabel}
|
||||
<a
|
||||
href={ctaHref}
|
||||
class="mt-4 inline-block text-sm text-[hsl(var(--color-primary))] hover:underline"
|
||||
>
|
||||
{ctaLabel} →
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -17,6 +17,9 @@
|
|||
return `${m[1]}.${Number(m[2]) + 1}.0`;
|
||||
}
|
||||
|
||||
// Modal-Lebenszyklus: pro Klick frisch gemountet, Props sind invariant.
|
||||
// Init-Capture aus `latestSemver` ist daher gewollt — keine Re-Reactivity.
|
||||
// svelte-ignore state_referenced_locally
|
||||
let semver = $state(latestSemver ? bumpMinor(latestSemver) : '1.0.0');
|
||||
let changelog = $state('');
|
||||
let cardsJson = $state('[]');
|
||||
|
|
|
|||
24
apps/web/src/lib/components/marketplace/SkeletonGrid.svelte
Normal file
24
apps/web/src/lib/components/marketplace/SkeletonGrid.svelte
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
count?: number;
|
||||
}
|
||||
|
||||
const { count = 6 }: Props = $props();
|
||||
</script>
|
||||
|
||||
<ul class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3" aria-busy="true" aria-label="Lade Decks">
|
||||
{#each Array.from({ length: count }) as _, i (i)}
|
||||
<li
|
||||
class="animate-pulse rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4"
|
||||
>
|
||||
<div class="h-4 w-3/4 rounded bg-[hsl(var(--color-border))]"></div>
|
||||
<div class="mt-2 h-3 w-full rounded bg-[hsl(var(--color-border))]/60"></div>
|
||||
<div class="mt-1 h-3 w-5/6 rounded bg-[hsl(var(--color-border))]/60"></div>
|
||||
<div class="mt-3 flex gap-2">
|
||||
<div class="h-2 w-16 rounded bg-[hsl(var(--color-border))]/40"></div>
|
||||
<div class="h-2 w-12 rounded bg-[hsl(var(--color-border))]/40"></div>
|
||||
<div class="h-2 w-20 rounded bg-[hsl(var(--color-border))]/40"></div>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
|
@ -13,9 +13,12 @@
|
|||
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.
|
||||
// Modal-Lebenszyklus: pro Klick frisch gemountet, `card`-Prop ist
|
||||
// invariant. Init-Capture aus card.fields ist daher gewollt.
|
||||
// svelte-ignore state_referenced_locally
|
||||
let title = $state(`„${(card.fields.front ?? card.fields.text ?? '').slice(0, 60)}" verbessern`);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let editedFields = $state<Record<string, string>>({ ...card.fields });
|
||||
let busy = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
|
|
|||
|
|
@ -31,6 +31,13 @@
|
|||
|
||||
let deck = $state<MarketplaceDeck | null>(null);
|
||||
let latestVersion = $state<MarketplaceVersion | null>(null);
|
||||
let owner = $state<{
|
||||
slug: string;
|
||||
display_name: string;
|
||||
verified_mana: boolean;
|
||||
verified_community: boolean;
|
||||
pseudonym: boolean;
|
||||
} | null>(null);
|
||||
let cards = $state<MarketplaceVersionCard[]>([]);
|
||||
let discussionCounts = $state<Record<string, number>>({});
|
||||
let starred = $state(false);
|
||||
|
|
@ -56,6 +63,7 @@
|
|||
const detail = await getMarketplaceDeck(slug);
|
||||
deck = detail.deck;
|
||||
latestVersion = detail.latest_version;
|
||||
owner = detail.owner;
|
||||
|
||||
const [stateChecks, version, counts] = await Promise.all([
|
||||
myUserId
|
||||
|
|
@ -251,18 +259,17 @@
|
|||
{/if}
|
||||
</div>
|
||||
|
||||
{#if deck}
|
||||
{#if owner}
|
||||
<section>
|
||||
<header class="mb-3 flex items-center gap-2 text-sm">
|
||||
<span>Author:</span>
|
||||
<header class="flex items-center gap-2 text-sm">
|
||||
<span class="text-[hsl(var(--color-muted-foreground))]">Author:</span>
|
||||
<AuthorBadge
|
||||
slug=""
|
||||
displayName={deck.owner_user_id.slice(0, 12) + '…'}
|
||||
size="sm"
|
||||
slug={owner.slug}
|
||||
displayName={owner.display_name}
|
||||
verifiedMana={owner.verified_mana}
|
||||
verifiedCommunity={owner.verified_community}
|
||||
size="md"
|
||||
/>
|
||||
<span class="text-[hsl(var(--color-muted-foreground))]">
|
||||
(Profil-Slug auf Author-Page sichtbar)
|
||||
</span>
|
||||
</header>
|
||||
</section>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
type DeckListEntry,
|
||||
} from '$lib/api/marketplace.ts';
|
||||
import DeckListGrid from '$lib/components/marketplace/DeckListGrid.svelte';
|
||||
import EmptyState from '$lib/components/marketplace/EmptyState.svelte';
|
||||
import SkeletonGrid from '$lib/components/marketplace/SkeletonGrid.svelte';
|
||||
import { toasts } from '$lib/stores/toasts.svelte.ts';
|
||||
|
||||
let featured = $state<DeckListEntry[]>([]);
|
||||
|
|
@ -87,7 +89,10 @@
|
|||
</header>
|
||||
|
||||
{#if loadingExplore}
|
||||
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">Lade Featured + Trending…</p>
|
||||
<section>
|
||||
<h2 class="mb-3 text-xl font-semibold">🔥 Trending</h2>
|
||||
<SkeletonGrid count={6} />
|
||||
</section>
|
||||
{:else}
|
||||
{#if featured.length > 0}
|
||||
<section>
|
||||
|
|
@ -101,6 +106,14 @@
|
|||
<h2 class="mb-3 text-xl font-semibold">🔥 Trending</h2>
|
||||
<DeckListGrid items={trending} />
|
||||
</section>
|
||||
{:else if !loadingExplore && featured.length === 0}
|
||||
<EmptyState
|
||||
icon="📚"
|
||||
title="Library ist noch leer"
|
||||
description="Bald gibt's hier viele Decks. Wenn du selbst publizieren willst: leg dir ein Author-Profil an."
|
||||
ctaHref="/me/published"
|
||||
ctaLabel="Author-Profil anlegen"
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
|
|
@ -151,7 +164,11 @@
|
|||
</button>
|
||||
</form>
|
||||
|
||||
<DeckListGrid items={browseResults} emptyMessage="Keine Decks zu deinem Filter." />
|
||||
{#if loadingBrowse && browseResults.length === 0}
|
||||
<SkeletonGrid count={6} />
|
||||
{:else}
|
||||
<DeckListGrid items={browseResults} emptyMessage="Keine Decks zu deinem Filter." />
|
||||
{/if}
|
||||
|
||||
{#if browseResults.length < browseTotal}
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import { pullUpdate } from '$lib/api/marketplace.ts';
|
||||
import type { Deck } from '@cards/domain';
|
||||
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
|
||||
import EmptyState from '$lib/components/marketplace/EmptyState.svelte';
|
||||
import { toasts } from '$lib/stores/toasts.svelte.ts';
|
||||
|
||||
let forks = $state<Deck[]>([]);
|
||||
|
|
@ -24,14 +25,8 @@
|
|||
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
|
||||
);
|
||||
const result = await listDecks({ forkedFromMarketplace: true });
|
||||
forks = result.decks;
|
||||
} catch (e) {
|
||||
toasts.error(`Forks laden fehlgeschlagen: ${(e as Error).message}`);
|
||||
} finally {
|
||||
|
|
@ -76,17 +71,13 @@
|
|||
{#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>
|
||||
<EmptyState
|
||||
icon="🔱"
|
||||
title="Noch nichts geforkt"
|
||||
description={'Forks geben dir eine eigene Lern-Kopie eines Marketplace-Decks. FSRS-Reviews bleiben bei dir; Updates des Original-Authors kannst du via „Update ziehen“ einspielen.'}
|
||||
ctaHref="/explore"
|
||||
ctaLabel="Library durchstöbern"
|
||||
/>
|
||||
{:else}
|
||||
<ul class="space-y-2">
|
||||
{#each forks as fork (fork.id)}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import { getMySubscriptions, type SubscriptionEntry } from '$lib/api/marketplace.ts';
|
||||
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
|
||||
import EmptyState from '$lib/components/marketplace/EmptyState.svelte';
|
||||
import { toasts } from '$lib/stores/toasts.svelte.ts';
|
||||
|
||||
let items = $state<SubscriptionEntry[]>([]);
|
||||
|
|
@ -42,19 +43,13 @@
|
|||
{#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>
|
||||
<EmptyState
|
||||
icon="↩︎"
|
||||
title="Noch keine Abos"
|
||||
description="Subscribe einen Deck im Explore, dann siehst du hier Updates des Authors."
|
||||
ctaHref="/explore"
|
||||
ctaLabel="Library durchstöbern"
|
||||
/>
|
||||
{:else}
|
||||
<ul class="space-y-2">
|
||||
{#each items as sub (sub.deck_slug)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue