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}
- 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"
- >
-
-
-
- ))}
-
-
{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}
- patch.mutate({ id: c.id, body: { hidden: false } })}
- title={t("channels.hidden.unhide")}
- aria-label={t("channels.hidden.unhide")}
- className="rounded-full p-0.5 text-muted hover:text-accent transition"
- >
-
-
-
- ))}
-
-
{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}
+ />
+
+
onAction(c.id)}
+ title={actionLabel}
+ aria-label={actionLabel}
+ className="shrink-0 inline-flex items-center gap-1 rounded-full border border-border px-2 py-1 text-xs text-muted transition hover:border-accent hover:text-accent"
+ >
+ {actionIcon}
+ {actionLabel}
+
+
+ ))}
+
+ {hint}
+ >
+ );
+}