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:
parent
c1a87a4f88
commit
608b385c05
2 changed files with 207 additions and 36 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue