feat(web): decks-page auf Explore-Layout migriert + Subscriptions sichtbar

- /decks zeigt jetzt zwei horizontal scrollbare Abschnitte (wie Explore):
  "Eigene Decks" (DeckStack + NewDeckCard) und "Abonniert" (MarketplaceDeckStack)
- Subscriptions werden über getMySubscriptions() + getMarketplaceDeck() geladen
  und als vollwertige DeckListEntry-Objekte dargestellt
- DeckListGrid: padding-block-start 0→1.25rem, padding-block-end 1rem→2.5rem
  damit Hover-Schatten (translateY-2px + box-shadow 0 12px 28px) nicht abgeschnitten wird
- Eigene Decks verwenden identisches Scroll-CSS wie DeckListGrid (visuell einheitlich)
- Beide Sektionen laden parallel, je mit SkeletonGrid-Platzhalter

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-10 16:08:21 +02:00
parent c1a87a4f88
commit 608b385c05
2 changed files with 207 additions and 36 deletions

View file

@ -37,7 +37,7 @@
.deck-row { .deck-row {
list-style: none; list-style: none;
margin: 0; margin: 0;
padding-block: 0 1rem; padding-block: 1.25rem 2.5rem;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 1rem; gap: 1rem;

View file

@ -5,10 +5,19 @@
import { listDecks } from '$lib/api/decks.ts'; import { listDecks } from '$lib/api/decks.ts';
import { listCards } from '$lib/api/cards.ts'; import { listCards } from '$lib/api/cards.ts';
import { listDueReviews } from '$lib/api/reviews.ts'; import { listDueReviews } from '$lib/api/reviews.ts';
import {
getMySubscriptions,
getMarketplaceDeck,
type DeckListEntry,
} from '$lib/api/marketplace.ts';
import { devUser } from '$lib/auth/dev-stub.svelte.ts'; import { devUser } from '$lib/auth/dev-stub.svelte.ts';
import InboxBanner from '$lib/components/InboxBanner.svelte'; import InboxBanner from '$lib/components/InboxBanner.svelte';
import DeckGrid from '$lib/components/DeckGrid.svelte'; import DeckStack from '$lib/components/DeckStack.svelte';
import NewDeckCard from '$lib/components/NewDeckCard.svelte';
import DeckListGrid from '$lib/components/marketplace/DeckListGrid.svelte';
import SkeletonGrid from '$lib/components/marketplace/SkeletonGrid.svelte';
import { t } from '$lib/i18n/index.svelte.ts'; import { t } from '$lib/i18n/index.svelte.ts';
import { Books, Star } from '@mana/shared-icons';
interface DeckWithCounts { interface DeckWithCounts {
deck: Deck; deck: Deck;
@ -17,8 +26,9 @@
} }
let decks = $state<DeckWithCounts[]>([]); let decks = $state<DeckWithCounts[]>([]);
let loading = $state(true); let subscriptions = $state<DeckListEntry[]>([]);
let error = $state<string | null>(null); let loadingOwn = $state(true);
let loadingSubs = $state(true);
let selectedId = $state<string | null>(null); let selectedId = $state<string | null>(null);
onMount(async () => { onMount(async () => {
@ -26,12 +36,12 @@
goto('/'); goto('/');
return; return;
} }
await refresh(); void loadOwnDecks();
void loadSubscriptions();
}); });
async function refresh() { async function loadOwnDecks() {
try { try {
loading = true;
const r = await listDecks(); const r = await listDecks();
const enriched = await Promise.all( const enriched = await Promise.all(
r.decks.map(async (deck) => { r.decks.map(async (deck) => {
@ -47,54 +57,215 @@
}), }),
); );
decks = enriched; decks = enriched;
error = null;
} catch (e) {
error = (e as Error).message;
} finally { } finally {
loading = false; loadingOwn = false;
}
}
async function loadSubscriptions() {
try {
const { subscriptions: subs } = await getMySubscriptions();
const entries = await Promise.all(
subs.map(async (sub) => {
try {
const { deck, latest_version, owner } = await getMarketplaceDeck(sub.deck_slug);
const entry: DeckListEntry = {
slug: deck.slug,
title: deck.title,
description: deck.description,
language: deck.language,
category: deck.category,
license: deck.license,
price_credits: deck.price_credits,
card_count: latest_version?.card_count ?? 0,
star_count: 0,
subscriber_count: 0,
is_featured: deck.is_featured,
created_at: deck.created_at,
owner: owner
? {
slug: owner.slug,
display_name: owner.display_name,
verified_mana: owner.verified_mana,
verified_community: owner.verified_community,
}
: {
slug: '',
display_name: '',
verified_mana: false as boolean,
verified_community: false as boolean,
},
};
return entry;
} catch {
return null;
}
}),
);
subscriptions = entries.filter((e): e is DeckListEntry => e !== null);
} finally {
loadingSubs = false;
} }
} }
function handleSelect(deckId: string) { function handleSelect(deckId: string) {
selectedId = deckId; selectedId = deckId;
setTimeout(() => { setTimeout(() => goto(`/study/${deckId}`), 220);
goto(`/study/${deckId}`);
}, 220);
} }
</script> </script>
<h1 class="title">{t('decks.title')}</h1> <svelte:head>
<title>Meine Decks · Cardecky</title>
</svelte:head>
<div class="inbox"> <div class="space-y-10">
<InboxBanner /> <header>
<h1 class="text-3xl font-semibold">{t('decks.title')}</h1>
<div class="mt-4">
<InboxBanner />
</div>
</header>
<!-- Eigene Decks -->
<section>
<h2 class="section-head">
<Books size={20} weight="duotone" />
Eigene Decks
</h2>
{#if loadingOwn}
<SkeletonGrid count={4} />
{:else}
<ul
class="deck-row"
aria-label={t('decks.title')}
style:--has-selection={selectedId ? 1 : 0}
>
<li class="deck-item" class:fading={selectedId !== null}>
<NewDeckCard />
</li>
{#each decks as { deck, cardCount, dueCount } (deck.id)}
<li
class="deck-item"
class:fading={selectedId !== null && selectedId !== deck.id}
class:selected={selectedId === deck.id}
>
<DeckStack
{deck}
{cardCount}
{dueCount}
href={`/decks/${deck.id}`}
onclick={(e: MouseEvent) => {
e.preventDefault();
handleSelect(deck.id);
}}
/>
</li>
{/each}
</ul>
{/if}
</section>
<!-- Abonnierte Marketplace-Decks -->
{#if loadingSubs || subscriptions.length > 0}
<section>
<h2 class="section-head">
<Star size={20} weight="duotone" />
Abonniert
</h2>
{#if loadingSubs}
<SkeletonGrid count={4} />
{:else}
<DeckListGrid items={subscriptions} />
{/if}
</section>
{/if}
</div> </div>
{#if loading}
<p class="muted">{t('decks.loading')}</p>
{:else if error}
<p class="error">{t('decks.error', { msg: error })}</p>
{:else}
<DeckGrid {decks} {selectedId} onSelect={handleSelect} />
{/if}
<style> <style>
.title { .section-head {
margin: 0 0 1rem; display: flex;
font-size: 1.5rem; align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
font-size: 1.25rem;
font-weight: 600; font-weight: 600;
color: hsl(var(--color-foreground));
} }
.inbox { /* Horizontal-Scroll-Reihe für eigene Decks — identisches Layout wie
margin-bottom: 1.5rem; DeckListGrid, damit beide Abschnitte visuell einheitlich wirken. */
.deck-row {
list-style: none;
margin: 0;
padding-block: 1.25rem 2.5rem;
display: flex;
flex-direction: row;
gap: 1rem;
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-padding-inline-start: max(1rem, calc((100dvw - 72rem) / 2 + 1rem));
scrollbar-width: thin;
scrollbar-color: hsl(var(--color-border)) transparent;
width: 100dvw;
margin-left: calc(50% - 50dvw);
} }
.muted { .deck-row::before {
color: hsl(var(--color-muted-foreground)); content: '';
margin-top: 2rem; flex-shrink: 0;
width: max(1rem, calc((100dvw - 72rem) / 2 + 1rem));
} }
.error { .deck-row::after {
color: hsl(var(--color-error)); content: '';
margin-top: 2rem; flex-shrink: 0;
width: max(1rem, calc((100dvw - 72rem) / 2 + 1rem));
}
.deck-row::-webkit-scrollbar {
height: 4px;
}
.deck-row::-webkit-scrollbar-track {
background: transparent;
}
.deck-row::-webkit-scrollbar-thumb {
background: hsl(var(--color-border));
border-radius: 9999px;
}
.deck-item {
flex: 0 0 auto;
width: 16rem;
scroll-snap-align: start;
transition: opacity 0.25s ease, transform 0.3s ease;
}
.deck-item.fading {
opacity: 0;
transform: scale(0.96) translateY(8px);
pointer-events: none;
}
.deck-item.selected {
transform: scale(1.06);
z-index: 2;
}
@media (prefers-reduced-motion: reduce) {
.deck-item {
transition: none;
}
.deck-item.fading {
transform: none;
}
.deck-item.selected {
transform: none;
}
} }
</style> </style>