mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
feat(library): M3 — progress tracking (pages, episodes, issues) + restart
ProgressControls.svelte renders typ-spezifische Fortschritts-UI:
- book → range slider + page input + "Fertig"-Button; auto-completes
the entry (status=completed, times++) when current == total
- series → collapsible season/episode grid; each episode is a toggleable
pill that writes into details.watched with a watchedAt stamp;
auto-completes once watched.length == totalEpisodes
- comic → ±1 issue bumper; auto-completes on issueCount reach
- movie → atomic, no progress widget
libraryEntriesStore.restartEntry: flips a completed entry back to active,
stamps startedAt=today, clears completedAt. Preserves the per-episode
watched list so users keep the history of the previous run-through; they
can reset individual episodes via the tracker if they want a fresh pass.
DetailView embeds <ProgressControls {entry}> below the status row and
renders a "↻ Nochmal lesen/sehen" button whenever status === 'completed'.
docs/plans/library-module.md: M1 + M2 + M3 marked DONE with commit IDs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
364178496a
commit
a252160585
4 changed files with 433 additions and 11 deletions
|
|
@ -0,0 +1,354 @@
|
||||||
|
<!--
|
||||||
|
ProgressControls — typ-spezifische Fortschritts-UI.
|
||||||
|
book → page slider (current / total), mark-as-read button at 100%
|
||||||
|
movie → no progress (atomic) — renders nothing
|
||||||
|
series → collapsible season/episode checklist
|
||||||
|
comic → current-issue bumper (±1)
|
||||||
|
Updates land on libraryEntriesStore.updateEntry so the encrypted fields are
|
||||||
|
left alone; only `details` (plaintext) changes here.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { libraryEntriesStore } from '../stores/entries.svelte';
|
||||||
|
import type {
|
||||||
|
LibraryEntry,
|
||||||
|
BookDetails,
|
||||||
|
SeriesDetails,
|
||||||
|
ComicDetails,
|
||||||
|
WatchedEpisode,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
let { entry }: { entry: LibraryEntry } = $props();
|
||||||
|
|
||||||
|
// ─── Book: page slider ───────────────────────────────
|
||||||
|
|
||||||
|
async function setCurrentPage(next: number | null) {
|
||||||
|
if (entry.details.kind !== 'book') return;
|
||||||
|
const total = entry.details.pages ?? null;
|
||||||
|
const clamped = next == null ? null : Math.max(0, total ? Math.min(total, next) : next);
|
||||||
|
const patch: BookDetails = { ...entry.details, currentPage: clamped };
|
||||||
|
await libraryEntriesStore.updateEntry(entry.id, { details: patch });
|
||||||
|
if (total && clamped === total && entry.status !== 'completed') {
|
||||||
|
await libraryEntriesStore.setStatus(entry.id, 'completed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookProgressPct = $derived.by(() => {
|
||||||
|
if (entry.details.kind !== 'book') return 0;
|
||||||
|
const { pages, currentPage } = entry.details;
|
||||||
|
if (!pages || !currentPage) return 0;
|
||||||
|
return Math.round((currentPage / pages) * 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Series: episode tracker ─────────────────────────
|
||||||
|
|
||||||
|
function isWatched(watched: readonly WatchedEpisode[], season: number, episode: number) {
|
||||||
|
return watched.some((w) => w.season === season && w.episode === episode);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleEpisode(season: number, episode: number) {
|
||||||
|
if (entry.details.kind !== 'series') return;
|
||||||
|
const current = entry.details.watched ?? [];
|
||||||
|
const exists = isWatched(current, season, episode);
|
||||||
|
const next: WatchedEpisode[] = exists
|
||||||
|
? current.filter((w) => !(w.season === season && w.episode === episode))
|
||||||
|
: [...current, { season, episode, watchedAt: new Date().toISOString() }];
|
||||||
|
const patch: SeriesDetails = { ...entry.details, watched: next };
|
||||||
|
await libraryEntriesStore.updateEntry(entry.id, { details: patch });
|
||||||
|
|
||||||
|
if (entry.details.totalEpisodes && next.length === entry.details.totalEpisodes) {
|
||||||
|
if (entry.status !== 'completed') await libraryEntriesStore.setStatus(entry.id, 'completed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function countWatchedInSeason(watched: readonly WatchedEpisode[], season: number) {
|
||||||
|
return watched.filter((w) => w.season === season).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seriesTotals = $derived.by(() => {
|
||||||
|
if (entry.details.kind !== 'series') return { watched: 0, total: 0 };
|
||||||
|
const watched = entry.details.watched?.length ?? 0;
|
||||||
|
const total = entry.details.totalEpisodes ?? 0;
|
||||||
|
return { watched, total };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Default distribution when per-season counts aren't tracked: assume equal
|
||||||
|
// split across totalSeasons. For a more accurate tracker the user can edit
|
||||||
|
// the entry; this keeps the UI usable without demanding per-season data.
|
||||||
|
const seasonList = $derived.by(() => {
|
||||||
|
if (entry.details.kind !== 'series') return [] as { season: number; episodeCount: number }[];
|
||||||
|
const { totalSeasons, totalEpisodes } = entry.details;
|
||||||
|
if (!totalSeasons || totalSeasons < 1) return [];
|
||||||
|
const total = totalEpisodes ?? totalSeasons * 10;
|
||||||
|
const perSeason = Math.max(1, Math.ceil(total / totalSeasons));
|
||||||
|
return Array.from({ length: totalSeasons }, (_, i) => ({
|
||||||
|
season: i + 1,
|
||||||
|
episodeCount: i === totalSeasons - 1 ? total - perSeason * (totalSeasons - 1) : perSeason,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
let openSeason = $state<number | null>(null);
|
||||||
|
|
||||||
|
// ─── Comic: issue bumper ─────────────────────────────
|
||||||
|
|
||||||
|
async function bumpIssue(delta: number) {
|
||||||
|
if (entry.details.kind !== 'comic') return;
|
||||||
|
const cur = entry.details.currentIssue ?? 0;
|
||||||
|
const next = Math.max(0, cur + delta);
|
||||||
|
const total = entry.details.issueCount ?? null;
|
||||||
|
const clamped = total ? Math.min(next, total) : next;
|
||||||
|
const patch: ComicDetails = { ...entry.details, currentIssue: clamped };
|
||||||
|
await libraryEntriesStore.updateEntry(entry.id, { details: patch });
|
||||||
|
if (total && clamped === total && entry.status !== 'completed') {
|
||||||
|
await libraryEntriesStore.setStatus(entry.id, 'completed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if entry.details.kind === 'book'}
|
||||||
|
<section class="progress book">
|
||||||
|
<div class="row-head">
|
||||||
|
<h3>Lesefortschritt</h3>
|
||||||
|
{#if entry.details.pages}
|
||||||
|
<span class="pct">{bookProgressPct}%</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if entry.details.pages}
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max={entry.details.pages}
|
||||||
|
value={entry.details.currentPage ?? 0}
|
||||||
|
onchange={(e) => setCurrentPage(parseInt((e.target as HTMLInputElement).value))}
|
||||||
|
/>
|
||||||
|
<div class="pages-row">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max={entry.details.pages}
|
||||||
|
value={entry.details.currentPage ?? 0}
|
||||||
|
onchange={(e) => setCurrentPage(parseInt((e.target as HTMLInputElement).value) || 0)}
|
||||||
|
/>
|
||||||
|
<span class="muted">/ {entry.details.pages}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mark-done"
|
||||||
|
onclick={() =>
|
||||||
|
setCurrentPage(entry.details.kind === 'book' ? (entry.details.pages ?? null) : null)}
|
||||||
|
>
|
||||||
|
Fertig
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="muted">Trage die Seitenzahl ein, um den Fortschritt zu tracken.</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{:else if entry.details.kind === 'series'}
|
||||||
|
<section class="progress series">
|
||||||
|
<div class="row-head">
|
||||||
|
<h3>Episoden-Tracker</h3>
|
||||||
|
<span class="muted">{seriesTotals.watched} / {seriesTotals.total || '?'}</span>
|
||||||
|
</div>
|
||||||
|
{#if seasonList.length === 0}
|
||||||
|
<p class="muted">
|
||||||
|
Trage Staffeln + Episoden in den Details ein, um die einzelnen Folgen abzuhaken.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<div class="seasons">
|
||||||
|
{#each seasonList as s (s.season)}
|
||||||
|
{@const watched = entry.details.kind === 'series' ? (entry.details.watched ?? []) : []}
|
||||||
|
{@const seasonWatched = countWatchedInSeason(watched, s.season)}
|
||||||
|
<details
|
||||||
|
open={openSeason === s.season}
|
||||||
|
ontoggle={(e) => {
|
||||||
|
const target = e.currentTarget as HTMLDetailsElement;
|
||||||
|
if (target.open) openSeason = s.season;
|
||||||
|
else if (openSeason === s.season) openSeason = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<summary>
|
||||||
|
<span>Staffel {s.season}</span>
|
||||||
|
<span class="season-count">{seasonWatched} / {s.episodeCount}</span>
|
||||||
|
</summary>
|
||||||
|
<div class="episode-grid">
|
||||||
|
{#each Array.from({ length: s.episodeCount }, (_, i) => i + 1) as ep (ep)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="episode"
|
||||||
|
class:watched={isWatched(watched, s.season, ep)}
|
||||||
|
onclick={() => toggleEpisode(s.season, ep)}
|
||||||
|
aria-label={`Staffel ${s.season} Episode ${ep}`}
|
||||||
|
>
|
||||||
|
{ep}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{:else if entry.details.kind === 'comic'}
|
||||||
|
<section class="progress comic">
|
||||||
|
<div class="row-head">
|
||||||
|
<h3>Ausgaben-Fortschritt</h3>
|
||||||
|
<span class="muted">
|
||||||
|
{entry.details.currentIssue ?? 0}
|
||||||
|
{#if entry.details.issueCount}/ {entry.details.issueCount}{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="bumper">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => bumpIssue(-1)}
|
||||||
|
disabled={(entry.details.currentIssue ?? 0) <= 0}
|
||||||
|
aria-label="Ausgabe zurück"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<span class="big-num">#{entry.details.currentIssue ?? 0}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => bumpIssue(1)}
|
||||||
|
disabled={!!entry.details.issueCount &&
|
||||||
|
(entry.details.currentIssue ?? 0) >= entry.details.issueCount}
|
||||||
|
aria-label="Ausgabe weiter"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.progress {
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
background: var(--color-surface, rgba(0, 0, 0, 0.03));
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.row-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
.row-head h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: var(--color-text-muted, #64748b);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.pct {
|
||||||
|
color: #a855f7;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
input[type='range'] {
|
||||||
|
width: 100%;
|
||||||
|
accent-color: #a855f7;
|
||||||
|
}
|
||||||
|
.pages-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.pages-row input[type='number'] {
|
||||||
|
width: 90px;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
|
||||||
|
background: var(--color-background, white);
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.mark-done {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
border: 1px solid #a855f7;
|
||||||
|
background: transparent;
|
||||||
|
color: #a855f7;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.mark-done:hover {
|
||||||
|
background: color-mix(in srgb, #a855f7 10%, transparent);
|
||||||
|
}
|
||||||
|
.seasons {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
.seasons details {
|
||||||
|
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.08));
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
padding: 0.4rem 0.7rem;
|
||||||
|
}
|
||||||
|
.seasons summary {
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
.season-count {
|
||||||
|
color: var(--color-text-muted, #64748b);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
.episode-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(40px, 1fr));
|
||||||
|
gap: 0.3rem;
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
}
|
||||||
|
.episode {
|
||||||
|
padding: 0.3rem 0;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-text-muted, #64748b);
|
||||||
|
}
|
||||||
|
.episode.watched {
|
||||||
|
background: color-mix(in srgb, #a855f7 20%, transparent);
|
||||||
|
border-color: #a855f7;
|
||||||
|
color: #a855f7;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.bumper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.bumper button {
|
||||||
|
width: 2.2rem;
|
||||||
|
height: 2.2rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.bumper button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.bumper button:not(:disabled):hover {
|
||||||
|
border-color: #a855f7;
|
||||||
|
color: #a855f7;
|
||||||
|
}
|
||||||
|
.big-num {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 3rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -145,6 +145,26 @@ export const libraryEntriesStore = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Nochmal" — re-start a completed entry. Leaves `times` alone (it was
|
||||||
|
* already incremented when the entry was marked complete); resets
|
||||||
|
* `startedAt` to today, clears `completedAt`, flips status back to
|
||||||
|
* 'active'. For series, the per-episode watched list is preserved so
|
||||||
|
* the user has a record of the previous run-through (and can reset
|
||||||
|
* individual episodes via the tracker if they want).
|
||||||
|
*/
|
||||||
|
async restartEntry(id: string) {
|
||||||
|
const existing = await libraryEntryTable.get(id);
|
||||||
|
if (!existing) return;
|
||||||
|
const nowDate = new Date().toISOString().slice(0, 10);
|
||||||
|
await libraryEntryTable.update(id, {
|
||||||
|
status: 'active',
|
||||||
|
startedAt: nowDate,
|
||||||
|
completedAt: null,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
async rate(id: string, rating: number | null) {
|
async rate(id: string, rating: number | null) {
|
||||||
await libraryEntryTable.update(id, {
|
await libraryEntryTable.update(id, {
|
||||||
rating,
|
rating,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import CoverImage from '../components/CoverImage.svelte';
|
import CoverImage from '../components/CoverImage.svelte';
|
||||||
import StatusBadge from '../components/StatusBadge.svelte';
|
|
||||||
import RatingStars from '../components/RatingStars.svelte';
|
import RatingStars from '../components/RatingStars.svelte';
|
||||||
import EntryForm from '../components/EntryForm.svelte';
|
import EntryForm from '../components/EntryForm.svelte';
|
||||||
|
import ProgressControls from '../components/ProgressControls.svelte';
|
||||||
import { KIND_LABELS, STATUS_LABELS, BOOK_FORMAT_LABELS } from '../constants';
|
import { KIND_LABELS, STATUS_LABELS, BOOK_FORMAT_LABELS } from '../constants';
|
||||||
import { libraryEntriesStore } from '../stores/entries.svelte';
|
import { libraryEntriesStore } from '../stores/entries.svelte';
|
||||||
import type { LibraryEntry, LibraryStatus } from '../types';
|
import type { LibraryEntry, LibraryStatus } from '../types';
|
||||||
|
|
@ -31,6 +31,20 @@
|
||||||
await libraryEntriesStore.deleteEntry(entry.id);
|
await libraryEntriesStore.deleteEntry(entry.id);
|
||||||
goto('/library');
|
goto('/library');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onRestart() {
|
||||||
|
await libraryEntriesStore.restartEntry(entry.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const restartLabel = $derived.by(() => {
|
||||||
|
switch (entry.kind) {
|
||||||
|
case 'book':
|
||||||
|
case 'comic':
|
||||||
|
return 'Nochmal lesen';
|
||||||
|
default:
|
||||||
|
return 'Nochmal sehen';
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="detail">
|
<div class="detail">
|
||||||
|
|
@ -91,10 +105,20 @@
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if entry.times > 0}
|
{#if entry.times > 0 || entry.status === 'completed'}
|
||||||
<p class="times">
|
<div class="times-row">
|
||||||
{entry.kind === 'book' || entry.kind === 'comic' ? 'Gelesen' : 'Gesehen'}: {entry.times}×
|
{#if entry.times > 0}
|
||||||
</p>
|
<span class="times">
|
||||||
|
{entry.kind === 'book' || entry.kind === 'comic' ? 'Gelesen' : 'Gesehen'}:
|
||||||
|
{entry.times}×
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if entry.status === 'completed'}
|
||||||
|
<button type="button" class="restart" onclick={onRestart}>
|
||||||
|
↻ {restartLabel}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if entry.genres.length > 0 || entry.tags.length > 0}
|
{#if entry.genres.length > 0 || entry.tags.length > 0}
|
||||||
|
|
@ -168,6 +192,8 @@
|
||||||
{/if}
|
{/if}
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
|
<ProgressControls {entry} />
|
||||||
|
|
||||||
{#if entry.review}
|
{#if entry.review}
|
||||||
<section class="review">
|
<section class="review">
|
||||||
<h2>Review</h2>
|
<h2>Review</h2>
|
||||||
|
|
@ -296,10 +322,29 @@
|
||||||
color: #a855f7;
|
color: #a855f7;
|
||||||
border-color: #a855f7;
|
border-color: #a855f7;
|
||||||
}
|
}
|
||||||
|
.times-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
.times {
|
.times {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--color-text-muted, #64748b);
|
color: var(--color-text-muted, #64748b);
|
||||||
}
|
}
|
||||||
|
.restart {
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
border: 1px solid #a855f7;
|
||||||
|
background: transparent;
|
||||||
|
color: #a855f7;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.restart:hover {
|
||||||
|
background: color-mix(in srgb, #a855f7 10%, transparent);
|
||||||
|
}
|
||||||
.tag-row {
|
.tag-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,13 @@
|
||||||
|
|
||||||
## Status (2026-04-17)
|
## Status (2026-04-17)
|
||||||
|
|
||||||
**M1 Skelett: DONE.** Modul registriert, Dexie v26 angelegt, Encryption-Registry
|
**M1 Skelett: DONE** (commit 8c6502d0f) — Modul registriert, Dexie v26, Encryption-Registry, Route mountet, Guest-Seed (Dune, Arrival, Severance, Saga).
|
||||||
eingetragen, Route `/library` mountet einen minimalen Listen-View. Guest-Seed mit
|
|
||||||
je einem Eintrag pro `kind` (Dune, Arrival, Severance, Saga) vorhanden.
|
**M2 CRUD / Grid / Detail: DONE** (commit 364178496) — KindTabs, StatusFilter, RatingStars, EntryCard, EntryForm (Create + Edit, typ-spezifische Details-Accordion), GridView, DetailView, `/library/entry/[id]`-Route.
|
||||||
Nächster Schritt: M2 (volles CRUD + Form + Grid + DetailView).
|
|
||||||
|
**M3 Fortschritt: DONE** — `ProgressControls.svelte`: Seiten-Slider für Bücher (auto-completes bei 100%), Episode-Tracker für Serien (abhakbare Folgen pro Staffel), Issue-Bumper für Comics. `libraryEntriesStore.restartEntry` + "Nochmal"-Button im DetailView für abgeschlossene Einträge.
|
||||||
|
|
||||||
|
Nächster Schritt: M4 (Cover-Upload via `uload`) oder M6 (AI-Tools).
|
||||||
|
|
||||||
Vor M2 entschieden:
|
Vor M2 entschieden:
|
||||||
- Audiobooks: `kind='book'` mit `details.format='audio'` (nicht eigener `kind`).
|
- Audiobooks: `kind='book'` mit `details.format='audio'` (nicht eigener `kind`).
|
||||||
|
|
@ -212,8 +215,8 @@ Missionen wie *"Trage meine letzten 5 gesehenen Filme ein"* können dann über d
|
||||||
## Reihenfolge (Milestones)
|
## 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.*
|
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.
|
2. **M2 — CRUD [DONE 2026-04-17]**: 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.
|
3. **M3 — Fortschritt [DONE 2026-04-17]**: Status-Wechsel, Rating, times-Counter, Episode-Tracker für Serien, Seiten-Slider für Bücher, Issue-Bumper für Comics, Nochmal-Button.
|
||||||
4. **M4 — Cover-Upload**: Integration mit `uload`. Encryption-Registry final.
|
4. **M4 — Cover-Upload**: Integration mit `uload`. Encryption-Registry final.
|
||||||
5. **M5 — Stats + Dashboard-Widget**: useStats, kleiner Widget für Dashboard.
|
5. **M5 — Stats + Dashboard-Widget**: useStats, kleiner Widget für Dashboard.
|
||||||
6. **M6 — AI-Tools**: list/create/complete/rate Tools, Shared-AI-Policy.
|
6. **M6 — AI-Tools**: list/create/complete/rate Tools, Shared-AI-Policy.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue