Files
siftlode/frontend/src/components/PlexCollectionAdd.tsx
T
peter dae0b80f6a feat(plex): add a movie or show to a collection from its card menu
"Add to collection…" opens a panel beside the card: the title's library's
manual collections tick it in or out, and a name + Enter creates a new one with
the title in it. It used to take the Collections page, a new collection, its
name and a title search, then another search per title. A lean endpoint lists
the collections with whether each holds the title. Admin only, as on the
title's page.

Also: a collection seeded with a show is made as a show collection (Plex type
2). Measured: as a movie one it came out empty and refused the show after.
2026-10-08 03:09:55 +02:00

149 lines
5.5 KiB
TypeScript

import { useLayoutEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus } from "lucide-react";
import { qk } from "../lib/queryKeys";
import { api, type PlexCollection } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { useScrollFade } from "../lib/useScrollFade";
import { MenuCheck } from "./ui/MenuCheck";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
// "Add to collection…" from a movie or show card's menu: a panel beside the card that ticks the title
// into or out of its library's manual collections, and names a new one — created with this title in
// it, in one step (a collection cannot be empty on Plex). It was a trip to the Collections page, a new
// collection, its name, a search for the title, then another search to add the next one. The panel is
// the video card's "Add to playlist" (AddToPlaylist), so it keeps that file's two conventions:
// typeahead off, and the name field keeping every key but Escape to itself.
export interface PlexCollectionTarget {
ratingKey: string;
anchor: Element;
}
export default function PlexCollectionAddAt({
target,
onClose,
}: {
target: PlexCollectionTarget;
onClose: () => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const pop = useAnchoredPopover({
open: true,
onOpenChange: (next) => {
if (!next) onClose();
},
offset: 6,
hostsInput: true,
});
const { setPositionReference } = pop.refs;
useLayoutEffect(() => setPositionReference(target.anchor), [target.anchor, setPositionReference]);
const { ref: fadeRef, style: fadeStyle } = useScrollFade();
const [newName, setNewName] = useState("");
const [busy, setBusy] = useState(false);
const { ratingKey } = target;
const list = useQuery({
queryKey: qk.plexCollections("for", ratingKey),
queryFn: () => api.plexCollectionsFor(ratingKey),
});
// The title's own page, the collections' pages and the library grid all show membership.
function refresh() {
void qc.invalidateQueries({ queryKey: qk.plexCollections() });
void qc.invalidateQueries({ queryKey: qk.plexItem(ratingKey) });
void qc.invalidateQueries({ queryKey: qk.plexLibrary() });
}
async function toggle(c: PlexCollection & { contains: boolean }) {
if (busy) return;
setBusy(true);
try {
if (c.contains) await api.plexCollectionRemoveItem(c.id, ratingKey);
else await api.plexCollectionAddItem(c.id, ratingKey);
} catch (e) {
notifyActionError(e, t("plex.collection.editFailed"));
}
refresh();
setBusy(false);
}
async function create() {
const name = newName.trim();
if (!name || busy) return;
setBusy(true);
try {
// No library: the server takes the seed title's own.
await api.plexCreateCollection("", name, ratingKey);
setNewName("");
} catch (e) {
notifyActionError(e, t("plex.collection.editFailed"));
}
refresh();
setBusy(false);
}
const rows = list.data?.collections ?? [];
return (
<PopoverSurface popover={pop} className={`${SURFACE.popover} w-64 p-2`}>
{/* A portal still bubbles through the React tree: keep a click here off the card that opened it. */}
<div onClick={(e) => e.stopPropagation()}>
<div className="text-xs text-muted px-1.5 pb-1.5">{t("plex.collectionAdd.title")}</div>
<div
ref={fadeRef}
style={fadeStyle}
aria-busy={busy}
className="max-h-56 overflow-y-auto no-scrollbar"
>
{list.isError ? (
<div className="text-xs text-danger px-1.5 py-2">
{t("plex.collectionAdd.loadFailed")}
</div>
) : rows.length === 0 && !list.isLoading ? (
<div className="text-xs text-muted px-1.5 py-2">{t("plex.collectionAdd.none")}</div>
) : null}
{rows.map((c) => (
<PopoverItem
key={c.id}
role="menuitemcheckbox"
checked={c.contains}
onSelect={() => void toggle(c)}
// Not `disabled={busy}`: that blurs the focused row and ends roving focus (see
// AddToPlaylist); toggle() no-ops while busy and the list says aria-busy.
label={c.title}
className="w-full flex items-center gap-2 text-sm px-1.5 py-1.5 rounded-lg text-fg outline-hidden transition"
>
<MenuCheck kind="many" on={c.contains} />
<span className="truncate flex-1 text-left">{c.title}</span>
</PopoverItem>
))}
</div>
<div className="flex items-center gap-1.5 border-t border-border mt-1.5 pt-1.5">
<Plus className="w-3.5 h-3.5 text-muted shrink-0" />
<Input
className="flex-1"
size="sm"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") return;
e.stopPropagation();
if (e.key === "Enter") {
e.preventDefault();
void create();
}
}}
placeholder={t("plex.collection.newCollection")}
aria-label={t("plex.collection.namePlaceholder")}
/>
</div>
</div>
</PopoverSurface>
);
}