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:
@@ -31,7 +31,7 @@ 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";
|
||||
|
||||
@@ -383,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)}
|
||||
@@ -400,27 +400,14 @@ export default function Channels({
|
||||
</div>
|
||||
);
|
||||
|
||||
if (view === "discovery") {
|
||||
return (
|
||||
<>
|
||||
{tabs}
|
||||
<ChannelDiscovery
|
||||
canWrite={canWrite}
|
||||
search={search}
|
||||
onOpenWizard={onOpenWizard}
|
||||
onViewChannel={onViewChannel}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* The whole manager header — tabs, stats, description, tags, status chips — is fixed chrome:
|
||||
it portals into the shell's fixed band, so only the table rows scroll (under the sticky
|
||||
header row). */}
|
||||
{/* 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}
|
||||
{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). */}
|
||||
@@ -540,61 +527,77 @@ export default function Channels({
|
||||
{t("channels.tags.manage")}
|
||||
</button>
|
||||
</div>
|
||||
{/* Sync-status chips — the last band of fixed chrome, right above the table header. */}
|
||||
{statusChips}
|
||||
</div>
|
||||
)}
|
||||
</PageToolbar>
|
||||
{tagManagerOpen && (
|
||||
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
|
||||
)}
|
||||
|
||||
<div className="px-4 pb-4 pt-2 max-w-7xl mx-auto">
|
||||
{/* Channel table — only the rows scroll; the header row is sticky (see DataTable). */}
|
||||
{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"
|
||||
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>
|
||||
{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>
|
||||
<p className="text-[11px] text-muted mt-2">{t("channels.blocked.hint")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</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 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 { 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,9 +352,18 @@ 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">
|
||||
|
||||
Reference in New Issue
Block a user