feat(library): M2 — CRUD form, grid view, detail view

Adds the user-facing surface for the Bibliothek module:

Components (components/):
  - KindTabs         — Alle / Bücher / Filme / Serien / Comics with counts
  - StatusFilter     — Geplant / Läuft / Fertig / Pausiert / Abgebrochen chips
  - RatingStars      — 0–5 stars, click to set, click again to clear
  - StatusBadge      — colored status pill
  - CoverImage       — lazy <img> with kind-emoji fallback
  - EntryCard        — grid card (cover + title + rating + status)
  - EntryForm        — create/edit with kind selector + typ-spezifische
                       Felder (pages, runtime, seasons, issues, format)

Views (views/):
  - GridView         — responsive grid of EntryCards + empty state
  - DetailView       — cover, metadata, status picker, rating, delete/fav
                       actions, review display, typ-spezifische details
                       (book pages/format, movie runtime/director, series
                       seasons/episodes, comic issues/publisher).
                       Edit toggles in-place to EntryForm.

Route:
  - /library              — rewritten ListView (tabs + filters + grid + FAB)
  - /library/entry/[id]   — DetailView

Progress features (episode tracker for series, page slider for books,
times-counter button for re-reads/re-watches) are deferred to M3 since
they need dedicated interaction patterns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-17 03:57:55 +02:00
parent 8c6502d0ff
commit 364178496a
11 changed files with 1585 additions and 77 deletions

View file

@ -1,68 +1,156 @@
<!--
Library — ListView (M1 skeleton)
Minimal placeholder that lists all entries grouped by kind. The full
grid + KindTabs + DetailView land in M2.
Library — ListView
Tabbed grid: KindTabs + StatusFilter + search + favourites toggle.
Click an entry → navigate to detail route.
"+ Neu" opens EntryForm in an overlay.
-->
<script lang="ts">
import { useAllEntries, groupByKind } from './queries';
import { KIND_LABELS, STATUS_LABELS } from './constants';
import type { LibraryKind } from './types';
import { goto } from '$app/navigation';
import KindTabs from './components/KindTabs.svelte';
import StatusFilter from './components/StatusFilter.svelte';
import EntryForm from './components/EntryForm.svelte';
import GridView from './views/GridView.svelte';
import { useAllEntries, searchEntries, filterByKind, filterByStatus } from './queries';
import type { LibraryEntry, LibraryKind, LibraryStatus } from './types';
const entries$ = useAllEntries();
const entries = $derived(entries$.value);
const grouped = $derived(groupByKind(entries));
const KIND_ORDER: LibraryKind[] = ['book', 'movie', 'series', 'comic'];
let activeKind = $state<LibraryKind | 'all'>('all');
let activeStatus = $state<LibraryStatus | null>(null);
let searchQuery = $state('');
let showFavoritesOnly = $state(false);
let showCreate = $state(false);
const counts = $derived<Record<LibraryKind, number>>({
book: entries.filter((e) => e.kind === 'book').length,
movie: entries.filter((e) => e.kind === 'movie').length,
series: entries.filter((e) => e.kind === 'series').length,
comic: entries.filter((e) => e.kind === 'comic').length,
});
const filtered = $derived.by(() => {
let result = entries;
if (activeKind !== 'all') result = filterByKind(result, activeKind);
if (activeStatus) result = filterByStatus(result, activeStatus);
if (showFavoritesOnly) result = result.filter((e) => e.isFavorite);
if (searchQuery.trim()) result = searchEntries(result, searchQuery.trim());
return result;
});
function openEntry(e: LibraryEntry) {
goto(`/library/entry/${e.id}`);
}
function presetKind(): LibraryKind | undefined {
return activeKind === 'all' ? undefined : activeKind;
}
</script>
<div class="library-shell">
<header class="library-header">
<h1>Bibliothek</h1>
<p class="muted">Bücher, Filme, Serien und Comics — alles an einem Ort.</p>
<div class="title-row">
<div>
<h1>Bibliothek</h1>
<p class="muted">Bücher, Filme, Serien, Comics — was du liest und schaust.</p>
</div>
<button type="button" class="create-btn" onclick={() => (showCreate = true)}> + Neu </button>
</div>
</header>
<div class="controls">
<KindTabs active={activeKind} {counts} onselect={(k) => (activeKind = k)} />
<div class="filter-row">
<StatusFilter active={activeStatus} onselect={(s) => (activeStatus = s)} />
<label class="fav-toggle">
<input type="checkbox" bind:checked={showFavoritesOnly} />
<span>Nur Favoriten</span>
</label>
</div>
<input
type="search"
class="search"
bind:value={searchQuery}
placeholder="Suche nach Titel oder Creator…"
/>
</div>
{#if entries$.loading}
<p class="muted">Lädt…</p>
<p class="muted center">Lädt…</p>
{:else}
{#each KIND_ORDER as kind (kind)}
{@const list = grouped[kind]}
<section class="kind-section">
<h2>
{KIND_LABELS[kind].emoji}
{KIND_LABELS[kind].de}
<span class="count">({list.length})</span>
</h2>
{#if list.length === 0}
<p class="muted">Noch keine Einträge.</p>
{:else}
<ul class="entry-list">
{#each list as entry (entry.id)}
<li class="entry-row">
<span class="entry-title">{entry.title}</span>
{#if entry.year}
<span class="muted"> · {entry.year}</span>
{/if}
<span class="badge">{STATUS_LABELS[entry.status].de}</span>
{#if entry.rating != null}
<span class="rating">{entry.rating.toFixed(1)}</span>
{/if}
</li>
{/each}
</ul>
{/if}
</section>
{/each}
<GridView entries={filtered} onopen={openEntry} />
{/if}
</div>
{#if showCreate}
<div
class="overlay"
role="dialog"
aria-modal="true"
aria-label="Neuer Bibliothekseintrag"
tabindex="-1"
onclick={(e) => {
if (e.target === e.currentTarget) showCreate = false;
}}
onkeydown={(e) => {
if (e.key === 'Escape') showCreate = false;
}}
>
<EntryForm
mode="create"
initial={presetKind()
? ({
kind: presetKind()!,
status: 'planned',
title: '',
originalTitle: null,
creators: [],
year: null,
coverUrl: null,
coverMediaId: null,
rating: null,
review: null,
tags: [],
genres: [],
startedAt: null,
completedAt: null,
isFavorite: false,
times: 0,
externalIds: null,
details:
presetKind() === 'book'
? { kind: 'book' }
: presetKind() === 'movie'
? { kind: 'movie' }
: presetKind() === 'series'
? { kind: 'series', watched: [] }
: { kind: 'comic' },
createdAt: '',
updatedAt: '',
id: '',
} as LibraryEntry)
: undefined}
onclose={() => (showCreate = false)}
/>
</div>
{/if}
<style>
.library-shell {
max-width: 960px;
max-width: 1100px;
margin: 0 auto;
padding: 1.5rem;
}
.library-header {
margin-bottom: 2rem;
margin-bottom: 1.5rem;
}
.title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.library-header h1 {
margin: 0 0 0.25rem 0;
@ -70,51 +158,70 @@
}
.muted {
color: var(--color-text-muted, #64748b);
font-size: 0.875rem;
}
.kind-section {
margin-bottom: 2rem;
}
.kind-section h2 {
font-size: 1.1rem;
margin: 0 0 0.75rem 0;
display: flex;
align-items: baseline;
gap: 0.5rem;
}
.count {
color: var(--color-text-muted, #64748b);
font-weight: normal;
font-size: 0.9rem;
}
.entry-list {
list-style: none;
padding: 0;
margin: 0;
.muted.center {
text-align: center;
margin-top: 2rem;
}
.create-btn {
padding: 0.5rem 1.15rem;
border-radius: 0.55rem;
border: none;
background: #a855f7;
color: white;
cursor: pointer;
font: inherit;
font-weight: 500;
white-space: nowrap;
}
.create-btn:hover {
background: #9333ea;
}
.controls {
display: flex;
flex-direction: column;
gap: 0.25rem;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.entry-row {
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
background: var(--color-surface, rgba(0, 0, 0, 0.03));
.filter-row {
display: flex;
align-items: center;
gap: 0.5rem;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.entry-title {
font-weight: 500;
.fav-toggle {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
color: var(--color-text-muted, #64748b);
cursor: pointer;
}
.badge {
margin-left: auto;
font-size: 0.75rem;
padding: 0.15rem 0.5rem;
border-radius: 999px;
background: var(--color-surface-muted, rgba(0, 0, 0, 0.06));
.search {
padding: 0.55rem 0.85rem;
border-radius: 0.5rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
background: var(--color-surface, transparent);
font: inherit;
color: inherit;
max-width: 320px;
}
.rating {
font-size: 0.8rem;
color: #f59e0b;
.search:focus {
outline: 2px solid #a855f7;
outline-offset: 1px;
border-color: transparent;
}
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 4vh 1rem;
overflow-y: auto;
z-index: 100;
}
</style>

View file

@ -0,0 +1,66 @@
<script lang="ts">
import { KIND_LABELS } from '../constants';
import type { LibraryKind } from '../types';
let {
url,
kind,
alt = '',
size = 'md',
}: {
url: string | null;
kind: LibraryKind;
alt?: string;
size?: 'sm' | 'md' | 'lg';
} = $props();
let errored = $state(false);
</script>
<div class="cover size-{size}" aria-label={alt || KIND_LABELS[kind].de}>
{#if url && !errored}
<img src={url} {alt} onerror={() => (errored = true)} loading="lazy" />
{:else}
<span class="placeholder">{KIND_LABELS[kind].emoji}</span>
{/if}
</div>
<style>
.cover {
position: relative;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #a855f7, #d946ef);
border-radius: 0.5rem;
overflow: hidden;
aspect-ratio: 2 / 3;
}
.cover.size-sm {
width: 40px;
}
.cover.size-md {
width: 100%;
max-width: 140px;
}
.cover.size-lg {
width: 200px;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.placeholder {
font-size: 2.5rem;
color: white;
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.2));
}
.cover.size-sm .placeholder {
font-size: 1.2rem;
}
.cover.size-lg .placeholder {
font-size: 4rem;
}
</style>

View file

@ -0,0 +1,105 @@
<script lang="ts">
import CoverImage from './CoverImage.svelte';
import StatusBadge from './StatusBadge.svelte';
import RatingStars from './RatingStars.svelte';
import type { LibraryEntry } from '../types';
let {
entry,
onclick,
}: {
entry: LibraryEntry;
onclick?: (entry: LibraryEntry) => void;
} = $props();
</script>
<button type="button" class="card" onclick={() => onclick?.(entry)}>
<div class="cover-wrap">
<CoverImage url={entry.coverUrl} kind={entry.kind} alt={entry.title} />
{#if entry.isFavorite}
<span class="fav" aria-label="Favorit"></span>
{/if}
</div>
<div class="meta">
<div class="title" title={entry.title}>{entry.title}</div>
<div class="sub">
{#if entry.creators.length > 0}<span class="creators">{entry.creators[0]}</span>{/if}
{#if entry.year}<span class="year">· {entry.year}</span>{/if}
</div>
<div class="row">
<StatusBadge status={entry.status} />
{#if entry.rating != null}
<RatingStars value={entry.rating} readonly size="sm" />
{/if}
</div>
</div>
</button>
<style>
.card {
display: flex;
flex-direction: column;
padding: 0;
background: var(--color-surface, rgba(0, 0, 0, 0.03));
border-radius: 0.75rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.06));
cursor: pointer;
text-align: left;
transition:
transform 0.12s ease,
border-color 0.12s ease;
overflow: hidden;
width: 100%;
color: inherit;
font: inherit;
}
.card:hover {
transform: translateY(-2px);
border-color: #a855f7;
}
.cover-wrap {
position: relative;
background: var(--color-surface-muted, rgba(0, 0, 0, 0.05));
}
.fav {
position: absolute;
top: 0.4rem;
right: 0.5rem;
color: #f59e0b;
font-size: 1.2rem;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.4));
}
.meta {
padding: 0.6rem 0.75rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.title {
font-weight: 500;
font-size: 0.92rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sub {
font-size: 0.78rem;
color: var(--color-text-muted, #64748b);
display: flex;
gap: 0.25rem;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.creators {
overflow: hidden;
text-overflow: ellipsis;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 0.25rem;
gap: 0.3rem;
}
</style>

View file

@ -0,0 +1,534 @@
<script lang="ts">
import { KIND_LABELS, STATUS_LABELS, BOOK_FORMAT_LABELS, DEFAULT_GENRES } from '../constants';
import { libraryEntriesStore } from '../stores/entries.svelte';
import type {
LibraryEntry,
LibraryKind,
LibraryStatus,
LibraryDetails,
BookFormat,
} from '../types';
let {
mode = 'create',
initial,
onclose,
}: {
mode?: 'create' | 'edit';
initial?: LibraryEntry;
onclose?: () => void;
} = $props();
// Form state is seeded once from `initial`; it never re-syncs because the
// form gets a fresh mount every time it opens. The svelte-ignore comments
// silence the (correct-in-general) Svelte 5 warning.
/* svelte-ignore state_referenced_locally */
let kind = $state<LibraryKind>(initial?.kind ?? 'book');
/* svelte-ignore state_referenced_locally */
let status = $state<LibraryStatus>(initial?.status ?? 'planned');
/* svelte-ignore state_referenced_locally */
let title = $state(initial?.title ?? '');
/* svelte-ignore state_referenced_locally */
let originalTitle = $state(initial?.originalTitle ?? '');
/* svelte-ignore state_referenced_locally */
let creators = $state(initial?.creators.join(', ') ?? '');
/* svelte-ignore state_referenced_locally */
let year = $state<number | null>(initial?.year ?? null);
/* svelte-ignore state_referenced_locally */
let coverUrl = $state(initial?.coverUrl ?? '');
/* svelte-ignore state_referenced_locally */
let rating = $state<number | null>(initial?.rating ?? null);
/* svelte-ignore state_referenced_locally */
let review = $state(initial?.review ?? '');
/* svelte-ignore state_referenced_locally */
let tags = $state(initial?.tags.join(', ') ?? '');
/* svelte-ignore state_referenced_locally */
let genres = $state<string[]>(initial?.genres ?? []);
/* svelte-ignore state_referenced_locally */
let isFavorite = $state(initial?.isFavorite ?? false);
// Book details
/* svelte-ignore state_referenced_locally */
let bookPages = $state<number | null>(
initial?.details.kind === 'book' ? (initial.details.pages ?? null) : null
);
/* svelte-ignore state_referenced_locally */
let bookCurrentPage = $state<number | null>(
initial?.details.kind === 'book' ? (initial.details.currentPage ?? null) : null
);
/* svelte-ignore state_referenced_locally */
let bookFormat = $state<BookFormat | ''>(
initial?.details.kind === 'book' ? (initial.details.format ?? '') : ''
);
// Movie details
/* svelte-ignore state_referenced_locally */
let movieRuntime = $state<number | null>(
initial?.details.kind === 'movie' ? (initial.details.runtimeMin ?? null) : null
);
/* svelte-ignore state_referenced_locally */
let movieDirector = $state(
initial?.details.kind === 'movie' ? (initial.details.director ?? '') : ''
);
// Series details
/* svelte-ignore state_referenced_locally */
let seriesSeasons = $state<number | null>(
initial?.details.kind === 'series' ? (initial.details.totalSeasons ?? null) : null
);
/* svelte-ignore state_referenced_locally */
let seriesEpisodes = $state<number | null>(
initial?.details.kind === 'series' ? (initial.details.totalEpisodes ?? null) : null
);
// Comic details
/* svelte-ignore state_referenced_locally */
let comicIssueCount = $state<number | null>(
initial?.details.kind === 'comic' ? (initial.details.issueCount ?? null) : null
);
/* svelte-ignore state_referenced_locally */
let comicCurrentIssue = $state<number | null>(
initial?.details.kind === 'comic' ? (initial.details.currentIssue ?? null) : null
);
/* svelte-ignore state_referenced_locally */
let comicPublisher = $state(
initial?.details.kind === 'comic' ? (initial.details.publisher ?? '') : ''
);
/* svelte-ignore state_referenced_locally */
let comicIsOngoing = $state(
initial?.details.kind === 'comic' ? (initial.details.isOngoing ?? false) : false
);
const KIND_ORDER: LibraryKind[] = ['book', 'movie', 'series', 'comic'];
const STATUS_ORDER: LibraryStatus[] = ['planned', 'active', 'completed', 'paused', 'dropped'];
function buildDetails(): LibraryDetails {
switch (kind) {
case 'book':
return {
kind: 'book',
pages: bookPages,
currentPage: bookCurrentPage,
format: bookFormat || null,
};
case 'movie':
return {
kind: 'movie',
runtimeMin: movieRuntime,
director: movieDirector.trim() || null,
};
case 'series': {
const prev = initial?.details.kind === 'series' ? (initial.details.watched ?? []) : [];
return {
kind: 'series',
totalSeasons: seriesSeasons,
totalEpisodes: seriesEpisodes,
watched: prev,
};
}
case 'comic':
return {
kind: 'comic',
issueCount: comicIssueCount,
currentIssue: comicCurrentIssue,
publisher: comicPublisher.trim() || null,
isOngoing: comicIsOngoing,
};
}
}
function toggleGenre(g: string) {
genres = genres.includes(g) ? genres.filter((x) => x !== g) : [...genres, g];
}
async function handleSubmit(e: Event) {
e.preventDefault();
if (!title.trim()) return;
const payload = {
kind,
status,
title: title.trim(),
originalTitle: originalTitle.trim() || null,
creators: creators
.split(',')
.map((c) => c.trim())
.filter(Boolean),
year,
coverUrl: coverUrl.trim() || null,
review: review.trim() || null,
tags: tags
.split(',')
.map((t) => t.trim())
.filter(Boolean),
genres,
isFavorite,
details: buildDetails(),
};
if (mode === 'edit' && initial) {
await libraryEntriesStore.updateEntry(initial.id, payload);
if (rating !== initial.rating) {
await libraryEntriesStore.rate(initial.id, rating);
}
} else {
await libraryEntriesStore.createEntry({ ...payload, rating });
}
onclose?.();
}
</script>
<form class="form" onsubmit={handleSubmit}>
<header>
<h2>{mode === 'edit' ? 'Eintrag bearbeiten' : 'Neuer Eintrag'}</h2>
<button type="button" class="close" onclick={() => onclose?.()} aria-label="Schließen">
×
</button>
</header>
{#if mode === 'create'}
<fieldset class="kind-group">
<legend>Typ</legend>
<div class="kind-chips">
{#each KIND_ORDER as k (k)}
<button
type="button"
class="kind-chip"
class:active={kind === k}
onclick={() => (kind = k)}
>
<span class="emoji">{KIND_LABELS[k].emoji}</span>
{KIND_LABELS[k].de}
</button>
{/each}
</div>
</fieldset>
{/if}
<label>
Titel *
<input type="text" bind:value={title} required placeholder="z.B. Dune" />
</label>
<div class="row">
<label>
Original-Titel
<input type="text" bind:value={originalTitle} placeholder="optional" />
</label>
<label class="year">
Jahr
<input type="number" bind:value={year} placeholder="2026" min="1800" max="2100" />
</label>
</div>
<label>
{kind === 'book'
? 'Autor(en)'
: kind === 'movie'
? 'Regie'
: kind === 'series'
? 'Showrunner'
: 'Zeichner / Autor(en)'}
<input type="text" bind:value={creators} placeholder="Kommagetrennt" />
</label>
<label>
Cover-URL
<input type="url" bind:value={coverUrl} placeholder="https://…" />
</label>
<div class="row">
<label>
Status
<select bind:value={status}>
{#each STATUS_ORDER as s (s)}
<option value={s}>{STATUS_LABELS[s].de}</option>
{/each}
</select>
</label>
<label>
Bewertung
<input type="number" bind:value={rating} min="0" max="5" step="0.5" placeholder="0 5" />
</label>
</div>
<label>
Tags
<input type="text" bind:value={tags} placeholder="kommagetrennt, z.B. klassiker, lieblings" />
</label>
<fieldset>
<legend>Genres</legend>
<div class="genre-chips">
{#each DEFAULT_GENRES as g (g)}
<button
type="button"
class="genre-chip"
class:active={genres.includes(g)}
onclick={() => toggleGenre(g)}
>
{g}
</button>
{/each}
</div>
</fieldset>
<details class="details-section" open>
<summary>Typ-spezifische Details</summary>
<div class="details-body">
{#if kind === 'book'}
<div class="row">
<label>
Seiten gesamt
<input type="number" bind:value={bookPages} min="0" placeholder="688" />
</label>
<label>
Aktuelle Seite
<input type="number" bind:value={bookCurrentPage} min="0" placeholder="0" />
</label>
</div>
<label>
Format
<select bind:value={bookFormat}>
<option value="">(keins)</option>
{#each Object.entries(BOOK_FORMAT_LABELS) as [key, label] (key)}
<option value={key}>{label.de}</option>
{/each}
</select>
</label>
{:else if kind === 'movie'}
<div class="row">
<label>
Laufzeit (min)
<input type="number" bind:value={movieRuntime} min="0" placeholder="116" />
</label>
<label>
Regie
<input type="text" bind:value={movieDirector} placeholder="optional" />
</label>
</div>
{:else if kind === 'series'}
<div class="row">
<label>
Staffeln
<input type="number" bind:value={seriesSeasons} min="0" placeholder="2" />
</label>
<label>
Episoden gesamt
<input type="number" bind:value={seriesEpisodes} min="0" placeholder="19" />
</label>
</div>
{:else if kind === 'comic'}
<div class="row">
<label>
Ausgaben gesamt
<input type="number" bind:value={comicIssueCount} min="0" />
</label>
<label>
Aktuelle Ausgabe
<input type="number" bind:value={comicCurrentIssue} min="0" />
</label>
</div>
<label>
Verlag
<input type="text" bind:value={comicPublisher} placeholder="Image Comics" />
</label>
<label class="checkbox">
<input type="checkbox" bind:checked={comicIsOngoing} />
Laufende Serie
</label>
{/if}
</div>
</details>
<label>
Review / Notizen
<textarea
bind:value={review}
rows="4"
placeholder="Was war bemerkenswert? (verschlüsselt gespeichert)"
></textarea>
</label>
<label class="checkbox">
<input type="checkbox" bind:checked={isFavorite} />
Favorit
</label>
<div class="actions">
<button type="button" class="secondary" onclick={() => onclose?.()}>Abbrechen</button>
<button type="submit" class="primary" disabled={!title.trim()}>
{mode === 'edit' ? 'Speichern' : 'Anlegen'}
</button>
</div>
</form>
<style>
.form {
display: flex;
flex-direction: column;
gap: 0.85rem;
background: var(--color-background, white);
padding: 1.25rem;
border-radius: 0.75rem;
max-width: 640px;
width: 100%;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
}
header h2 {
margin: 0;
font-size: 1.2rem;
}
.close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--color-text-muted, #64748b);
padding: 0 0.25rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
color: var(--color-text-muted, #64748b);
}
label.checkbox {
flex-direction: row;
align-items: center;
gap: 0.5rem;
color: inherit;
}
input,
select,
textarea {
padding: 0.55rem 0.7rem;
border-radius: 0.45rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.12));
background: var(--color-surface, transparent);
font: inherit;
color: inherit;
}
input:focus,
select:focus,
textarea:focus {
outline: 2px solid #a855f7;
outline-offset: 1px;
border-color: transparent;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.row .year {
max-width: 140px;
}
fieldset {
border: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
legend {
font-size: 0.85rem;
color: var(--color-text-muted, #64748b);
padding: 0;
}
.kind-chips {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
}
.kind-chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.45rem 0.85rem;
border-radius: 0.55rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.12));
background: transparent;
cursor: pointer;
font-size: 0.9rem;
color: inherit;
}
.kind-chip.active {
background: color-mix(in srgb, #a855f7 15%, transparent);
border-color: #a855f7;
color: #a855f7;
}
.emoji {
font-size: 1.1rem;
}
.genre-chips {
display: flex;
gap: 0.3rem;
flex-wrap: wrap;
}
.genre-chip {
padding: 0.2rem 0.65rem;
border-radius: 999px;
border: 1px solid transparent;
background: var(--color-surface, rgba(0, 0, 0, 0.04));
cursor: pointer;
font-size: 0.78rem;
color: inherit;
}
.genre-chip.active {
background: color-mix(in srgb, #a855f7 14%, transparent);
border-color: #a855f7;
color: #a855f7;
}
.details-section {
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.08));
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
}
.details-section summary {
cursor: pointer;
font-size: 0.88rem;
font-weight: 500;
}
.details-body {
margin-top: 0.6rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 0.25rem;
}
.primary,
.secondary {
padding: 0.55rem 1.15rem;
border-radius: 0.5rem;
border: none;
cursor: pointer;
font: inherit;
font-weight: 500;
}
.primary {
background: #a855f7;
color: white;
}
.primary:disabled {
background: var(--color-surface-muted, rgba(0, 0, 0, 0.08));
color: var(--color-text-muted, #64748b);
cursor: not-allowed;
}
.secondary {
background: var(--color-surface, rgba(0, 0, 0, 0.05));
color: inherit;
}
</style>

View file

@ -0,0 +1,91 @@
<script lang="ts">
import { KIND_LABELS } from '../constants';
import type { LibraryKind } from '../types';
let {
active,
counts,
onselect,
}: {
active: LibraryKind | 'all';
counts: Record<LibraryKind, number>;
onselect: (kind: LibraryKind | 'all') => void;
} = $props();
const ORDER: LibraryKind[] = ['book', 'movie', 'series', 'comic'];
const total = $derived(ORDER.reduce((s, k) => s + counts[k], 0));
</script>
<div class="tabs" role="tablist">
<button
type="button"
class="tab"
class:active={active === 'all'}
onclick={() => onselect('all')}
role="tab"
aria-selected={active === 'all'}
>
Alle <span class="count">{total}</span>
</button>
{#each ORDER as kind (kind)}
<button
type="button"
class="tab"
class:active={active === kind}
onclick={() => onselect(kind)}
role="tab"
aria-selected={active === kind}
>
<span class="emoji">{KIND_LABELS[kind].emoji}</span>
{KIND_LABELS[kind].de}
<span class="count">{counts[kind]}</span>
</button>
{/each}
</div>
<style>
.tabs {
display: flex;
gap: 0.25rem;
overflow-x: auto;
padding-bottom: 0.25rem;
}
.tab {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.45rem 0.85rem;
border-radius: 0.6rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.08));
background: transparent;
cursor: pointer;
font-size: 0.9rem;
color: var(--color-text, inherit);
white-space: nowrap;
transition:
background 0.15s ease,
border-color 0.15s ease;
}
.tab:hover {
background: var(--color-surface, rgba(0, 0, 0, 0.04));
}
.tab.active {
background: color-mix(in srgb, #a855f7 12%, transparent);
border-color: #a855f7;
color: #a855f7;
font-weight: 500;
}
.emoji {
font-size: 1rem;
}
.count {
font-size: 0.75rem;
opacity: 0.7;
background: var(--color-surface-muted, rgba(0, 0, 0, 0.05));
padding: 0.05rem 0.4rem;
border-radius: 999px;
}
.tab.active .count {
background: color-mix(in srgb, #a855f7 20%, transparent);
}
</style>

View file

@ -0,0 +1,90 @@
<script lang="ts">
let {
value,
onchange,
readonly = false,
size = 'sm',
}: {
value: number | null;
onchange?: (next: number | null) => void;
readonly?: boolean;
size?: 'sm' | 'md' | 'lg';
} = $props();
const positions = [1, 2, 3, 4, 5];
function click(pos: number) {
if (readonly || !onchange) return;
if (value === pos) {
onchange(null);
} else {
onchange(pos);
}
}
function half(pos: number): 'full' | 'half' | 'empty' {
const v = value ?? 0;
if (v >= pos) return 'full';
if (v >= pos - 0.5) return 'half';
return 'empty';
}
</script>
<div class="stars" class:md={size === 'md'} class:lg={size === 'lg'} class:readonly>
{#each positions as pos (pos)}
<button
type="button"
class="star state-{half(pos)}"
aria-label={`${pos} Sterne`}
onclick={() => click(pos)}
disabled={readonly}
>
</button>
{/each}
{#if value != null && !readonly}
<span class="value">{value.toFixed(1)}</span>
{/if}
</div>
<style>
.stars {
display: inline-flex;
align-items: center;
gap: 0.1rem;
}
.star {
background: none;
border: none;
padding: 0 0.1rem;
cursor: pointer;
font-size: 1rem;
line-height: 1;
color: #cbd5e1;
transition: color 0.12s ease;
}
.stars.md .star {
font-size: 1.3rem;
}
.stars.lg .star {
font-size: 1.65rem;
}
.star.state-full {
color: #f59e0b;
}
.star.state-half {
color: #fbbf24;
opacity: 0.7;
}
.stars.readonly .star {
cursor: default;
}
.star:hover:not(:disabled) {
color: #f59e0b;
}
.value {
margin-left: 0.35rem;
font-size: 0.8rem;
color: var(--color-text-muted, #64748b);
}
</style>

View file

@ -0,0 +1,27 @@
<script lang="ts">
import { STATUS_LABELS, STATUS_COLORS } from '../constants';
import type { LibraryStatus } from '../types';
let { status, size = 'sm' }: { status: LibraryStatus; size?: 'sm' | 'md' } = $props();
</script>
<span class="badge" class:md={size === 'md'} style:--badge-color={STATUS_COLORS[status]}>
{STATUS_LABELS[status].de}
</span>
<style>
.badge {
display: inline-block;
font-size: 0.72rem;
padding: 0.12rem 0.55rem;
border-radius: 999px;
color: var(--badge-color);
background: color-mix(in srgb, var(--badge-color) 15%, transparent);
font-weight: 500;
white-space: nowrap;
}
.badge.md {
font-size: 0.85rem;
padding: 0.25rem 0.75rem;
}
</style>

View file

@ -0,0 +1,56 @@
<script lang="ts">
import { STATUS_LABELS } from '../constants';
import type { LibraryStatus } from '../types';
let {
active,
onselect,
}: {
active: LibraryStatus | null;
onselect: (status: LibraryStatus | null) => void;
} = $props();
const ORDER: LibraryStatus[] = ['planned', 'active', 'completed', 'paused', 'dropped'];
</script>
<div class="chips">
<button type="button" class="chip" class:active={active === null} onclick={() => onselect(null)}>
Alle Status
</button>
{#each ORDER as status (status)}
<button
type="button"
class="chip"
class:active={active === status}
onclick={() => onselect(status)}
>
{STATUS_LABELS[status].de}
</button>
{/each}
</div>
<style>
.chips {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
}
.chip {
padding: 0.3rem 0.7rem;
border-radius: 999px;
border: 1px solid transparent;
background: var(--color-surface, rgba(0, 0, 0, 0.04));
cursor: pointer;
font-size: 0.8rem;
color: var(--color-text-muted, #64748b);
transition: all 0.12s ease;
}
.chip:hover {
background: var(--color-surface-muted, rgba(0, 0, 0, 0.08));
}
.chip.active {
background: color-mix(in srgb, #a855f7 14%, transparent);
color: #a855f7;
border-color: #a855f7;
}
</style>

View file

@ -0,0 +1,351 @@
<script lang="ts">
import { goto } from '$app/navigation';
import CoverImage from '../components/CoverImage.svelte';
import StatusBadge from '../components/StatusBadge.svelte';
import RatingStars from '../components/RatingStars.svelte';
import EntryForm from '../components/EntryForm.svelte';
import { KIND_LABELS, STATUS_LABELS, BOOK_FORMAT_LABELS } from '../constants';
import { libraryEntriesStore } from '../stores/entries.svelte';
import type { LibraryEntry, LibraryStatus } from '../types';
let { entry }: { entry: LibraryEntry } = $props();
let editing = $state(false);
const STATUS_ORDER: LibraryStatus[] = ['planned', 'active', 'completed', 'paused', 'dropped'];
async function onRatingChange(next: number | null) {
await libraryEntriesStore.rate(entry.id, next);
}
async function onStatusChange(next: LibraryStatus) {
await libraryEntriesStore.setStatus(entry.id, next);
}
async function onToggleFavorite() {
await libraryEntriesStore.toggleFavorite(entry.id);
}
async function onDelete() {
if (!confirm(`Eintrag "${entry.title}" wirklich löschen?`)) return;
await libraryEntriesStore.deleteEntry(entry.id);
goto('/library');
}
</script>
<div class="detail">
<a href="/library" class="back">← Zurück zur Bibliothek</a>
{#if editing}
<EntryForm mode="edit" initial={entry} onclose={() => (editing = false)} />
{:else}
<div class="layout">
<div class="cover-col">
<CoverImage url={entry.coverUrl} kind={entry.kind} alt={entry.title} size="lg" />
<div class="cover-actions">
<button type="button" onclick={() => (editing = true)} class="primary">
Bearbeiten
</button>
<button
type="button"
onclick={onToggleFavorite}
class="icon-btn"
aria-label={entry.isFavorite ? 'Favorit entfernen' : 'Favorisieren'}
>
{entry.isFavorite ? '★' : '☆'}
</button>
<button type="button" onclick={onDelete} class="icon-btn danger" aria-label="Löschen">
🗑
</button>
</div>
</div>
<div class="meta-col">
<div class="kind-pill">
{KIND_LABELS[entry.kind].emoji}
{KIND_LABELS[entry.kind].de}
</div>
<h1>{entry.title}</h1>
{#if entry.originalTitle && entry.originalTitle !== entry.title}
<p class="original">{entry.originalTitle}</p>
{/if}
{#if entry.creators.length > 0}
<p class="creators">{entry.creators.join(' · ')}</p>
{/if}
{#if entry.year}<p class="year">{entry.year}</p>{/if}
<div class="rating-row">
<RatingStars value={entry.rating} onchange={onRatingChange} size="md" />
</div>
<div class="status-row">
{#each STATUS_ORDER as s (s)}
<button
type="button"
class="status-pill"
class:active={entry.status === s}
onclick={() => onStatusChange(s)}
>
{STATUS_LABELS[s].de}
</button>
{/each}
</div>
{#if entry.times > 0}
<p class="times">
{entry.kind === 'book' || entry.kind === 'comic' ? 'Gelesen' : 'Gesehen'}: {entry.times}×
</p>
{/if}
{#if entry.genres.length > 0 || entry.tags.length > 0}
<div class="tag-row">
{#each entry.genres as g (g)}
<span class="genre">{g}</span>
{/each}
{#each entry.tags as t (t)}
<span class="tag">#{t}</span>
{/each}
</div>
{/if}
<dl class="details">
{#if entry.details.kind === 'book'}
{#if entry.details.pages}
<dt>Seiten</dt>
<dd>
{entry.details.currentPage
? `${entry.details.currentPage} / ${entry.details.pages}`
: entry.details.pages}
</dd>
{/if}
{#if entry.details.format}
<dt>Format</dt>
<dd>{BOOK_FORMAT_LABELS[entry.details.format].de}</dd>
{/if}
{:else if entry.details.kind === 'movie'}
{#if entry.details.runtimeMin}
<dt>Laufzeit</dt>
<dd>{entry.details.runtimeMin} min</dd>
{/if}
{#if entry.details.director}
<dt>Regie</dt>
<dd>{entry.details.director}</dd>
{/if}
{:else if entry.details.kind === 'series'}
{#if entry.details.totalSeasons}
<dt>Staffeln</dt>
<dd>{entry.details.totalSeasons}</dd>
{/if}
{#if entry.details.totalEpisodes}
<dt>Episoden</dt>
<dd>{entry.details.totalEpisodes}</dd>
{/if}
{:else if entry.details.kind === 'comic'}
{#if entry.details.publisher}
<dt>Verlag</dt>
<dd>{entry.details.publisher}</dd>
{/if}
{#if entry.details.issueCount}
<dt>Ausgaben</dt>
<dd>
{entry.details.currentIssue
? `${entry.details.currentIssue} / ${entry.details.issueCount}`
: entry.details.issueCount}
</dd>
{/if}
{#if entry.details.isOngoing}
<dt>Status</dt>
<dd>laufend</dd>
{/if}
{/if}
{#if entry.startedAt}
<dt>Gestartet</dt>
<dd>{entry.startedAt}</dd>
{/if}
{#if entry.completedAt}
<dt>Fertig</dt>
<dd>{entry.completedAt}</dd>
{/if}
</dl>
{#if entry.review}
<section class="review">
<h2>Review</h2>
<p>{entry.review}</p>
</section>
{/if}
</div>
</div>
{/if}
</div>
<style>
.detail {
max-width: 960px;
margin: 0 auto;
padding: 1.5rem;
}
.back {
display: inline-block;
margin-bottom: 1rem;
color: var(--color-text-muted, #64748b);
text-decoration: none;
font-size: 0.9rem;
}
.back:hover {
color: #a855f7;
}
.layout {
display: grid;
grid-template-columns: 220px 1fr;
gap: 2rem;
}
@media (max-width: 700px) {
.layout {
grid-template-columns: 1fr;
}
}
.cover-col {
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: center;
}
.cover-actions {
display: flex;
gap: 0.5rem;
width: 100%;
}
.primary {
flex: 1;
padding: 0.5rem 0.85rem;
border-radius: 0.5rem;
border: none;
background: #a855f7;
color: white;
cursor: pointer;
font: inherit;
}
.icon-btn {
padding: 0.4rem 0.65rem;
border-radius: 0.5rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
background: transparent;
cursor: pointer;
font-size: 1.05rem;
color: inherit;
}
.icon-btn.danger:hover {
background: color-mix(in srgb, #ef4444 12%, transparent);
border-color: #ef4444;
color: #ef4444;
}
.meta-col {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.kind-pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.78rem;
color: #a855f7;
font-weight: 500;
}
.meta-col h1 {
margin: 0;
font-size: 1.85rem;
line-height: 1.15;
}
.original {
font-style: italic;
color: var(--color-text-muted, #64748b);
margin: 0;
}
.creators {
margin: 0;
color: var(--color-text-muted, #64748b);
}
.year {
margin: 0;
color: var(--color-text-muted, #64748b);
font-size: 0.9rem;
}
.rating-row {
margin-top: 0.5rem;
}
.status-row {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
margin: 0.6rem 0;
}
.status-pill {
padding: 0.25rem 0.7rem;
border-radius: 999px;
border: 1px solid transparent;
background: var(--color-surface, rgba(0, 0, 0, 0.04));
cursor: pointer;
font: inherit;
font-size: 0.8rem;
color: var(--color-text-muted, #64748b);
}
.status-pill.active {
background: color-mix(in srgb, #a855f7 14%, transparent);
color: #a855f7;
border-color: #a855f7;
}
.times {
font-size: 0.85rem;
color: var(--color-text-muted, #64748b);
}
.tag-row {
display: flex;
gap: 0.3rem;
flex-wrap: wrap;
margin: 0.3rem 0;
}
.genre {
padding: 0.15rem 0.55rem;
border-radius: 999px;
background: color-mix(in srgb, #a855f7 10%, transparent);
color: #a855f7;
font-size: 0.76rem;
}
.tag {
padding: 0.15rem 0.55rem;
border-radius: 999px;
background: var(--color-surface, rgba(0, 0, 0, 0.05));
font-size: 0.76rem;
color: var(--color-text-muted, #64748b);
}
.details {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.25rem 0.85rem;
margin: 0.6rem 0;
font-size: 0.9rem;
}
.details dt {
color: var(--color-text-muted, #64748b);
}
.details dd {
margin: 0;
}
.review {
margin-top: 1rem;
padding: 0.9rem 1rem;
background: var(--color-surface, rgba(0, 0, 0, 0.03));
border-radius: 0.6rem;
}
.review h2 {
font-size: 0.9rem;
margin: 0 0 0.4rem 0;
color: var(--color-text-muted, #64748b);
}
.review p {
margin: 0;
white-space: pre-wrap;
}
</style>

View file

@ -0,0 +1,46 @@
<script lang="ts">
import EntryCard from '../components/EntryCard.svelte';
import type { LibraryEntry } from '../types';
let {
entries,
onopen,
}: {
entries: LibraryEntry[];
onopen?: (entry: LibraryEntry) => void;
} = $props();
</script>
{#if entries.length === 0}
<div class="empty">
<p>Noch keine Einträge.</p>
<p class="muted">Klick auf <strong>„+ Neu"</strong> um deinen ersten Eintrag anzulegen.</p>
</div>
{:else}
<div class="grid">
{#each entries as entry (entry.id)}
<EntryCard {entry} onclick={onopen} />
{/each}
</div>
{/if}
<style>
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 1rem;
}
.empty {
padding: 2.5rem 1rem;
text-align: center;
border: 1px dashed var(--color-border, rgba(0, 0, 0, 0.12));
border-radius: 0.75rem;
}
.empty p {
margin: 0.25rem 0;
}
.muted {
color: var(--color-text-muted, #64748b);
font-size: 0.9rem;
}
</style>

View file

@ -0,0 +1,35 @@
<script lang="ts">
import { page } from '$app/state';
import DetailView from '$lib/modules/library/views/DetailView.svelte';
import { useAllEntries } from '$lib/modules/library/queries';
const entries$ = useAllEntries();
const entry = $derived(entries$.value.find((e) => e.id === page.params.id));
</script>
<svelte:head>
<title>{entry?.title ?? 'Bibliothek'} - Mana</title>
</svelte:head>
{#if entries$.loading}
<p class="loading">Lädt…</p>
{:else if !entry}
<div class="not-found">
<p>Eintrag nicht gefunden.</p>
<a href="/library">← Zurück zur Bibliothek</a>
</div>
{:else}
<DetailView {entry} />
{/if}
<style>
.loading,
.not-found {
max-width: 640px;
margin: 4rem auto;
text-align: center;
}
.not-found a {
color: #a855f7;
}
</style>