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.
245 lines
9.4 KiB
TypeScript
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>
|
|
);
|
|
}
|