feat(channels): pager into the chip row, Blocked as its own tab

Address manager UAT:
- The DataTable controls row (status chips + pager) now portals into the
  fixed band via a new controlsInBand prop, so it stays put above the
  sticky header instead of scrolling away — and the pager fills the empty
  space next to the status chips, on one row.
- The tab bar is always fixed chrome now (not just on Subscriptions), so
  it doesn't jump when switching tabs; on Subscriptions the stats/intro/
  tags ride with it.
- Blocked channels moves off the bottom of the Subscriptions list into its
  own 'Blocked' tab (with an empty state), alongside Subscriptions and
  Discover.

Verified with a 90-channel test account: everything above the rows stays
fixed while the rows scroll under the sticky header. Suite 15/15, tsc +
knip clean.
This commit is contained in:
2026-07-18 05:34:07 +02:00
parent dcadbe346d
commit 867daa0dcf
5 changed files with 101 additions and 73 deletions
+5 -1
View File
@@ -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".)
+70 -67
View File
@@ -31,7 +31,7 @@ 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";
@@ -383,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)}
@@ -400,27 +400,14 @@ export default function Channels({
</div> </div>
); );
if (view === "discovery") {
return (
<>
{tabs}
<ChannelDiscovery
canWrite={canWrite}
search={search}
onOpenWizard={onOpenWizard}
onViewChannel={onViewChannel}
/>
</>
);
}
return ( return (
<> <>
{/* The whole manager header — tabs, stats, description, tags, status chips — is fixed chrome: {/* The tab bar is always fixed chrome (it portals into the shell's fixed band, so it never
it portals into the shell's fixed band, so only the table rows scroll (under the sticky scrolls and doesn't jump between tabs). On Subscriptions the stats/description/tags ride
header row). */} with it, and the status-chip + pager row lands just below (DataTable's controlsInBand). */}
<PageToolbar> <PageToolbar>
{tabs} {tabs}
{view === "subscribed" && (
<div className="px-4 pt-3 pb-2 max-w-7xl mx-auto"> <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). */}
@@ -540,61 +527,77 @@ export default function Channels({
{t("channels.tags.manage")} {t("channels.tags.manage")}
</button> </button>
</div> </div>
{/* Sync-status chips — the last band of fixed chrome, right above the table header. */}
{statusChips}
</div> </div>
)}
</PageToolbar> </PageToolbar>
{tagManagerOpen && ( {tagManagerOpen && (
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} /> <TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
)} )}
<div className="px-4 pb-4 pt-2 max-w-7xl mx-auto"> {view === "discovery" ? (
{/* Channel table — only the rows scroll; the header row is sticky (see DataTable). */} <ChannelDiscovery
{channelsQuery.isLoading ? ( canWrite={canWrite}
<div className="text-muted py-8">{t("channels.loading")}</div> search={search}
) : ( onOpenWizard={onOpenWizard}
<DataTable onViewChannel={onViewChannel}
rows={visibleChannels} />
columns={columns} ) : view === "blocked" ? (
rowKey={(c) => c.id} /* Blocked channels — videos from these are hidden everywhere and dropped from live search. */
persistKey={accountKey(LS.channelsTable) ?? undefined} <div className="px-4 pb-4 pt-3 max-w-7xl mx-auto">
controlsPosition="top" {(blockedQuery.data?.length ?? 0) === 0 ? (
rowClassName={(c) => (c.hidden ? "opacity-60" : "")} <p className="text-sm text-muted py-8">{t("channels.blocked.empty")}</p>
emptyText={t("channels.empty")} ) : (
/> <>
)} <div className="flex items-center gap-2 mb-3">
<Ban className="w-4 h-4 text-muted" />
{/* Blocked channels — videos from these are hidden everywhere and dropped from live search. */} <span className="text-xs uppercase tracking-wide text-muted">
{(blockedQuery.data?.length ?? 0) > 0 && ( {t("channels.blocked.title")}
<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> </span>
))} </div>
</div> <div className="flex flex-wrap gap-1.5">
<p className="text-[11px] text-muted mt-2">{t("channels.blocked.hint")}</p> {blockedQuery.data!.map((c) => (
</div> <span
)} key={c.id}
</div> 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 className="px-4 pb-4 pt-2 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>
)}
</> </>
); );
} }
+18 -1
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,9 +352,18 @@ 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">
+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."
} }
} }