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:
@@ -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".)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user