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:
@@ -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);
|
||||
|
||||
@@ -195,37 +195,42 @@ export default function PlexInfo({
|
||||
{detail.studio}
|
||||
</Filterable>
|
||||
)}
|
||||
{detail.imdb_rating != null && (
|
||||
// decorative rating gold — intentionally NOT a status token
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-amber-400/15 px-1.5 py-0.5 font-semibold text-amber-500">
|
||||
<button
|
||||
onClick={
|
||||
onFilter
|
||||
? () => onFilter({ ratingMin: Math.floor(detail.imdb_rating!) })
|
||||
: undefined
|
||||
}
|
||||
className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:opacity-80" : "cursor-default"}`}
|
||||
title={
|
||||
onFilter
|
||||
? t("plex.info.filterRating", { n: Math.floor(detail.imdb_rating!) })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Star className="w-3.5 h-3.5 fill-current" />
|
||||
{detail.imdb_rating.toFixed(1)}
|
||||
</button>
|
||||
{detail.imdb_url && (
|
||||
<a
|
||||
href={detail.imdb_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={t("plex.info.openImdb")}
|
||||
{(() => {
|
||||
// IMDb score when we have one (with the IMDb link); otherwise the Plex audience
|
||||
// score (no IMDb link/branding, so a non-IMDb score is never mislabelled as IMDb).
|
||||
const score = detail.imdb_rating ?? detail.rating;
|
||||
if (score == null) return null;
|
||||
const isImdb = detail.imdb_rating != null;
|
||||
return (
|
||||
// decorative rating gold — intentionally NOT a status token
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-amber-400/15 px-1.5 py-0.5 font-semibold text-amber-500">
|
||||
<button
|
||||
onClick={
|
||||
onFilter ? () => onFilter({ ratingMin: Math.floor(score) }) : undefined
|
||||
}
|
||||
className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:opacity-80" : "cursor-default"}`}
|
||||
title={
|
||||
onFilter
|
||||
? t("plex.info.filterRating", { n: Math.floor(score) })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<ExternalLink className="w-3 h-3 opacity-70 hover:opacity-100" />
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<Star className="w-3.5 h-3.5 fill-current" />
|
||||
{score.toFixed(1)}
|
||||
</button>
|
||||
{isImdb && detail.imdb_url && (
|
||||
<a
|
||||
href={detail.imdb_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={t("plex.info.openImdb")}
|
||||
>
|
||||
<ExternalLink className="w-3 h-3 opacity-70 hover:opacity-100" />
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{detail.tagline && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { plexLang } from "../i18n";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Layers, ListMusic, Plus, SlidersHorizontal, X } from "lucide-react";
|
||||
import { api, plexFilterCount, type PlexFilters } from "../lib/api";
|
||||
@@ -80,9 +81,10 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
||||
onOpenPlaylist(pl.id);
|
||||
}
|
||||
const { collectionTitle: _ct, ...facetKey } = filters;
|
||||
const lang = plexLang(); // HU/EN genre vocabulary — refetch facets on a language switch
|
||||
const facetsQ = useQuery({
|
||||
queryKey: qk.plexFacets(scope, show, facetKey),
|
||||
queryFn: () => api.plexFacets(scope, filters, show),
|
||||
queryKey: qk.plexFacets(scope, show, facetKey, lang),
|
||||
queryFn: () => api.plexFacets(scope, filters, show, lang),
|
||||
enabled: !!scope,
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user