mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 00:01:10 +02:00
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:
parent
8c6502d0ff
commit
364178496a
11 changed files with 1585 additions and 77 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue