mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 06:21:09 +02:00
feat(library): add Bibliothek module — books/movies/series/comics log
M1 skeleton for a new media-consumption module. Single-table design with
a `kind: 'book' | 'movie' | 'series' | 'comic'` discriminator and a
discriminated `details` union for kind-specific fields (pages / runtime /
episode tracker / issue count). Shared kern: status, rating, review,
favourites, times counter, completedAt — which enables cross-media
queries like a year-in-review.
Dexie migration v26 was already registered in module-registry.ts /
database.ts via the preceding wetter commit (62aac6dfd); this commit
adds the actual module code, encryption registry entry, app-icon,
MANA_APPS entry, Kreativität & Medien category row, and the module
plan at docs/plans/library-module.md.
Encrypted fields (via ENCRYPTION_REGISTRY):
title, originalTitle, creators, review, tags
Plaintext (intentional):
kind, status, year, rating, genres, completedAt, isFavorite, times,
externalIds, details — all needed for the tab filter, status chips,
Jahresrückblick range-scan, and progress UIs.
Product decisions (frozen in the plan):
- audiobooks = kind='book' with details.format='audio'
- manga = kind='comic' (no sub-discriminator)
- metadata lookup (M7) lands as an endpoint in apps/api, not a
standalone service
Guest seed ships one example per kind (Dune, Arrival, Severance, Saga)
so first-run users immediately see what the module does.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
62aac6dfdb
commit
8c6502d0ff
14 changed files with 996 additions and 2 deletions
|
|
@ -518,6 +518,25 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
||||||
// and belong under encryption. Policy + budgets + state are pure
|
// and belong under encryption. Policy + budgets + state are pure
|
||||||
// structural fields.
|
// structural fields.
|
||||||
agents: { enabled: true, fields: ['systemPrompt', 'memory'] },
|
agents: { enabled: true, fields: ['systemPrompt', 'memory'] },
|
||||||
|
|
||||||
|
// ─── Library ─────────────────────────────────────────────
|
||||||
|
// Reading / watching log with a kind discriminator (book / movie /
|
||||||
|
// series / comic) in one table. User-typed text (title, original
|
||||||
|
// title, creators, review, tags) is encrypted; structural fields
|
||||||
|
// (kind, status, year, rating, completedAt, genres, isFavorite,
|
||||||
|
// times, externalIds, details) stay plaintext — they drive the
|
||||||
|
// tab filter, the status chips, the Jahresrückblick query, and
|
||||||
|
// the episode/page progress UI.
|
||||||
|
//
|
||||||
|
// `details` is the discriminated union and sometimes carries
|
||||||
|
// free-text (publisher, director). Those are factual metadata,
|
||||||
|
// not user-typed reflection, so they ship plaintext alongside the
|
||||||
|
// other structural fields. If a future feature adds free-text
|
||||||
|
// notes *inside* details, add that specific path here.
|
||||||
|
libraryEntries: {
|
||||||
|
enabled: true,
|
||||||
|
fields: ['title', 'originalTitle', 'creators', 'review', 'tags'],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
120
apps/mana/apps/web/src/lib/modules/library/ListView.svelte
Normal file
120
apps/mana/apps/web/src/lib/modules/library/ListView.svelte
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
<!--
|
||||||
|
Library — ListView (M1 skeleton)
|
||||||
|
Minimal placeholder that lists all entries grouped by kind. The full
|
||||||
|
grid + KindTabs + DetailView land in M2.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { useAllEntries, groupByKind } from './queries';
|
||||||
|
import { KIND_LABELS, STATUS_LABELS } from './constants';
|
||||||
|
import type { LibraryKind } from './types';
|
||||||
|
|
||||||
|
const entries$ = useAllEntries();
|
||||||
|
const entries = $derived(entries$.value);
|
||||||
|
const grouped = $derived(groupByKind(entries));
|
||||||
|
|
||||||
|
const KIND_ORDER: LibraryKind[] = ['book', 'movie', 'series', 'comic'];
|
||||||
|
</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>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if entries$.loading}
|
||||||
|
<p class="muted">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}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.library-shell {
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
.library-header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.library-header h1 {
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.entry-row {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--color-surface, rgba(0, 0, 0, 0.03));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.entry-title {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.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));
|
||||||
|
}
|
||||||
|
.rating {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
110
apps/mana/apps/web/src/lib/modules/library/collections.ts
Normal file
110
apps/mana/apps/web/src/lib/modules/library/collections.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
/**
|
||||||
|
* Library module — Dexie accessors and guest seed.
|
||||||
|
*
|
||||||
|
* Table name: `libraryEntries` (single-table design with `kind` discriminator).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { db } from '$lib/data/database';
|
||||||
|
import type { LocalLibraryEntry } from './types';
|
||||||
|
|
||||||
|
export const libraryEntryTable = db.table<LocalLibraryEntry>('libraryEntries');
|
||||||
|
|
||||||
|
// ─── Guest Seed ────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// One entry per kind so first-run users immediately see what the module is
|
||||||
|
// for without needing to create anything. Content is the kind of thing a
|
||||||
|
// curious user would recognise.
|
||||||
|
|
||||||
|
export const LIBRARY_GUEST_SEED = {
|
||||||
|
libraryEntries: [
|
||||||
|
{
|
||||||
|
id: 'demo-book-dune',
|
||||||
|
kind: 'book' as const,
|
||||||
|
status: 'completed' as const,
|
||||||
|
title: 'Dune',
|
||||||
|
originalTitle: 'Dune',
|
||||||
|
creators: ['Frank Herbert'],
|
||||||
|
year: 1965,
|
||||||
|
coverUrl: null,
|
||||||
|
coverMediaId: null,
|
||||||
|
rating: 4.5,
|
||||||
|
review: null,
|
||||||
|
tags: ['klassiker'],
|
||||||
|
genres: ['Sci-Fi'],
|
||||||
|
startedAt: null,
|
||||||
|
completedAt: '2026-01-15',
|
||||||
|
isFavorite: true,
|
||||||
|
times: 1,
|
||||||
|
externalIds: null,
|
||||||
|
details: { kind: 'book' as const, pages: 688, format: 'paperback' as const },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-movie-arrival',
|
||||||
|
kind: 'movie' as const,
|
||||||
|
status: 'completed' as const,
|
||||||
|
title: 'Arrival',
|
||||||
|
originalTitle: null,
|
||||||
|
creators: ['Denis Villeneuve'],
|
||||||
|
year: 2016,
|
||||||
|
coverUrl: null,
|
||||||
|
coverMediaId: null,
|
||||||
|
rating: 4,
|
||||||
|
review: null,
|
||||||
|
tags: [],
|
||||||
|
genres: ['Sci-Fi', 'Drama'],
|
||||||
|
startedAt: null,
|
||||||
|
completedAt: '2026-02-08',
|
||||||
|
isFavorite: false,
|
||||||
|
times: 1,
|
||||||
|
externalIds: null,
|
||||||
|
details: { kind: 'movie' as const, runtimeMin: 116, director: 'Denis Villeneuve' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-series-severance',
|
||||||
|
kind: 'series' as const,
|
||||||
|
status: 'active' as const,
|
||||||
|
title: 'Severance',
|
||||||
|
originalTitle: null,
|
||||||
|
creators: ['Dan Erickson'],
|
||||||
|
year: 2022,
|
||||||
|
coverUrl: null,
|
||||||
|
coverMediaId: null,
|
||||||
|
rating: null,
|
||||||
|
review: null,
|
||||||
|
tags: [],
|
||||||
|
genres: ['Sci-Fi', 'Thriller'],
|
||||||
|
startedAt: '2026-03-01',
|
||||||
|
completedAt: null,
|
||||||
|
isFavorite: false,
|
||||||
|
times: 0,
|
||||||
|
externalIds: null,
|
||||||
|
details: { kind: 'series' as const, totalSeasons: 2, totalEpisodes: 19, watched: [] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-comic-saga',
|
||||||
|
kind: 'comic' as const,
|
||||||
|
status: 'active' as const,
|
||||||
|
title: 'Saga',
|
||||||
|
originalTitle: null,
|
||||||
|
creators: ['Brian K. Vaughan', 'Fiona Staples'],
|
||||||
|
year: 2012,
|
||||||
|
coverUrl: null,
|
||||||
|
coverMediaId: null,
|
||||||
|
rating: null,
|
||||||
|
review: null,
|
||||||
|
tags: [],
|
||||||
|
genres: ['Sci-Fi', 'Fantasy'],
|
||||||
|
startedAt: null,
|
||||||
|
completedAt: null,
|
||||||
|
isFavorite: false,
|
||||||
|
times: 0,
|
||||||
|
externalIds: null,
|
||||||
|
details: {
|
||||||
|
kind: 'comic' as const,
|
||||||
|
currentIssue: 12,
|
||||||
|
publisher: 'Image Comics',
|
||||||
|
isOngoing: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
48
apps/mana/apps/web/src/lib/modules/library/constants.ts
Normal file
48
apps/mana/apps/web/src/lib/modules/library/constants.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import type { LibraryKind, LibraryStatus, BookFormat } from './types';
|
||||||
|
|
||||||
|
export const KIND_LABELS: Record<LibraryKind, { de: string; en: string; emoji: string }> = {
|
||||||
|
book: { de: 'Bücher', en: 'Books', emoji: '📚' },
|
||||||
|
movie: { de: 'Filme', en: 'Movies', emoji: '🎬' },
|
||||||
|
series: { de: 'Serien', en: 'Series', emoji: '📺' },
|
||||||
|
comic: { de: 'Comics', en: 'Comics', emoji: '💥' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STATUS_LABELS: Record<LibraryStatus, { de: string; en: string }> = {
|
||||||
|
planned: { de: 'Geplant', en: 'Planned' },
|
||||||
|
active: { de: 'Läuft', en: 'In progress' },
|
||||||
|
completed: { de: 'Fertig', en: 'Completed' },
|
||||||
|
paused: { de: 'Pausiert', en: 'Paused' },
|
||||||
|
dropped: { de: 'Abgebrochen', en: 'Dropped' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STATUS_COLORS: Record<LibraryStatus, string> = {
|
||||||
|
planned: '#64748b',
|
||||||
|
active: '#3b82f6',
|
||||||
|
completed: '#22c55e',
|
||||||
|
paused: '#f59e0b',
|
||||||
|
dropped: '#ef4444',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BOOK_FORMAT_LABELS: Record<BookFormat, { de: string; en: string }> = {
|
||||||
|
hardcover: { de: 'Hardcover', en: 'Hardcover' },
|
||||||
|
paperback: { de: 'Taschenbuch', en: 'Paperback' },
|
||||||
|
ebook: { de: 'E-Book', en: 'E-Book' },
|
||||||
|
audio: { de: 'Hörbuch', en: 'Audiobook' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_GENRES = [
|
||||||
|
'Sci-Fi',
|
||||||
|
'Fantasy',
|
||||||
|
'Thriller',
|
||||||
|
'Krimi',
|
||||||
|
'Romanze',
|
||||||
|
'Drama',
|
||||||
|
'Komödie',
|
||||||
|
'Horror',
|
||||||
|
'Biografie',
|
||||||
|
'Sachbuch',
|
||||||
|
'Action',
|
||||||
|
'Animation',
|
||||||
|
'Dokumentation',
|
||||||
|
'Historisch',
|
||||||
|
];
|
||||||
42
apps/mana/apps/web/src/lib/modules/library/index.ts
Normal file
42
apps/mana/apps/web/src/lib/modules/library/index.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
/**
|
||||||
|
* Library module — barrel exports.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { libraryEntriesStore } from './stores/entries.svelte';
|
||||||
|
|
||||||
|
export {
|
||||||
|
useAllEntries,
|
||||||
|
toLibraryEntry,
|
||||||
|
filterByKind,
|
||||||
|
filterByStatus,
|
||||||
|
searchEntries,
|
||||||
|
groupByKind,
|
||||||
|
computeStats,
|
||||||
|
} from './queries';
|
||||||
|
|
||||||
|
export { libraryEntryTable, LIBRARY_GUEST_SEED } from './collections';
|
||||||
|
|
||||||
|
export {
|
||||||
|
KIND_LABELS,
|
||||||
|
STATUS_LABELS,
|
||||||
|
STATUS_COLORS,
|
||||||
|
BOOK_FORMAT_LABELS,
|
||||||
|
DEFAULT_GENRES,
|
||||||
|
} from './constants';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
LocalLibraryEntry,
|
||||||
|
LibraryEntry,
|
||||||
|
LibraryKind,
|
||||||
|
LibraryStatus,
|
||||||
|
LibraryDetails,
|
||||||
|
BookDetails,
|
||||||
|
MovieDetails,
|
||||||
|
SeriesDetails,
|
||||||
|
ComicDetails,
|
||||||
|
BookFormat,
|
||||||
|
WatchedEpisode,
|
||||||
|
LibraryExternalIds,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export type { LibraryStats } from './queries';
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import type { ModuleConfig } from '$lib/data/module-registry';
|
||||||
|
|
||||||
|
export const libraryModuleConfig: ModuleConfig = {
|
||||||
|
appId: 'library',
|
||||||
|
tables: [{ name: 'libraryEntries' }],
|
||||||
|
};
|
||||||
112
apps/mana/apps/web/src/lib/modules/library/queries.ts
Normal file
112
apps/mana/apps/web/src/lib/modules/library/queries.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
/**
|
||||||
|
* Reactive queries and pure helpers for the Library module.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||||
|
import { decryptRecords } from '$lib/data/crypto';
|
||||||
|
import { db } from '$lib/data/database';
|
||||||
|
import type { LocalLibraryEntry, LibraryEntry, LibraryKind, LibraryStatus } from './types';
|
||||||
|
|
||||||
|
// ─── Type Converter ──────────────────────────────────────
|
||||||
|
|
||||||
|
export function toLibraryEntry(local: LocalLibraryEntry): LibraryEntry {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
return {
|
||||||
|
id: local.id,
|
||||||
|
kind: local.kind,
|
||||||
|
status: local.status,
|
||||||
|
title: local.title,
|
||||||
|
originalTitle: local.originalTitle ?? null,
|
||||||
|
creators: local.creators ?? [],
|
||||||
|
year: local.year ?? null,
|
||||||
|
coverUrl: local.coverUrl ?? null,
|
||||||
|
coverMediaId: local.coverMediaId ?? null,
|
||||||
|
rating: local.rating ?? null,
|
||||||
|
review: local.review ?? null,
|
||||||
|
tags: local.tags ?? [],
|
||||||
|
genres: local.genres ?? [],
|
||||||
|
startedAt: local.startedAt ?? null,
|
||||||
|
completedAt: local.completedAt ?? null,
|
||||||
|
isFavorite: local.isFavorite ?? false,
|
||||||
|
times: local.times ?? 0,
|
||||||
|
externalIds: local.externalIds ?? null,
|
||||||
|
details: local.details,
|
||||||
|
createdAt: local.createdAt ?? now,
|
||||||
|
updatedAt: local.updatedAt ?? now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Live Queries ─────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useAllEntries() {
|
||||||
|
return useLiveQueryWithDefault(async () => {
|
||||||
|
const locals = await db.table<LocalLibraryEntry>('libraryEntries').toArray();
|
||||||
|
const visible = locals.filter((e) => !e.deletedAt);
|
||||||
|
const decrypted = await decryptRecords('libraryEntries', visible);
|
||||||
|
return decrypted.map(toLibraryEntry);
|
||||||
|
}, [] as LibraryEntry[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Pure Helpers ─────────────────────────────────────────
|
||||||
|
|
||||||
|
export function filterByKind(entries: LibraryEntry[], kind: LibraryKind): LibraryEntry[] {
|
||||||
|
return entries.filter((e) => e.kind === kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterByStatus(entries: LibraryEntry[], status: LibraryStatus): LibraryEntry[] {
|
||||||
|
return entries.filter((e) => e.status === status);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchEntries(entries: LibraryEntry[], query: string): LibraryEntry[] {
|
||||||
|
const lower = query.toLowerCase();
|
||||||
|
return entries.filter(
|
||||||
|
(e) =>
|
||||||
|
e.title.toLowerCase().includes(lower) ||
|
||||||
|
(e.originalTitle?.toLowerCase().includes(lower) ?? false) ||
|
||||||
|
e.creators.some((c) => c.toLowerCase().includes(lower))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupByKind(entries: LibraryEntry[]): Record<LibraryKind, LibraryEntry[]> {
|
||||||
|
const out: Record<LibraryKind, LibraryEntry[]> = {
|
||||||
|
book: [],
|
||||||
|
movie: [],
|
||||||
|
series: [],
|
||||||
|
comic: [],
|
||||||
|
};
|
||||||
|
for (const e of entries) out[e.kind].push(e);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LibraryStats {
|
||||||
|
totalByKind: Record<LibraryKind, number>;
|
||||||
|
completedThisYear: number;
|
||||||
|
currentlyActive: number;
|
||||||
|
avgRating: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeStats(entries: LibraryEntry[], year: number): LibraryStats {
|
||||||
|
const totalByKind: Record<LibraryKind, number> = { book: 0, movie: 0, series: 0, comic: 0 };
|
||||||
|
let completedThisYear = 0;
|
||||||
|
let currentlyActive = 0;
|
||||||
|
let ratingSum = 0;
|
||||||
|
let ratingCount = 0;
|
||||||
|
const yearPrefix = String(year);
|
||||||
|
|
||||||
|
for (const e of entries) {
|
||||||
|
totalByKind[e.kind]++;
|
||||||
|
if (e.status === 'active') currentlyActive++;
|
||||||
|
if (e.status === 'completed' && e.completedAt?.startsWith(yearPrefix)) completedThisYear++;
|
||||||
|
if (typeof e.rating === 'number') {
|
||||||
|
ratingSum += e.rating;
|
||||||
|
ratingCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalByKind,
|
||||||
|
completedThisYear,
|
||||||
|
currentlyActive,
|
||||||
|
avgRating: ratingCount > 0 ? ratingSum / ratingCount : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
/**
|
||||||
|
* Library entries store — mutation-only service.
|
||||||
|
*
|
||||||
|
* All reads happen via the liveQuery helpers in queries.ts. Writes go through
|
||||||
|
* this store so the encryption step + event emission are consistent.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { encryptRecord } from '$lib/data/crypto';
|
||||||
|
import { emitDomainEvent } from '$lib/data/events';
|
||||||
|
import { libraryEntryTable } from '../collections';
|
||||||
|
import { toLibraryEntry } from '../queries';
|
||||||
|
import type {
|
||||||
|
LocalLibraryEntry,
|
||||||
|
LibraryKind,
|
||||||
|
LibraryStatus,
|
||||||
|
LibraryDetails,
|
||||||
|
LibraryExternalIds,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
function defaultDetails(kind: LibraryKind): LibraryDetails {
|
||||||
|
switch (kind) {
|
||||||
|
case 'book':
|
||||||
|
return { kind: 'book' };
|
||||||
|
case 'movie':
|
||||||
|
return { kind: 'movie' };
|
||||||
|
case 'series':
|
||||||
|
return { kind: 'series', watched: [] };
|
||||||
|
case 'comic':
|
||||||
|
return { kind: 'comic' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateEntryInput {
|
||||||
|
kind: LibraryKind;
|
||||||
|
title: string;
|
||||||
|
originalTitle?: string | null;
|
||||||
|
creators?: string[];
|
||||||
|
year?: number | null;
|
||||||
|
coverUrl?: string | null;
|
||||||
|
coverMediaId?: string | null;
|
||||||
|
status?: LibraryStatus;
|
||||||
|
rating?: number | null;
|
||||||
|
review?: string | null;
|
||||||
|
tags?: string[];
|
||||||
|
genres?: string[];
|
||||||
|
startedAt?: string | null;
|
||||||
|
completedAt?: string | null;
|
||||||
|
isFavorite?: boolean;
|
||||||
|
externalIds?: LibraryExternalIds | null;
|
||||||
|
details?: LibraryDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const libraryEntriesStore = {
|
||||||
|
async createEntry(input: CreateEntryInput) {
|
||||||
|
const details = input.details ?? defaultDetails(input.kind);
|
||||||
|
if (details.kind !== input.kind) {
|
||||||
|
throw new Error(
|
||||||
|
`[library] details.kind "${details.kind}" does not match entry.kind "${input.kind}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const newLocal: LocalLibraryEntry = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
kind: input.kind,
|
||||||
|
status: input.status ?? 'planned',
|
||||||
|
title: input.title,
|
||||||
|
originalTitle: input.originalTitle ?? null,
|
||||||
|
creators: input.creators ?? [],
|
||||||
|
year: input.year ?? null,
|
||||||
|
coverUrl: input.coverUrl ?? null,
|
||||||
|
coverMediaId: input.coverMediaId ?? null,
|
||||||
|
rating: input.rating ?? null,
|
||||||
|
review: input.review ?? null,
|
||||||
|
tags: input.tags ?? [],
|
||||||
|
genres: input.genres ?? [],
|
||||||
|
startedAt: input.startedAt ?? null,
|
||||||
|
completedAt: input.completedAt ?? null,
|
||||||
|
isFavorite: input.isFavorite ?? false,
|
||||||
|
times: 0,
|
||||||
|
externalIds: input.externalIds ?? null,
|
||||||
|
details,
|
||||||
|
};
|
||||||
|
const snapshot = toLibraryEntry({ ...newLocal });
|
||||||
|
await encryptRecord('libraryEntries', newLocal);
|
||||||
|
await libraryEntryTable.add(newLocal);
|
||||||
|
emitDomainEvent('LibraryEntryCreated', 'library', 'libraryEntries', newLocal.id, {
|
||||||
|
entryId: newLocal.id,
|
||||||
|
kind: input.kind,
|
||||||
|
title: input.title,
|
||||||
|
});
|
||||||
|
return snapshot;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateEntry(
|
||||||
|
id: string,
|
||||||
|
patch: Partial<
|
||||||
|
Pick<
|
||||||
|
LocalLibraryEntry,
|
||||||
|
| 'title'
|
||||||
|
| 'originalTitle'
|
||||||
|
| 'creators'
|
||||||
|
| 'year'
|
||||||
|
| 'coverUrl'
|
||||||
|
| 'coverMediaId'
|
||||||
|
| 'status'
|
||||||
|
| 'rating'
|
||||||
|
| 'review'
|
||||||
|
| 'tags'
|
||||||
|
| 'genres'
|
||||||
|
| 'startedAt'
|
||||||
|
| 'completedAt'
|
||||||
|
| 'isFavorite'
|
||||||
|
| 'externalIds'
|
||||||
|
| 'details'
|
||||||
|
| 'times'
|
||||||
|
>
|
||||||
|
>
|
||||||
|
) {
|
||||||
|
const wrapped = { ...patch } as Record<string, unknown>;
|
||||||
|
await encryptRecord('libraryEntries', wrapped);
|
||||||
|
await libraryEntryTable.update(id, {
|
||||||
|
...wrapped,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async setStatus(id: string, status: LibraryStatus) {
|
||||||
|
const existing = await libraryEntryTable.get(id);
|
||||||
|
if (!existing) return;
|
||||||
|
const nowDate = new Date().toISOString().slice(0, 10);
|
||||||
|
const patch: Partial<LocalLibraryEntry> = { status };
|
||||||
|
if (status === 'active' && !existing.startedAt) patch.startedAt = nowDate;
|
||||||
|
if (status === 'completed') {
|
||||||
|
if (!existing.completedAt) patch.completedAt = nowDate;
|
||||||
|
patch.times = (existing.times ?? 0) + 1;
|
||||||
|
}
|
||||||
|
await libraryEntryTable.update(id, {
|
||||||
|
...patch,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
if (status === 'completed') {
|
||||||
|
emitDomainEvent('LibraryEntryCompleted', 'library', 'libraryEntries', id, {
|
||||||
|
entryId: id,
|
||||||
|
kind: existing.kind,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async rate(id: string, rating: number | null) {
|
||||||
|
await libraryEntryTable.update(id, {
|
||||||
|
rating,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async toggleFavorite(id: string) {
|
||||||
|
const existing = await libraryEntryTable.get(id);
|
||||||
|
if (!existing) return;
|
||||||
|
await libraryEntryTable.update(id, {
|
||||||
|
isFavorite: !existing.isFavorite,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteEntry(id: string) {
|
||||||
|
await libraryEntryTable.update(id, {
|
||||||
|
deletedAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
emitDomainEvent('LibraryEntryDeleted', 'library', 'libraryEntries', id, { entryId: id });
|
||||||
|
},
|
||||||
|
};
|
||||||
112
apps/mana/apps/web/src/lib/modules/library/types.ts
Normal file
112
apps/mana/apps/web/src/lib/modules/library/types.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
/**
|
||||||
|
* Library module types — a single module tracking books, movies, series, comics
|
||||||
|
* the user has consumed (or plans to). Kind-specific metadata lives in the
|
||||||
|
* discriminated `details` union so we keep one table + one route while still
|
||||||
|
* giving each medium its own shape.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { BaseRecord } from '@mana/local-store';
|
||||||
|
|
||||||
|
// ─── Discriminators & Enums ──────────────────────────────
|
||||||
|
|
||||||
|
export type LibraryKind = 'book' | 'movie' | 'series' | 'comic';
|
||||||
|
|
||||||
|
export type LibraryStatus = 'planned' | 'active' | 'completed' | 'paused' | 'dropped';
|
||||||
|
|
||||||
|
export type BookFormat = 'hardcover' | 'paperback' | 'ebook' | 'audio';
|
||||||
|
|
||||||
|
// ─── Kind-specific Details ───────────────────────────────
|
||||||
|
|
||||||
|
export interface BookDetails {
|
||||||
|
kind: 'book';
|
||||||
|
pages?: number | null;
|
||||||
|
currentPage?: number | null;
|
||||||
|
format?: BookFormat | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MovieDetails {
|
||||||
|
kind: 'movie';
|
||||||
|
runtimeMin?: number | null;
|
||||||
|
director?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WatchedEpisode {
|
||||||
|
season: number;
|
||||||
|
episode: number;
|
||||||
|
watchedAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeriesDetails {
|
||||||
|
kind: 'series';
|
||||||
|
totalSeasons?: number | null;
|
||||||
|
totalEpisodes?: number | null;
|
||||||
|
watched?: WatchedEpisode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComicDetails {
|
||||||
|
kind: 'comic';
|
||||||
|
issueCount?: number | null;
|
||||||
|
currentIssue?: number | null;
|
||||||
|
publisher?: string | null;
|
||||||
|
isOngoing?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LibraryDetails = BookDetails | MovieDetails | SeriesDetails | ComicDetails;
|
||||||
|
|
||||||
|
// ─── External Metadata IDs ───────────────────────────────
|
||||||
|
|
||||||
|
export interface LibraryExternalIds {
|
||||||
|
isbn?: string | null;
|
||||||
|
tmdbId?: string | null;
|
||||||
|
openLibraryId?: string | null;
|
||||||
|
comicVineId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Local Record (Dexie) ────────────────────────────────
|
||||||
|
|
||||||
|
export interface LocalLibraryEntry extends BaseRecord {
|
||||||
|
kind: LibraryKind;
|
||||||
|
status: LibraryStatus;
|
||||||
|
title: string;
|
||||||
|
originalTitle?: string | null;
|
||||||
|
creators: string[];
|
||||||
|
year?: number | null;
|
||||||
|
coverUrl?: string | null;
|
||||||
|
coverMediaId?: string | null;
|
||||||
|
rating?: number | null;
|
||||||
|
review?: string | null;
|
||||||
|
tags: string[];
|
||||||
|
genres: string[];
|
||||||
|
startedAt?: string | null;
|
||||||
|
completedAt?: string | null;
|
||||||
|
isFavorite: boolean;
|
||||||
|
times: number;
|
||||||
|
externalIds?: LibraryExternalIds | null;
|
||||||
|
details: LibraryDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Domain Type (plaintext, for UI) ─────────────────────
|
||||||
|
|
||||||
|
export interface LibraryEntry {
|
||||||
|
id: string;
|
||||||
|
kind: LibraryKind;
|
||||||
|
status: LibraryStatus;
|
||||||
|
title: string;
|
||||||
|
originalTitle: string | null;
|
||||||
|
creators: string[];
|
||||||
|
year: number | null;
|
||||||
|
coverUrl: string | null;
|
||||||
|
coverMediaId: string | null;
|
||||||
|
rating: number | null;
|
||||||
|
review: string | null;
|
||||||
|
tags: string[];
|
||||||
|
genres: string[];
|
||||||
|
startedAt: string | null;
|
||||||
|
completedAt: string | null;
|
||||||
|
isFavorite: boolean;
|
||||||
|
times: number;
|
||||||
|
externalIds: LibraryExternalIds | null;
|
||||||
|
details: LibraryDetails;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
9
apps/mana/apps/web/src/routes/(app)/library/+page.svelte
Normal file
9
apps/mana/apps/web/src/routes/(app)/library/+page.svelte
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import ListView from '$lib/modules/library/ListView.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Bibliothek - Mana</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<ListView />
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Mana Module Registry
|
# Mana Module Registry
|
||||||
|
|
||||||
Alle 72 Module der Mana-App (`apps/mana/apps/web/src/lib/modules/`).
|
Alle 73 Module der Mana-App (`apps/mana/apps/web/src/lib/modules/`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -33,7 +33,7 @@ Alle 72 Module der Mana-App (`apps/mana/apps/web/src/lib/modules/`).
|
||||||
| `mood` | Mood | Stimmungs-Tracking mehrmals täglich mit Kontext und Mustern |
|
| `mood` | Mood | Stimmungs-Tracking mehrmals täglich mit Kontext und Mustern |
|
||||||
| `moodlit` | Moodlit | Beruhigende Ambient-Beleuchtung mit animierten Farbverläufen |
|
| `moodlit` | Moodlit | Beruhigende Ambient-Beleuchtung mit animierten Farbverläufen |
|
||||||
|
|
||||||
## Kreativität & Medien (7)
|
## Kreativität & Medien (8)
|
||||||
|
|
||||||
| Modul | Name | Beschreibung |
|
| Modul | Name | Beschreibung |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -43,6 +43,7 @@ Alle 72 Module der Mana-App (`apps/mana/apps/web/src/lib/modules/`).
|
||||||
| `presi` | Presi | Präsentationen mit AI-Design-Vorschlägen |
|
| `presi` | Presi | Präsentationen mit AI-Design-Vorschlägen |
|
||||||
| `cards` | Cards | AI-generierte Flashcards mit Spaced Repetition |
|
| `cards` | Cards | AI-generierte Flashcards mit Spaced Repetition |
|
||||||
| `recipes` | Rezepte | Rezepte sammeln mit Zutaten und Schritten |
|
| `recipes` | Rezepte | Rezepte sammeln mit Zutaten und Schritten |
|
||||||
|
| `library` | Bibliothek | Bücher, Filme, Serien, Comics — Status, Rating, Fortschritt |
|
||||||
| `storage` | Storage | Cloud-Storage mit Ordnern, Versionierung und Sharing |
|
| `storage` | Storage | Cloud-Storage mit Ordnern, Versionierung und Sharing |
|
||||||
|
|
||||||
## Wissen & Lernen (7)
|
## Wissen & Lernen (7)
|
||||||
|
|
|
||||||
221
docs/plans/library-module.md
Normal file
221
docs/plans/library-module.md
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
# Library — Module Plan
|
||||||
|
|
||||||
|
## Status (2026-04-17)
|
||||||
|
|
||||||
|
**M1 Skelett: DONE.** Modul registriert, Dexie v26 angelegt, Encryption-Registry
|
||||||
|
eingetragen, Route `/library` mountet einen minimalen Listen-View. Guest-Seed mit
|
||||||
|
je einem Eintrag pro `kind` (Dune, Arrival, Severance, Saga) vorhanden.
|
||||||
|
Nächster Schritt: M2 (volles CRUD + Form + Grid + DetailView).
|
||||||
|
|
||||||
|
Vor M2 entschieden:
|
||||||
|
- Audiobooks: `kind='book'` mit `details.format='audio'` (nicht eigener `kind`).
|
||||||
|
- Manga: in `kind='comic'` ohne Sub-Typ (bis Bedarf für Chapters vs. Issues auftaucht).
|
||||||
|
- Metadata-Lookup (M7): Endpoint in `apps/api` (`/api/v1/library/lookup?kind=...&q=...`),
|
||||||
|
kein eigener Service — Extraktion erst bei Crawler-artigem Bedarf.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Ein einziges Modul, mit dem der Nutzer **konsumierte Medien** festhält: Bücher, Filme, Serien, Comics. Kernfrage: *"Habe ich das schon gesehen/gelesen? Wann? Wie fand ich's?"*
|
||||||
|
|
||||||
|
Nicht im Scope: Streaming-Integration, Kauf-Tracking, Leseplan-Automatisierung. Kein Ersatz für Goodreads/Letterboxd — eher privates Log mit Rating und Fortschritt.
|
||||||
|
|
||||||
|
## Abgrenzung
|
||||||
|
|
||||||
|
- **Kein `inventory`**: dort geht's um Besitz (Seriennummer, Garantie, Standort). Hier geht's um Konsum — ein Buch, das man aus der Bibliothek ausgeliehen und gelesen hat, gehört hierher, nicht in `inventory`.
|
||||||
|
- **Kein `music`**: Musik hat eigene Primitive (Playlists, Projekte). Soundtracks landen weiter in `music/`.
|
||||||
|
- **Kein `photos`/`picture`**: Fotos und AI-Bilder bleiben getrennt.
|
||||||
|
- **Cross-Link zu `goals`**: "Lese 20 Bücher 2026" bleibt im Ziel-Modul, liest aber `library.completedAt` über die bestehende Cross-Module-Mechanik.
|
||||||
|
|
||||||
|
## Entscheidung: ein Modul, vier Typen
|
||||||
|
|
||||||
|
Ein Modul `library` mit Diskriminator `kind: 'book' | 'movie' | 'series' | 'comic'`. Geteiltes Kern-Schema; typ-spezifische Felder in `details: jsonb`. Begründung:
|
||||||
|
|
||||||
|
- Ein Sync-Endpoint, eine Encryption-Registry-Zeile, eine Route, ein Settings-Panel
|
||||||
|
- Quer-Abfragen ("Jahresrückblick über alles") fallen gratis ab
|
||||||
|
- Konsistenz mit `inventory` (auch ein Typ-übergreifendes "Sammlung"-Modul)
|
||||||
|
- UI kann trotzdem Tabs pro Typ zeigen und typ-spezifische Listenansichten/Detail-Views laden
|
||||||
|
|
||||||
|
Der Tradeoff: pro Typ hat man kein eigenes Launcher-Icon. Falls das später wichtig wird, kann ein Typ als eigenes Modul mit Alias auf dieselbe Tabelle ausgegliedert werden — das Schema muss dafür nicht brechen.
|
||||||
|
|
||||||
|
## Modul-Struktur
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/mana/apps/web/src/lib/modules/library/
|
||||||
|
├── types.ts # LocalLibraryEntry, LibraryEntry, Kind, Status, kind-spezifische Detail-Typen
|
||||||
|
├── collections.ts # libraryEntries-Table + Guest-Seed (1 Buch, 1 Film, 1 Serie, 1 Comic)
|
||||||
|
├── queries.ts # useAllEntries, useEntriesByKind, useEntry(id), useStats
|
||||||
|
├── stores/
|
||||||
|
│ └── entries.svelte.ts # createEntry, updateEntry, setStatus, rate, bumpProgress, deleteEntry
|
||||||
|
├── components/
|
||||||
|
│ ├── EntryCard.svelte # kompakter Listeneintrag (Cover + Titel + Rating + Status-Badge)
|
||||||
|
│ ├── EntryForm.svelte # Create/Edit — rendert typ-spezifische Felder aus details
|
||||||
|
│ ├── KindTabs.svelte # Filter-Tabs (Alle | Bücher | Filme | Serien | Comics)
|
||||||
|
│ ├── RatingStars.svelte # 0–5 Sterne
|
||||||
|
│ ├── StatusBadge.svelte # geplant / läuft / fertig / abgebrochen
|
||||||
|
│ ├── EpisodeTracker.svelte # nur für kind='series': Staffel/Episode checkliste
|
||||||
|
│ └── CoverImage.svelte # lazy-load, Fallback-Platzhalter pro Typ
|
||||||
|
├── views/
|
||||||
|
│ ├── GridView.svelte # Cover-Grid (Default)
|
||||||
|
│ ├── ListView.svelte # Kompakte Liste mit Filterung
|
||||||
|
│ └── DetailView.svelte # Einzelansicht inkl. Review + Re-Watches/Re-Reads
|
||||||
|
├── tools.ts # AI-Tools (später — siehe AI-Integration)
|
||||||
|
├── constants.ts # KIND_LABELS, STATUS_LABELS, DEFAULT_TAGS
|
||||||
|
├── ListView.svelte # Modul-Root-View (komponiert KindTabs + GridView)
|
||||||
|
├── module.config.ts # { appId: 'library', tables: [{ name: 'libraryEntries' }] }
|
||||||
|
└── index.ts # Re-Exports
|
||||||
|
```
|
||||||
|
|
||||||
|
## Daten-Schema
|
||||||
|
|
||||||
|
### `LocalLibraryEntry` (Dexie)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type LibraryKind = 'book' | 'movie' | 'series' | 'comic';
|
||||||
|
export type LibraryStatus = 'planned' | 'active' | 'completed' | 'dropped' | 'paused';
|
||||||
|
|
||||||
|
export interface LocalLibraryEntry extends BaseRecord {
|
||||||
|
kind: LibraryKind; // plaintext — Discriminator, filterbar
|
||||||
|
status: LibraryStatus; // plaintext — filterbar
|
||||||
|
title: string; // encrypted
|
||||||
|
originalTitle?: string | null; // encrypted (z.B. engl. Original)
|
||||||
|
creators: string[]; // encrypted — Autor / Regie / Showrunner / Zeichner
|
||||||
|
year?: number | null; // plaintext
|
||||||
|
coverUrl?: string | null; // plaintext (externe URL) ODER
|
||||||
|
coverMediaId?: string | null; // plaintext (Referenz in uload/media)
|
||||||
|
rating?: number | null; // plaintext — 0..5, Schritt 0.5
|
||||||
|
review?: string | null; // encrypted — Freitext
|
||||||
|
tags: string[]; // encrypted
|
||||||
|
genres: string[]; // plaintext — "Sci-Fi", "Thriller"...
|
||||||
|
startedAt?: string | null; // plaintext ISO-Datum
|
||||||
|
completedAt?: string | null; // plaintext — für Jahresrückblick / Ziele
|
||||||
|
isFavorite: boolean; // plaintext
|
||||||
|
times: number; // plaintext — Zähler "Re-Reads / Re-Watches"
|
||||||
|
externalIds?: { // plaintext — für spätere Metadata-Sync
|
||||||
|
isbn?: string;
|
||||||
|
tmdbId?: string;
|
||||||
|
openLibraryId?: string;
|
||||||
|
comicVineId?: string;
|
||||||
|
} | null;
|
||||||
|
details: LibraryDetails; // typ-spezifische Felder — siehe unten
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `details` pro `kind`
|
||||||
|
|
||||||
|
Diskriminierte Union, damit TypeScript Typ-Sicherheit gibt:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type LibraryDetails =
|
||||||
|
| { kind: 'book'; pages?: number; currentPage?: number; format?: 'hardcover' | 'paperback' | 'ebook' | 'audio' }
|
||||||
|
| { kind: 'movie'; runtimeMin?: number; director?: string }
|
||||||
|
| { kind: 'series'; totalSeasons?: number; totalEpisodes?: number; watched?: Array<{ season: number; episode: number; watchedAt?: string }> }
|
||||||
|
| { kind: 'comic'; issueCount?: number; currentIssue?: number; publisher?: string; isOngoing?: boolean };
|
||||||
|
```
|
||||||
|
|
||||||
|
Die `details` bleiben **plaintext** — keine sensiblen Daten drin (Seiten-Zahlen, Episoden-Zähler). Falls sich das ändert (z.B. Spoiler-lastige Episoden-Notizen), Feld nachziehen.
|
||||||
|
|
||||||
|
### Encryption-Registry
|
||||||
|
|
||||||
|
`apps/mana/apps/web/src/lib/data/crypto/registry.ts` — neuer Eintrag:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
libraryEntries: {
|
||||||
|
fields: ['title', 'originalTitle', 'creators', 'review', 'tags'],
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
## Routing
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/mana/apps/web/src/routes/(app)/library/
|
||||||
|
├── +page.svelte # Grid mit KindTabs
|
||||||
|
├── [kind]/+page.svelte # Deep-Link: /library/books, /library/movies, ...
|
||||||
|
└── entry/[id]/+page.svelte # DetailView
|
||||||
|
```
|
||||||
|
|
||||||
|
## UI-Konzept
|
||||||
|
|
||||||
|
### Landing (`/library`)
|
||||||
|
|
||||||
|
- **Top**: `KindTabs` (Alle | Bücher | Filme | Serien | Comics) mit Count-Badges
|
||||||
|
- **Sekundärleiste**: Status-Filter-Chips (Geplant | Läuft | Fertig), Sort (Zuletzt fertig | Rating | Titel), Favoriten-Toggle
|
||||||
|
- **Grid**: Cover-Kacheln mit Titel + Rating + Status-Badge; Click → DetailView
|
||||||
|
- **FAB**: "+" öffnet `EntryForm` mit Typ-Vorauswahl basierend auf aktivem Tab
|
||||||
|
|
||||||
|
### EntryForm
|
||||||
|
|
||||||
|
- Zuerst `kind` wählen (wenn nicht vorgegeben)
|
||||||
|
- Kern-Felder (Titel, Jahr, Creators, Cover, Status, Rating, Tags, Review) sind identisch
|
||||||
|
- Unter "Details"-Accordion erscheinen typ-spezifische Felder aus `details`
|
||||||
|
- Cover: entweder URL einfügen oder Upload via bestehende `uload`-Infrastruktur → `coverMediaId`
|
||||||
|
- Optional (Phase 2): "Vorschlag abrufen" → OpenLibrary/TMDB lookup → füllt Metadaten + Cover vor
|
||||||
|
|
||||||
|
### DetailView
|
||||||
|
|
||||||
|
- Cover links, Metadaten rechts
|
||||||
|
- Unten: Review (Markdown, encrypted), Tags, Status-Verlauf
|
||||||
|
- Für `kind='series'`: eingebetteter `EpisodeTracker` (Staffeln ausklappbar, Episoden abhakbar, Fortschritts-Balken)
|
||||||
|
- Für `kind='book'`: Seiten-Slider zum Fortschritt, "Buch fertig"-Button setzt `completedAt` + `times++`
|
||||||
|
- Unten: "Nochmal gelesen/gesehen"-Button → `times++` und `startedAt`/`completedAt` reset (alte Instanz in `notes` im Activity-Log)
|
||||||
|
|
||||||
|
## Stats / Jahresrückblick
|
||||||
|
|
||||||
|
`queries.ts` liefert:
|
||||||
|
|
||||||
|
- `useStats()` → `{ totalByKind, completedThisYear, avgRating, topGenres, currentlyActive }`
|
||||||
|
- `useStreak(kind)` → "X Bücher in Folge fertig gemacht" (optional)
|
||||||
|
|
||||||
|
Diese Daten kann der Dashboard-Widget-Grid anzeigen (vgl. `drink`/`habits`).
|
||||||
|
|
||||||
|
## Registrierung (Checklist)
|
||||||
|
|
||||||
|
1. `apps/mana/apps/web/src/lib/modules/library/module.config.ts` anlegen
|
||||||
|
2. Config in `apps/mana/apps/web/src/lib/data/module-registry.ts` importieren + in `MODULE_CONFIGS` aufnehmen
|
||||||
|
3. Dexie-Schema-Migration: in `apps/mana/apps/web/src/lib/data/database.ts` neue `db.version(N+1).stores({ libraryEntries: 'id, kind, status, userId, completedAt' })` hinzufügen (NICHT bestehende Versionen ändern)
|
||||||
|
4. Encryption-Registry-Eintrag (siehe oben)
|
||||||
|
5. Routes unter `(app)/library/` anlegen
|
||||||
|
6. App-Eintrag in `packages/shared-branding/src/mana-apps.ts`:
|
||||||
|
```typescript
|
||||||
|
{ id: 'library', name: 'Bibliothek', description: {...}, icon: APP_ICONS.library, color: '#a855f7', status: 'development', requiredTier: 'guest' }
|
||||||
|
```
|
||||||
|
7. Icon in `packages/shared-branding/src/app-icons.ts` (SVG as data URL)
|
||||||
|
8. `docs/MODULE_REGISTRY.md` unter "Kreativität & Medien" ergänzen
|
||||||
|
9. Guest-Seed in `collections.ts` (1 Eintrag pro `kind`, damit neue Nutzer sofort was sehen)
|
||||||
|
10. Vitest-Tests für store-Mutationen + encryption roundtrip
|
||||||
|
|
||||||
|
## AI-Integration (Phase 2)
|
||||||
|
|
||||||
|
Nachdem das Modul steht, Tools in `tools.ts` registrieren und in `@mana/shared-ai/src/policy/proposable-tools.ts` aufnehmen:
|
||||||
|
|
||||||
|
| Tool | Policy | Beschreibung |
|
||||||
|
|------|--------|--------------|
|
||||||
|
| `list_library_entries` | auto | Liefert Einträge gefiltert nach `kind`/`status`, read-only |
|
||||||
|
| `create_library_entry` | propose | User muss jeden neuen Eintrag bestätigen |
|
||||||
|
| `complete_library_entry` | propose | Status → `completed`, `completedAt` = heute |
|
||||||
|
| `rate_library_entry` | propose | Rating setzen |
|
||||||
|
|
||||||
|
Missionen wie *"Trage meine letzten 5 gesehenen Filme ein"* können dann über den Runner laufen.
|
||||||
|
|
||||||
|
## Offene Fragen
|
||||||
|
|
||||||
|
- **Externe Metadata-Quellen**: OpenLibrary (Bücher, frei) + TMDB (Filme/Serien, API-Key nötig) + Comic Vine (Comics, API-Key nötig). Lohnt sich ein neuer `services/mana-metadata`-Service, der die Quellen proxyed, oder reicht ein Endpoint in `apps/api`? Vorschlag: **Endpoint in `apps/api`** zuerst (`/api/v1/library/lookup?kind=...&q=...`), service-Extraktion nur wenn's Crawler-artig wird.
|
||||||
|
- **Goodreads/Letterboxd-Import**: Phase 3. CSV-Upload reicht fürs erste, später GoodReads-API (sobald die offen ist) oder Screen-Scraping via mana-crawler.
|
||||||
|
- **Serien-Episoden**: in `details.watched` als flaches Array reicht für die meisten Fälle. Falls detaillierte Episoden-Metadaten (Titel, Runtime) benötigt werden, TMDB-Sync in Phase 2 nachziehen.
|
||||||
|
- **Audiobooks**: als `kind='book'` mit `details.format='audio'` oder als eigener `kind='audiobook'`? **Vorschlag**: format-Feld. Kernlogik bleibt "Buch".
|
||||||
|
- **Manga**: in `kind='comic'` einordnen oder separat? **Vorschlag**: comic. Falls Manga-spezifische Features (Chapters statt Issues) wichtig werden, `details` um ein Manga-Variant erweitern.
|
||||||
|
|
||||||
|
## Reihenfolge (Milestones)
|
||||||
|
|
||||||
|
1. **M1 — Skelett [DONE 2026-04-17]**: types, collections, module.config, Registry-Eintrag, Dexie-Migration (v26), leere Route. *Ziel: App zeigt leeres Modul an, nichts crasht.*
|
||||||
|
2. **M2 — CRUD**: entries-Store, EntryForm, GridView, DetailView. Manuelles Anlegen/Editieren funktioniert für alle 4 `kind`s. Cover via URL.
|
||||||
|
3. **M3 — Fortschritt**: Status-Wechsel, Rating, times-Counter, Episode-Tracker für Serien, Seiten-Slider für Bücher. Guest-Seed komplett.
|
||||||
|
4. **M4 — Cover-Upload**: Integration mit `uload`. Encryption-Registry final.
|
||||||
|
5. **M5 — Stats + Dashboard-Widget**: useStats, kleiner Widget für Dashboard.
|
||||||
|
6. **M6 — AI-Tools**: list/create/complete/rate Tools, Shared-AI-Policy.
|
||||||
|
7. **M7 — Metadata-Lookup**: `/api/v1/library/lookup` gegen OpenLibrary + TMDB.
|
||||||
|
8. **M8 — Import**: CSV-Upload (Goodreads/Letterboxd-Export-Formate).
|
||||||
|
|
@ -222,6 +222,12 @@ export const APP_ICONS = {
|
||||||
// Sky-blue gradient for the weather theme.
|
// Sky-blue gradient for the weather theme.
|
||||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="wt" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#38bdf8"/><stop offset="100%" style="stop-color:#0284c7"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#wt)"/><circle cx="62" cy="32" r="14" fill="white" fill-opacity="0.9"/><line x1="62" y1="12" x2="62" y2="18" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.7"/><line x1="62" y1="46" x2="62" y2="52" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.5"/><line x1="42" y1="32" x2="48" y2="32" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.7"/><line x1="76" y1="32" x2="82" y2="32" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.7"/><line x1="48" y1="18" x2="52" y2="22" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.6"/><line x1="76" y1="18" x2="72" y2="22" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.6"/><path d="M28 56a14 14 0 0 1 14-14h0a14 14 0 0 1 13 9 10 10 0 0 1 11 10 10 10 0 0 1-10 10H30a10 10 0 0 1-10-10 10 10 0 0 1 8-5z" fill="white" fill-opacity="0.95"/><line x1="34" y1="76" x2="30" y2="84" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.7"/><line x1="46" y1="76" x2="42" y2="84" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.7"/><line x1="58" y1="76" x2="54" y2="84" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.7"/></svg>`
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="wt" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#38bdf8"/><stop offset="100%" style="stop-color:#0284c7"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#wt)"/><circle cx="62" cy="32" r="14" fill="white" fill-opacity="0.9"/><line x1="62" y1="12" x2="62" y2="18" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.7"/><line x1="62" y1="46" x2="62" y2="52" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.5"/><line x1="42" y1="32" x2="48" y2="32" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.7"/><line x1="76" y1="32" x2="82" y2="32" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.7"/><line x1="48" y1="18" x2="52" y2="22" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.6"/><line x1="76" y1="18" x2="72" y2="22" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.6"/><path d="M28 56a14 14 0 0 1 14-14h0a14 14 0 0 1 13 9 10 10 0 0 1 11 10 10 10 0 0 1-10 10H30a10 10 0 0 1-10-10 10 10 0 0 1 8-5z" fill="white" fill-opacity="0.95"/><line x1="34" y1="76" x2="30" y2="84" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.7"/><line x1="46" y1="76" x2="42" y2="84" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.7"/><line x1="58" y1="76" x2="54" y2="84" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.7"/></svg>`
|
||||||
),
|
),
|
||||||
|
library: svgToDataUrl(
|
||||||
|
// Stack of books (left) + a book with a spine/pages motif (right) — the
|
||||||
|
// "Bibliothek" theme covering books/movies/series/comics. Purple→fuchsia
|
||||||
|
// gradient sits next to music/photos/picture in the Kreativität & Medien row.
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="lb" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#a855f7"/><stop offset="100%" style="stop-color:#d946ef"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#lb)"/><rect x="22" y="28" width="10" height="44" rx="2" fill="white" fill-opacity="0.95"/><rect x="34" y="24" width="10" height="48" rx="2" fill="white" fill-opacity="0.8"/><rect x="46" y="30" width="10" height="42" rx="2" fill="white" fill-opacity="0.95"/><rect x="58" y="28" width="22" height="44" rx="3" fill="white"/><rect x="62" y="34" width="4" height="4" fill="#a855f7"/><rect x="72" y="34" width="4" height="4" fill="#a855f7"/><rect x="62" y="44" width="4" height="4" fill="#a855f7"/><rect x="72" y="44" width="4" height="4" fill="#a855f7"/><rect x="62" y="54" width="4" height="4" fill="#a855f7"/><rect x="72" y="54" width="4" height="4" fill="#a855f7"/><rect x="62" y="64" width="4" height="4" fill="#a855f7"/><rect x="72" y="64" width="4" height="4" fill="#a855f7"/><rect x="20" y="74" width="62" height="4" rx="2" fill="white" fill-opacity="0.5"/></svg>`
|
||||||
|
),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type AppIconId = keyof typeof APP_ICONS;
|
export type AppIconId = keyof typeof APP_ICONS;
|
||||||
|
|
|
||||||
|
|
@ -986,6 +986,23 @@ export const MANA_APPS: ManaApp[] = [
|
||||||
status: 'development',
|
status: 'development',
|
||||||
requiredTier: 'guest',
|
requiredTier: 'guest',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'library',
|
||||||
|
name: 'Bibliothek',
|
||||||
|
description: {
|
||||||
|
de: 'Bücher, Filme, Serien, Comics',
|
||||||
|
en: 'Books, Movies, Series, Comics',
|
||||||
|
},
|
||||||
|
longDescription: {
|
||||||
|
de: 'Protokolliere was du liest und schaust — Bücher, Filme, Serien, Comics. Mit Status, Rating, Fortschritt und Jahresrückblick.',
|
||||||
|
en: 'Log what you read and watch — books, movies, series, comics. With status, rating, progress tracking and year-in-review.',
|
||||||
|
},
|
||||||
|
icon: APP_ICONS.library,
|
||||||
|
color: '#a855f7',
|
||||||
|
comingSoon: false,
|
||||||
|
status: 'development',
|
||||||
|
requiredTier: 'guest',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue