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.
131 lines
4.5 KiB
TypeScript
131 lines
4.5 KiB
TypeScript
import { useLayoutEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { api, type PlexCollection } from "../lib/api";
|
|
import { notifyActionError } from "../lib/actionErrors";
|
|
import { PopoverSurface } from "./ui/AnchoredPopover";
|
|
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
|
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. Its body is
|
|
// the same MembershipMenu as the video card's "Add to playlist".
|
|
|
|
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 [busy, setBusy] = useState(false);
|
|
const { ratingKey } = target;
|
|
const forKey = qk.plexCollections("for", ratingKey);
|
|
|
|
const list = useQuery({
|
|
queryKey: forKey,
|
|
queryFn: () => api.plexCollectionsFor(ratingKey),
|
|
});
|
|
|
|
// 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"));
|
|
}
|
|
refresh();
|
|
setBusy(false);
|
|
}
|
|
|
|
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);
|
|
made = true;
|
|
} catch (e) {
|
|
notifyActionError(e, t("plex.collection.editFailed"));
|
|
}
|
|
refresh();
|
|
setBusy(false);
|
|
return made;
|
|
}
|
|
|
|
const collections = list.data?.collections ?? [];
|
|
return (
|
|
<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)),
|
|
};
|
|
}
|