Merge Platform S4 + S4b: PageShell contract + fixed-chrome sweep

S4: PageShell/PageToolbar contract, one page scroller, scroll restore on
Back + reset on new filter (bugs 1,3), pinned feed toolbar (bug 4), and
the fix for the ref-churn 'max update depth' loop.

S4b: the fixed-header / scrollable-content pattern applied across nine
modules (feed, channel detail, channel manager + discovery, playlist,
plex, notifications, downloads, admin users/config), plus the sticky
DataTable header with the header+fade 3D-depth edge the user liked.

E2E 17 specs green (incl. bug 1/3/4 locks); tsc + knip clean. Not shipped.
This commit is contained in:
2026-07-18 06:40:36 +02:00
19 changed files with 537 additions and 169 deletions
+51
View File
@@ -29,4 +29,55 @@ test.describe("channel view", () => {
await expect(page.getByTestId("channel-title")).toHaveCount(0); await expect(page.getByTestId("channel-title")).toHaveCount(0);
await expect(page.getByTestId("video-card").first()).toBeVisible(); await expect(page.getByTestId("video-card").first()).toBeVisible();
}); });
// Bug 1 (Platform S4): opening a channel and coming Back restores the feed's scroll position
// rather than dumping you at the top. The shell owns the scroller and remembers each view's offset.
test("Back restores the feed scroll position", async ({ page }) => {
await openApp(page);
await expect(page.getByTestId("video-card").first()).toBeVisible();
const main = page.getByRole("main");
await main.evaluate((el) => el.scrollBy(0, 3000));
// Let any page-fetch triggered by the scroll settle, so the same content is cached for the
// return trip (a fetch still in flight at navigation would leave a shorter list to restore into).
await page.waitForTimeout(600);
const saved = await main.evaluate((el) => el.scrollTop);
expect(saved).toBeGreaterThan(1500);
await page.getByTestId("video-card-channel").first().click();
await expect(page.getByTestId("channel-title")).toBeVisible();
await page.getByTestId("channel-back").click();
await expect(page.getByTestId("video-card").first()).toBeVisible();
// Restored to roughly where we were, not reset to the top. Tolerance absorbs the virtualizer's
// estimate-vs-measured row heights differing slightly between the two mounts.
await expect
.poll(() => page.getByRole("main").evaluate((el) => el.scrollTop), { timeout: 5000 })
.toBeGreaterThan(saved - 500);
});
// Platform S4b: the whole channel chrome (banner, identity, tabs, toolbar) is fixed — it stays
// put while only the video grid scrolls.
test("the channel chrome stays fixed while the video grid scrolls", async ({ page }) => {
await openApp(page);
await page.getByTestId("video-card-channel").first().click();
const count = page.getByTestId("feed-result-count");
const title = page.getByTestId("channel-title");
await expect(count).toBeVisible();
await expect(page.getByTestId("video-card").first()).toBeVisible();
const titleTop = await title.evaluate((el) => el.getBoundingClientRect().top);
const countTop = await count.evaluate((el) => el.getBoundingClientRect().top);
// Keep nudging until the grid is tall enough to actually scroll (it may still be filling in).
await expect
.poll(async () => {
await page.getByRole("main").evaluate((el) => el.scrollBy(0, 1500));
return page.getByRole("main").evaluate((el) => el.scrollTop);
})
.toBeGreaterThan(400);
// The chrome didn't move — banner title and toolbar are in exactly the same place.
expect(await title.evaluate((el) => el.getBoundingClientRect().top)).toBe(titleTop);
expect(await count.evaluate((el) => el.getBoundingClientRect().top)).toBe(countTop);
});
}); });
+31
View File
@@ -47,4 +47,35 @@ test.describe("feed", () => {
.poll(async () => (await renderedIds()).some((id) => id && !before.includes(id))) .poll(async () => (await renderedIds()).some((id) => id && !before.includes(id)))
.toBe(true); .toBe(true);
}); });
// Bug 4 (Platform S4): the toolbar (show/content chips, count, sort, view) is fixed chrome — it
// pins in the shell's toolbar band above the scroller, so it stays put while the cards scroll.
test("the feed toolbar stays pinned while cards scroll", async ({ page }) => {
await openApp(page);
const count = page.getByTestId("feed-result-count");
await expect(count).toBeVisible();
const topBefore = await count.evaluate((el) => el.getBoundingClientRect().top);
await page.getByRole("main").evaluate((el) => el.scrollBy(0, 4000));
await expect(count).toBeInViewport();
const topAfter = await count.evaluate((el) => el.getBoundingClientRect().top);
expect(Math.abs(topAfter - topBefore)).toBeLessThan(4);
});
// Bug 3 (Platform S4): changing a filter is a fresh result set, so the scroll jumps back to the
// top rather than stranding you deep in the old list's offset.
test("a new filter resets the scroll to the top", async ({ page }) => {
await openApp(page);
await expect(page.getByTestId("video-card").first()).toBeVisible();
await page.getByRole("main").evaluate((el) => el.scrollBy(0, 4000));
expect(await page.getByRole("main").evaluate((el) => el.scrollTop)).toBeGreaterThan(500);
// Switch the show filter → a different result set → back to the top.
await page.getByTestId("feed-show-all").click();
await expect
.poll(() => page.getByRole("main").evaluate((el) => el.scrollTop))
.toBeLessThan(50);
});
}); });
+52 -33
View File
@@ -53,7 +53,7 @@ import NavSidebar from "./components/NavSidebar";
import Sidebar from "./components/Sidebar"; import Sidebar from "./components/Sidebar";
import PlaylistsRail from "./components/PlaylistsRail"; import PlaylistsRail from "./components/PlaylistsRail";
import BackToTop from "./components/BackToTop"; import BackToTop from "./components/BackToTop";
import PageScroller from "./components/PageScroller"; import PageShell from "./components/PageShell";
import GlassTuner from "./components/GlassTuner"; import GlassTuner from "./components/GlassTuner";
import ChatDock from "./components/ChatDock"; import ChatDock from "./components/ChatDock";
import Toaster from "./components/Toaster"; import Toaster from "./components/Toaster";
@@ -204,7 +204,11 @@ export default function App() {
// of dumping the user on whatever tab they last left open. // of dumping the user on whatever tab they last left open.
const [channelsViewRaw, setChannelsView] = useAccountPersistedState(LS.channelsView, "subscribed"); const [channelsViewRaw, setChannelsView] = useAccountPersistedState(LS.channelsView, "subscribed");
const channelsView: ChannelsView = const channelsView: ChannelsView =
channelsViewRaw === "discovery" ? "discovery" : "subscribed"; channelsViewRaw === "discovery"
? "discovery"
: channelsViewRaw === "blocked"
? "blocked"
: "subscribed";
// Plex module filters (its own left-sidebar filter section) — per-account persisted. // Plex module filters (its own left-sidebar filter section) — per-account persisted.
// The former per-library picker is now a cross-library SCOPE: movie | show | both (unified library). // The former per-library picker is now a cross-library SCOPE: movie | show | both (unified library).
// (Reuses the old LS.plexLibrary key; an old stored library-id value falls back to "both".) // (Reuses the old LS.plexLibrary key; an old stored library-id value falls back to "both".)
@@ -632,6 +636,17 @@ export default function App() {
<div className="flex-1 grid place-items-center text-muted">{t("common.loading")}</div> <div className="flex-1 grid place-items-center text-muted">{t("common.loading")}</div>
); );
// Scroll restore/reset key for the shared normal-page scroller (see PageScroller): the same
// page/feed-filters returns you to where you were (bug 1); a changed feed filter is a new key, so
// the fresh result set starts at the top (bug 3). `q` is normalised out so typing in the search
// box doesn't reset the scroll on every keystroke; the live-YouTube sub-view gets its own key.
const normalScrollKey =
page !== "feed"
? `page:${page}`
: ytSearch != null
? `yt:${ytSearch}`
: `feed:${JSON.stringify({ ...filters, q: "" })}`;
return ( return (
<div className="h-screen flex text-fg"> <div className="h-screen flex text-fg">
<NavSidebar <NavSidebar
@@ -689,6 +704,7 @@ export default function App() {
)} )}
<div className="relative flex-1 min-w-0 flex flex-col"> <div className="relative flex-1 min-w-0 flex flex-col">
{channelView ? ( {channelView ? (
<PageShell scrollKey={`channel:${channelView.id}`}>
<Suspense fallback={pageFallback}> <Suspense fallback={pageFallback}>
<ChannelPage <ChannelPage
key={channelView.id} key={channelView.id}
@@ -714,35 +730,40 @@ export default function App() {
}} }}
/> />
</Suspense> </Suspense>
</PageShell>
) : ( ) : (
<> <PageShell
<Header scrollKey={normalScrollKey}
me={meQuery.data!} // Channels + Audit render a table with a sticky header at the scroller top; the top
filters={filters} // fade would sit on it and flip on with the first scroll, so turn it off there.
setFilters={setFilters} fadeTop={page !== "channels" && page !== "audit"}
plexQ={plexQ} header={
setPlexQ={setPlexQ} <Header
channelsSearch={channelsSearch} me={meQuery.data!}
setChannelsSearch={setChannelsSearch} filters={filters}
playlistsSearch={playlistsSearch} setFilters={setFilters}
setPlaylistsSearch={setPlaylistsSearch} plexQ={plexQ}
onYtSearch={enterYtSearch} setPlexQ={setPlexQ}
onGoToFullHistory={() => { channelsSearch={channelsSearch}
setChannelFilter("needs_full"); setChannelsSearch={setChannelsSearch}
setChannelsView("subscribed"); // the status filter applies to the subscriptions tab playlistsSearch={playlistsSearch}
setChannelsFilterReset((n) => n + 1); // drop any stale column filter hiding the rows setPlaylistsSearch={setPlaylistsSearch}
setPage("channels"); onYtSearch={enterYtSearch}
}} onGoToFullHistory={() => {
/> setChannelFilter("needs_full");
{/* The header floats (fixed) over the content, so it no longer occupies flow space; this setChannelsView("subscribed"); // the status filter applies to the subscriptions tab
wrapper clears it with a top pad. (All left rails, incl. Playlists, are App-level setChannelsFilterReset((n) => n + 1); // drop any stale column filter hiding the rows
floating panels beside the nav — none sit inside this content column anymore.) */} setPage("channels");
<div }}
className="flex-1 min-w-0 min-h-0 flex flex-col pt-[var(--hdr-h)]" />
> }
{meQuery.data!.is_demo && <DemoBanner />} banners={
<VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} /> <>
<PageScroller> {meQuery.data!.is_demo && <DemoBanner />}
<VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} />
</>
}
>
<Suspense fallback={pageFallback}> <Suspense fallback={pageFallback}>
{page === "channels" ? ( {page === "channels" ? (
<Channels <Channels
@@ -823,9 +844,7 @@ export default function App() {
/> />
)} )}
</Suspense> </Suspense>
</PageScroller> </PageShell>
</div>
</>
)} )}
{/* Toasts rise from the bottom-left, by the notification bell in the nav rail. {/* Toasts rise from the bottom-left, by the notification bell in the nav rail.
Anchored inside the content column so they clear the sidebar automatically Anchored inside the content column so they clear the sidebar automatically
+14 -6
View File
@@ -9,6 +9,7 @@ import Tooltip from "./Tooltip";
import { Section } from "./ui/form"; import { Section } from "./ui/form";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
import Tabs, { usePersistedTab } from "./Tabs"; import Tabs, { usePersistedTab } from "./Tabs";
import { PageToolbar } from "./PageShell";
// Admin-only user & access management: roles, access requests (the Invite whitelist), and the // Admin-only user & access management: roles, access requests (the Invite whitelist), and the
// demo whitelist + reset. Each section is its own tab (persisted across reloads); the Access tab // demo whitelist + reset. Each section is its own tab (persisted across reloads); the Access tab
@@ -26,12 +27,19 @@ export default function AdminUsers({ me }: { me: Me }) {
]; ];
const active = tabs.some((x) => x.id === tab) ? tab : "roles"; const active = tabs.some((x) => x.id === tab) ? tab : "roles";
return ( return (
<div className="p-4 max-w-3xl w-full mx-auto"> <>
<Tabs tabs={tabs} active={active} onChange={setTab} /> {/* The tab bar is fixed chrome; the tab content scrolls under it. */}
{active === "roles" && <UsersRoles me={me} />} <PageToolbar>
{active === "access" && <AdminInvites />} <div className="px-4 pt-3 max-w-3xl w-full mx-auto">
{active === "demo" && <AdminDemo />} <Tabs tabs={tabs} active={active} onChange={setTab} />
</div> </div>
</PageToolbar>
<div className="px-4 pb-4 pt-3 max-w-3xl w-full mx-auto">
{active === "roles" && <UsersRoles me={me} />}
{active === "access" && <AdminInvites />}
{active === "demo" && <AdminDemo />}
</div>
</>
); );
} }
+25 -17
View File
@@ -10,6 +10,7 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import ChannelLink from "./ChannelLink"; import ChannelLink from "./ChannelLink";
import DataTable, { type Column } from "./DataTable"; import DataTable, { type Column } from "./DataTable";
import { PageToolbar } from "./PageShell";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
// The Channel manager's "Discovery" tab: channels that turn up in the user's playlists but // The Channel manager's "Discovery" tab: channels that turn up in the user's playlists but
@@ -154,22 +155,29 @@ export default function ChannelDiscovery({
]; ];
return ( return (
<div className="px-4 pb-4 pt-3 max-w-7xl mx-auto"> <>
<p className="text-xs text-muted mb-4 leading-relaxed"> {/* Intro + the table controls are fixed chrome; only the table rows scroll (sticky header). */}
{t("channels.discovery.intro")} <PageToolbar>
</p> <div className="px-4 pt-3 max-w-7xl mx-auto">
{query.isLoading ? ( <p className="text-xs text-muted leading-relaxed">{t("channels.discovery.intro")}</p>
<div className="text-muted py-8">{t("channels.discovery.loading")}</div> </div>
) : ( </PageToolbar>
<DataTable <div className="px-4 pb-4 max-w-7xl mx-auto">
rows={rows} {query.isLoading ? (
columns={columns} <div className="text-muted py-8">{t("channels.discovery.loading")}</div>
rowKey={(c) => c.id} ) : (
persistKey={accountKey(LS.channelDiscoveryTable) ?? undefined} <DataTable
controlsPosition="top" rows={rows}
emptyText={t("channels.discovery.empty")} columns={columns}
/> rowKey={(c) => c.id}
)} persistKey={accountKey(LS.channelDiscoveryTable) ?? undefined}
</div> controlsPosition="top"
controlsInBand
controlsBandClassName="px-4 max-w-7xl mx-auto"
emptyText={t("channels.discovery.empty")}
/>
)}
</div>
</>
); );
} }
+20 -4
View File
@@ -5,7 +5,7 @@ import { useNavigationActions } from "./NavigationProvider";
import { ArrowLeft, Ban, Check, ExternalLink, Loader2, Plus, RefreshCw, SlidersHorizontal } from "lucide-react"; import { ArrowLeft, Ban, Check, ExternalLink, Loader2, Plus, RefreshCw, SlidersHorizontal } from "lucide-react";
import Avatar from "./Avatar"; import Avatar from "./Avatar";
import Feed from "./Feed"; import Feed from "./Feed";
import PageScroller from "./PageScroller"; import { PageToolbar, PageToolbarSlot } from "./PageShell";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { notifyYouTubeActionError } from "../lib/youtubeErrors";
@@ -77,6 +77,11 @@ export default function ChannelPage({
const qc = useQueryClient(); const qc = useQueryClient();
const confirm = useConfirm(); const confirm = useConfirm();
const [tab, setTab] = useState<"videos" | "about">("videos"); const [tab, setTab] = useState<"videos" | "about">("videos");
// The whole channel chrome (banner, identity, tabs, toolbar) is fixed: it portals into the shell's
// fixed band so only the video grid scrolls. This node sits at the bottom of that chrome and is
// where the channelScoped Feed portals ITS toolbar (via the overridden PageToolbarSlot below), so
// the toolbar lands under the tabs inside the fixed band rather than in the scroller.
const [chromeSlot, setChromeSlot] = useState<HTMLDivElement | null>(null);
// The banner URL is valid, but googleusercontent intermittently throttles it when the channel // The banner URL is valid, but googleusercontent intermittently throttles it when the channel
// page fires the banner + avatar + ~16 thumbnails in one burst — the request is dropped and the // page fires the banner + avatar + ~16 thumbnails in one burst — the request is dropped and the
// browser then NEGATIVE-CACHES the miss, so the banner stays broken even though a retry would // browser then NEGATIVE-CACHES the miss, so the banner stays broken even though a retry would
@@ -206,8 +211,12 @@ export default function ChannelPage({
? "pb-2 text-sm font-medium border-b-2 border-fg text-fg" ? "pb-2 text-sm font-medium border-b-2 border-fg text-fg"
: "pb-2 text-sm text-muted hover:text-fg border-b-2 border-transparent"; : "pb-2 text-sm text-muted hover:text-fg border-b-2 border-transparent";
// The channel page owns no scroller of its own (PageShell provides the one app scroller). Its
// whole chrome is fixed — wrapped in <PageToolbar> it portals into the shell's fixed band, so the
// banner/identity/tabs/toolbar stay put and only the video grid scrolls.
return ( return (
<PageScroller> <>
<PageToolbar>
{/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */} {/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */}
<div className="relative px-4 sm:px-6 pt-3"> <div className="relative px-4 sm:px-6 pt-3">
{ch?.banner_url && bannerAttempt <= MAX_BANNER_RETRIES ? ( {ch?.banner_url && bannerAttempt <= MAX_BANNER_RETRIES ? (
@@ -365,8 +374,14 @@ export default function ChannelPage({
</div> </div>
</div> </div>
</div> </div>
{/* The channelScoped Feed portals its toolbar into this node — the bottom of the fixed chrome,
just under the tabs. */}
<div ref={setChromeSlot} />
</PageToolbar>
{/* Tab content */} {/* Tab content — the feed's toolbar portals up into the fixed chrome via this slot override;
only the grid (and About) render here, in the scroller. */}
<PageToolbarSlot.Provider value={chromeSlot}>
{tab === "videos" ? ( {tab === "videos" ? (
<> <>
<Feed <Feed
@@ -466,6 +481,7 @@ export default function ChannelPage({
)} )}
</div> </div>
)} )}
</PageScroller> </PageToolbarSlot.Provider>
</>
); );
} }
+82 -67
View File
@@ -25,12 +25,13 @@ import { subsColumn } from "./channelColumns";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import DataTable, { type Column } from "./DataTable"; import DataTable, { type Column } from "./DataTable";
import { PageToolbar } from "./PageShell";
import ChannelLink from "./ChannelLink"; import ChannelLink from "./ChannelLink";
import ChannelDiscovery from "./ChannelDiscovery"; import ChannelDiscovery from "./ChannelDiscovery";
import TagManager from "./TagManager"; import TagManager from "./TagManager";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
export type ChannelsView = "subscribed" | "discovery"; export type ChannelsView = "subscribed" | "discovery" | "blocked";
export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden"; export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden";
@@ -382,7 +383,7 @@ export default function Channels({
const tabs = ( const tabs = (
<div className="px-4 pt-4 max-w-7xl mx-auto"> <div className="px-4 pt-4 max-w-7xl mx-auto">
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
{(["subscribed", "discovery"] as ChannelsView[]).map((v) => ( {(["subscribed", "discovery", "blocked"] as ChannelsView[]).map((v) => (
<button <button
key={v} key={v}
onClick={() => setView(v)} onClick={() => setView(v)}
@@ -399,24 +400,15 @@ export default function Channels({
</div> </div>
); );
if (view === "discovery") {
return (
<>
{tabs}
<ChannelDiscovery
canWrite={canWrite}
search={search}
onOpenWizard={onOpenWizard}
onViewChannel={onViewChannel}
/>
</>
);
}
return ( return (
<> <>
{/* The tab bar is always fixed chrome (it portals into the shell's fixed band, so it never
scrolls and doesn't jump between tabs). On Subscriptions the stats/description/tags ride
with it, and the status-chip + pager row lands just below (DataTable's controlsInBand). */}
<PageToolbar>
{tabs} {tabs}
<div className="px-4 pb-4 pt-3 max-w-7xl mx-auto"> {view === "subscribed" && (
<div className="px-4 pt-3 pb-2 max-w-7xl mx-auto">
{/* Per-user sync status + catalog-wide actions on one row (search/tags filtering {/* Per-user sync status + catalog-wide actions on one row (search/tags filtering
now lives in the table headers). */} now lives in the table headers). */}
<div className="flex items-start justify-between gap-4 flex-wrap mb-4"> <div className="flex items-start justify-between gap-4 flex-wrap mb-4">
@@ -535,57 +527,80 @@ export default function Channels({
{t("channels.tags.manage")} {t("channels.tags.manage")}
</button> </button>
</div> </div>
{tagManagerOpen && (
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
)}
{/* Channel table */}
{channelsQuery.isLoading ? (
<div className="text-muted py-8">{t("channels.loading")}</div>
) : (
<DataTable
rows={visibleChannels}
columns={columns}
rowKey={(c) => c.id}
persistKey={accountKey(LS.channelsTable) ?? undefined}
controlsPosition="top"
controlsLeading={statusChips}
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
emptyText={t("channels.empty")}
/>
)}
{/* Blocked channels — videos from these are hidden everywhere and dropped from live search. */}
{(blockedQuery.data?.length ?? 0) > 0 && (
<div className="mt-6 border-t border-border pt-4">
<div className="flex items-center gap-2 mb-2">
<Ban className="w-4 h-4 text-muted" />
<span className="text-xs uppercase tracking-wide text-muted">
{t("channels.blocked.title")}
</span>
</div>
<div className="flex flex-wrap gap-1.5">
{blockedQuery.data!.map((c) => (
<span
key={c.id}
className="inline-flex items-center gap-1.5 text-xs pl-2.5 pr-1 py-1 rounded-full bg-card border border-border"
>
{c.title ?? c.id}
<button
onClick={() => unblock.mutate(c.id)}
title={t("channel.unblock")}
aria-label={t("channel.unblock")}
className="rounded-full p-0.5 text-muted hover:text-danger transition"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
<p className="text-[11px] text-muted mt-2">{t("channels.blocked.hint")}</p>
</div>
)}
</div> </div>
)}
</PageToolbar>
{tagManagerOpen && (
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
)}
{view === "discovery" ? (
<ChannelDiscovery
canWrite={canWrite}
search={search}
onOpenWizard={onOpenWizard}
onViewChannel={onViewChannel}
/>
) : view === "blocked" ? (
/* Blocked channels — videos from these are hidden everywhere and dropped from live search. */
<div className="px-4 pb-4 pt-3 max-w-7xl mx-auto">
{(blockedQuery.data?.length ?? 0) === 0 ? (
<p className="text-sm text-muted py-8">{t("channels.blocked.empty")}</p>
) : (
<>
<div className="flex items-center gap-2 mb-3">
<Ban className="w-4 h-4 text-muted" />
<span className="text-xs uppercase tracking-wide text-muted">
{t("channels.blocked.title")}
</span>
</div>
<div className="flex flex-wrap gap-1.5">
{blockedQuery.data!.map((c) => (
<span
key={c.id}
className="inline-flex items-center gap-1.5 text-xs pl-2.5 pr-1 py-1 rounded-full bg-card border border-border"
>
{c.title ?? c.id}
<button
onClick={() => unblock.mutate(c.id)}
title={t("channel.unblock")}
aria-label={t("channel.unblock")}
className="rounded-full p-0.5 text-muted hover:text-danger transition"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
<p className="text-[11px] text-muted mt-2">{t("channels.blocked.hint")}</p>
</>
)}
</div>
) : (
/* No top padding: the sticky header sits flush at the scroller top so it doesn't shift when
`position: sticky` engages on the first scroll (the band's controls row above gives the
visual gap). */
<div className="px-4 pb-4 max-w-7xl mx-auto">
{/* Channel table — only the rows scroll; the header row is sticky, and the controls row
(status chips + pager) rides in the fixed band right above it (controlsInBand). */}
{channelsQuery.isLoading ? (
<div className="text-muted py-8">{t("channels.loading")}</div>
) : (
<DataTable
rows={visibleChannels}
columns={columns}
rowKey={(c) => c.id}
persistKey={accountKey(LS.channelsTable) ?? undefined}
controlsPosition="top"
controlsLeading={statusChips}
controlsInBand
controlsBandClassName="px-4 max-w-7xl mx-auto"
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
emptyText={t("channels.empty")}
/>
)}
</div>
)}
</> </>
); );
} }
+11 -5
View File
@@ -6,6 +6,7 @@ import { api, type ConfigItem, type PlexTestResult } from "../lib/api";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import Tabs, { usePersistedTab } from "./Tabs"; import Tabs, { usePersistedTab } from "./Tabs";
import { PageToolbar } from "./PageShell";
import { Switch } from "./ui/form"; import { Switch } from "./ui/form";
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar"; import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
import { LS } from "../lib/storage"; import { LS } from "../lib/storage";
@@ -151,11 +152,15 @@ export default function ConfigPanel() {
})); }));
return ( return (
<div className="p-4 max-w-3xl w-full mx-auto pb-24"> <>
<p className="text-xs text-muted mb-4 leading-relaxed">{t("config.intro")}</p> {/* Intro + tab bar are fixed chrome; the config fields scroll under them. */}
<PageToolbar>
<Tabs tabs={groupTabs} active={activeGroup} onChange={setTab} /> <div className="px-4 pt-3 max-w-3xl w-full mx-auto">
<p className="text-xs text-muted mb-3 leading-relaxed">{t("config.intro")}</p>
<Tabs tabs={groupTabs} active={activeGroup} onChange={setTab} />
</div>
</PageToolbar>
<div className="px-4 pb-24 pt-3 max-w-3xl w-full mx-auto">
{activeGroup && ( {activeGroup && (
<div className="glass rounded-2xl p-4 mb-4"> <div className="glass rounded-2xl p-4 mb-4">
<div className="divide-y divide-border/60"> <div className="divide-y divide-border/60">
@@ -264,6 +269,7 @@ export default function ConfigPanel() {
}} }}
/> />
</div> </div>
</>
); );
} }
+27 -4
View File
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ListFilter, X } from "lucide-react"; import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ListFilter, X } from "lucide-react";
import { useDismiss } from "../lib/useDismiss"; import { useDismiss } from "../lib/useDismiss";
import { useScrollFade } from "../lib/useScrollFade"; import { useScrollFade } from "../lib/useScrollFade";
import { PageToolbar } from "./PageShell";
// A reusable, client-side data table: per-column sort + filter (in the header) + pagination, // A reusable, client-side data table: per-column sort + filter (in the header) + pagination,
// with its sort/filter/page state optionally persisted to localStorage so a reload (F5) keeps // with its sort/filter/page state optionally persisted to localStorage so a reload (F5) keeps
@@ -84,6 +85,8 @@ export default function DataTable<T>({
rowClassName, rowClassName,
controlsPosition = "bottom", controlsPosition = "bottom",
controlsLeading, controlsLeading,
controlsInBand = false,
controlsBandClassName,
externalFilter, externalFilter,
resetFiltersToken, resetFiltersToken,
}: { }: {
@@ -97,6 +100,11 @@ export default function DataTable<T>({
rowClassName?: (row: T) => string; rowClassName?: (row: T) => string;
controlsPosition?: "top" | "bottom" | "both"; controlsPosition?: "top" | "bottom" | "both";
controlsLeading?: ReactNode; controlsLeading?: ReactNode;
// Render the controls row (controlsLeading + pager) into the shell's fixed toolbar band instead
// of inline, so it stays put above the sticky header while the rows scroll. `controlsBandClassName`
// aligns it with the page's fixed chrome (e.g. the same max-width wrapper).
controlsInBand?: boolean;
controlsBandClassName?: string;
// Set a column's filter from outside (e.g. "focus this channel" deep-links here). Applied // Set a column's filter from outside (e.g. "focus this channel" deep-links here). Applied
// whenever its value changes; the user can still clear it from the header afterwards. // whenever its value changes; the user can still clear it from the header afterwards.
externalFilter?: { key: string; value: string } | null; externalFilter?: { key: string; value: string } | null;
@@ -344,14 +352,29 @@ export default function DataTable<T>({
</div> </div>
) : null; ) : null;
const topControls =
controlsInBand && controls ? (
<PageToolbar>
<div className={controlsBandClassName}>{controls}</div>
</PageToolbar>
) : (
controls
);
return ( return (
<div> <div>
{(controlsPosition === "top" || controlsPosition === "both") && controls} {(controlsPosition === "top" || controlsPosition === "both") && topControls}
{/* Wide screens: table. The wrapper isn't clipped so header filter popovers can overflow. */} {/* Wide screens: table. The wrapper isn't clipped so header filter popovers can overflow. */}
<div className="hidden md:block"> <div className="hidden md:block">
<table className="w-full border-collapse text-sm"> <table className="w-full border-collapse text-sm">
<thead> {/* Sticky header row: the column headers stay put at the top of the page scroller while
<tr className="text-muted border-b border-border"> the rows scroll under them. `bg-bg` on each cell keeps rows from showing through; the
border rides a pseudo-element (box-shadow) since a sticky <tr>'s own border scrolls
away with the collapsed table box in some browsers. The `::after` paints a short
fade just BELOW the header so rows dissolve as they slide under it (the header itself
stays crisp — the page scroller's own top fade is off on these pages). */}
<thead className="sticky top-0 z-10 after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-[linear-gradient(to_bottom,var(--bg),transparent)] after:content-['']">
<tr className="text-muted [&>th]:shadow-[inset_0_-1px_0_var(--border)]">
{columns.map((col) => { {columns.map((col) => {
const sorted_ = sort?.key === col.key; const sorted_ = sort?.key === col.key;
const filterOn = isActive(filters[col.key]); const filterOn = isActive(filters[col.key]);
@@ -359,7 +382,7 @@ export default function DataTable<T>({
<th <th
key={col.key} key={col.key}
style={col.width ? { width: col.width } : undefined} style={col.width ? { width: col.width } : undefined}
className={`relative font-medium px-2 py-2 whitespace-nowrap ${align(col.align)}`} className={`relative font-medium px-2 py-2 whitespace-nowrap bg-bg ${align(col.align)}`}
> >
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
<button <button
+9 -1
View File
@@ -17,6 +17,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import clsx from "clsx"; import clsx from "clsx";
import Tabs, { usePersistedTab } from "./Tabs"; import Tabs, { usePersistedTab } from "./Tabs";
import { PageToolbar } from "./PageShell";
import Modal from "./Modal"; import Modal from "./Modal";
// Lazy: these dialogs/editors open on demand, so they stay out of the Downloads page chunk until // Lazy: these dialogs/editors open on demand, so they stay out of the Downloads page chunk until
// used (the video editor in particular is heavy — filmstrip + scrubber). // used (the video editor in particular is heavy — filmstrip + scrubber).
@@ -600,7 +601,10 @@ export default function DownloadCenter({ me }: { me: Me }) {
); );
return ( return (
<div className="p-4 max-w-4xl w-full mx-auto"> <>
{/* Page title + subtitle + tabs are fixed chrome; the tab content scrolls under them. */}
<PageToolbar>
<div className="px-4 pt-3 max-w-4xl w-full mx-auto">
<div className="flex items-center justify-between gap-3 mb-1"> <div className="flex items-center justify-between gap-3 mb-1">
<h1 className="text-xl font-semibold">{t("downloads.page.title")}</h1> <h1 className="text-xl font-semibold">{t("downloads.page.title")}</h1>
<button <button
@@ -613,7 +617,10 @@ export default function DownloadCenter({ me }: { me: Me }) {
<p className="text-sm text-muted mb-4">{t("downloads.page.subtitle")}</p> <p className="text-sm text-muted mb-4">{t("downloads.page.subtitle")}</p>
<Tabs tabs={tabs} active={tab} onChange={setTab} /> <Tabs tabs={tabs} active={tab} onChange={setTab} />
</div>
</PageToolbar>
<div className="px-4 pb-4 pt-3 max-w-4xl w-full mx-auto">
{tab === "queue" && ( {tab === "queue" && (
<> <>
<div className="flex gap-2 mb-4"> <div className="flex gap-2 mb-4">
@@ -739,5 +746,6 @@ export default function DownloadCenter({ me }: { me: Me }) {
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />} {editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
</Suspense> </Suspense>
</div> </div>
</>
); );
} }
+19 -12
View File
@@ -31,6 +31,7 @@ import {
import { FEED_VIEWS, type FeedView } from "../lib/feedView"; import { FEED_VIEWS, type FeedView } from "../lib/feedView";
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters"; import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
import ActiveFilterChips from "./ActiveFilterChips"; import ActiveFilterChips from "./ActiveFilterChips";
import { PageToolbar } from "./PageShell";
import ViewSwitcher from "./ViewSwitcher"; import ViewSwitcher from "./ViewSwitcher";
import VirtualFeed from "./VirtualFeed"; import VirtualFeed from "./VirtualFeed";
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened. // Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
@@ -654,18 +655,24 @@ export default function Feed({
); );
return ( return (
<div className="p-4"> <div className={channelScoped ? "px-4 sm:px-6 pb-4" : "px-4 pb-4 pt-2"}>
{toolbar} {/* The toolbar (+ active-filter chips) is FIXED chrome: it portals into a PageToolbarSlot so
{/* Channel pages get no chips: they have no filter panel to reveal (App hides the sidebar for it stays put while the cards scroll (bug 4). On the feed page that slot is the shell's top
them), and their Feed starts from its OWN baseline — channel-scoped, whole-catalog, band; on a channel page ChannelPage overrides the slot with its own sticky sub-header, so
show-all — so every one of those would render as a "filter you applied" when it's just the the toolbar rides with the tabs. Channel pages get no chips: their Feed starts from its OWN
page being itself. */} baseline (channel-scoped, whole-catalog, show-all), so each would read as a "filter you
{!channelScoped && ( applied" when it's just the page being itself. */}
<ActiveFilterChips <PageToolbar>
filters={activeFilters} <div className={channelScoped ? "px-4 sm:px-6" : "px-4 pt-4 pb-1"}>
onClearAll={() => setFilters(clearedFilters(filters))} {toolbar}
/> {!channelScoped && (
)} <ActiveFilterChips
filters={activeFilters}
onClearAll={() => setFilters(clearedFilters(filters))}
/>
)}
</div>
</PageToolbar>
{items.length === 0 ? ( {items.length === 0 ? (
<div className="py-16 text-center text-muted"> <div className="py-16 text-center text-muted">
<p>{t("feed.noMatches")}</p> <p>{t("feed.noMatches")}</p>
+11 -3
View File
@@ -21,6 +21,7 @@ import {
} from "../lib/notifications"; } from "../lib/notifications";
import { channelYouTubeUrl, relativeFromMs, relativeTime } from "../lib/format"; import { channelYouTubeUrl, relativeFromMs, relativeTime } from "../lib/format";
import { LEVEL_STYLE } from "./Toaster"; import { LEVEL_STYLE } from "./Toaster";
import { PageToolbar } from "./PageShell";
import { useNavigationActions } from "./NavigationProvider"; import { useNavigationActions } from "./NavigationProvider";
// The single notification center. "System" = durable, server-backed notifications (inbox // The single notification center. "System" = durable, server-backed notifications (inbox
@@ -102,8 +103,11 @@ export default function NotificationsPanel({
} }
return ( return (
<div className="p-4 max-w-3xl w-full mx-auto space-y-4"> <>
<div className="glass rounded-2xl p-4 flex items-center gap-3"> {/* The inbox header (title + mark-all / clear-all) is fixed chrome; only the list scrolls. */}
<PageToolbar>
<div className="px-4 pt-3 max-w-3xl w-full mx-auto">
<div className="glass rounded-2xl p-4 flex items-center gap-3">
<Bell className="w-5 h-5 text-accent shrink-0" /> <Bell className="w-5 h-5 text-accent shrink-0" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-semibold">{t("inbox.title")}</div> <div className="font-semibold">{t("inbox.title")}</div>
@@ -130,7 +134,10 @@ export default function NotificationsPanel({
</div> </div>
)} )}
</div> </div>
</div>
</PageToolbar>
<div className="px-4 pb-4 pt-3 max-w-3xl w-full mx-auto space-y-4">
{q.isLoading && !q.data && clientItems.length === 0 ? ( {q.isLoading && !q.data && clientItems.length === 0 ? (
<div className="p-8 text-muted text-center">{t("common.loading")}</div> <div className="p-8 text-muted text-center">{t("common.loading")}</div>
) : !hasAny ? ( ) : !hasAny ? (
@@ -185,7 +192,8 @@ export default function NotificationsPanel({
)} )}
</div> </div>
)} )}
</div> </div>
</>
); );
} }
+88 -8
View File
@@ -1,10 +1,39 @@
import { type ReactNode } from "react"; import { useCallback, useLayoutEffect, useRef, type ReactNode } from "react";
import { useScrollFade } from "../lib/useScrollFade"; import { useScrollFade } from "../lib/useScrollFade";
// The page's scroll container. Two of these exist and are never both mounted: App renders one for // Per-view saved scroll offsets. Module-level so a view's position survives its scroller
// the normal pages, ChannelPage renders its own (it's a separate branch of App's tree, not a page // unmounting — opening a channel page swaps in a different shell/scroller — and is restored when
// inside the usual one). The feed, the channel page and Plex browse all scroll inside it rather // the same view returns. Keyed by the shell's `scrollKey`.
// than owning a scroller — which is why the edge-fade lives here once instead of three times. const savedScroll = new Map<string, number>();
// Put the scroller back to `top`. The feed is virtualized, so on a remount the list needs a frame
// or two to render enough rows to reach its full height; an immediate `scrollTop` would otherwise
// clamp short. Re-apply over a few frames until it sticks (or the content is simply shorter now).
function restoreScroll(el: HTMLElement, top: number): () => void {
if (top <= 0) {
el.scrollTop = 0;
return () => {};
}
let raf = 0;
let frames = 0;
let cancelled = false;
const tick = () => {
if (cancelled) return;
if (el.scrollHeight - el.clientHeight >= top) el.scrollTop = top;
if (el.scrollTop >= top - 1 || frames++ > 90) return; // reached, or gave up after ~1.5s
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => {
cancelled = true;
cancelAnimationFrame(raf);
};
}
// The page's scroll container — the ONE scroller in the app, owned by PageShell. The feed, the
// channel page and Plex browse all scroll inside it rather than owning a scroller of their own,
// which is why the edge-fade and the scroll restore/reset policy live here once instead of three
// times.
// //
// Two invariants this element carries, both learned the hard way: // Two invariants this element carries, both learned the hard way:
// //
@@ -19,13 +48,64 @@ import { useScrollFade } from "../lib/useScrollFade";
// the gutter and re-open that loop. Unlike the rail and the side panels, this scroller wears the // the gutter and re-open that loop. Unlike the rail and the side panels, this scroller wears the
// fade next to its scrollbar, not instead of it. // fade next to its scrollbar, not instead of it.
// //
// Scroll restore/reset policy (`scrollKey` identifies the logical view):
// - returning to a view with the SAME key — feed → open a channel → Back, feed filters unchanged —
// restores where you were (bug 1). The channel page is a different scroller, so the feed's
// scroller unmounts and remounts; the saved offset survives in `savedScroll` and is re-applied.
// - a NEW key — the feed's filters changed, i.e. a fresh result set — starts at the top (bug 3).
// The offset is recorded on every scroll rather than at unmount: by the time an unmount cleanup
// runs the scroller's scrollTop has already been zeroed by the teardown, so a cleanup-time read
// saves 0 and the restore is lost. Recording live sidesteps that (and StrictMode's synthetic
// unmount, which would otherwise persist a 0 too).
//
// The fade state lives in here rather than in App so that a flip re-renders this component alone — // The fade state lives in here rather than in App so that a flip re-renders this component alone —
// `children` arrives as an already-built element, so React skips the page subtree underneath. // `children` arrives as an already-built element, so React skips the page subtree underneath.
export default function PageScroller({ children }: { children: ReactNode }) { export default function PageScroller({
const fade = useScrollFade(); children,
scrollKey,
fadeTop = true,
}: {
children: ReactNode;
scrollKey?: string;
// Off when the scroller's top is a sticky element (a table's sticky header) — see useScrollFade.
fadeTop?: boolean;
}) {
const fade = useScrollFade(20, fadeTop);
const fadeRef = fade.ref;
const elRef = useRef<HTMLElement | null>(null);
// Stable identity: an inline ref callback changes every render, which makes React detach and
// re-attach the ref each commit — and since fade.ref is a state setter, that re-attach loops it
// into "Maximum update depth exceeded". useScrollFade's ref setter is itself stable, so binding
// once here is safe.
const setRef = useCallback(
(node: HTMLElement | null) => {
elRef.current = node;
fadeRef(node);
},
[fadeRef],
);
useLayoutEffect(() => {
const el = elRef.current;
if (!el || scrollKey == null) return;
const key = scrollKey;
const want = savedScroll.get(key) ?? 0;
const cancelRestore = restoreScroll(el, want);
// Record position continuously (not at unmount): the scroller's scrollTop is unreliable by the
// time an unmount cleanup runs, and a save-on-scroll survives whatever tears down last.
const onScroll = () => {
savedScroll.set(key, el.scrollTop);
};
el.addEventListener("scroll", onScroll, { passive: true });
return () => {
cancelRestore();
el.removeEventListener("scroll", onScroll);
};
}, [scrollKey]);
return ( return (
<main <main
ref={fade.ref} ref={setRef}
style={fade.style} style={fade.style}
className="flex-1 min-w-0 overflow-y-auto [scrollbar-gutter:stable]" className="flex-1 min-w-0 overflow-y-auto [scrollbar-gutter:stable]"
> >
+67
View File
@@ -0,0 +1,67 @@
import { createContext, useContext, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import PageScroller from "./PageScroller";
// The DOM node a module's toolbar portals into (see PageToolbar below). PageShell provides its
// fixed band as the default target; a module can override it with its own <PageToolbarSlot.Provider>
// to redirect its toolbar somewhere else — the channel page points it at a sticky sub-header so the
// toolbar rides with the tabs instead of the shell's top band.
export const PageToolbarSlot = createContext<HTMLElement | null>(null);
// A module renders its per-module fixed band (filter chips / sort / counts) through this. The
// content lands in the shell's toolbar band — ABOVE the scroller, so it stays put while the content
// scrolls and is never touched by the scroller's edge-fade mask. It's a portal, so the toolbar's
// event handlers and closures still belong to the module's React subtree (setFilters etc. work as
// if it were rendered inline). Renders nothing until the band mounts (one tick on first mount) and
// nothing when there's no shell — so a module works with or without one.
export function PageToolbar({ children }: { children: ReactNode }) {
const slot = useContext(PageToolbarSlot);
return slot ? createPortal(children, slot) : null;
}
// The page shell: the fixed chrome around a single scrollable content region. It formalizes "what
// is fixed vs what scrolls" per module, so the chrome App used to hand-assemble around every page
// (a floating Header + a top-padded wrapper + PageScroller, plus the channel page's separate
// branch that rendered its OWN scroller) collapses to one contract and one scroller.
//
// Regions, top to bottom:
// - `header` — floats (absolute, height `var(--hdr-h)`) over the content column; the wrapper pads
// its top by that height so nothing hides under it. Omit it (channel page) → no pad
// and no fixed top band, so the module's own banner can scroll from the very top.
// - `banners` — in-flow, fixed notices (demo/version) above the scroll region.
// - toolbar band — in-flow, FIXED per-module band (filter chips / sort / counts); filled by a
// module via <PageToolbar>. Empty ⇒ zero height, so pages without one lose nothing.
// - children — the ONE scrollable region. `PageScroller` owns the scroller and its two
// invariants (`scrollbar-gutter: stable`, edge fade); see that file.
//
// The shell renders a fragment so it drops straight into App's `relative` content column — the
// floating header keeps resolving its `absolute` position against that same ancestor.
export default function PageShell({
header,
banners,
children,
scrollKey,
fadeTop = true,
}: {
header?: ReactNode;
banners?: ReactNode;
children: ReactNode;
// Identifies the logical view for the scroll restore/reset policy — see PageScroller.
scrollKey?: string;
// Off when this page's scroll region starts with a sticky header — see PageScroller.
fadeTop?: boolean;
}) {
const [toolbarSlot, setToolbarSlot] = useState<HTMLDivElement | null>(null);
return (
<PageToolbarSlot.Provider value={toolbarSlot}>
{header}
<div className={`flex-1 min-w-0 min-h-0 flex flex-col ${header ? "pt-[var(--hdr-h)]" : ""}`}>
{banners}
<div ref={setToolbarSlot} className="shrink-0" />
<PageScroller scrollKey={scrollKey} fadeTop={fadeTop}>
{children}
</PageScroller>
</div>
</PageToolbarSlot.Provider>
);
}
+8 -1
View File
@@ -38,6 +38,7 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { useUndoable } from "../lib/useUndoable"; import { useUndoable } from "../lib/useUndoable";
const PlayerModal = lazy(() => import("./PlayerModal")); const PlayerModal = lazy(() => import("./PlayerModal"));
import UndoToolbar from "./UndoToolbar"; import UndoToolbar from "./UndoToolbar";
import { PageToolbar } from "./PageShell";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
type SortKey = "manual" | "title" | "duration" | "channel"; type SortKey = "manual" | "title" | "duration" | "channel";
@@ -443,13 +444,17 @@ export default function Playlists({
} }
return ( return (
<div className="px-4 pb-4"> <div className="px-4 pb-4 pt-3">
{selectedId == null || !detail ? ( {selectedId == null || !detail ? (
<div className="text-muted text-sm p-4"> <div className="text-muted text-sm p-4">
{selectedId == null ? t("playlists.pickOne") : t("playlists.loading")} {selectedId == null ? t("playlists.pickOne") : t("playlists.loading")}
</div> </div>
) : ( ) : (
<> <>
{/* Header (title, actions, sort/group) is fixed chrome — it portals into the shell's
fixed band, so only the video list scrolls. */}
<PageToolbar>
<div className="px-4 pt-3 pb-2">
<div className="flex gap-3 items-start mb-4"> <div className="flex gap-3 items-start mb-4">
<div className="w-24 h-[54px] rounded-lg bg-card overflow-hidden shrink-0"> <div className="w-24 h-[54px] rounded-lg bg-card overflow-hidden shrink-0">
{items[0]?.thumbnail_url && ( {items[0]?.thumbnail_url && (
@@ -624,6 +629,8 @@ export default function Playlists({
</div> </div>
</div> </div>
)} )}
</div>
</PageToolbar>
{items.length === 0 ? ( {items.length === 0 ? (
<div className="text-sm text-muted grid place-items-center text-center py-12"> <div className="text-sm text-muted grid place-items-center text-center py-12">
+9 -2
View File
@@ -33,6 +33,7 @@ import { useHistorySubview } from "../lib/history";
import { DetailCustomizeMenu, Filterable, useArtBackdrop, useDetailPrefs } from "../lib/plexDetailUi"; import { DetailCustomizeMenu, Filterable, useArtBackdrop, useDetailPrefs } from "../lib/plexDetailUi";
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd"; import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
import PlexCollectionEditor from "./PlexCollectionEditor"; import PlexCollectionEditor from "./PlexCollectionEditor";
import { PageToolbar } from "./PageShell";
// Lazy: the rich player (pulls in hls.js) loads only when something is first played. // Lazy: the rich player (pulls in hls.js) loads only when something is first played.
const PlexPlayer = lazy(() => import("./PlexPlayer")); const PlexPlayer = lazy(() => import("./PlexPlayer"));
@@ -286,9 +287,15 @@ export default function PlexBrowse({
return ( return (
<div className="p-4 max-w-[1600px] mx-auto"> <div className="p-4 max-w-[1600px] mx-auto">
<p className="text-xs text-muted mb-3"> {/* The title count is fixed chrome — it portals into the shell's fixed band so it stays put
while only the poster grid scrolls. */}
<PageToolbar>
<div className="px-4 pt-3 pb-1 max-w-[1600px] mx-auto">
<p className="text-xs text-muted">
{browseQ.isLoading ? " " : dq ? t("plex.searchCount", { count: total }) : t("plex.count", { count: total })} {browseQ.isLoading ? " " : dq ? t("plex.searchCount", { count: total }) : t("plex.count", { count: total })}
</p> </p>
</div>
</PageToolbar>
{browseQ.isLoading ? ( {browseQ.isLoading ? (
<p className="text-muted text-sm">{t("plex.loading")}</p> <p className="text-muted text-sm">{t("plex.loading")}</p>
+4 -2
View File
@@ -6,7 +6,8 @@
"backfillEverythingHint": "Request full back-catalog backfill for every channel you're subscribed to. Older videos and search become complete as the shared daily quota allows — this can take a while.", "backfillEverythingHint": "Request full back-catalog backfill for every channel you're subscribed to. Older videos and search become complete as the shared daily quota allows — this can take a while.",
"tabs": { "tabs": {
"subscribed": "Subscriptions", "subscribed": "Subscriptions",
"discovery": "Discover from playlists" "discovery": "Discover from playlists",
"blocked": "Blocked"
}, },
"discovery": { "discovery": {
"intro": "Channels that appear in your playlists but that you don't subscribe to. Subscribe to follow them — their new uploads will start showing in your feed (subscribing costs a little YouTube quota; existing videos aren't re-fetched).", "intro": "Channels that appear in your playlists but that you don't subscribe to. Subscribe to follow them — their new uploads will start showing in your feed (subscribing costs a little YouTube quota; existing videos aren't re-fetched).",
@@ -119,6 +120,7 @@
"searchPlaceholder": "Search channels…", "searchPlaceholder": "Search channels…",
"blocked": { "blocked": {
"title": "Blocked channels", "title": "Blocked channels",
"hint": "Their videos are hidden from your feed, Library, and live search." "hint": "Their videos are hidden from your feed, Library, and live search.",
"empty": "No blocked channels. Block one from its channel page to hide all its videos without unsubscribing."
} }
} }
+4 -2
View File
@@ -6,7 +6,8 @@
"backfillEverythingHint": "Teljes archívum letöltését kéri minden csatornához, amelyre feliratkoztál. A régebbi videók és a keresés a megosztott napi kvóta függvényében válik teljessé — ez eltarthat egy ideig.", "backfillEverythingHint": "Teljes archívum letöltését kéri minden csatornához, amelyre feliratkoztál. A régebbi videók és a keresés a megosztott napi kvóta függvényében válik teljessé — ez eltarthat egy ideig.",
"tabs": { "tabs": {
"subscribed": "Feliratkozások", "subscribed": "Feliratkozások",
"discovery": "Felfedezés a lejátszási listákból" "discovery": "Felfedezés a lejátszási listákból",
"blocked": "Letiltott"
}, },
"discovery": { "discovery": {
"intro": "Csatornák, amelyek megjelennek a lejátszási listáidban, de még nem iratkoztál fel rájuk. Iratkozz fel, hogy kövesd őket — az új feltöltéseik megjelennek a hírfolyamodban (a feliratkozás kevés YouTube-kvótát használ; a meglévő videókat nem tölti le újra).", "intro": "Csatornák, amelyek megjelennek a lejátszási listáidban, de még nem iratkoztál fel rájuk. Iratkozz fel, hogy kövesd őket — az új feltöltéseik megjelennek a hírfolyamodban (a feliratkozás kevés YouTube-kvótát használ; a meglévő videókat nem tölti le újra).",
@@ -119,6 +120,7 @@
"searchPlaceholder": "Csatornák keresése…", "searchPlaceholder": "Csatornák keresése…",
"blocked": { "blocked": {
"title": "Tiltott csatornák", "title": "Tiltott csatornák",
"hint": "A videóik el vannak rejtve a feededből, a Library-ből és a YouTube-keresésből." "hint": "A videóik el vannak rejtve a feededből, a Library-ből és a YouTube-keresésből.",
"empty": "Nincs tiltott csatorna. Egy csatorna oldaláról tilthatsz le egyet, hogy minden videóját elrejtsd leiratkozás nélkül."
} }
} }
+5 -2
View File
@@ -7,7 +7,10 @@ import { useEffect, useState, type CSSProperties } from "react";
// to attach to the scroller and the mask `style` to spread onto it. Uses a node-state callback // to attach to the scroller and the mask `style` to spread onto it. Uses a node-state callback
// ref (not useRef) so the effect (re)runs whenever the element attaches — e.g. a SidePanel that // ref (not useRef) so the effect (re)runs whenever the element attaches — e.g. a SidePanel that
// mounts collapsed and only renders its scroll body once expanded still wires up correctly. // mounts collapsed and only renders its scroll body once expanded still wires up correctly.
export function useScrollFade(fadePx = 20): { // `fadeTop` gates the top edge only. Pass false when the scroller's top is a sticky element (a
// table's sticky header): fading there would eat into the header instead of hinting at content, and
// the header would visibly change as the fade turns on with the first scroll.
export function useScrollFade(fadePx = 20, fadeTop = true): {
ref: (node: HTMLElement | null) => void; ref: (node: HTMLElement | null) => void;
style: CSSProperties; style: CSSProperties;
} { } {
@@ -53,7 +56,7 @@ export function useScrollFade(fadePx = 20): {
}; };
}, [el]); }, [el]);
const mask = `linear-gradient(to bottom, transparent, #000 ${fade.up ? fadePx : 0}px, #000 calc(100% - ${ const mask = `linear-gradient(to bottom, transparent, #000 ${fade.up && fadeTop ? fadePx : 0}px, #000 calc(100% - ${
fade.down ? fadePx : 0 fade.down ? fadePx : 0
}px), transparent)`; }px), transparent)`;
return { ref: setEl, style: { maskImage: mask, WebkitMaskImage: mask } }; return { ref: setEl, style: { maskImage: mask, WebkitMaskImage: mask } };