Every locale formatter takes the language as an argument. Under the React
Compiler a formatter that read the i18n singleton was memoized on its other
arguments, so a card kept "2 hr 4 min" after a switch to Hungarian; plexLang()
had the same flaw and left Plex queries in the old language. Components now
take the language from useTranslation, and the signature makes forgetting it
a type error.
- ratings, file sizes, stream stats: the language's decimal mark ("8,3")
- view and subscriber counts compact in the language ("1,2 E")
- seconds as the language's unit ("6 mp") in the sliders and the stats
- a person's birth and death dates as dates, not ISO strings
- A-Z lists by the shown name in the language's alphabet (compareText),
the tag catalog re-sorted by its translated names
- lint local/locale-format (was date-format) also refuses Intl.NumberFormat
outside lib/format.ts, localeCompare, and a component reading the language
off the i18n singleton
244 lines
9.1 KiB
TypeScript
244 lines
9.1 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";
|
|
import { Button } from "./ui/Button";
|
|
import { IconButton } from "./ui/IconButton";
|
|
import { Input } from "./ui/Input";
|
|
import { SURFACE } from "./ui/surfaces";
|
|
|
|
// 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, lang: string): 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, lang);
|
|
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 shape="poster" 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={`${SURFACE.panel} mb-6 p-4`}>
|
|
<div className="mb-2 flex items-center justify-between gap-2">
|
|
<h3 className="text-sm font-semibold">{t("plex.collection.newCollectionTitle")}</h3>
|
|
<IconButton size="md" onClick={onClose} label={t("common.close")}>
|
|
<X />
|
|
</IconButton>
|
|
</div>
|
|
<Input
|
|
className="w-full"
|
|
autoFocus
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder={t("plex.collection.namePlaceholder")}
|
|
aria-label={t("plex.collection.newCollection")}
|
|
/>
|
|
{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
|
|
className="flex-1"
|
|
value={term}
|
|
onChange={(e) => setTerm(e.target.value)}
|
|
disabled={!nameOk}
|
|
placeholder={t("plex.collection.addSearch")}
|
|
aria-label={t("plex.collection.addSearch")}
|
|
/>
|
|
</div>
|
|
<PlexLibraryPicker
|
|
scope="both"
|
|
term={term}
|
|
enabled={nameOk}
|
|
renderAction={(c) => (
|
|
<Button
|
|
variant="glass"
|
|
size="xs"
|
|
icon={<Check />}
|
|
onClick={() => create(c)}
|
|
loading={busy}
|
|
>
|
|
{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, i18n } = 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
|
|
className="w-full max-w-sm"
|
|
size="sm"
|
|
pill
|
|
type="search"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder={t("plex.filter.collectionSearch")}
|
|
aria-label={t("plex.filter.collectionSearch")}
|
|
/>
|
|
{isAdmin && !creating && (
|
|
<Button variant="glass" size="sm" icon={<Plus />} onClick={() => setCreating(true)}>
|
|
{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 shape="poster" 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, i18n.language)}
|
|
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>
|
|
);
|
|
}
|