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 {
list-style: none;
margin: 0;
padding-block: 0 1rem;
padding-block: 1.25rem 2.5rem;
display: flex;
flex-direction: row;
gap: 1rem;

View file

@ -5,10 +5,19 @@
import { listDecks } from '$lib/api/decks.ts';
import { listCards } from '$lib/api/cards.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 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 { Books, Star } from '@mana/shared-icons';
interface DeckWithCounts {
deck: Deck;
@ -17,8 +26,9 @@
}
let decks = $state<DeckWithCounts[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let subscriptions = $state<DeckListEntry[]>([]);
let loadingOwn = $state(true);
let loadingSubs = $state(true);
let selectedId = $state<string | null>(null);
onMount(async () => {
@ -26,12 +36,12 @@
goto('/');
return;
}
await refresh();
void loadOwnDecks();
void loadSubscriptions();
});
async function refresh() {
async function loadOwnDecks() {
try {
loading = true;
const r = await listDecks();
const enriched = await Promise.all(
r.decks.map(async (deck) => {
@ -47,54 +57,215 @@
}),
);
decks = enriched;
error = null;
} catch (e) {
error = (e as Error).message;
} 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) {
selectedId = deckId;
setTimeout(() => {
goto(`/study/${deckId}`);
}, 220);
setTimeout(() => goto(`/study/${deckId}`), 220);
}
</script>
<h1 class="title">{t('decks.title')}</h1>
<svelte:head>
<title>Meine Decks · Cardecky</title>
</svelte:head>
<div class="inbox">
<InboxBanner />
<div class="space-y-10">
<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>
{#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>
.title {
margin: 0 0 1rem;
font-size: 1.5rem;
.section-head {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
font-size: 1.25rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.inbox {
margin-bottom: 1.5rem;
/* Horizontal-Scroll-Reihe für eigene Decks — identisches Layout wie
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 {
color: hsl(var(--color-muted-foreground));
margin-top: 2rem;
.deck-row::before {
content: '';
flex-shrink: 0;
width: max(1rem, calc((100dvw - 72rem) / 2 + 1rem));
}
.error {
color: hsl(var(--color-error));
margin-top: 2rem;
.deck-row::after {
content: '';
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>