From 002ef0ab841edb3ee7cfa5beaf8a8be489027978 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 14 Aug 2026 00:49:09 +0200 Subject: [PATCH] feat(channels): proper channel cards for Hidden + Blocked tabs (R13 S2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Hidden and Blocked tabs were a bare row of name chips ("two withered chips"). Both now render a responsive grid of real channel cards via a shared ExcludedChannelList: avatar + name + open-on-YouTube (reusing ChannelLink) and one labelled reversing action (Unhide / Unblock). One presentation, parameterised by icon/label/action/copy — the two "excluded channel" tabs now match. Gate green: tsc/eslint-0/prettier/knip clean. Self-E2E'd: Hidden tab renders both channels as cards with avatars, YT links, and Unhide buttons. --- frontend/src/components/Channels.tsx | 87 +++++-------------- .../src/components/ExcludedChannelList.tsx | 70 +++++++++++++++ 2 files changed, 94 insertions(+), 63 deletions(-) create mode 100644 frontend/src/components/ExcludedChannelList.tsx diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index a3f0a987..63fb9c1b 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; import { Ban, Eye, EyeOff, History, LayoutGrid, Pencil, RefreshCw, Table, X } from "lucide-react"; +import ExcludedChannelList from "./ExcludedChannelList"; import { api, type ManagedChannel } from "../lib/api"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { accountKey, LS } from "../lib/storage"; @@ -522,73 +523,33 @@ export default function Channels() { ) : 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")} - -
-
- {blockedQuery.data!.map((c) => ( - - {c.title ?? c.id} - - - ))} -
-

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

- - )} + } + title={t("channels.blocked.title")} + hint={t("channels.blocked.hint")} + emptyText={t("channels.blocked.empty")} + actionLabel={t("channel.unblock")} + actionIcon={} + onAction={(id) => unblock.mutate(id)} + onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))} + />
) : view === "hidden" ? ( /* Hidden channels — kept out of the Mine feed but still in the shared Library; managed here - instead of dimmed in the subscribed table. Mirrors the Blocked tab (flat list + one action). */ + instead of dimmed in the subscribed table. Same card grid as Blocked (ExcludedChannelList). */
- {hiddenChannels.length === 0 ? ( -

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

- ) : ( - <> -
- - - {t("channels.hidden.title")} - -
-
- {hiddenChannels.map((c) => ( - - {c.title ?? c.id} - - - ))} -
-

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

- - )} + } + title={t("channels.hidden.title")} + hint={t("channels.hidden.hint")} + emptyText={t("channels.hidden.empty")} + actionLabel={t("channels.hidden.unhide")} + actionIcon={} + onAction={(id) => patch.mutate({ id, body: { hidden: false } })} + onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))} + />
) : ( /* No top padding: the sticky header sits flush at the scroller top so it doesn't shift when diff --git a/frontend/src/components/ExcludedChannelList.tsx b/frontend/src/components/ExcludedChannelList.tsx new file mode 100644 index 00000000..5caf33dc --- /dev/null +++ b/frontend/src/components/ExcludedChannelList.tsx @@ -0,0 +1,70 @@ +import { type ReactNode } from "react"; +import ChannelLink from "./ChannelLink"; + +// The presentation shared by the channel manager's "excluded channel" tabs — Hidden and Blocked. +// A responsive grid of proper channel cards (avatar + name + open-on-YouTube, via ChannelLink) each +// with one reversing action, instead of a bare row of name chips. Both tabs pass the same shape; only +// the header icon/label, the action, and the copy differ. +export interface ExcludedChannel { + id: string; + title: string | null; + handle?: string | null; + thumbnail_url: string | null; +} + +export default function ExcludedChannelList({ + channels, + icon, + title, + hint, + emptyText, + actionLabel, + actionIcon, + onAction, + onView, +}: { + channels: ExcludedChannel[]; + icon: ReactNode; + title: string; + hint: string; + emptyText: string; + actionLabel: string; + actionIcon: ReactNode; + onAction: (id: string) => void; + onView?: (c: ExcludedChannel) => void; +}) { + if (channels.length === 0) return

{emptyText}

; + return ( + <> +
+ {icon} + {title} +
+
+ {channels.map((c) => ( +
+
+ onView(c) : undefined} + /> +
+ +
+ ))} +
+

{hint}

+ + ); +}