feat: eigene Sammlung — „hab ich" mit Quelle
CI / pruefen (push) Canceled after 0s
Release / veroeffentlichen (push) Canceled after 0s

Streaming-Verfügbarkeit beantwortet nur die halbe Frage: manche Filme laufen
nirgends mehr, die hat man gekauft und liegen zu Hause — in Jellyfin, Plex,
Emby, auf dem NAS oder als Scheibe.

Bewusst getrennt vom Feld „wo schaue ich das": das eine sagt, wo ein Titel
gerade läuft, und das ändert sich ständig; das andere sagt, was einem gehört,
und das bleibt. Auf der Detailseite steht die Sammlung deshalb vor der
Verfügbarkeit — was man hat, ist die verlässlichere Antwort.

Durchgezogen bis in Filter, Export, CSV-Import und REST-API.
This commit is contained in:
Florian Wilde
2026-09-01 21:15:53 +02:00
parent cd9953c47c
commit 9ee8664233
26 changed files with 467 additions and 52 deletions
+95 -1
View File
@@ -1,7 +1,8 @@
"use client";
import { useEffect, useRef, useState, useTransition } from "react";
import { useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { clsx } from "clsx";
import { setTagsAction, updateEntryAction } from "@/app/actions/library";
import { useToasts } from "@/components/Toasts";
@@ -17,6 +18,8 @@ export function EntryNotes({
whereToWatch,
tags,
suggestion,
owned,
ownedSource,
}: {
entryId: string;
notes: string | null;
@@ -24,9 +27,12 @@ export function EntryNotes({
tags: string[];
/** Vorschlag aus der Streaming-Verfügbarkeit. */
suggestion: string | null;
owned: boolean;
ownedSource: string | null;
}) {
return (
<div className="space-y-4">
<OwnedEditor entryId={entryId} owned={owned} source={ownedSource} />
<WhereToWatch entryId={entryId} value={whereToWatch} suggestion={suggestion} />
<TagEditor entryId={entryId} tags={tags} />
<Notes entryId={entryId} value={notes} />
@@ -34,6 +40,94 @@ export function EntryNotes({
);
}
/**
* „Hab ich" — der Titel liegt in der eigenen Sammlung.
*
* Bewusst getrennt von „wo schaue ich das": Streaming-Verfügbarkeit kommt und
* geht, der gekaufte Film auf dem Heimserver bleibt. Genau die Titel, die
* nirgends mehr laufen, will man ja wiederfinden.
*/
function OwnedEditor({
entryId,
owned,
source,
}: {
entryId: string;
owned: boolean;
source: string | null;
}) {
const save = useAutosave(entryId);
const [isOwned, setIsOwned] = useState(owned);
const [text, setText] = useState(source ?? "");
const initial = useRef(source ?? "");
return (
<div className="space-y-2 rounded-xl border border-[var(--fl-border)] p-3">
<label className="flex cursor-pointer items-start gap-3">
<input
type="checkbox"
checked={isOwned}
onChange={(event) => {
setIsOwned(event.target.checked);
save({ owned: event.target.checked });
}}
className="sr-only"
/>
<span
aria-hidden
className={clsx(
"mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-md border transition-all duration-200",
isOwned ? "border-transparent" : "border-[var(--fl-border-strong)]",
)}
style={isOwned ? { backgroundImage: "var(--fl-gradient)" } : undefined}
>
{isOwned && (
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="var(--fl-on-accent)" strokeWidth="3.5">
<path d="m5 13 4.5 4.5L19 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</span>
<span className="min-w-0">
<span className="block text-sm font-medium">Hab ich</span>
<span className="block text-xs text-[var(--fl-faint)]">
Liegt in der eigenen Sammlung — unabhängig davon, ob es gerade irgendwo läuft.
</span>
</span>
</label>
{isOwned && (
<div className="pl-8">
<label htmlFor="ownedSource" className="sr-only">
Wo liegt der Titel?
</label>
<input
id="ownedSource"
value={text}
onChange={(event) => setText(event.target.value)}
onBlur={() => {
if (text === initial.current) return;
initial.current = text;
save({ ownedSource: text });
}}
list="fl-owned-quellen"
placeholder="Jellyfin, Plex, NAS, Blu-ray …"
className="fl-input py-1.5 text-sm"
/>
<datalist id="fl-owned-quellen">
<option value="Jellyfin" />
<option value="Plex" />
<option value="Emby" />
<option value="NAS" />
<option value="Blu-ray" />
<option value="DVD" />
<option value="Gekauft (digital)" />
</datalist>
</div>
)}
</div>
);
}
function useAutosave(entryId: string) {
const { report } = useToasts();
const router = useRouter();
+30 -10
View File
@@ -19,15 +19,31 @@ export function LibraryCard({ item }: { item: LibraryItem }) {
<div className="relative">
<Poster path={item.posterPath} alt={item.title} size="w342" />
<span
className="absolute left-2 top-2 rounded-full px-2 py-0.5 text-[0.65rem] font-semibold backdrop-blur-md"
style={{
color: STATUS_COLOR[status],
backgroundColor: `color-mix(in oklab, var(--fl-bg) 72%, transparent)`,
boxShadow: `inset 0 0 0 1px color-mix(in oklab, ${STATUS_COLOR[status]} 45%, transparent)`,
}}
>
{STATUS_LABEL[status]}
{/* Status und Sammlung teilen sich die obere Zeile — unten sitzt bei
den Postern oft der Titel, dort würden sie ihn verdecken. */}
<span className="absolute left-2 top-2 flex flex-wrap gap-1">
<span
className="rounded-full px-2 py-0.5 text-[0.65rem] font-semibold backdrop-blur-md"
style={{
color: STATUS_COLOR[status],
backgroundColor: `color-mix(in oklab, var(--fl-bg) 72%, transparent)`,
boxShadow: `inset 0 0 0 1px color-mix(in oklab, ${STATUS_COLOR[status]} 45%, transparent)`,
}}
>
{STATUS_LABEL[status]}
</span>
{item.owned && (
<span
className="rounded-full px-2 py-0.5 text-[0.65rem] font-semibold text-[var(--fl-on-accent)]"
style={{ backgroundImage: "var(--fl-gradient)" }}
title={
item.ownedSource ? `In deiner Sammlung: ${item.ownedSource}` : "In deiner Sammlung"
}
>
Hab ich
</span>
)}
</span>
{item.rating != null && (
@@ -51,7 +67,11 @@ export function LibraryCard({ item }: { item: LibraryItem }) {
<p className="text-xs text-[var(--fl-faint)]">
{item.type === "SERIES" ? "Serie" : "Film"}
{item.year ? ` · ${item.year}` : ""}
{item.whereToWatch ? ` · ${item.whereToWatch}` : ""}
{item.owned && item.ownedSource
? ` · ${item.ownedSource}`
: item.whereToWatch
? ` · ${item.whereToWatch}`
: ""}
</p>
{showProgress && (
+12 -1
View File
@@ -56,7 +56,9 @@ export function LibraryFilters({ facets }: { facets: LibraryFacets }) {
const status = params.get("status");
const type = params.get("typ");
const sort = params.get("sortierung") ?? "aktualisiert";
const activeCount = ["genre", "tag", "dienst", "typ"].filter((key) => params.get(key)).length;
const activeCount = ["genre", "tag", "dienst", "typ", "sammlung"].filter((key) =>
params.get(key),
).length;
return (
<div className="space-y-3">
@@ -78,6 +80,15 @@ export function LibraryFilters({ facets }: { facets: LibraryFacets }) {
{STATUS_LABEL[key as StatusKey]} <Count value={facets.counts[key]} />
</Chip>
))}
{facets.owned > 0 && (
<Chip
active={params.get("sammlung") === "1"}
onClick={() => apply({ sammlung: params.get("sammlung") === "1" ? null : "1" })}
>
Hab ich <Count value={facets.owned} />
</Chip>
)}
</div>
<div className="flex flex-wrap items-center gap-2">