From 60620079475a1371775ec6de648aeb325910e8ae Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 14 Aug 2026 00:38:47 +0200 Subject: [PATCH] feat(channels): add a Hidden tab; move hidden channels out of Subscriptions (R13 S2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hidden subscriptions get their own tab (mirroring Blocked): a flat list with an Unhide action, instead of dimmed rows scattered through the Subscriptions table. - ChannelsView += "hidden"; the subscribed table now EXCLUDES hidden channels, so hiding a channel (the eye button) moves it to the Hidden tab and unhiding (Eye) returns it — both via the existing patch mutation. - The "Hidden" top-level status chip is dropped (ChannelStatusFilter loses "hidden"); the status row is now purely sync-status (all/needs_full/fully_synced). - Bilingual i18n (en/hu): channels.tabs.hidden + a channels.hidden block; the now unused channels.filters.hidden removed. Hidden = out of the personal (Mine) feed only, still in the shared Library — a softer exclusion than Blocked (hidden everywhere). Gate green: tsc/eslint-0/ prettier/vitest 106. Self-E2E'd: hide → appears in Hidden tab → unhide → back, all /api 200. --- frontend/src/components/Channels.tsx | 65 +++++++++++++++----- frontend/src/components/ChannelsProvider.tsx | 9 +-- frontend/src/i18n/locales/en/channels.json | 10 ++- frontend/src/i18n/locales/hu/channels.json | 10 ++- 4 files changed, 69 insertions(+), 25 deletions(-) diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 6105c7c9..a3f0a987 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; -import { Ban, History, LayoutGrid, Pencil, RefreshCw, Table, X } from "lucide-react"; +import { Ban, Eye, EyeOff, History, LayoutGrid, Pencil, RefreshCw, Table, X } from "lucide-react"; import { api, type ManagedChannel } from "../lib/api"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { accountKey, LS } from "../lib/storage"; @@ -31,15 +31,14 @@ import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; import { useWizard } from "./WizardProvider"; import { useMe } from "../lib/useMe"; -export type ChannelsView = "subscribed" | "discovery" | "blocked"; +export type ChannelsView = "subscribed" | "discovery" | "hidden" | "blocked"; -export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden"; +export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced"; const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [ { id: "all", labelKey: "channels.filters.all" }, { id: "needs_full", labelKey: "channels.filters.needsFull" }, { id: "fully_synced", labelKey: "channels.filters.fullySynced" }, - { id: "hidden", labelKey: "channels.filters.hidden" }, ]; const LAYOUT_ICON: Record = { @@ -225,16 +224,18 @@ export default function Channels() { // The Sync-status filter stays a top-level chip set (not a column filter) because the // header's "go to full history" deep-link drives it; search/tag filtering + sort + pagination - // run over whatever the status chip leaves. - const channels = (channelsQuery.data ?? []).filter((c) => - statusFilter === "needs_full" - ? !c.backfill_done - : statusFilter === "fully_synced" - ? c.backfill_done - : statusFilter === "hidden" - ? c.hidden + // run over whatever the status chip leaves. Hidden channels have their own tab, so they're + // excluded from the subscribed table (no more dimmed rows) rather than shown via a status chip. + const hiddenChannels = (channelsQuery.data ?? []).filter((c) => c.hidden); + const channels = (channelsQuery.data ?? []) + .filter((c) => !c.hidden) + .filter((c) => + statusFilter === "needs_full" + ? !c.backfill_done + : statusFilter === "fully_synced" + ? c.backfill_done : true, - ); + ); const q = search.trim().toLowerCase(); const visibleChannels = channels.filter((c) => { if (q && !`${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q)) return false; @@ -426,7 +427,7 @@ export default function Channels() { const tabs = (
- {(["subscribed", "discovery", "blocked"] as ChannelsView[]).map((v) => ( + {(["subscribed", "discovery", "hidden", "blocked"] as ChannelsView[]).map((v) => (
+ ) : 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). */ +
+ {hiddenChannels.length === 0 ? ( +

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

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

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

+ + )} +
) : ( /* No top padding: the sticky header sits flush at the scroller top so it doesn't shift when `position: sticky` engages on the first scroll (the band's controls row above gives the diff --git a/frontend/src/components/ChannelsProvider.tsx b/frontend/src/components/ChannelsProvider.tsx index ca42088e..833b381e 100644 --- a/frontend/src/components/ChannelsProvider.tsx +++ b/frontend/src/components/ChannelsProvider.tsx @@ -27,18 +27,13 @@ import type { ChannelStatusFilter, ChannelsView } from "./Channels"; // per-account persisted (localStorage); search / focus / the reset token are ephemeral. An account // switch does a full reload, so init-from-storage is all the hydration the persisted ones need. -const STATUS_FILTERS: readonly ChannelStatusFilter[] = [ - "needs_full", - "fully_synced", - "hidden", - "all", -]; +const STATUS_FILTERS: readonly ChannelStatusFilter[] = ["needs_full", "fully_synced", "all"]; function clampStatus(v: string | null): ChannelStatusFilter { return STATUS_FILTERS.includes(v as ChannelStatusFilter) ? (v as ChannelStatusFilter) : "all"; } function clampView(v: string | null): ChannelsView { - return v === "discovery" ? "discovery" : v === "blocked" ? "blocked" : "subscribed"; + return v === "discovery" || v === "blocked" || v === "hidden" ? v : "subscribed"; } type ChannelsState = { diff --git a/frontend/src/i18n/locales/en/channels.json b/frontend/src/i18n/locales/en/channels.json index 7ec4d8ca..136abcb2 100644 --- a/frontend/src/i18n/locales/en/channels.json +++ b/frontend/src/i18n/locales/en/channels.json @@ -6,6 +6,7 @@ "tabs": { "subscribed": "Subscriptions", "discovery": "Discover from playlists", + "hidden": "Hidden", "blocked": "Blocked" }, "layoutLabel": "Layout", @@ -40,8 +41,7 @@ "filters": { "all": "All", "needsFull": "Needs full history", - "fullySynced": "Fully synced", - "hidden": "Hidden" + "fullySynced": "Fully synced" }, "tags": { "yourTags": "Your tags", @@ -139,5 +139,11 @@ "title": "Blocked channels", "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." + }, + "hidden": { + "title": "Hidden channels", + "hint": "Their videos are kept out of your personal feed, but still appear in the shared Library.", + "empty": "No hidden channels. Hide a subscription (the eye icon on its row) to keep its uploads out of your personal feed without unsubscribing.", + "unhide": "Unhide" } } diff --git a/frontend/src/i18n/locales/hu/channels.json b/frontend/src/i18n/locales/hu/channels.json index e61d737c..76e19a25 100644 --- a/frontend/src/i18n/locales/hu/channels.json +++ b/frontend/src/i18n/locales/hu/channels.json @@ -6,6 +6,7 @@ "tabs": { "subscribed": "Feliratkozások", "discovery": "Felfedezés a lejátszási listákból", + "hidden": "Elrejtett", "blocked": "Letiltott" }, "layoutLabel": "Nézet", @@ -40,8 +41,7 @@ "filters": { "all": "Mind", "needsFull": "Hiányos előzmény", - "fullySynced": "Teljesen szinkronizálva", - "hidden": "Elrejtett" + "fullySynced": "Teljesen szinkronizálva" }, "tags": { "yourTags": "Címkéid", @@ -139,5 +139,11 @@ "title": "Tiltott csatornák", "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." + }, + "hidden": { + "title": "Elrejtett csatornák", + "hint": "A videóik kimaradnak a személyes feededből, de a megosztott Library-ben továbbra is megjelennek.", + "empty": "Nincs elrejtett csatorna. Egy feliratkozást a sorában (a szem ikonnal) rejthetsz el, hogy kimaradjon a személyes feededből leiratkozás nélkül.", + "unhide": "Felfedés" } }