feat(channels): add a Hidden tab; move hidden channels out of Subscriptions (R13 S2)

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.
This commit is contained in:
2026-08-14 00:38:47 +02:00
parent ccd7170a20
commit 6062007947
4 changed files with 69 additions and 25 deletions
+51 -14
View File
@@ -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<ChannelLayout, typeof Table> = {
@@ -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 = (
<div className="px-4 pt-4 max-w-384 mx-auto flex items-center justify-between gap-4 flex-wrap">
<div className="flex flex-wrap items-center gap-1.5">
{(["subscribed", "discovery", "blocked"] as ChannelsView[]).map((v) => (
{(["subscribed", "discovery", "hidden", "blocked"] as ChannelsView[]).map((v) => (
<button
key={v}
onClick={() => setView(v)}
@@ -553,6 +554,42 @@ export default function Channels() {
</>
)}
</div>
) : 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). */
<div className="px-4 pb-4 pt-3 max-w-384 mx-auto">
{hiddenChannels.length === 0 ? (
<p className="text-sm text-muted py-8">{t("channels.hidden.empty")}</p>
) : (
<>
<div className="flex items-center gap-2 mb-3">
<EyeOff className="w-4 h-4 text-muted" />
<span className="text-xs uppercase tracking-wide text-muted">
{t("channels.hidden.title")}
</span>
</div>
<div className="flex flex-wrap gap-1.5">
{hiddenChannels.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={() => 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"
>
<Eye className="w-3 h-3" />
</button>
</span>
))}
</div>
<p className="text-2xs text-muted mt-2">{t("channels.hidden.hint")}</p>
</>
)}
</div>
) : (
/* 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