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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user