Files
siftlode/frontend/src/components/plexBrowseLists.tsx
T
peter 81b7660232 test(plex): cover create write-through + the 409->nameTaken mapping (F-F)
Backend: create-collection happy path (write-through + editable) and the
create resync-failure 422 (honest reconcile detail), mirroring the rename/remove
tests. Frontend: extract notifyCollectionEditError (the 409->nameTaken vs
generic-fallback mapping that was inline in the New-collection create()) and
unit-test it; wire create() through it.
2026-08-23 08:08:34 +02:00

245 lines
9.4 KiB
TypeScript

import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, ListMusic, Lock, Plus, Search, X } from "lucide-react";
import { qk } from "../lib/queryKeys";
import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollection } from "../lib/api";
import { formatRuntime } from "../lib/format";
import { notifyCollectionEditError } from "../lib/actionErrors";
import { useDebounced } from "../lib/useDebounced";
import { LoadingState, StateMessage } from "./QueryState";
import PosterTile from "./PosterTile";
import PlexLibraryPicker from "./PlexLibraryPicker";
// A collection tile's sub-line: item count · total runtime (each dropped when unknown, e.g. show-only
// collections have no movie runtime).
function collectionMeta(c: PlexCollection, t: TFunction): string {
const parts: string[] = [];
if (c.child_count != null) parts.push(t("plex.browse.items", { count: c.child_count }));
const rt = formatRuntime(c.total_runtime_s);
if (rt) parts.push(rt);
return parts.join(" · ");
}
// The Playlist / Collection browse surfaces reached from the header pill (Piece 2): a grid of tiles,
// each a way INTO that area — a playlist tile opens the playlist view, a collection tile filters the
// title grid by that collection. Both reuse the existing list endpoints (plexPlaylists / plexCollections
// union), so this is a pure front-end surface. Collection tiles show a poster collage of their top
// films (PosterTile), falling back to a title-hash colour; playlists have no server art yet, so they
// take the colour fallback.
const GRID = "grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6";
// A grid of the user's Plex playlists; a tile opens that playlist's view.
export function PlexPlaylistsBrowse({ onOpen }: { onOpen: (id: number) => void }) {
const { t } = useTranslation();
const q = useQuery({ queryKey: qk.plexPlaylists(), queryFn: () => api.plexPlaylists() });
const playlists = q.data?.playlists ?? [];
if (q.isError && !q.data) {
return (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
);
}
if (q.isLoading) return <LoadingState label={t("plex.loading")} />;
if (playlists.length === 0)
return <p className="text-muted text-sm">{t("plex.playlist.none")}</p>;
return (
<div className={GRID}>
{playlists.map((p) => (
<PosterTile
key={p.id}
title={p.title}
subtitle={t("plex.browse.items", { count: p.item_count })}
topRight={<ListMusic className="h-4 w-4 text-white/80 drop-shadow" />}
onClick={() => onOpen(p.id)}
className="h-28"
/>
))}
</div>
);
}
// The "New collection" panel (admin, ask B-route): name (rejected if it duplicates an existing one —
// Plex conflates same-named collections) + a search to pick the FIRST title, since Plex can't create an
// empty collection. Creating opens the new collection's detail page (onCreated → onPick).
function NewCollectionPanel({
existingNames,
onCreated,
onClose,
}: {
existingNames: Set<string>;
onCreated: (rk: string) => void;
onClose: () => void;
}) {
const { t } = useTranslation();
const [name, setName] = useState("");
const [term, setTerm] = useState("");
const [busy, setBusy] = useState(false);
const trimmed = name.trim();
// Advisory only: `existingNames` is the search-scoped, all-library collection union, whereas the
// backend enforces uniqueness PER library (Plex only conflates same-named collections within one
// section). So a match here is a hint, not a hard block — it must not gate creation, or a valid
// same-name collection in another library would be un-creatable. The backend's 409 is authoritative.
const dupHint = trimmed.length > 0 && existingNames.has(trimmed.toLowerCase());
const nameOk = trimmed.length > 0;
async function create(card: PlexCard) {
if (!nameOk || busy) return;
setBusy(true);
try {
const col = await api.plexCreateCollection("", trimmed, card.id); // backend derives the library
onCreated(col.id);
} catch (e) {
// The backend is authoritative on per-library name uniqueness — its 409 is localized to nameTaken.
notifyCollectionEditError(
e,
t("plex.collection.editFailed"),
t("plex.collection.nameTaken"),
);
setBusy(false);
}
}
return (
<div className="glass-card mb-6 rounded-2xl p-4">
<div className="mb-2 flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold">{t("plex.collection.newCollection")}</h3>
<button
onClick={onClose}
aria-label={t("common.close")}
className="glass-hover grid h-8 w-8 place-items-center rounded-lg"
>
<X className="h-4 w-4" />
</button>
</div>
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("plex.collection.namePlaceholder")}
aria-label={t("plex.collection.newCollection")}
className="glass-card w-full rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
{dupHint ? (
<p className="mt-1 mb-2 text-xs text-muted">{t("plex.collection.nameMaybeTaken")}</p>
) : (
<p className="mt-1 mb-2 text-xs text-muted">{t("plex.collection.newHint")}</p>
)}
<div className="mb-3 flex items-center gap-2">
<Search className="h-4 w-4 shrink-0 text-muted" />
<input
value={term}
onChange={(e) => setTerm(e.target.value)}
disabled={!nameOk}
placeholder={t("plex.collection.addSearch")}
aria-label={t("plex.collection.addSearch")}
className="glass-card flex-1 rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40 disabled:opacity-50"
/>
</div>
<PlexLibraryPicker
scope="both"
term={term}
enabled={nameOk}
renderAction={(c) => (
<button
onClick={() => create(c)}
disabled={busy}
className="glass-card glass-hover inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs disabled:opacity-50"
>
<Check className="h-3.5 w-3.5" />
{t("plex.collection.createWith")}
</button>
)}
/>
</div>
);
}
// A grid of Plex collections (searchable — the union can run to hundreds); a tile opens its info page.
export function PlexCollectionsBrowse({ onPick }: { onPick: (id: string, title: string) => void }) {
const { t } = useTranslation();
const qc = useQueryClient();
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
const [search, setSearch] = useState("");
const [creating, setCreating] = useState(false);
const searchDeb = useDebounced(search.trim(), 300);
const q = useQuery({
queryKey: qk.plexCollections("union", searchDeb),
queryFn: () => api.plexCollections(undefined, searchDeb || undefined),
});
const collections = useMemo(() => q.data?.collections ?? [], [q.data?.collections]);
const existingNames = useMemo(
() => new Set(collections.map((c) => c.title.trim().toLowerCase())),
[collections],
);
return (
<div>
<div className="mb-4 flex flex-wrap items-center gap-2">
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("plex.filter.collectionSearch")}
aria-label={t("plex.filter.collectionSearch")}
className="glass-card w-full max-w-sm rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
{isAdmin && !creating && (
<button
onClick={() => setCreating(true)}
className="glass-card glass-hover inline-flex shrink-0 items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm"
>
<Plus className="h-4 w-4" />
{t("plex.collection.newCollection")}
</button>
)}
</div>
{creating && (
<NewCollectionPanel
existingNames={existingNames}
onCreated={(rk) => {
setCreating(false);
onPick(rk, "");
}}
onClose={() => setCreating(false)}
/>
)}
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading ? (
<LoadingState label={t("plex.loading")} />
) : collections.length === 0 ? (
<p className="text-muted text-sm">{t("plex.filter.collectionEmpty")}</p>
) : (
<div className={GRID}>
{collections.map((c) => (
<PosterTile
key={c.id}
title={c.title}
art={c.art}
subtitle={collectionMeta(c, t)}
topRight={
isIntrinsicReadOnly(c) ? (
<Lock
className="h-3.5 w-3.5 text-white/80 drop-shadow"
aria-label={t("plex.collection.readOnly")}
/>
) : undefined
}
onClick={() => onPick(c.id, c.title)}
className="h-28"
/>
))}
</div>
)}
</div>
);
}