feat(plex): S6 frontend — language threading, rating badge, i18n parity

E5 S6 frontend half.

Language threading (?lang=hu serves the HU overlay; en is the default):
- api/plex.ts: `lang` on plexLibrary / plexFacets / plexItem / plexShow (only
  sent for hu, so EN URLs stay byte-stable); PlexItemDetail gains `rating`.
- queryKeys: plexItem / plexShow take an optional trailing lang so a switch caches
  HU/EN separately; the ...rest keys (library/facets) already carry it. Prefix
  invalidations (qk.plexShow(id) / qk.plexItem(id)) still match either.
- i18n: plexLang() collapses the UI language to en|hu for non-component code.
- PlexBrowse / PlexSidebar: thread lang into the grid, facets, item and show
  queries (useTranslation re-renders on switch → the key changes → refetch).
- plexPlayerHooks: the optimistic resume setQueryData now keys by plexLang() so
  it still lands on the language-keyed detail query.
- Clear the genre-filter selection on a language switch — genre chips are
  language-specific strings, so a stale one silently empties the grid.

IMDb-only rating badge (folded-in S5b review #5):
- PlexInfo.tsx shows the IMDb badge + imdb.com link only when imdb_rating is
  present; otherwise it renders the Plex audience `rating` (no IMDb link/branding),
  so a non-IMDb score is never mislabelled. ratingMin filter keys off whichever
  score is shown.

Tests: i18n parity.test.ts (en/hu key sets identical per namespace — currently
green, no drift). FE gate green: tsc / knip / lint clean, 213 vitest tests pass.
This commit is contained in:
2026-08-17 19:19:03 +02:00
parent 030ba7297a
commit 4169cfe4ff
8 changed files with 143 additions and 46 deletions
+23 -4
View File
@@ -15,6 +15,7 @@ import SortControl from "./SortControl";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { plexLang } from "../i18n";
import {
useInfiniteQuery,
useQuery,
@@ -117,6 +118,9 @@ export default function PlexBrowse() {
const { q, scope, show, sort, filters, field, playlistOpen: openPlaylist, jumpTo } = usePlex();
const { setScope, setSort, setFilters, setPlaylistOpen, setJumpTo } = usePlexActions();
const dq = useDebounced(q.trim(), 350);
// Active language for the HU overlay: "hu" serves Hungarian metadata, else EN. In the query keys so
// a switch refetches the grid/facets/detail; useTranslation re-renders this component on switch.
const lang = plexLang();
// Plex's active-filter chip row (above the grid), the shared feed idiom: one removable chip per
// narrowing, plus a Clear-all (shared with the sidebar) that resets filters + watch-state + sort.
const plexChips = usePlexActiveFilters();
@@ -191,7 +195,7 @@ export default function PlexBrowse() {
}, [sub]);
const browseQ = useInfiniteQuery({
queryKey: qk.plexLibrary(scope, dq, sortState, show, filters, fieldState),
queryKey: qk.plexLibrary(scope, dq, sortState, show, filters, fieldState, lang),
enabled: !!scope && sub.view.kind === "grid",
initialPageParam: 0,
queryFn: ({ pageParam }) =>
@@ -201,6 +205,7 @@ export default function PlexBrowse() {
field: fieldState,
sort: sortState,
show,
lang,
filters,
offset: pageParam as number,
limit: PAGE,
@@ -211,6 +216,17 @@ export default function PlexBrowse() {
},
});
// Genre chips are language-specific strings (the HU column stores "Dráma", the EN one "Drama"), so a
// selection made in one language matches nothing against the other's column and would silently empty
// the grid. Drop the genre selection when the language flips (only on an actual change, not mount).
const prevLangRef = useRef(lang);
useEffect(() => {
if (prevLangRef.current !== lang) {
prevLangRef.current = lang;
if (filters.genres.length) setFilters({ ...filters, genres: [] });
}
}, [lang, filters, setFilters]);
const items = browseQ.data?.pages.flatMap((p) => p.items) ?? [];
const total = browseQ.data?.pages[0]?.total ?? 0;
// Cards for the actors currently in the filter (name + headshot + in-scope count) — shown above the
@@ -690,7 +706,8 @@ function PlexInfoView({
onOpenPerson: (name: string) => void;
}) {
const { t } = useTranslation();
const q = useQuery({ queryKey: qk.plexItem(id), queryFn: () => api.plexItem(id) });
const lang = plexLang();
const q = useQuery({ queryKey: qk.plexItem(id, lang), queryFn: () => api.plexItem(id, lang) });
return (
<div className="w-[90%] max-w-[1600px] mx-auto">
<div className="p-4 pb-0">
@@ -1302,7 +1319,8 @@ function PlexShowView({
const { t } = useTranslation();
const qc = useQueryClient();
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
const q = useQuery({ queryKey: qk.plexShow(showId), queryFn: () => api.plexShow(showId) });
const lang = plexLang();
const q = useQuery({ queryKey: qk.plexShow(showId, lang), queryFn: () => api.plexShow(showId, lang) });
const d = q.data;
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
const [collOpen, setCollOpen] = useState(false);
@@ -1533,7 +1551,8 @@ function PlexSeasonView({
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const q = useQuery({ queryKey: qk.plexShow(showId), queryFn: () => api.plexShow(showId) });
const lang = plexLang();
const q = useQuery({ queryKey: qk.plexShow(showId, lang), queryFn: () => api.plexShow(showId, lang) });
const d = q.data;
const se = d?.seasons.find((s) => s.id === seasonId);
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);