fix(ui): one membership menu for playlists, collections and tags

Three hand-copied panels had drifted: Add to playlist said a failed load as 'no playlists yet'. ui/MembershipMenu now holds them: a loading line and an error with Retry instead of a blank or an empty list, the up/down arrows step from the name field into rows that arrived after the menu opened, and the panels opened from a menu carry their own accessible name. A write that landed flips its tick at once, so a second tap before the refetch undoes it instead of repeating it; a collection edit also refreshes a show's own page.
This commit is contained in:
2026-10-08 21:18:48 +02:00
parent fadb4f17a9
commit 7e94da72b9
7 changed files with 416 additions and 220 deletions
+52 -70
View File
@@ -1,23 +1,19 @@
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 { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { Input } from "./ui/Input";
import { MembershipMenu } from "./ui/MembershipMenu";
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.
// collection, its name, a search for the title, then another search to add the next one. Its body is
// the same MembershipMenu as the video card's "Add to playlist".
export interface PlexCollectionTarget {
ratingKey: string;
@@ -43,29 +39,33 @@ export default function PlexCollectionAddAt({
});
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 forKey = qk.plexCollections("for", ratingKey);
const list = useQuery({
queryKey: qk.plexCollections("for", ratingKey),
queryKey: forKey,
queryFn: () => api.plexCollectionsFor(ratingKey),
});
// The title's own page, the collections' pages and the library grid all show membership.
// The title's own page (a movie's item, a show's show), 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.plexShow(ratingKey) });
void qc.invalidateQueries({ queryKey: qk.plexLibrary() });
}
// A write that landed flips the tick at once: the row's state comes from the list, and a second tap
// before its refetch returned would otherwise repeat the write instead of undoing it.
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);
qc.setQueryData<typeof list.data>(forKey, (old) => flipContains(old, c.id));
} catch (e) {
notifyActionError(e, t("plex.collection.editFailed"));
}
@@ -73,76 +73,58 @@ export default function PlexCollectionAddAt({
setBusy(false);
}
async function create() {
const name = newName.trim();
if (!name || busy) return;
async function create(name: string) {
if (busy) return false;
setBusy(true);
let made = false;
try {
// No library: the server takes the seed title's own.
await api.plexCreateCollection("", name, ratingKey);
setNewName("");
made = true;
} catch (e) {
notifyActionError(e, t("plex.collection.editFailed"));
}
refresh();
setBusy(false);
return made;
}
const rows = list.data?.collections ?? [];
const collections = 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
popover={pop}
className={`${SURFACE.popover} w-64 p-2`}
ariaLabel={t("plex.collectionAdd.title")}
>
<MembershipMenu
heading={t("plex.collectionAdd.title")}
rows={collections.map((c) => ({ id: c.id, label: c.title, checked: c.contains }))}
onToggle={(row) => {
const c = collections.find((x) => x.id === row.id);
if (c) void toggle(c);
}}
status={list.isError && !list.data ? "error" : list.isLoading ? "loading" : "ready"}
onRetry={() => void list.refetch()}
emptyText={t("plex.collectionAdd.none")}
errorText={t("plex.collectionAdd.loadFailed")}
busy={busy}
create={{
placeholder: t("plex.collection.newCollection"),
ariaLabel: t("plex.collection.namePlaceholder"),
onCreate: create,
}}
/>
</PopoverSurface>
);
}
function flipContains<T extends { collections: { id: string; contains: boolean }[] }>(
old: T | undefined,
id: string,
): T | undefined {
if (!old) return old;
return {
...old,
collections: old.collections.map((c) => (c.id === id ? { ...c, contains: !c.contains } : c)),
};
}