refactor: component files export only components; lint fails on a warning
The 43 react-refresh/only-export-components warnings the gate let through meant an edit to a provider (or AnchoredPopover, Toaster, plexDetailUi, …) could not be hot-swapped: Fast Refresh reloads a module that also exports hooks or helpers. - Each provider's contexts, the types its hooks return and the hooks themselves live in a sibling context module (navigationContext.ts, channelsContext.ts, …); the provider file keeps the component. loadAccountFilters moved next to the feed filters' defaults (feedFiltersInit.ts), following navigationInit/playlistsInit. - Helpers that sat in component files have their own modules: welcomeMode, downloadTitle, plexPlayable, channelSync, ui/anchoredPopoverHooks, ui/pageToolbarSlot, ui/pagerContent, ui/toastLevels, ui/overlayOrder, ui/tableAlign, lib/plexDetail; the cells of the channel columns and the registry's page wrappers are components files of their own (channelCells, modulePages). - usePersistedTab, an alias of useAccountPersistedState, is gone; its callers use the original. npm run lint is now eslint --max-warnings 0, so a warning fails the gate like an error does.
This commit is contained in:
@@ -2,7 +2,7 @@ import { LS, setAccountRaw } from "./storage";
|
||||
|
||||
// The admin Users page's persisted-tab key + the Access-requests tab id, plus a helper to
|
||||
// pre-select that tab before navigating there (a notification's "Review" link uses it —
|
||||
// usePersistedTab reads the key on mount, and the page mounts fresh on navigation).
|
||||
// useAccountPersistedState reads the key on mount, and the page mounts fresh on navigation).
|
||||
//
|
||||
// Kept out of AdminUsers.tsx so callers (App, NotificationsPanel) can pre-select the tab
|
||||
// WITHOUT statically importing the — now lazy-loaded — admin page, which would otherwise pull
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { coerceTagModes, coerceUntagged, DEFAULT_TAG_MODES } from "./tagModes";
|
||||
|
||||
// coerceTagModes runs on every persisted saved-view blob (SavedViewsProvider.asFilters), on the URL
|
||||
// coerceTagModes runs on every persisted saved-view blob (savedViewsContext.asFilters), on the URL
|
||||
// parse, and on the localStorage seed — the single migration point for the legacy global `tagMode`.
|
||||
describe("coerceTagModes", () => {
|
||||
it("defaults to all-'or' for empty/undefined input", () => {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// per-test; the hook reads it at effect time.
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, cleanup } from "@testing-library/react";
|
||||
import { useCrossfadeBackdrop } from "./plexDetailUi";
|
||||
import { useCrossfadeBackdrop } from "./plexDetail";
|
||||
|
||||
function mockReducedMotion(reduce: boolean) {
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useLayoutEffect } from "react";
|
||||
import { usePref, useSavePref } from "./usePref";
|
||||
import type { PlexCard } from "./api";
|
||||
|
||||
// The non-component half of the shared Plex "detail page" UI (see plexDetailUi): the per-user display
|
||||
// prefs, the TMDB backdrop URL, the crossfading art backdrop hook and the episode label. Split out so
|
||||
// plexDetailUi exports only components (Fast Refresh).
|
||||
|
||||
export function useDetailPrefs() {
|
||||
// Three per-user detail-page toggles, each a server pref adopted on login (usePref). Default ON:
|
||||
// undefined → true, only an explicit false hides. The detail pages mount after `me` is cached, so
|
||||
// usePref seeds from the cache (no localStorage mirror).
|
||||
const [artBg, setArtBg] = usePref<boolean>("plexInfoArtBg", true);
|
||||
const [showCast, setShowCast] = usePref<boolean>("plexInfoCast", true);
|
||||
const [showRelated, setShowRelated] = usePref<boolean>("plexInfoRelated", true);
|
||||
// E5 S8: slowly cross-dissolve the art backdrop through the item's backdrops/related art. Independent
|
||||
// of prefers-reduced-motion, which hard-disables the rotation regardless of this toggle.
|
||||
const [crossfade, setCrossfade] = usePref<boolean>("plexInfoArtCrossfade", true);
|
||||
const savePref = useSavePref();
|
||||
return {
|
||||
artBg,
|
||||
showCast,
|
||||
showRelated,
|
||||
crossfade,
|
||||
savePref, // the raw persister for callers with extra per-user prefs (e.g. strip sources)
|
||||
toggleArtBg: () => setArtBg(!artBg),
|
||||
toggleCast: () => setShowCast(!showCast),
|
||||
toggleRelated: () => setShowRelated(!showRelated),
|
||||
toggleCrossfade: () => setCrossfade(!crossfade),
|
||||
};
|
||||
}
|
||||
|
||||
// A stored TMDB backdrop is a bare file path ("/xyz.jpg"); the CSP allows the TMDB image CDN (only
|
||||
// frame-ancestors is set), so we render it straight from there — a rich multi-image pool for the
|
||||
// crossfade even on a lone movie with no collection.
|
||||
export function tmdbBackdropUrl(path: string): string {
|
||||
return `https://image.tmdb.org/t/p/w1280${path}`;
|
||||
}
|
||||
|
||||
// A faint full-viewport art backdrop that slowly cross-dissolves through a pool of images (E5 S8).
|
||||
//
|
||||
// Painting layer: the SETTLED image lives on the app root (#root) background — the proven full-viewport
|
||||
// layer (#root is exactly the viewport, transparent by default so the art shows through the glassy nav
|
||||
// / header / content that float over it; a fixed layer at negative z-index that is NOT a #root child is
|
||||
// hidden by the body scheme-background). The incoming image cross-fades in on an OVERLAY layer that IS
|
||||
// a `z-index:-1` child of #root: it paints ABOVE #root's own background (the settled art) but BELOW the
|
||||
// content, so the dissolve is visible under the chrome. Once faded, the settled image is committed to
|
||||
// #root and the overlay reset to transparent — a double buffer, so only ever ONE extra DOM node.
|
||||
//
|
||||
// The rotation is HARD-OFF under prefers-reduced-motion (shows only the first image, no movement) and
|
||||
// when there are fewer than two images or it's disabled; `attachment:fixed` keeps it viewport-anchored
|
||||
// while the content scrolls. Cleared on unmount / disable / empty pool.
|
||||
const BACKDROP_FADE_MS = 1600;
|
||||
const BACKDROP_INTERVAL_MS = 7000;
|
||||
const BACKDROP_DIM =
|
||||
"linear-gradient(color-mix(in srgb, var(--bg) 60%, transparent), " +
|
||||
"color-mix(in srgb, var(--bg) 64%, transparent))";
|
||||
|
||||
export function useCrossfadeBackdrop(
|
||||
arts: (string | null | undefined)[],
|
||||
enabled: boolean,
|
||||
animate = true,
|
||||
) {
|
||||
// Deduped, non-null pool, collapsed to a string dep so the effect only re-runs on a real change.
|
||||
const key = Array.from(new Set(arts.filter((a): a is string => !!a))).join("|");
|
||||
useLayoutEffect(() => {
|
||||
const root = document.getElementById("root");
|
||||
if (!root) return;
|
||||
const paint = (url: string) => {
|
||||
root.style.backgroundImage = `${BACKDROP_DIM}, url("${url}")`;
|
||||
root.style.backgroundSize = "cover";
|
||||
root.style.backgroundPosition = "center 20%";
|
||||
root.style.backgroundRepeat = "no-repeat";
|
||||
root.style.backgroundAttachment = "fixed";
|
||||
// #root must be a stacking context for the z-index:-1 crossfade overlay to paint ABOVE #root's
|
||||
// own background (the settled art) rather than behind it — without this the overlay is invisible.
|
||||
root.style.isolation = "isolate";
|
||||
};
|
||||
const clear = () => {
|
||||
root.style.backgroundImage = "";
|
||||
root.style.backgroundSize = "";
|
||||
root.style.backgroundPosition = "";
|
||||
root.style.backgroundRepeat = "";
|
||||
root.style.backgroundAttachment = "";
|
||||
root.style.isolation = "";
|
||||
};
|
||||
|
||||
const pool = key ? key.split("|") : [];
|
||||
if (!enabled || pool.length === 0) {
|
||||
clear();
|
||||
return clear; // and clear again on unmount / dep-change (idempotent)
|
||||
}
|
||||
paint(pool[0]!);
|
||||
|
||||
const reduce =
|
||||
typeof window !== "undefined" &&
|
||||
typeof window.matchMedia === "function" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
// Static case (one image / crossfade off / reduced motion): still MUST clear on unmount — otherwise
|
||||
// #root keeps the painted backdrop after leaving the page (e.g. switching to another module).
|
||||
if (!animate || reduce || pool.length < 2) return clear;
|
||||
|
||||
const layer = document.createElement("div");
|
||||
Object.assign(layer.style, {
|
||||
position: "fixed",
|
||||
inset: "0",
|
||||
zIndex: "-1",
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center 20%",
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundAttachment: "fixed",
|
||||
opacity: "0",
|
||||
pointerEvents: "none",
|
||||
});
|
||||
root.appendChild(layer);
|
||||
|
||||
let idx = 0;
|
||||
let fadeTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const tick = () => {
|
||||
const next = (idx + 1) % pool.length;
|
||||
layer.style.transition = "none";
|
||||
layer.style.opacity = "0";
|
||||
layer.style.backgroundImage = `${BACKDROP_DIM}, url("${pool[next]}")`;
|
||||
requestAnimationFrame(() => {
|
||||
layer.style.transition = `opacity ${BACKDROP_FADE_MS}ms ease`;
|
||||
layer.style.opacity = "1";
|
||||
});
|
||||
fadeTimer = setTimeout(() => {
|
||||
paint(pool[next]!); // settle the new image on #root, then reset the overlay (no visible change)
|
||||
layer.style.transition = "none";
|
||||
layer.style.opacity = "0";
|
||||
idx = next;
|
||||
}, BACKDROP_FADE_MS);
|
||||
};
|
||||
const interval = setInterval(tick, BACKDROP_INTERVAL_MS);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
if (fadeTimer) clearTimeout(fadeTimer);
|
||||
layer.remove();
|
||||
clear();
|
||||
};
|
||||
}, [key, enabled, animate]);
|
||||
}
|
||||
|
||||
// Series detail pages (show + season subpage): the S·E label under a resume/next-up episode.
|
||||
export function epLabel(ep?: PlexCard | null): string | undefined {
|
||||
if (!ep) return undefined;
|
||||
if (ep.season_number != null && ep.episode_number != null)
|
||||
return `S${ep.season_number} · E${ep.episode_number}`;
|
||||
return ep.title;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -9,44 +9,11 @@ import {
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useDismiss } from "./useDismiss";
|
||||
import { usePref, useSavePref } from "./usePref";
|
||||
import type { PlexCard } from "./api";
|
||||
|
||||
// Shared "detail page" UI reused by the movie info page (PlexInfo) and the series show/season pages,
|
||||
// so they look consistent (the standing glassy/HTPC brief): the faint fixed art backdrop, the two
|
||||
// per-user display prefs (art background / cast row — same keys as the movie info page), and the
|
||||
// customize menu that toggles them.
|
||||
|
||||
export function useDetailPrefs() {
|
||||
// Three per-user detail-page toggles, each a server pref adopted on login (usePref). Default ON:
|
||||
// undefined → true, only an explicit false hides. The detail pages mount after `me` is cached, so
|
||||
// usePref seeds from the cache (no localStorage mirror).
|
||||
const [artBg, setArtBg] = usePref<boolean>("plexInfoArtBg", true);
|
||||
const [showCast, setShowCast] = usePref<boolean>("plexInfoCast", true);
|
||||
const [showRelated, setShowRelated] = usePref<boolean>("plexInfoRelated", true);
|
||||
// E5 S8: slowly cross-dissolve the art backdrop through the item's backdrops/related art. Independent
|
||||
// of prefers-reduced-motion, which hard-disables the rotation regardless of this toggle.
|
||||
const [crossfade, setCrossfade] = usePref<boolean>("plexInfoArtCrossfade", true);
|
||||
const savePref = useSavePref();
|
||||
return {
|
||||
artBg,
|
||||
showCast,
|
||||
showRelated,
|
||||
crossfade,
|
||||
savePref, // the raw persister for callers with extra per-user prefs (e.g. strip sources)
|
||||
toggleArtBg: () => setArtBg(!artBg),
|
||||
toggleCast: () => setShowCast(!showCast),
|
||||
toggleRelated: () => setShowRelated(!showRelated),
|
||||
toggleCrossfade: () => setCrossfade(!crossfade),
|
||||
};
|
||||
}
|
||||
|
||||
// A stored TMDB backdrop is a bare file path ("/xyz.jpg"); the CSP allows the TMDB image CDN (only
|
||||
// frame-ancestors is set), so we render it straight from there — a rich multi-image pool for the
|
||||
// crossfade even on a lone movie with no collection.
|
||||
export function tmdbBackdropUrl(path: string): string {
|
||||
return `https://image.tmdb.org/t/p/w1280${path}`;
|
||||
}
|
||||
// customize menu that toggles them. The prefs hook, backdrop helpers and epLabel live in plexDetail.ts.
|
||||
|
||||
// A metadata value that filters the grid when clicked (if onClick is wired), else plain text. Shared
|
||||
// by the movie info page (PlexInfo) and the unified grid / show pages (PlexBrowse). The optional
|
||||
@@ -74,111 +41,6 @@ export function Filterable({
|
||||
);
|
||||
}
|
||||
|
||||
// A faint full-viewport art backdrop that slowly cross-dissolves through a pool of images (E5 S8).
|
||||
//
|
||||
// Painting layer: the SETTLED image lives on the app root (#root) background — the proven full-viewport
|
||||
// layer (#root is exactly the viewport, transparent by default so the art shows through the glassy nav
|
||||
// / header / content that float over it; a fixed layer at negative z-index that is NOT a #root child is
|
||||
// hidden by the body scheme-background). The incoming image cross-fades in on an OVERLAY layer that IS
|
||||
// a `z-index:-1` child of #root: it paints ABOVE #root's own background (the settled art) but BELOW the
|
||||
// content, so the dissolve is visible under the chrome. Once faded, the settled image is committed to
|
||||
// #root and the overlay reset to transparent — a double buffer, so only ever ONE extra DOM node.
|
||||
//
|
||||
// The rotation is HARD-OFF under prefers-reduced-motion (shows only the first image, no movement) and
|
||||
// when there are fewer than two images or it's disabled; `attachment:fixed` keeps it viewport-anchored
|
||||
// while the content scrolls. Cleared on unmount / disable / empty pool.
|
||||
const BACKDROP_FADE_MS = 1600;
|
||||
const BACKDROP_INTERVAL_MS = 7000;
|
||||
const BACKDROP_DIM =
|
||||
"linear-gradient(color-mix(in srgb, var(--bg) 60%, transparent), " +
|
||||
"color-mix(in srgb, var(--bg) 64%, transparent))";
|
||||
|
||||
export function useCrossfadeBackdrop(
|
||||
arts: (string | null | undefined)[],
|
||||
enabled: boolean,
|
||||
animate = true,
|
||||
) {
|
||||
// Deduped, non-null pool, collapsed to a string dep so the effect only re-runs on a real change.
|
||||
const key = Array.from(new Set(arts.filter((a): a is string => !!a))).join("|");
|
||||
useLayoutEffect(() => {
|
||||
const root = document.getElementById("root");
|
||||
if (!root) return;
|
||||
const paint = (url: string) => {
|
||||
root.style.backgroundImage = `${BACKDROP_DIM}, url("${url}")`;
|
||||
root.style.backgroundSize = "cover";
|
||||
root.style.backgroundPosition = "center 20%";
|
||||
root.style.backgroundRepeat = "no-repeat";
|
||||
root.style.backgroundAttachment = "fixed";
|
||||
// #root must be a stacking context for the z-index:-1 crossfade overlay to paint ABOVE #root's
|
||||
// own background (the settled art) rather than behind it — without this the overlay is invisible.
|
||||
root.style.isolation = "isolate";
|
||||
};
|
||||
const clear = () => {
|
||||
root.style.backgroundImage = "";
|
||||
root.style.backgroundSize = "";
|
||||
root.style.backgroundPosition = "";
|
||||
root.style.backgroundRepeat = "";
|
||||
root.style.backgroundAttachment = "";
|
||||
root.style.isolation = "";
|
||||
};
|
||||
|
||||
const pool = key ? key.split("|") : [];
|
||||
if (!enabled || pool.length === 0) {
|
||||
clear();
|
||||
return clear; // and clear again on unmount / dep-change (idempotent)
|
||||
}
|
||||
paint(pool[0]!);
|
||||
|
||||
const reduce =
|
||||
typeof window !== "undefined" &&
|
||||
typeof window.matchMedia === "function" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
// Static case (one image / crossfade off / reduced motion): still MUST clear on unmount — otherwise
|
||||
// #root keeps the painted backdrop after leaving the page (e.g. switching to another module).
|
||||
if (!animate || reduce || pool.length < 2) return clear;
|
||||
|
||||
const layer = document.createElement("div");
|
||||
Object.assign(layer.style, {
|
||||
position: "fixed",
|
||||
inset: "0",
|
||||
zIndex: "-1",
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center 20%",
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundAttachment: "fixed",
|
||||
opacity: "0",
|
||||
pointerEvents: "none",
|
||||
});
|
||||
root.appendChild(layer);
|
||||
|
||||
let idx = 0;
|
||||
let fadeTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const tick = () => {
|
||||
const next = (idx + 1) % pool.length;
|
||||
layer.style.transition = "none";
|
||||
layer.style.opacity = "0";
|
||||
layer.style.backgroundImage = `${BACKDROP_DIM}, url("${pool[next]}")`;
|
||||
requestAnimationFrame(() => {
|
||||
layer.style.transition = `opacity ${BACKDROP_FADE_MS}ms ease`;
|
||||
layer.style.opacity = "1";
|
||||
});
|
||||
fadeTimer = setTimeout(() => {
|
||||
paint(pool[next]!); // settle the new image on #root, then reset the overlay (no visible change)
|
||||
layer.style.transition = "none";
|
||||
layer.style.opacity = "0";
|
||||
idx = next;
|
||||
}, BACKDROP_FADE_MS);
|
||||
};
|
||||
const interval = setInterval(tick, BACKDROP_INTERVAL_MS);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
if (fadeTimer) clearTimeout(fadeTimer);
|
||||
layer.remove();
|
||||
clear();
|
||||
};
|
||||
}, [key, enabled, animate]);
|
||||
}
|
||||
|
||||
export function DetailCustomizeMenu({
|
||||
artBg,
|
||||
onToggleArtBg,
|
||||
@@ -291,13 +153,6 @@ export function PrefToggle({
|
||||
// --- Series detail-page shared UI (the show page + the season subpage share these) -------------------
|
||||
// Moved out of PlexBrowse — small building blocks the show/season detail views render verbatim.
|
||||
|
||||
export function epLabel(ep?: PlexCard | null): string | undefined {
|
||||
if (!ep) return undefined;
|
||||
if (ep.season_number != null && ep.episode_number != null)
|
||||
return `S${ep.season_number} · E${ep.episode_number}`;
|
||||
return ep.title;
|
||||
}
|
||||
|
||||
export function BackBtn({ onBack, label }: { onBack: () => void; label: string }) {
|
||||
return (
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user