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:
@@ -29,4 +29,55 @@ test.describe("channel view", () => {
|
||||
await expect(page.getByTestId("channel-title")).toHaveCount(0);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,4 +47,35 @@ test.describe("feed", () => {
|
||||
.poll(async () => (await renderedIds()).some((id) => id && !before.includes(id)))
|
||||
.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
@@ -53,7 +53,7 @@ import NavSidebar from "./components/NavSidebar";
|
||||
import Sidebar from "./components/Sidebar";
|
||||
import PlaylistsRail from "./components/PlaylistsRail";
|
||||
import BackToTop from "./components/BackToTop";
|
||||
import PageScroller from "./components/PageScroller";
|
||||
import PageShell from "./components/PageShell";
|
||||
import GlassTuner from "./components/GlassTuner";
|
||||
import ChatDock from "./components/ChatDock";
|
||||
import Toaster from "./components/Toaster";
|
||||
@@ -204,7 +204,11 @@ export default function App() {
|
||||
// of dumping the user on whatever tab they last left open.
|
||||
const [channelsViewRaw, setChannelsView] = useAccountPersistedState(LS.channelsView, "subscribed");
|
||||
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.
|
||||
// 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".)
|
||||
@@ -632,6 +636,17 @@ export default function App() {
|
||||
<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 (
|
||||
<div className="h-screen flex text-fg">
|
||||
<NavSidebar
|
||||
@@ -689,6 +704,7 @@ export default function App() {
|
||||
)}
|
||||
<div className="relative flex-1 min-w-0 flex flex-col">
|
||||
{channelView ? (
|
||||
<PageShell scrollKey={`channel:${channelView.id}`}>
|
||||
<Suspense fallback={pageFallback}>
|
||||
<ChannelPage
|
||||
key={channelView.id}
|
||||
@@ -714,35 +730,40 @@ export default function App() {
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</PageShell>
|
||||
) : (
|
||||
<>
|
||||
<Header
|
||||
me={meQuery.data!}
|
||||
filters={filters}
|
||||
setFilters={setFilters}
|
||||
plexQ={plexQ}
|
||||
setPlexQ={setPlexQ}
|
||||
channelsSearch={channelsSearch}
|
||||
setChannelsSearch={setChannelsSearch}
|
||||
playlistsSearch={playlistsSearch}
|
||||
setPlaylistsSearch={setPlaylistsSearch}
|
||||
onYtSearch={enterYtSearch}
|
||||
onGoToFullHistory={() => {
|
||||
setChannelFilter("needs_full");
|
||||
setChannelsView("subscribed"); // the status filter applies to the subscriptions tab
|
||||
setChannelsFilterReset((n) => n + 1); // drop any stale column filter hiding the rows
|
||||
setPage("channels");
|
||||
}}
|
||||
/>
|
||||
{/* The header floats (fixed) over the content, so it no longer occupies flow space; this
|
||||
wrapper clears it with a top pad. (All left rails, incl. Playlists, are App-level
|
||||
floating panels beside the nav — none sit inside this content column anymore.) */}
|
||||
<div
|
||||
className="flex-1 min-w-0 min-h-0 flex flex-col pt-[var(--hdr-h)]"
|
||||
>
|
||||
{meQuery.data!.is_demo && <DemoBanner />}
|
||||
<VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} />
|
||||
<PageScroller>
|
||||
<PageShell
|
||||
scrollKey={normalScrollKey}
|
||||
// Channels + Audit render a table with a sticky header at the scroller top; the top
|
||||
// fade would sit on it and flip on with the first scroll, so turn it off there.
|
||||
fadeTop={page !== "channels" && page !== "audit"}
|
||||
header={
|
||||
<Header
|
||||
me={meQuery.data!}
|
||||
filters={filters}
|
||||
setFilters={setFilters}
|
||||
plexQ={plexQ}
|
||||
setPlexQ={setPlexQ}
|
||||
channelsSearch={channelsSearch}
|
||||
setChannelsSearch={setChannelsSearch}
|
||||
playlistsSearch={playlistsSearch}
|
||||
setPlaylistsSearch={setPlaylistsSearch}
|
||||
onYtSearch={enterYtSearch}
|
||||
onGoToFullHistory={() => {
|
||||
setChannelFilter("needs_full");
|
||||
setChannelsView("subscribed"); // the status filter applies to the subscriptions tab
|
||||
setChannelsFilterReset((n) => n + 1); // drop any stale column filter hiding the rows
|
||||
setPage("channels");
|
||||
}}
|
||||
/>
|
||||
}
|
||||
banners={
|
||||
<>
|
||||
{meQuery.data!.is_demo && <DemoBanner />}
|
||||
<VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={pageFallback}>
|
||||
{page === "channels" ? (
|
||||
<Channels
|
||||
@@ -823,9 +844,7 @@ export default function App() {
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
</PageScroller>
|
||||
</div>
|
||||
</>
|
||||
</PageShell>
|
||||
)}
|
||||
{/* 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
|
||||
|
||||
@@ -9,6 +9,7 @@ import Tooltip from "./Tooltip";
|
||||
import { Section } from "./ui/form";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import Tabs, { usePersistedTab } from "./Tabs";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
|
||||
// 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
|
||||
@@ -26,12 +27,19 @@ export default function AdminUsers({ me }: { me: Me }) {
|
||||
];
|
||||
const active = tabs.some((x) => x.id === tab) ? tab : "roles";
|
||||
return (
|
||||
<div className="p-4 max-w-3xl w-full mx-auto">
|
||||
<Tabs tabs={tabs} active={active} onChange={setTab} />
|
||||
{active === "roles" && <UsersRoles me={me} />}
|
||||
{active === "access" && <AdminInvites />}
|
||||
{active === "demo" && <AdminDemo />}
|
||||
</div>
|
||||
<>
|
||||
{/* The tab bar is fixed chrome; the tab content scrolls under it. */}
|
||||
<PageToolbar>
|
||||
<div className="px-4 pt-3 max-w-3xl w-full mx-auto">
|
||||
<Tabs tabs={tabs} active={active} onChange={setTab} />
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import Tooltip from "./Tooltip";
|
||||
import ChannelLink from "./ChannelLink";
|
||||
import DataTable, { type Column } from "./DataTable";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
|
||||
// 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 (
|
||||
<div className="px-4 pb-4 pt-3 max-w-7xl mx-auto">
|
||||
<p className="text-xs text-muted mb-4 leading-relaxed">
|
||||
{t("channels.discovery.intro")}
|
||||
</p>
|
||||
{query.isLoading ? (
|
||||
<div className="text-muted py-8">{t("channels.discovery.loading")}</div>
|
||||
) : (
|
||||
<DataTable
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
rowKey={(c) => c.id}
|
||||
persistKey={accountKey(LS.channelDiscoveryTable) ?? undefined}
|
||||
controlsPosition="top"
|
||||
emptyText={t("channels.discovery.empty")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<>
|
||||
{/* Intro + the table controls are fixed chrome; only the table rows scroll (sticky header). */}
|
||||
<PageToolbar>
|
||||
<div className="px-4 pt-3 max-w-7xl mx-auto">
|
||||
<p className="text-xs text-muted leading-relaxed">{t("channels.discovery.intro")}</p>
|
||||
</div>
|
||||
</PageToolbar>
|
||||
<div className="px-4 pb-4 max-w-7xl mx-auto">
|
||||
{query.isLoading ? (
|
||||
<div className="text-muted py-8">{t("channels.discovery.loading")}</div>
|
||||
) : (
|
||||
<DataTable
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
rowKey={(c) => c.id}
|
||||
persistKey={accountKey(LS.channelDiscoveryTable) ?? undefined}
|
||||
controlsPosition="top"
|
||||
controlsInBand
|
||||
controlsBandClassName="px-4 max-w-7xl mx-auto"
|
||||
emptyText={t("channels.discovery.empty")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useNavigationActions } from "./NavigationProvider";
|
||||
import { ArrowLeft, Ban, Check, ExternalLink, Loader2, Plus, RefreshCw, SlidersHorizontal } from "lucide-react";
|
||||
import Avatar from "./Avatar";
|
||||
import Feed from "./Feed";
|
||||
import PageScroller from "./PageScroller";
|
||||
import { PageToolbar, PageToolbarSlot } from "./PageShell";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
@@ -77,6 +77,11 @@ export default function ChannelPage({
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
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
|
||||
// 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
|
||||
@@ -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 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 (
|
||||
<PageScroller>
|
||||
<>
|
||||
<PageToolbar>
|
||||
{/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */}
|
||||
<div className="relative px-4 sm:px-6 pt-3">
|
||||
{ch?.banner_url && bannerAttempt <= MAX_BANNER_RETRIES ? (
|
||||
@@ -365,8 +374,14 @@ export default function ChannelPage({
|
||||
</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" ? (
|
||||
<>
|
||||
<Feed
|
||||
@@ -466,6 +481,7 @@ export default function ChannelPage({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</PageScroller>
|
||||
</PageToolbarSlot.Provider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,12 +25,13 @@ import { subsColumn } from "./channelColumns";
|
||||
import { notify } from "../lib/notifications";
|
||||
import Tooltip from "./Tooltip";
|
||||
import DataTable, { type Column } from "./DataTable";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import ChannelLink from "./ChannelLink";
|
||||
import ChannelDiscovery from "./ChannelDiscovery";
|
||||
import TagManager from "./TagManager";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
|
||||
export type ChannelsView = "subscribed" | "discovery";
|
||||
export type ChannelsView = "subscribed" | "discovery" | "blocked";
|
||||
|
||||
export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden";
|
||||
|
||||
@@ -382,7 +383,7 @@ export default function Channels({
|
||||
const tabs = (
|
||||
<div className="px-4 pt-4 max-w-7xl mx-auto">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{(["subscribed", "discovery"] as ChannelsView[]).map((v) => (
|
||||
{(["subscribed", "discovery", "blocked"] as ChannelsView[]).map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => setView(v)}
|
||||
@@ -399,24 +400,15 @@ export default function Channels({
|
||||
</div>
|
||||
);
|
||||
|
||||
if (view === "discovery") {
|
||||
return (
|
||||
<>
|
||||
{tabs}
|
||||
<ChannelDiscovery
|
||||
canWrite={canWrite}
|
||||
search={search}
|
||||
onOpenWizard={onOpenWizard}
|
||||
onViewChannel={onViewChannel}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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}
|
||||
<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
|
||||
now lives in the table headers). */}
|
||||
<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")}
|
||||
</button>
|
||||
</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>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { api, type ConfigItem, type PlexTestResult } from "../lib/api";
|
||||
import { notify } from "../lib/notifications";
|
||||
import Tooltip from "./Tooltip";
|
||||
import Tabs, { usePersistedTab } from "./Tabs";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import { Switch } from "./ui/form";
|
||||
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
|
||||
import { LS } from "../lib/storage";
|
||||
@@ -151,11 +152,15 @@ export default function ConfigPanel() {
|
||||
}));
|
||||
|
||||
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>
|
||||
|
||||
<Tabs tabs={groupTabs} active={activeGroup} onChange={setTab} />
|
||||
|
||||
<>
|
||||
{/* Intro + tab bar are fixed chrome; the config fields scroll under them. */}
|
||||
<PageToolbar>
|
||||
<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 && (
|
||||
<div className="glass rounded-2xl p-4 mb-4">
|
||||
<div className="divide-y divide-border/60">
|
||||
@@ -264,6 +269,7 @@ export default function ConfigPanel() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ListFilter, X } from "lucide-react";
|
||||
import { useDismiss } from "../lib/useDismiss";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
|
||||
// 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
|
||||
@@ -84,6 +85,8 @@ export default function DataTable<T>({
|
||||
rowClassName,
|
||||
controlsPosition = "bottom",
|
||||
controlsLeading,
|
||||
controlsInBand = false,
|
||||
controlsBandClassName,
|
||||
externalFilter,
|
||||
resetFiltersToken,
|
||||
}: {
|
||||
@@ -97,6 +100,11 @@ export default function DataTable<T>({
|
||||
rowClassName?: (row: T) => string;
|
||||
controlsPosition?: "top" | "bottom" | "both";
|
||||
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
|
||||
// whenever its value changes; the user can still clear it from the header afterwards.
|
||||
externalFilter?: { key: string; value: string } | null;
|
||||
@@ -344,14 +352,29 @@ export default function DataTable<T>({
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const topControls =
|
||||
controlsInBand && controls ? (
|
||||
<PageToolbar>
|
||||
<div className={controlsBandClassName}>{controls}</div>
|
||||
</PageToolbar>
|
||||
) : (
|
||||
controls
|
||||
);
|
||||
|
||||
return (
|
||||
<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. */}
|
||||
<div className="hidden md:block">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted border-b border-border">
|
||||
{/* Sticky header row: the column headers stay put at the top of the page scroller while
|
||||
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) => {
|
||||
const sorted_ = sort?.key === col.key;
|
||||
const filterOn = isActive(filters[col.key]);
|
||||
@@ -359,7 +382,7 @@ export default function DataTable<T>({
|
||||
<th
|
||||
key={col.key}
|
||||
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">
|
||||
<button
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import Tabs, { usePersistedTab } from "./Tabs";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import Modal from "./Modal";
|
||||
// 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).
|
||||
@@ -600,7 +601,10 @@ export default function DownloadCenter({ me }: { me: Me }) {
|
||||
);
|
||||
|
||||
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">
|
||||
<h1 className="text-xl font-semibold">{t("downloads.page.title")}</h1>
|
||||
<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>
|
||||
|
||||
<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" && (
|
||||
<>
|
||||
<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)} />}
|
||||
</Suspense>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import { FEED_VIEWS, type FeedView } from "../lib/feedView";
|
||||
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
||||
import ActiveFilterChips from "./ActiveFilterChips";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import ViewSwitcher from "./ViewSwitcher";
|
||||
import VirtualFeed from "./VirtualFeed";
|
||||
// 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 (
|
||||
<div className="p-4">
|
||||
{toolbar}
|
||||
{/* Channel pages get no chips: they have no filter panel to reveal (App hides the sidebar for
|
||||
them), and their Feed starts from its OWN baseline — channel-scoped, whole-catalog,
|
||||
show-all — so every one of those would render as a "filter you applied" when it's just the
|
||||
page being itself. */}
|
||||
{!channelScoped && (
|
||||
<ActiveFilterChips
|
||||
filters={activeFilters}
|
||||
onClearAll={() => setFilters(clearedFilters(filters))}
|
||||
/>
|
||||
)}
|
||||
<div className={channelScoped ? "px-4 sm:px-6 pb-4" : "px-4 pb-4 pt-2"}>
|
||||
{/* The toolbar (+ active-filter chips) is FIXED chrome: it portals into a PageToolbarSlot so
|
||||
it stays put while the cards scroll (bug 4). On the feed page that slot is the shell's top
|
||||
band; on a channel page ChannelPage overrides the slot with its own sticky sub-header, so
|
||||
the toolbar rides with the tabs. Channel pages get no chips: their Feed starts from its OWN
|
||||
baseline (channel-scoped, whole-catalog, show-all), so each would read as a "filter you
|
||||
applied" when it's just the page being itself. */}
|
||||
<PageToolbar>
|
||||
<div className={channelScoped ? "px-4 sm:px-6" : "px-4 pt-4 pb-1"}>
|
||||
{toolbar}
|
||||
{!channelScoped && (
|
||||
<ActiveFilterChips
|
||||
filters={activeFilters}
|
||||
onClearAll={() => setFilters(clearedFilters(filters))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PageToolbar>
|
||||
{items.length === 0 ? (
|
||||
<div className="py-16 text-center text-muted">
|
||||
<p>{t("feed.noMatches")}</p>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "../lib/notifications";
|
||||
import { channelYouTubeUrl, relativeFromMs, relativeTime } from "../lib/format";
|
||||
import { LEVEL_STYLE } from "./Toaster";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import { useNavigationActions } from "./NavigationProvider";
|
||||
|
||||
// The single notification center. "System" = durable, server-backed notifications (inbox
|
||||
@@ -102,8 +103,11 @@ export default function NotificationsPanel({
|
||||
}
|
||||
|
||||
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" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold">{t("inbox.title")}</div>
|
||||
@@ -130,7 +134,10 @@ export default function NotificationsPanel({
|
||||
</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 ? (
|
||||
<div className="p-8 text-muted text-center">{t("common.loading")}</div>
|
||||
) : !hasAny ? (
|
||||
@@ -185,7 +192,8 @@ export default function NotificationsPanel({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,39 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { useCallback, useLayoutEffect, useRef, type ReactNode } from "react";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
|
||||
// The page's scroll container. Two of these exist and are never both mounted: App renders one for
|
||||
// the normal pages, ChannelPage renders its own (it's a separate branch of App's tree, not a page
|
||||
// inside the usual one). The feed, the channel page and Plex browse all scroll inside it rather
|
||||
// than owning a scroller — which is why the edge-fade lives here once instead of three times.
|
||||
// Per-view saved scroll offsets. Module-level so a view's position survives its scroller
|
||||
// unmounting — opening a channel page swaps in a different shell/scroller — and is restored when
|
||||
// the same view returns. Keyed by the shell's `scrollKey`.
|
||||
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:
|
||||
//
|
||||
@@ -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
|
||||
// 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 —
|
||||
// `children` arrives as an already-built element, so React skips the page subtree underneath.
|
||||
export default function PageScroller({ children }: { children: ReactNode }) {
|
||||
const fade = useScrollFade();
|
||||
export default function PageScroller({
|
||||
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 (
|
||||
<main
|
||||
ref={fade.ref}
|
||||
ref={setRef}
|
||||
style={fade.style}
|
||||
className="flex-1 min-w-0 overflow-y-auto [scrollbar-gutter:stable]"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { useUndoable } from "../lib/useUndoable";
|
||||
const PlayerModal = lazy(() => import("./PlayerModal"));
|
||||
import UndoToolbar from "./UndoToolbar";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
|
||||
type SortKey = "manual" | "title" | "duration" | "channel";
|
||||
@@ -443,13 +444,17 @@ export default function Playlists({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-4">
|
||||
<div className="px-4 pb-4 pt-3">
|
||||
{selectedId == null || !detail ? (
|
||||
<div className="text-muted text-sm p-4">
|
||||
{selectedId == null ? t("playlists.pickOne") : t("playlists.loading")}
|
||||
</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="w-24 h-[54px] rounded-lg bg-card overflow-hidden shrink-0">
|
||||
{items[0]?.thumbnail_url && (
|
||||
@@ -624,6 +629,8 @@ export default function Playlists({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageToolbar>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="text-sm text-muted grid place-items-center text-center py-12">
|
||||
|
||||
@@ -33,6 +33,7 @@ import { useHistorySubview } from "../lib/history";
|
||||
import { DetailCustomizeMenu, Filterable, useArtBackdrop, useDetailPrefs } from "../lib/plexDetailUi";
|
||||
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
|
||||
import PlexCollectionEditor from "./PlexCollectionEditor";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
|
||||
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
|
||||
const PlexPlayer = lazy(() => import("./PlexPlayer"));
|
||||
@@ -286,9 +287,15 @@ export default function PlexBrowse({
|
||||
|
||||
return (
|
||||
<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 })}
|
||||
</p>
|
||||
</p>
|
||||
</div>
|
||||
</PageToolbar>
|
||||
|
||||
{browseQ.isLoading ? (
|
||||
<p className="text-muted text-sm">{t("plex.loading")}</p>
|
||||
|
||||
@@ -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.",
|
||||
"tabs": {
|
||||
"subscribed": "Subscriptions",
|
||||
"discovery": "Discover from playlists"
|
||||
"discovery": "Discover from playlists",
|
||||
"blocked": "Blocked"
|
||||
},
|
||||
"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).",
|
||||
@@ -119,6 +120,7 @@
|
||||
"searchPlaceholder": "Search channels…",
|
||||
"blocked": {
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
"tabs": {
|
||||
"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": {
|
||||
"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…",
|
||||
"blocked": {
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// 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.
|
||||
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;
|
||||
style: CSSProperties;
|
||||
} {
|
||||
@@ -53,7 +56,7 @@ export function useScrollFade(fadePx = 20): {
|
||||
};
|
||||
}, [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
|
||||
}px), transparent)`;
|
||||
return { ref: setEl, style: { maskImage: mask, WebkitMaskImage: mask } };
|
||||
|
||||
Reference in New Issue
Block a user