refactor(marketplace): UI-Verbesserungen, MarketplaceDeckStack, Explore-Icons

- DeckListGrid: überarbeitetes Layout
- EmptyState + SkeletonGrid: aufgeräumt
- Neuer MarketplaceDeckStack für Marketplace-Karten-Darstellung
- Explore: Icons (Fire, Star, MagnifyingGlass, Books) + Header-Cleanup
- me/forks + me/subscribed: kleinere Korrekturen
- docs/deck_ideas: initiale Ideen-Sammlung

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-10 16:00:11 +02:00
parent 1f1abf3c4f
commit 0c68186563
8 changed files with 451 additions and 101 deletions

View file

@ -1,9 +1,6 @@
<script lang="ts">
import type { DeckListEntry } from '$lib/api/marketplace.ts';
import type { DeckCategoryId } from '@cards/domain';
import { DECK_CATEGORY_IDS } from '@cards/domain';
import AuthorBadge from './AuthorBadge.svelte';
import DeckCategoryIcon from '$lib/components/DeckCategoryIcon.svelte';
import MarketplaceDeckStack from './MarketplaceDeckStack.svelte';
interface Props {
items: DeckListEntry[];
@ -11,83 +8,82 @@
}
const { items, emptyMessage = 'Noch keine Decks gefunden.' }: Props = $props();
function isValidCategory(c: string | null): c is DeckCategoryId {
return c !== null && (DECK_CATEGORY_IDS as readonly string[]).includes(c);
}
function languageLabel(code: string | null): string {
if (!code) return '';
const map: Record<string, string> = { de: 'Deutsch', en: 'English', es: 'Español', fr: 'Français' };
return map[code] ?? code.toUpperCase();
}
</script>
{#if items.length === 0}
<p class="rounded-lg border border-dashed border-[hsl(var(--color-border))] p-8 text-center text-sm text-[hsl(var(--color-muted-foreground))]">
<p class="empty">
{emptyMessage}
</p>
{:else}
<ul class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<ul class="deck-row" aria-label="Decks">
{#each items as deck (deck.slug)}
<li
class="group relative rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4 transition-colors hover:border-[hsl(var(--color-primary))]"
>
<a href="/d/{deck.slug}" class="block">
<div class="flex items-start justify-between gap-2">
<h3 class="truncate font-medium">{deck.title}</h3>
<div class="flex shrink-0 items-center gap-1.5">
{#if isValidCategory(deck.category)}
<span class="text-[hsl(var(--color-muted-foreground))]" aria-hidden="true">
<DeckCategoryIcon category={deck.category} size={16} weight="duotone" />
</span>
{/if}
{#if deck.is_featured}
<span
class="rounded-full bg-[hsl(var(--color-primary))]/15 px-2 py-0.5 text-[10px] font-medium text-[hsl(var(--color-primary))]"
title="Editorial Pick"
>
★ Featured
</span>
{/if}
</div>
</div>
{#if deck.description}
<p
class="mt-1 line-clamp-2 text-xs text-[hsl(var(--color-muted-foreground))]"
>
{deck.description}
</p>
{/if}
<div class="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[hsl(var(--color-muted-foreground))]">
<AuthorBadge
slug={deck.owner.slug}
displayName={deck.owner.display_name}
verifiedMana={deck.owner.verified_mana}
verifiedCommunity={deck.owner.verified_community}
size="sm"
/>
<span>·</span>
<span title="Karten">{deck.card_count} 🃏</span>
<span>·</span>
<span title="Stars">{deck.star_count}</span>
{#if deck.subscriber_count > 0}
<span>·</span>
<span title="Subscribers">{deck.subscriber_count} ↩︎</span>
{/if}
{#if deck.language}
<span>·</span>
<span class="uppercase">{languageLabel(deck.language)}</span>
{/if}
{#if deck.price_credits > 0}
<span>·</span>
<span class="font-medium text-[hsl(var(--color-primary))]">
{deck.price_credits} Credits
</span>
{/if}
</div>
</a>
<li class="deck-item">
<MarketplaceDeckStack {deck} />
</li>
{/each}
</ul>
{/if}
<style>
.empty {
border: 1px dashed hsl(var(--color-border));
border-radius: 0.75rem;
padding: 2rem;
text-align: center;
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
}
.deck-row {
list-style: none;
margin: 0;
padding-block: 0 1rem;
display: flex;
flex-direction: row;
gap: 1rem;
overflow-x: auto;
scroll-snap-type: x mandatory;
/* snap-padding entspricht dem ::before-Spacer */
scroll-padding-inline-start: max(1rem, calc((100dvw - 72rem) / 2 + 1rem));
scrollbar-width: thin;
scrollbar-color: hsl(var(--color-border)) transparent;
/* Full-bleed: break out of main's max-width + padding */
width: 100dvw;
margin-left: calc(50% - 50dvw);
}
/* Flex-Spacer am Anfang schafft zuverlässig den initialen Einzug —
padding-inline-start auf scroll-containern ist browser-inkonsistent */
.deck-row::before {
content: '';
flex-shrink: 0;
width: max(1rem, calc((100dvw - 72rem) / 2 + 1rem));
}
/* Trailing spacer damit letzte Karte nicht am rechten Rand klebt */
.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;
}
</style>

View file

@ -1,20 +1,24 @@
<script lang="ts">
import type { Component } from 'svelte';
interface Props {
icon?: string;
icon?: Component;
title: string;
description?: string;
ctaHref?: string;
ctaLabel?: string;
}
const { icon, title, description, ctaHref, ctaLabel }: Props = $props();
const { icon: IconComponent, 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 IconComponent}
<div class="icon-wrap" aria-hidden="true">
<IconComponent size={40} weight="duotone" />
</div>
{/if}
<h3 class="text-lg font-medium">{title}</h3>
{#if description}
@ -31,3 +35,12 @@
</a>
{/if}
</div>
<style>
.icon-wrap {
display: flex;
justify-content: center;
margin-bottom: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
</style>

View file

@ -0,0 +1,183 @@
<script lang="ts">
import type { DeckListEntry } from '$lib/api/marketplace.ts';
import type { DeckCategoryId } from '@cards/domain';
import { DECK_CATEGORY_IDS } from '@cards/domain';
import { stackLayers, deterministicRandoms } from '$lib/utils/deck-tilt';
import { Star } from '@mana/shared-icons';
import CardSurface from '$lib/components/CardSurface.svelte';
import DeckCategoryIcon from '$lib/components/DeckCategoryIcon.svelte';
interface Props {
deck: DeckListEntry;
}
let { deck }: Props = $props();
const layers = $derived(stackLayers(deck.slug, 3));
function isValidCategory(c: string | null): c is DeckCategoryId {
return c !== null && (DECK_CATEGORY_IDS as readonly string[]).includes(c);
}
const CATEGORY_COLORS: Record<DeckCategoryId, string> = {
language: '#3B82F6',
medicine: '#EF4444',
science: '#22C55E',
math: '#6366F1',
history: '#F59E0B',
law: '#64748B',
technology: '#06B6D4',
arts: '#A855F7',
music: '#EC4899',
sport: '#16A34A',
other: '#9CA3AF',
};
// Fallback: deterministisch aus slug-Hash, aus einem freundlichen Palette
const FALLBACK_PALETTE = ['#3B82F6','#22C55E','#F59E0B','#A855F7','#06B6D4','#EC4899','#EF4444'];
const accentColor = $derived((): string => {
if (isValidCategory(deck.category)) return CATEGORY_COLORS[deck.category];
const [r] = deterministicRandoms(deck.slug, 1);
return FALLBACK_PALETTE[Math.floor(r * FALLBACK_PALETTE.length)];
});
</script>
<div class="stack-wrap">
{#each layers as layer, i (i)}
<div
class="layer"
style:transform="translate({layer.dx}px, {layer.dy}px) rotate({layer.tilt}deg)"
aria-hidden="true"
></div>
{/each}
<CardSurface
size="md"
as="a"
href="/d/{deck.slug}"
ariaLabel="{deck.title} · {deck.card_count} Karten"
colorAccent={accentColor()}
class="cover"
>
<div class="cover-inner">
<div class="cover-corner" aria-hidden="true">
{#if isValidCategory(deck.category)}
<DeckCategoryIcon category={deck.category} size={20} color={accentColor()} weight="duotone" />
{/if}
{#if deck.is_featured}
<span class="featured-badge" title="Editorial Pick">
<Star size={14} weight="fill" />
</span>
{/if}
</div>
<div class="cover-body">
<h2 class="cover-title">{deck.title}</h2>
{#if deck.description}
<p class="cover-desc">{deck.description}</p>
{/if}
</div>
<div class="cover-meta">
<span class="meta-count">{deck.card_count} Karten</span>
{#if deck.star_count > 0}
<span class="meta-stars">
<Star size={11} weight="fill" />{deck.star_count}
</span>
{/if}
</div>
</div>
</CardSurface>
</div>
<style>
.stack-wrap {
position: relative;
width: 100%;
max-width: 18rem;
aspect-ratio: 5 / 7;
}
.layer {
position: absolute;
inset: 0;
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
border-radius: 0.875rem;
box-shadow: 0 1px 3px hsl(var(--color-foreground) / 0.06);
}
.cover-inner {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
padding: 1rem 1rem 1.125rem 1.375rem;
overflow: hidden;
}
.cover-corner {
position: absolute;
top: 0.875rem;
right: 0.875rem;
display: flex;
align-items: center;
gap: 0.375rem;
opacity: 0.85;
}
.featured-badge {
font-size: 0.75rem;
color: hsl(var(--color-primary));
font-weight: 700;
}
.cover-body {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
gap: 0.875rem;
padding: 2.5rem 0.5rem 0 0;
min-height: 0;
}
.cover-title {
margin: 0;
font-size: 1.0625rem;
font-weight: 600;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.cover-desc {
margin: 0;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
line-height: 1.4;
}
.cover-meta {
flex: 0 0 auto;
display: flex;
flex-wrap: wrap;
gap: 0.25rem 0.5rem;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
font-variant-numeric: tabular-nums;
}
.meta-stars {
display: inline-flex;
align-items: center;
gap: 0.2rem;
color: hsl(var(--color-primary));
font-weight: 500;
}
</style>

View file

@ -6,19 +6,43 @@
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">
<ul class="skeleton-row" 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 class="skeleton-item animate-pulse">
<div class="skeleton-card"></div>
</li>
{/each}
</ul>
<style>
.skeleton-row {
list-style: none;
margin: 0;
padding-block: 0 1rem;
display: flex;
flex-direction: row;
gap: 1rem;
overflow: hidden;
width: 100dvw;
margin-left: calc(50% - 50dvw);
}
.skeleton-row::before {
content: '';
flex-shrink: 0;
width: max(1rem, calc((100dvw - 72rem) / 2 + 1rem));
}
.skeleton-item {
flex: 0 0 auto;
width: 16rem;
}
.skeleton-card {
width: 100%;
aspect-ratio: 5 / 7;
border-radius: 0.875rem;
background: hsl(var(--color-border) / 0.5);
}
</style>

View file

@ -10,6 +10,7 @@
import EmptyState from '$lib/components/marketplace/EmptyState.svelte';
import SkeletonGrid from '$lib/components/marketplace/SkeletonGrid.svelte';
import { toasts } from '$lib/stores/toasts.svelte.ts';
import { Fire, Star, MagnifyingGlass, Books } from '@mana/shared-icons';
let featured = $state<DeckListEntry[]>([]);
let trending = $state<DeckListEntry[]>([]);
@ -79,36 +80,32 @@
<title>Explore · Cardecky</title>
</svelte:head>
<div class="mx-auto max-w-6xl space-y-10">
<div class="space-y-10">
<header>
<h1 class="text-3xl font-semibold">Cardecky-Library</h1>
<p class="mt-2 text-sm text-[hsl(var(--color-muted-foreground))]">
Decks von der Verein-Community + KI-kuratierten Cardecky-Author. Subscribe für Live-Updates,
fork für eigenen Lern-Stand, ✏️ Verbesserungen via Pull-Request einreichen.
</p>
</header>
{#if loadingExplore}
<section>
<h2 class="mb-3 text-xl font-semibold">🔥 Trending</h2>
<h2 class="section-head"><Fire size={20} weight="duotone" /> Trending</h2>
<SkeletonGrid count={6} />
</section>
{:else}
{#if featured.length > 0}
<section>
<h2 class="mb-3 text-xl font-semibold">★ Featured</h2>
<h2 class="section-head"><Star size={20} weight="duotone" /> Featured</h2>
<DeckListGrid items={featured} />
</section>
{/if}
{#if trending.length > 0}
<section>
<h2 class="mb-3 text-xl font-semibold">🔥 Trending</h2>
<h2 class="section-head"><Fire size={20} weight="duotone" /> Trending</h2>
<DeckListGrid items={trending} />
</section>
{:else if !loadingExplore && featured.length === 0}
<EmptyState
icon="📚"
icon={Books}
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"
@ -118,7 +115,7 @@
{/if}
<section>
<h2 class="mb-3 text-xl font-semibold">🔎 Stöbern</h2>
<h2 class="section-head"><MagnifyingGlass size={20} weight="duotone" /> Stöbern</h2>
<form
class="mb-4 flex flex-wrap items-end gap-3"
@ -182,3 +179,15 @@
{/if}
</section>
</div>
<style>
.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));
}
</style>

View file

@ -9,6 +9,7 @@
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';
import { GitFork } from '@mana/shared-icons';
let forks = $state<Deck[]>([]);
let loading = $state(true);
@ -72,7 +73,7 @@
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">Lade…</p>
{:else if forks.length === 0}
<EmptyState
icon="🔱"
icon={GitFork}
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"

View file

@ -7,6 +7,7 @@
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';
import { ArrowCounterClockwise } from '@mana/shared-icons';
let items = $state<SubscriptionEntry[]>([]);
let loading = $state(true);
@ -44,7 +45,7 @@
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">Lade…</p>
{:else if items.length === 0}
<EmptyState
icon="↩︎"
icon={ArrowCounterClockwise}
title="Noch keine Abos"
description="Subscribe einen Deck im Explore, dann siehst du hier Updates des Authors."
ctaHref="/explore"