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.
289 lines
8.6 KiB
TypeScript
289 lines
8.6 KiB
TypeScript
"use client";
|
|
|
|
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";
|
|
|
|
/**
|
|
* Notizfeld, „wo schaue ich das" und eigene Tags.
|
|
*
|
|
* Gespeichert wird beim Verlassen des Feldes, nicht bei jedem Tastendruck —
|
|
* das spart Anfragen und erspart das Suchen nach einem Speichern-Knopf.
|
|
*/
|
|
export function EntryNotes({
|
|
entryId,
|
|
notes,
|
|
whereToWatch,
|
|
tags,
|
|
suggestion,
|
|
owned,
|
|
ownedSource,
|
|
}: {
|
|
entryId: string;
|
|
notes: string | null;
|
|
whereToWatch: string | null;
|
|
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} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* „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();
|
|
const [, startTransition] = useTransition();
|
|
|
|
return (data: Record<string, unknown>) =>
|
|
startTransition(async () => {
|
|
const result = await updateEntryAction(entryId, data);
|
|
if (!result.ok) report(result);
|
|
router.refresh();
|
|
});
|
|
}
|
|
|
|
function WhereToWatch({
|
|
entryId,
|
|
value,
|
|
suggestion,
|
|
}: {
|
|
entryId: string;
|
|
value: string | null;
|
|
suggestion: string | null;
|
|
}) {
|
|
const save = useAutosave(entryId);
|
|
const [text, setText] = useState(value ?? "");
|
|
const initial = useRef(value ?? "");
|
|
|
|
return (
|
|
<div className="space-y-1.5">
|
|
<label htmlFor="whereToWatch" className="block text-sm font-medium text-[var(--fl-muted)]">
|
|
Wo schaue ich das?
|
|
</label>
|
|
<input
|
|
id="whereToWatch"
|
|
value={text}
|
|
onChange={(event) => setText(event.target.value)}
|
|
onBlur={() => {
|
|
if (text === initial.current) return;
|
|
initial.current = text;
|
|
save({ whereToWatch: text });
|
|
}}
|
|
placeholder={suggestion ? `Vorschlag: ${suggestion}` : "z. B. Netflix, Blu-ray, Mediathek"}
|
|
className="fl-input"
|
|
/>
|
|
{suggestion && text !== suggestion && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setText(suggestion);
|
|
initial.current = suggestion;
|
|
save({ whereToWatch: suggestion });
|
|
}}
|
|
className="text-xs text-[var(--fl-accent)] underline-offset-2 hover:underline"
|
|
>
|
|
{suggestion} übernehmen
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Notes({ entryId, value }: { entryId: string; value: string | null }) {
|
|
const save = useAutosave(entryId);
|
|
const [text, setText] = useState(value ?? "");
|
|
const initial = useRef(value ?? "");
|
|
|
|
return (
|
|
<div className="space-y-1.5">
|
|
<label htmlFor="notes" className="block text-sm font-medium text-[var(--fl-muted)]">
|
|
Notiz
|
|
</label>
|
|
<textarea
|
|
id="notes"
|
|
value={text}
|
|
rows={4}
|
|
onChange={(event) => setText(event.target.value)}
|
|
onBlur={() => {
|
|
if (text === initial.current) return;
|
|
initial.current = text;
|
|
save({ notes: text });
|
|
}}
|
|
placeholder="Was du dir merken willst — Lieblingsfolge, mit wem du das schaust, wo du aufgehört hast."
|
|
className="fl-input resize-y"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TagEditor({ entryId, tags }: { entryId: string; tags: string[] }) {
|
|
const { report } = useToasts();
|
|
const router = useRouter();
|
|
const [current, setCurrent] = useState(tags);
|
|
const [draft, setDraft] = useState("");
|
|
const [, startTransition] = useTransition();
|
|
|
|
function commit(next: string[]) {
|
|
setCurrent(next);
|
|
startTransition(async () => {
|
|
const result = await setTagsAction(entryId, next);
|
|
if (!result.ok) {
|
|
report(result);
|
|
setCurrent(tags);
|
|
}
|
|
router.refresh();
|
|
});
|
|
}
|
|
|
|
function add() {
|
|
const name = draft.trim().toLocaleLowerCase("de-DE");
|
|
setDraft("");
|
|
if (!name || current.includes(name)) return;
|
|
commit([...current, name]);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-1.5">
|
|
<label htmlFor="tag-input" className="block text-sm font-medium text-[var(--fl-muted)]">
|
|
Tags
|
|
</label>
|
|
|
|
{current.length > 0 && (
|
|
<ul className="flex flex-wrap gap-1.5">
|
|
{current.map((tag) => (
|
|
<li key={tag}>
|
|
<span className="inline-flex items-center gap-1 rounded-full border border-[var(--fl-border)] py-1 pl-3 pr-1.5 text-xs">
|
|
{tag}
|
|
<button
|
|
type="button"
|
|
onClick={() => commit(current.filter((entry) => entry !== tag))}
|
|
aria-label={`Tag ${tag} entfernen`}
|
|
className="rounded-full p-0.5 text-[var(--fl-faint)] hover:text-[var(--fl-danger)]"
|
|
>
|
|
<svg viewBox="0 0 24 24" className="h-3 w-3" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden>
|
|
<path d="m6 6 12 12M18 6 6 18" strokeLinecap="round" />
|
|
</svg>
|
|
</button>
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
<input
|
|
id="tag-input"
|
|
value={draft}
|
|
onChange={(event) => setDraft(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter" || event.key === ",") {
|
|
event.preventDefault();
|
|
add();
|
|
}
|
|
}}
|
|
onBlur={add}
|
|
placeholder="Tag eingeben und Enter drücken"
|
|
className="fl-input"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|