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 {
|
.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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue