feat(plex): Collections Phase 2 — admin collection editing (write-back to Plex)

Admins can curate Plex collections from a movie's info page ("Collections"
button → PlexCollectionEditor dialog): create a collection (seeded with the
movie), add/remove the movie to/from editable collections, delete a collection,
and "take over" an existing plain Plex collection (mark it editable). All writes
go to Plex (POST/PUT/DELETE via new PlexClient methods) and are reflected on
every client; a targeted single-collection re-sync (sync.resync_collection /
delete_collection_local) updates the local mirror without the full ~4-min sync.

Gating (per the design decisions): editing is ADMIN-ONLY (collections are shared
library-wide); only plain manual collections are editable — smart + external
auto-lists (IMDb/TMDb/…) are always read-only (can_edit = editable && !smart &&
source=="collection"). New admin endpoints under /api/plex/collections
(create/items add+remove/rename/delete/editable). Verified end-to-end incl. the
live Plex write API (create/add/rename/remove/delete all 200, self-cleaned) and
the editor UI (create + delete with confirm) on localdev.
This commit is contained in:
npeter83 2026-07-06 17:33:16 +02:00
parent 4b053a55da
commit 8291f06525
12 changed files with 602 additions and 13 deletions

View file

@ -5,6 +5,7 @@ import { useDismiss } from "../lib/useDismiss";
import {
Check,
ExternalLink,
FolderPlus,
Play,
RotateCcw,
SlidersHorizontal,
@ -12,6 +13,7 @@ import {
X,
} from "lucide-react";
import { api, type PlexFilters, type PlexItemDetail } from "../lib/api";
import PlexCollectionEditor from "./PlexCollectionEditor";
// The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating,
// genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from
@ -30,6 +32,8 @@ type Props = {
onFilter?: (patch: Partial<PlexFilters>) => void;
// Play a sibling title from a collection strip (opens its player).
onPlayItem?: (id: string) => void;
// The item's library key — enables the admin "Manage collections" editor (page variant, movies).
library?: string;
};
// A metadata value that filters the grid when clicked (if onFilter is wired), else plain text.
@ -67,6 +71,7 @@ export default function PlexInfo({
onStateChange,
onFilter,
onPlayItem,
library,
}: Props) {
const { t } = useTranslation();
const qc = useQueryClient();
@ -88,6 +93,8 @@ export default function PlexInfo({
return next;
});
};
const isAdmin = (qc.getQueryData<{ role?: string }>(["me"])?.role) === "admin";
const [editorOpen, setEditorOpen] = useState(false);
const [customizing, setCustomizing] = useState(false);
const custBtnRef = useRef<HTMLButtonElement>(null);
const custMenuRef = useRef<HTMLDivElement>(null);
@ -360,6 +367,15 @@ export default function PlexInfo({
{t("plex.info.clearResume")}
</button>
)}
{isAdmin && detail.kind === "movie" && library && (
<button
onClick={() => setEditorOpen(true)}
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
>
<FolderPlus className="w-4 h-4" />
{t("plex.collEditor.manage")}
</button>
)}
</div>
)}
</div>
@ -488,6 +504,15 @@ export default function PlexInfo({
</div>
))}
</div>
{editorOpen && library && (
<PlexCollectionEditor
item={{ id: detail.id, title: detail.title }}
library={library}
memberOf={(detail.collections ?? []).map((c) => c.id)}
onClose={() => setEditorOpen(false)}
onChanged={() => onStateChange?.()}
/>
)}
</div>
);
}