Second slice of the E11 keyboard layer (S4b). S4a gave the feed, the library and the channel page `j`/`k`, the arrows and Enter; Home's shelves and the whole Plex module were still Tab-stop country — a Plex poster is its own Tab stop, and a rail's cards sat behind every control of the cards before. - `lib/listNav`: a list that mounts every item no longer declares a count or columns — both are measured off the DOM when a key is pressed, so an auto-fill grid's columns follow its width and a rail is one row. ↑ on a list's top row and ↓ on its bottom row go on to the neighbouring list, onto the item horizontally nearest the one left (owner decision D4: ←→ within a rail, ↑↓ to the rail above or below). The first `j` skips cards a rail has scrolled out to the left. - `Carousel keyboardNav` registers the scroller; the scroller gets a 4px inset (against a -4px margin, layout unchanged) so the focus ring is not clipped, and `[data-nav-index]` a 2.5rem inline scroll margin so a card stepped into view clears the rail's edge fade and paging arrow. - `ui/NavGrid`: the plain, fully mounted grid that registers itself. - `navIndex` on PlexPosterCard / SeasonCard / EpisodeCard (the card root is the item, the poster the open element — the ring goes round poster and title, D2), PosterTile and Home's ShelfTile (the button is the item); cast, related and collection-strip buttons carry the attrs directly. - Wired: Home's comfort and resume rails; Plex Browse titles + episodes, Discover rails + genres, collection view, playlists/collections browse, person known-for + filmography, show seasons, season episodes, cast/related strips, info-page cast + collection strips. - `listNav.wiring.contract.test`: every `Carousel` says `keyboardNav`, every card says `navIndex`, and a card with `navIndex` sits inside a NavGrid / Carousel / VirtualGrid — both halves are opt-in, and either one missing leaves a list the keyboard silently skips.
245 lines
9.5 KiB
TypeScript
245 lines
9.5 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import type { TFunction } from "i18next";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { Check, ListMusic, Lock, Plus, Search, X } from "lucide-react";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollection } from "../lib/api";
|
|
import { formatRuntime } from "../lib/format";
|
|
import { notifyActionError } from "../lib/actionErrors";
|
|
import { useDebounced } from "../lib/useDebounced";
|
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
|
import PosterTile from "./PosterTile";
|
|
import PlexLibraryPicker from "./PlexLibraryPicker";
|
|
import NavGrid from "./ui/NavGrid";
|
|
|
|
// A collection tile's sub-line: item count · total runtime (each dropped when unknown, e.g. show-only
|
|
// collections have no movie runtime).
|
|
function collectionMeta(c: PlexCollection, t: TFunction): string {
|
|
const parts: string[] = [];
|
|
if (c.child_count != null) parts.push(t("plex.browse.items", { count: c.child_count }));
|
|
const rt = formatRuntime(c.total_runtime_s);
|
|
if (rt) parts.push(rt);
|
|
return parts.join(" · ");
|
|
}
|
|
|
|
// The Playlist / Collection browse surfaces reached from the header pill (Piece 2): a grid of tiles,
|
|
// each a way INTO that area — a playlist tile opens the playlist view, a collection tile filters the
|
|
// title grid by that collection. Both reuse the existing list endpoints (plexPlaylists / plexCollections
|
|
// union), so this is a pure front-end surface. Collection tiles show a poster collage of their top
|
|
// films (PosterTile), falling back to a title-hash colour; playlists have no server art yet, so they
|
|
// take the colour fallback.
|
|
|
|
const GRID = "grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6";
|
|
|
|
// A grid of the user's Plex playlists; a tile opens that playlist's view.
|
|
export function PlexPlaylistsBrowse({ onOpen }: { onOpen: (id: number) => void }) {
|
|
const { t } = useTranslation();
|
|
const q = useQuery({ queryKey: qk.plexPlaylists(), queryFn: () => api.plexPlaylists() });
|
|
const playlists = q.data?.playlists ?? [];
|
|
|
|
if (q.isError && !q.data) {
|
|
return (
|
|
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
|
{t("common.loadError")}
|
|
</StateMessage>
|
|
);
|
|
}
|
|
if (q.isLoading) return <LoadingState label={t("plex.loading")} />;
|
|
if (playlists.length === 0)
|
|
return <p className="text-muted text-sm">{t("plex.playlist.none")}</p>;
|
|
|
|
return (
|
|
<NavGrid className={GRID}>
|
|
{playlists.map((p, i) => (
|
|
<PosterTile
|
|
key={p.id}
|
|
navIndex={i}
|
|
title={p.title}
|
|
subtitle={t("plex.browse.items", { count: p.item_count })}
|
|
topRight={<ListMusic className="h-4 w-4 text-white/80 drop-shadow" />}
|
|
onClick={() => onOpen(p.id)}
|
|
className="h-28"
|
|
/>
|
|
))}
|
|
</NavGrid>
|
|
);
|
|
}
|
|
|
|
// The "New collection" panel (admin, ask B-route): name (rejected if it duplicates an existing one —
|
|
// Plex conflates same-named collections) + a search to pick the FIRST title, since Plex can't create an
|
|
// empty collection. Creating opens the new collection's detail page (onCreated → onPick).
|
|
function NewCollectionPanel({
|
|
existingNames,
|
|
onCreated,
|
|
onClose,
|
|
}: {
|
|
existingNames: Set<string>;
|
|
onCreated: (rk: string) => void;
|
|
onClose: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [name, setName] = useState("");
|
|
const [term, setTerm] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
const trimmed = name.trim();
|
|
// Advisory only: `existingNames` is the search-scoped, all-library collection union, whereas the
|
|
// backend enforces uniqueness PER library (Plex only conflates same-named collections within one
|
|
// section). So a match here is a hint, not a hard block — it must not gate creation, or a valid
|
|
// same-name collection in another library would be un-creatable. The backend's 409 is authoritative.
|
|
const dupHint = trimmed.length > 0 && existingNames.has(trimmed.toLowerCase());
|
|
const nameOk = trimmed.length > 0;
|
|
|
|
async function create(card: PlexCard) {
|
|
if (!nameOk || busy) return;
|
|
setBusy(true);
|
|
try {
|
|
const col = await api.plexCreateCollection("", trimmed, card.id); // backend derives the library
|
|
onCreated(col.id);
|
|
} catch (e) {
|
|
// The backend is authoritative on per-library name uniqueness — a taken name comes back
|
|
// structured, and so localized (see localizeDetail).
|
|
notifyActionError(e, t("plex.collection.editFailed"));
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="glass-card mb-6 rounded-2xl p-4">
|
|
<div className="mb-2 flex items-center justify-between gap-2">
|
|
<h3 className="text-sm font-semibold">{t("plex.collection.newCollection")}</h3>
|
|
<button
|
|
onClick={onClose}
|
|
aria-label={t("common.close")}
|
|
className="glass-hover grid h-8 w-8 place-items-center rounded-lg"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
<input
|
|
autoFocus
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder={t("plex.collection.namePlaceholder")}
|
|
aria-label={t("plex.collection.newCollection")}
|
|
className="glass-card w-full rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
|
/>
|
|
{dupHint ? (
|
|
<p className="mt-1 mb-2 text-xs text-muted">{t("plex.collection.nameMaybeTaken")}</p>
|
|
) : (
|
|
<p className="mt-1 mb-2 text-xs text-muted">{t("plex.collection.newHint")}</p>
|
|
)}
|
|
<div className="mb-3 flex items-center gap-2">
|
|
<Search className="h-4 w-4 shrink-0 text-muted" />
|
|
<input
|
|
value={term}
|
|
onChange={(e) => setTerm(e.target.value)}
|
|
disabled={!nameOk}
|
|
placeholder={t("plex.collection.addSearch")}
|
|
aria-label={t("plex.collection.addSearch")}
|
|
className="glass-card flex-1 rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40 disabled:opacity-50"
|
|
/>
|
|
</div>
|
|
<PlexLibraryPicker
|
|
scope="both"
|
|
term={term}
|
|
enabled={nameOk}
|
|
renderAction={(c) => (
|
|
<button
|
|
onClick={() => create(c)}
|
|
disabled={busy}
|
|
className="glass-card glass-hover inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs disabled:opacity-50"
|
|
>
|
|
<Check className="h-3.5 w-3.5" />
|
|
{t("plex.collection.createWith")}
|
|
</button>
|
|
)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// A grid of Plex collections (searchable — the union can run to hundreds); a tile opens its info page.
|
|
export function PlexCollectionsBrowse({ onPick }: { onPick: (id: string, title: string) => void }) {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
|
|
const [search, setSearch] = useState("");
|
|
const [creating, setCreating] = useState(false);
|
|
const searchDeb = useDebounced(search.trim(), 300);
|
|
const q = useQuery({
|
|
queryKey: qk.plexCollections("union", searchDeb),
|
|
queryFn: () => api.plexCollections(undefined, searchDeb || undefined),
|
|
});
|
|
const collections = useMemo(() => q.data?.collections ?? [], [q.data?.collections]);
|
|
const existingNames = useMemo(
|
|
() => new Set(collections.map((c) => c.title.trim().toLowerCase())),
|
|
[collections],
|
|
);
|
|
|
|
return (
|
|
<div>
|
|
<div className="mb-4 flex flex-wrap items-center gap-2">
|
|
<input
|
|
type="search"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder={t("plex.filter.collectionSearch")}
|
|
aria-label={t("plex.filter.collectionSearch")}
|
|
className="glass-card w-full max-w-sm rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
|
/>
|
|
{isAdmin && !creating && (
|
|
<button
|
|
onClick={() => setCreating(true)}
|
|
className="glass-card glass-hover inline-flex shrink-0 items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
{t("plex.collection.newCollection")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
{creating && (
|
|
<NewCollectionPanel
|
|
existingNames={existingNames}
|
|
onCreated={(rk) => {
|
|
setCreating(false);
|
|
onPick(rk, "");
|
|
}}
|
|
onClose={() => setCreating(false)}
|
|
/>
|
|
)}
|
|
{q.isError && !q.data ? (
|
|
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
|
{t("common.loadError")}
|
|
</StateMessage>
|
|
) : q.isLoading ? (
|
|
<LoadingState label={t("plex.loading")} />
|
|
) : collections.length === 0 ? (
|
|
<p className="text-muted text-sm">{t("plex.filter.collectionEmpty")}</p>
|
|
) : (
|
|
<NavGrid className={GRID}>
|
|
{collections.map((c, i) => (
|
|
<PosterTile
|
|
key={c.id}
|
|
navIndex={i}
|
|
title={c.title}
|
|
art={c.art}
|
|
subtitle={collectionMeta(c, t)}
|
|
topRight={
|
|
isIntrinsicReadOnly(c) ? (
|
|
<Lock
|
|
className="h-3.5 w-3.5 text-white/80 drop-shadow"
|
|
aria-label={t("plex.collection.readOnly")}
|
|
/>
|
|
) : undefined
|
|
}
|
|
onClick={() => onPick(c.id, c.title)}
|
|
className="h-28"
|
|
/>
|
|
))}
|
|
</NavGrid>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|