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.
This commit is contained in:
2026-10-08 03:09:55 +02:00
parent 75093458c9
commit dae0b80f6a
13 changed files with 421 additions and 15 deletions
+1 -1
View File
@@ -199,7 +199,7 @@ function useHomeCardMenu({
.map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it))
: target.plex
? plexActions
.items(target.plex, { play: false, pin: false })
.items(target.plex, { play: false, pin: false, anchor: menu.anchor })
.map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it))
: target.kind === "channel" || target.kind === "playlist"
? [{ ...openPage.item(target), separatorBefore: true }]
@@ -0,0 +1,148 @@
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>
);
}
+69 -3
View File
@@ -8,7 +8,8 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { PlexCard } from "../lib/api";
import { api, type PlexCard, type PlexCollection } from "../lib/api";
import { qk } from "../lib/queryKeys";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
@@ -101,15 +102,21 @@ function Grid({
);
}
function setup(c: PlexCard, opts: Omit<Parameters<typeof Grid>[0], "c" | "handlers"> = {}) {
function setup(
c: PlexCard,
opts: Omit<Parameters<typeof Grid>[0], "c" | "handlers"> = {},
{ admin = false }: { admin?: boolean } = {},
) {
const handlers = {
onOpen: vi.fn(),
onInfo: vi.fn(),
onToggleWatched: vi.fn(),
onRestart: vi.fn(),
};
const qc = new QueryClient();
if (admin) qc.setQueryData(qk.me(), { role: "admin" });
render(
<QueryClientProvider client={new QueryClient()}>
<QueryClientProvider client={qc}>
<FloatingTree>
<Grid c={c} handlers={handlers} {...opts} />
</FloatingTree>
@@ -223,3 +230,62 @@ describe("a Plex title's menu", () => {
await vi.waitFor(() => expect(remove).toHaveBeenCalledWith("m1"));
});
});
// Adding a title to a collection was a trip to the Collections page, a new collection, its name and a
// search for the title. From the card's menu it is a tick, or a name and Enter.
describe("a Plex title's menu — Add to collection", () => {
const coll = (id: string, title: string, contains: boolean) =>
({ id, title, contains }) as PlexCollection & { contains: boolean };
it("an admin gets the row on a movie and a show; an episode and a viewer do not", () => {
setup(card({}), {}, { admin: true });
rightClick();
expect(labels()).toContain("plex.collectionAdd.row");
cleanup();
setup(card({ type: "show" }), {}, { admin: true });
rightClick();
expect(labels()).toContain("plex.collectionAdd.row");
cleanup();
setup(card({ type: "episode" }), {}, { admin: true });
rightClick();
expect(labels()).not.toContain("plex.collectionAdd.row");
cleanup();
setup(card({}));
rightClick();
expect(labels()).not.toContain("plex.collectionAdd.row");
});
it("ticks the title into a collection, out of one, and names a new one with it inside", async () => {
vi.spyOn(api, "plexCollectionsFor").mockResolvedValue({
collections: [coll("cA", "Alpha", true), coll("cB", "Beta", false)],
});
const add = vi.spyOn(api, "plexCollectionAddItem").mockResolvedValue({} as PlexCollection);
const remove = vi
.spyOn(api, "plexCollectionRemoveItem")
.mockResolvedValue({} as PlexCollection);
const create = vi.spyOn(api, "plexCreateCollection").mockResolvedValue({} as PlexCollection);
setup(card({}), {}, { admin: true });
rightClick();
fireEvent.click(item("plex.collectionAdd.row"));
const row = async (name: string) =>
vi.waitFor(() => {
const found = screen
.queryAllByRole("menuitemcheckbox", { hidden: true })
.find((b) => b.textContent === name);
expect(found).toBeDefined();
return found!;
});
fireEvent.click(await row("Beta"));
await vi.waitFor(() => expect(add).toHaveBeenCalledWith("cB", "m1"));
// One write at a time: the next tick waits for this one to settle.
await vi.waitFor(() =>
expect(document.querySelector("[aria-busy]")?.getAttribute("aria-busy")).toBe("false"),
);
fireEvent.click(await row("Alpha"));
await vi.waitFor(() => expect(remove).toHaveBeenCalledWith("cA", "m1"));
const name = screen.getByLabelText("plex.collection.namePlaceholder");
fireEvent.change(name, { target: { value: " Rainy days " } });
fireEvent.keyDown(name, { key: "Enter" });
await vi.waitFor(() => expect(create).toHaveBeenCalledWith("", "Rainy days", "m1"));
});
});
+1 -1
View File
@@ -28,7 +28,7 @@ export function PlexMenu({
<ContextMenu
menu={menu}
ariaLabel={t("plex.menu")}
items={c ? [...actions.items(c, { pin }), ...(extra?.(c) ?? [])] : []}
items={c ? [...actions.items(c, { pin, anchor: menu.anchor }), ...(extra?.(c) ?? [])] : []}
/>
{actions.panel}
</>
+37 -4
View File
@@ -1,8 +1,11 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Check, Info, ListPlus, Play, SkipBack, Tv2, Undo2 } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { Check, Info, Layers, ListPlus, Play, SkipBack, Tv2, Undo2 } from "lucide-react";
import { type PlexCard } from "../lib/api";
import { qk } from "../lib/queryKeys";
import PlexCollectionAddAt, { type PlexCollectionTarget } from "./PlexCollectionAdd";
import { pinPlex, useComfortPin } from "./comfortPin";
import { useCopyItems } from "./copyItems";
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
@@ -46,15 +49,26 @@ export function usePlexCardActions(handlers: PlexActionHandlers, open: boolean)
const copy = useCopyItems();
const addLabel = (c: PlexCard) => t(plexAddLabelKey(c));
const [adding, setAdding] = useState<PlexAddTarget | null>(null);
const [collecting, setCollecting] = useState<PlexCollectionTarget | null>(null);
// Collections are shared and written to Plex itself, so only an admin edits them (as on the title's
// own page).
const qc = useQueryClient();
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
// A whole show needs its episode keys, which only the show page has ("Add whole show" there), so
// only a leaf is added from a card.
const add = (c: PlexCard) => setAdding({ kind: "single", ratingKey: c.id, title: c.title });
/** `play: false` where the host menu leads with its own Play row (Home's shelf resolves `next`);
* `pin: false` where the host carries its own shelf rows or the object is not a pin kind. */
* `pin: false` where the host carries its own shelf rows or the object is not a pin kind;
* `anchor`: the element the menu came from, which "Add to collection…" opens its panel beside —
* without one the row is left out rather than opening the panel at a corner. */
const items = (
c: PlexCard,
{ play = true, pin: withPin = true }: { play?: boolean; pin?: boolean } = {},
{
play = true,
pin: withPin = true,
anchor = null,
}: { play?: boolean; pin?: boolean; anchor?: Element | null } = {},
): ActionMenuItem[] => {
const { onOpen, onInfo, onToggleWatched, onRestart } = handlers;
const playable = isPlexPlayable(c);
@@ -104,6 +118,18 @@ export function usePlexCardActions(handlers: PlexActionHandlers, open: boolean)
},
]
: []),
// A movie or a show can be a collection member; an episode cannot.
...(isAdmin && anchor && c.type !== "episode"
? [
{
id: "collection",
label: t("plex.collectionAdd.row"),
icon: <Layers className={icon} />,
separatorBefore: !playable,
onSelect: () => setCollecting({ ratingKey: c.id, anchor }),
},
]
: []),
...(playable && onInfo
? [
{
@@ -120,7 +146,14 @@ export function usePlexCardActions(handlers: PlexActionHandlers, open: boolean)
];
};
const panel = adding && <PlexPlaylistAdd target={adding} onClose={() => setAdding(null)} />;
const panel = (
<>
{adding && <PlexPlaylistAdd target={adding} onClose={() => setAdding(null)} />}
{collecting && (
<PlexCollectionAddAt target={collecting} onClose={() => setCollecting(null)} />
)}
</>
);
return { items, add, panel };
}
+6
View File
@@ -384,5 +384,11 @@
"byGenre": "Browse by genre",
"becauseWatched": "Because you watched {{title}}",
"empty": "Nothing to discover yet — run a Plex sync from the admin Config page."
},
"collectionAdd": {
"row": "Add to collection…",
"title": "Add to collection",
"none": "No collection to add to yet — name one below.",
"loadFailed": "Couldn't load the collections"
}
}
+6
View File
@@ -384,5 +384,11 @@
"byGenre": "Böngészés műfaj szerint",
"becauseWatched": "Mert megnézted: {{title}}",
"empty": "Még nincs mit felfedezni — futtass egy Plex-szinkront az admin Konfiguráció oldalról."
},
"collectionAdd": {
"row": "Kollekcióhoz adás…",
"title": "Kollekcióhoz adás",
"none": "Még nincs kollekció, amihez adhatnád – adj meg egy nevet lent.",
"loadFailed": "Nem sikerült betölteni a kollekciókat"
}
}
+6
View File
@@ -508,6 +508,12 @@ export const plexApi = {
const qs = u.toString();
return req(`/api/plex/collections${qs ? `?${qs}` : ""}`);
},
// A movie or show card's "Add to collection": its library's manual collections, each saying whether
// the title is in it already.
plexCollectionsFor: (
itemRk: string,
): Promise<{ collections: (PlexCollection & { contains: boolean })[] }> =>
req(`/api/plex/collections/for/${encodeURIComponent(itemRk)}`),
// One collection's hero + its members in the requested order (the Collection info page). The sort
// vocabulary is the browse grid's, so the same Sort control means the same thing on both.
plexCollection: (rk: string, sort?: SortState, lang?: string): Promise<PlexCollectionDetail> => {