feat(decks): Deck-Kategorien über den ganzen Stack
Some checks are pending
CI / validate (push) Waiting to run

- cards-domain: DECK_CATEGORY_IDS, Labels, DeckCategorySchema,
  category-Feld im DeckSchema
- DB-Schema (decks + marketplace/decks): category-Spalte
- API-Routen: category in create/update/list/explore
- Web: DeckCategoryIcon-Komponente, Kategorie-Picker auf Deck-Detail,
  Kategorie-Icon in DeckListGrid (Marketplace)
- Layout: Bottom-Padding für floating Nav-Bar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-09 20:24:47 +02:00
parent 5876f95d85
commit 7bf61315b5
13 changed files with 251 additions and 11 deletions

View file

@ -16,6 +16,21 @@ export const decks = cardsSchema.table(
name: text('name').notNull(),
description: text('description'),
color: text('color'),
category: text('category', {
enum: [
'language',
'medicine',
'science',
'math',
'history',
'law',
'technology',
'arts',
'music',
'sport',
'other',
],
}),
visibility: text('visibility', { enum: ['private', 'space', 'public'] })
.notNull()
.default('private'),

View file

@ -53,6 +53,21 @@ export const publicDecks = marketplaceSchema.table(
description: text('description'),
// ISO-639-1 (z.B. 'de', 'en', 'es'). Nullable für mixed-language.
language: text('language'),
category: text('category', {
enum: [
'language',
'medicine',
'science',
'math',
'history',
'law',
'technology',
'arts',
'music',
'sport',
'other',
],
}),
// SPDX-style ID. CC0-1.0, CC-BY-4.0, CC-BY-SA-4.0,
// Cardecky-Personal-Use-1.0 (default für free), Cardecky-Pro-Only-1.0 (paid).
license: text('license').notNull().default('Cardecky-Personal-Use-1.0'),

View file

@ -37,6 +37,7 @@ export function decksRouter(deps: DecksDeps = {}): Hono<{ Variables: AuthVars }>
name: parsed.data.name,
description: parsed.data.description,
color: parsed.data.color,
category: parsed.data.category,
visibility: parsed.data.visibility ?? 'private',
fsrsSettings: parsed.data.fsrs_settings ?? {},
createdAt: now,
@ -89,6 +90,7 @@ export function decksRouter(deps: DecksDeps = {}): Hono<{ Variables: AuthVars }>
...(parsed.data.name !== undefined && { name: parsed.data.name }),
...(parsed.data.description !== undefined && { description: parsed.data.description }),
...(parsed.data.color !== undefined && { color: parsed.data.color }),
...(parsed.data.category !== undefined && { category: parsed.data.category }),
...(parsed.data.visibility !== undefined && { visibility: parsed.data.visibility }),
...(parsed.data.fsrs_settings !== undefined && {
fsrsSettings: parsed.data.fsrs_settings,
@ -122,6 +124,7 @@ function toDeckDto(row: typeof decks.$inferSelect) {
name: row.name,
description: row.description,
color: row.color,
category: row.category,
visibility: row.visibility,
fsrs_settings: row.fsrsSettings,
content_hash: row.contentHash,

View file

@ -41,6 +41,11 @@ export type MarketplaceDecksDeps = { db?: CardsDb };
const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/;
const MarketplaceCategorySchema = z.enum([
'language', 'medicine', 'science', 'math', 'history',
'law', 'technology', 'arts', 'music', 'sport', 'other',
]);
const InitSchema = z.object({
slug: z.string(),
title: z.string().min(1).max(120),
@ -51,6 +56,7 @@ const InitSchema = z.object({
.optional(),
license: z.string().max(60).optional(),
priceCredits: z.number().int().min(0).max(100_000).optional(),
category: MarketplaceCategorySchema.optional(),
});
const PatchSchema = z.object({
@ -59,6 +65,7 @@ const PatchSchema = z.object({
language: z.string().regex(/^[a-z]{2}$/).optional(),
license: z.string().max(60).optional(),
priceCredits: z.number().int().min(0).max(100_000).optional(),
category: MarketplaceCategorySchema.optional(),
});
const CardTypeSchema = z.enum([
@ -104,6 +111,7 @@ function toDeckDto(row: typeof publicDecks.$inferSelect) {
title: row.title,
description: row.description,
language: row.language,
category: row.category,
license: row.license,
price_credits: row.priceCredits,
owner_user_id: row.ownerUserId,
@ -236,6 +244,7 @@ export function marketplaceDecksRouter(
title: parsed.data.title,
description: parsed.data.description,
language: parsed.data.language,
category: parsed.data.category,
license,
priceCredits,
ownerUserId: userId,
@ -275,6 +284,7 @@ export function marketplaceDecksRouter(
...(parsed.data.title !== undefined && { title: parsed.data.title }),
...(parsed.data.description !== undefined && { description: parsed.data.description }),
...(parsed.data.language !== undefined && { language: parsed.data.language }),
...(parsed.data.category !== undefined && { category: parsed.data.category }),
...(parsed.data.license !== undefined && { license }),
...(parsed.data.priceCredits !== undefined && { priceCredits }),
})

View file

@ -54,6 +54,7 @@ interface DeckListEntry {
title: string;
description: string | null;
language: string | null;
category: string | null;
license: string;
price_credits: number;
card_count: number;
@ -117,6 +118,7 @@ async function browseImpl(
title: publicDecks.title,
description: publicDecks.description,
language: publicDecks.language,
category: publicDecks.category,
license: publicDecks.license,
priceCredits: publicDecks.priceCredits,
cardCount: cardCountExpr,
@ -148,6 +150,7 @@ async function browseImpl(
title: r.title,
description: r.description,
language: r.language,
category: r.category,
license: r.license,
price_credits: r.priceCredits,
card_count: Number(r.cardCount),

View file

@ -21,7 +21,8 @@
"marked": "^18.0.3",
"sql.js": "^1.14.1",
"@mana/themes": "^0.1.0",
"@mana/shared-ui-2": "^0.1.0"
"@mana/shared-ui-2": "^0.1.0",
"@mana/shared-icons": "^1.0.0"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.2.0",

View file

@ -29,6 +29,7 @@ export interface MarketplaceDeck {
title: string;
description: string | null;
language: string | null;
category: string | null;
license: string;
price_credits: number;
owner_user_id: string;
@ -61,6 +62,7 @@ export interface DeckListEntry {
title: string;
description: string | null;
language: string | null;
category: string | null;
license: string;
price_credits: number;
card_count: number;

View file

@ -0,0 +1,47 @@
<script lang="ts">
import type { DeckCategoryId } from '@cards/domain';
import {
Globe,
Heartbeat,
Leaf,
Brain,
Flag,
Medal,
Lightning,
Palette,
MusicNote,
Barbell,
Sparkle,
} from '@mana/shared-icons';
type Weight = 'thin' | 'light' | 'regular' | 'bold' | 'fill' | 'duotone';
interface Props {
category: DeckCategoryId;
size?: number;
color?: string | null;
weight?: Weight;
}
let { category, size = 32, color = null, weight = 'duotone' }: Props = $props();
const ICON_MAP: Record<DeckCategoryId, typeof Globe> = {
language: Globe,
medicine: Heartbeat,
science: Leaf,
math: Brain,
history: Flag,
law: Medal,
technology: Lightning,
arts: Palette,
music: MusicNote,
sport: Barbell,
other: Sparkle,
};
const IconComponent = $derived(ICON_MAP[category]);
</script>
{#if IconComponent}
<IconComponent {size} {weight} color={color ?? undefined} />
{/if}

View file

@ -1,6 +1,9 @@
<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';
interface Props {
items: DeckListEntry[];
@ -9,6 +12,10 @@
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' };
@ -29,14 +36,21 @@
<a href="/d/{deck.slug}" class="block">
<div class="flex items-start justify-between gap-2">
<h3 class="truncate font-medium">{deck.title}</h3>
{#if deck.is_featured}
<span
class="shrink-0 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 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

View file

@ -50,7 +50,7 @@
.default-pad {
max-width: 72rem;
padding: 2rem 1rem;
padding: 2rem 1rem 6rem;
}
/* Focus-Mode: keine max-width-Begrenzung, kein Außen-Padding —

View file

@ -3,13 +3,15 @@
import { page } from '$app/state';
import { goto } from '$app/navigation';
import type { Card, Deck } from '@cards/domain';
import { getDeck } from '$lib/api/decks.ts';
import { type DeckCategoryId, DECK_CATEGORY_IDS, DECK_CATEGORY_LABELS } from '@cards/domain';
import { getDeck, updateDeck } from '$lib/api/decks.ts';
import { listCards, deleteCard } from '$lib/api/cards.ts';
import { listDueReviews } from '$lib/api/reviews.ts';
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
import { toasts } from '$lib/stores/toasts.svelte.ts';
import { t, tn } from '$lib/i18n/index.svelte.ts';
import DeckFan from '$lib/components/DeckFan.svelte';
import DeckCategoryIcon from '$lib/components/DeckCategoryIcon.svelte';
let deck = $state<Deck | null>(null);
let cards = $state<Card[]>([]);
@ -46,6 +48,16 @@
}
}
async function onSetCategory(id: DeckCategoryId) {
if (!deck) return;
const next = deck.category === id ? null : id;
try {
deck = await updateDeck(deck.id, { category: next ?? undefined });
} catch (e) {
toasts.error((e as Error).message);
}
}
async function onDeleteCard(id: string) {
if (!confirm(t('deck_detail.card_delete_confirm'))) return;
try {
@ -112,6 +124,24 @@
{tn('decks.card_count', cards.length)} · {t('study.due_count', { n: dueCount })}
</div>
<div class="category-row mt-3">
{#each DECK_CATEGORY_IDS as id}
<button
class="cat-btn"
class:active={deck.category === id}
onclick={() => onSetCategory(id)}
>
<DeckCategoryIcon
category={id}
size={16}
color={deck.category === id ? (deck.color ?? null) : null}
weight={deck.category === id ? 'fill' : 'regular'}
/>
<span class="cat-label">{DECK_CATEGORY_LABELS[id]}</span>
</button>
{/each}
</div>
{#if cards.length === 0}
<div
class="mt-8 rounded-lg border border-dashed border-[hsl(var(--color-border))] p-12 text-center"
@ -183,6 +213,41 @@
{/if}
<style>
.category-row {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.cat-btn {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.25rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid transparent;
background: transparent;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
transition: border-color 0.12s, background 0.12s, color 0.12s;
}
.cat-btn:hover {
border-color: hsl(var(--color-border));
color: hsl(var(--color-foreground));
}
.cat-btn.active {
border-color: hsl(var(--color-primary) / 0.4);
background: hsl(var(--color-primary) / 0.08);
color: hsl(var(--color-foreground));
}
.cat-label {
font-size: 0.75rem;
font-weight: 500;
}
.list-toggle {
cursor: pointer;
font-size: 0.875rem;