From 8c6502d0ff04f8aa252adeb4bc3462e8736b52a2 Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 17 Apr 2026 03:49:01 +0200 Subject: [PATCH] =?UTF-8?q?feat(library):=20add=20Bibliothek=20module=20?= =?UTF-8?q?=E2=80=94=20books/movies/series/comics=20log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../apps/web/src/lib/data/crypto/registry.ts | 19 ++ .../src/lib/modules/library/ListView.svelte | 120 ++++++++++ .../src/lib/modules/library/collections.ts | 110 +++++++++ .../web/src/lib/modules/library/constants.ts | 48 ++++ .../apps/web/src/lib/modules/library/index.ts | 42 ++++ .../src/lib/modules/library/module.config.ts | 6 + .../web/src/lib/modules/library/queries.ts | 112 +++++++++ .../modules/library/stores/entries.svelte.ts | 171 ++++++++++++++ .../apps/web/src/lib/modules/library/types.ts | 112 +++++++++ .../web/src/routes/(app)/library/+page.svelte | 9 + docs/MODULE_REGISTRY.md | 5 +- docs/plans/library-module.md | 221 ++++++++++++++++++ packages/shared-branding/src/app-icons.ts | 6 + packages/shared-branding/src/mana-apps.ts | 17 ++ 14 files changed, 996 insertions(+), 2 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/modules/library/ListView.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/library/collections.ts create mode 100644 apps/mana/apps/web/src/lib/modules/library/constants.ts create mode 100644 apps/mana/apps/web/src/lib/modules/library/index.ts create mode 100644 apps/mana/apps/web/src/lib/modules/library/module.config.ts create mode 100644 apps/mana/apps/web/src/lib/modules/library/queries.ts create mode 100644 apps/mana/apps/web/src/lib/modules/library/stores/entries.svelte.ts create mode 100644 apps/mana/apps/web/src/lib/modules/library/types.ts create mode 100644 apps/mana/apps/web/src/routes/(app)/library/+page.svelte create mode 100644 docs/plans/library-module.md diff --git a/apps/mana/apps/web/src/lib/data/crypto/registry.ts b/apps/mana/apps/web/src/lib/data/crypto/registry.ts index e217c7a2a..89f7860bf 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/registry.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/registry.ts @@ -518,6 +518,25 @@ export const ENCRYPTION_REGISTRY: Record = { // and belong under encryption. Policy + budgets + state are pure // structural fields. 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'], + }, }; /** diff --git a/apps/mana/apps/web/src/lib/modules/library/ListView.svelte b/apps/mana/apps/web/src/lib/modules/library/ListView.svelte new file mode 100644 index 000000000..eee048a27 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/library/ListView.svelte @@ -0,0 +1,120 @@ + + + +
+
+

Bibliothek

+

Bücher, Filme, Serien und Comics — alles an einem Ort.

+
+ + {#if entries$.loading} +

Lädt…

+ {:else} + {#each KIND_ORDER as kind (kind)} + {@const list = grouped[kind]} +
+

+ {KIND_LABELS[kind].emoji} + {KIND_LABELS[kind].de} + ({list.length}) +

+ {#if list.length === 0} +

Noch keine Einträge.

+ {:else} +
    + {#each list as entry (entry.id)} +
  • + {entry.title} + {#if entry.year} + · {entry.year} + {/if} + {STATUS_LABELS[entry.status].de} + {#if entry.rating != null} + {entry.rating.toFixed(1)} ★ + {/if} +
  • + {/each} +
+ {/if} +
+ {/each} + {/if} +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/library/collections.ts b/apps/mana/apps/web/src/lib/modules/library/collections.ts new file mode 100644 index 000000000..ddfbc0542 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/library/collections.ts @@ -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('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, + }, + }, + ], +}; diff --git a/apps/mana/apps/web/src/lib/modules/library/constants.ts b/apps/mana/apps/web/src/lib/modules/library/constants.ts new file mode 100644 index 000000000..fc0bdcf9e --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/library/constants.ts @@ -0,0 +1,48 @@ +import type { LibraryKind, LibraryStatus, BookFormat } from './types'; + +export const KIND_LABELS: Record = { + 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 = { + 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 = { + planned: '#64748b', + active: '#3b82f6', + completed: '#22c55e', + paused: '#f59e0b', + dropped: '#ef4444', +}; + +export const BOOK_FORMAT_LABELS: Record = { + 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', +]; diff --git a/apps/mana/apps/web/src/lib/modules/library/index.ts b/apps/mana/apps/web/src/lib/modules/library/index.ts new file mode 100644 index 000000000..8c6df00bc --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/library/index.ts @@ -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'; diff --git a/apps/mana/apps/web/src/lib/modules/library/module.config.ts b/apps/mana/apps/web/src/lib/modules/library/module.config.ts new file mode 100644 index 000000000..33f6159c0 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/library/module.config.ts @@ -0,0 +1,6 @@ +import type { ModuleConfig } from '$lib/data/module-registry'; + +export const libraryModuleConfig: ModuleConfig = { + appId: 'library', + tables: [{ name: 'libraryEntries' }], +}; diff --git a/apps/mana/apps/web/src/lib/modules/library/queries.ts b/apps/mana/apps/web/src/lib/modules/library/queries.ts new file mode 100644 index 000000000..e7841c717 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/library/queries.ts @@ -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('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 { + const out: Record = { + book: [], + movie: [], + series: [], + comic: [], + }; + for (const e of entries) out[e.kind].push(e); + return out; +} + +export interface LibraryStats { + totalByKind: Record; + completedThisYear: number; + currentlyActive: number; + avgRating: number | null; +} + +export function computeStats(entries: LibraryEntry[], year: number): LibraryStats { + const totalByKind: Record = { 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, + }; +} diff --git a/apps/mana/apps/web/src/lib/modules/library/stores/entries.svelte.ts b/apps/mana/apps/web/src/lib/modules/library/stores/entries.svelte.ts new file mode 100644 index 000000000..0463cb415 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/library/stores/entries.svelte.ts @@ -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; + 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 = { 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 }); + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/library/types.ts b/apps/mana/apps/web/src/lib/modules/library/types.ts new file mode 100644 index 000000000..2d6466642 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/library/types.ts @@ -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; +} diff --git a/apps/mana/apps/web/src/routes/(app)/library/+page.svelte b/apps/mana/apps/web/src/routes/(app)/library/+page.svelte new file mode 100644 index 000000000..0a926f939 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/library/+page.svelte @@ -0,0 +1,9 @@ + + + + Bibliothek - Mana + + + diff --git a/docs/MODULE_REGISTRY.md b/docs/MODULE_REGISTRY.md index a6d6a7a00..5450b66d2 100644 --- a/docs/MODULE_REGISTRY.md +++ b/docs/MODULE_REGISTRY.md @@ -1,6 +1,6 @@ # 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 | | `moodlit` | Moodlit | Beruhigende Ambient-Beleuchtung mit animierten Farbverläufen | -## Kreativität & Medien (7) +## Kreativität & Medien (8) | 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 | | `cards` | Cards | AI-generierte Flashcards mit Spaced Repetition | | `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 | ## Wissen & Lernen (7) diff --git a/docs/plans/library-module.md b/docs/plans/library-module.md new file mode 100644 index 000000000..22bd684b1 --- /dev/null +++ b/docs/plans/library-module.md @@ -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). diff --git a/packages/shared-branding/src/app-icons.ts b/packages/shared-branding/src/app-icons.ts index 24c3c44f6..0ae3c6141 100644 --- a/packages/shared-branding/src/app-icons.ts +++ b/packages/shared-branding/src/app-icons.ts @@ -222,6 +222,12 @@ export const APP_ICONS = { // Sky-blue gradient for the weather theme. `` ), + 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. + `` + ), } as const; export type AppIconId = keyof typeof APP_ICONS; diff --git a/packages/shared-branding/src/mana-apps.ts b/packages/shared-branding/src/mana-apps.ts index 7fe5c0ea9..907e04d9e 100644 --- a/packages/shared-branding/src/mana-apps.ts +++ b/packages/shared-branding/src/mana-apps.ts @@ -986,6 +986,23 @@ export const MANA_APPS: ManaApp[] = [ status: 'development', 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', + }, ]; /**