diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8b6b7e0..f470160 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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".) diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 2ce7c8c..e1b2b58 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -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 = (
- {(["subscribed", "discovery"] as ChannelsView[]).map((v) => ( + {(["subscribed", "discovery", "blocked"] as ChannelsView[]).map((v) => (
); - if (view === "discovery") { - return ( - <> - {tabs} - - - ); - } - 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). */} {tabs} + {view === "subscribed" && (
{/* 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")}
- {/* Sync-status chips — the last band of fixed chrome, right above the table header. */} - {statusChips}
+ )} {tagManagerOpen && ( setTagManagerOpen(false)} onFocusChannel={onFocusChannel} /> )} -
- {/* Channel table — only the rows scroll; the header row is sticky (see DataTable). */} - {channelsQuery.isLoading ? ( -
{t("channels.loading")}
- ) : ( - 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 && ( -
-
- - - {t("channels.blocked.title")} - -
-
- {blockedQuery.data!.map((c) => ( - - {c.title ?? c.id} - + {view === "discovery" ? ( + + ) : view === "blocked" ? ( + /* Blocked channels — videos from these are hidden everywhere and dropped from live search. */ +
+ {(blockedQuery.data?.length ?? 0) === 0 ? ( +

{t("channels.blocked.empty")}

+ ) : ( + <> +
+ + + {t("channels.blocked.title")} - ))} -
-

{t("channels.blocked.hint")}

-
- )} -
+
+
+ {blockedQuery.data!.map((c) => ( + + {c.title ?? c.id} + + + ))} +
+

{t("channels.blocked.hint")}

+ + )} +
+ ) : ( +
+ {/* 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 ? ( +
{t("channels.loading")}
+ ) : ( + 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")} + /> + )} +
+ )} ); } diff --git a/frontend/src/components/DataTable.tsx b/frontend/src/components/DataTable.tsx index f247260..293c784 100644 --- a/frontend/src/components/DataTable.tsx +++ b/frontend/src/components/DataTable.tsx @@ -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({ rowClassName, controlsPosition = "bottom", controlsLeading, + controlsInBand = false, + controlsBandClassName, externalFilter, resetFiltersToken, }: { @@ -97,6 +100,11 @@ export default function DataTable({ 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({ ) : null; + const topControls = + controlsInBand && controls ? ( + +
{controls}
+
+ ) : ( + controls + ); + return (
- {(controlsPosition === "top" || controlsPosition === "both") && controls} + {(controlsPosition === "top" || controlsPosition === "both") && topControls} {/* Wide screens: table. The wrapper isn't clipped so header filter popovers can overflow. */}
diff --git a/frontend/src/i18n/locales/en/channels.json b/frontend/src/i18n/locales/en/channels.json index faf4ea0..7e474b9 100644 --- a/frontend/src/i18n/locales/en/channels.json +++ b/frontend/src/i18n/locales/en/channels.json @@ -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." } } diff --git a/frontend/src/i18n/locales/hu/channels.json b/frontend/src/i18n/locales/hu/channels.json index f533397..c0b4f68 100644 --- a/frontend/src/i18n/locales/hu/channels.json +++ b/frontend/src/i18n/locales/hu/channels.json @@ -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." } }