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:
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { qk } from "../lib/queryKeys";
|
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 { api, type ManagedChannel } from "../lib/api";
|
||||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||||
import { accountKey, LS } from "../lib/storage";
|
import { accountKey, LS } from "../lib/storage";
|
||||||
@@ -31,15 +31,14 @@ import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
|||||||
import { useWizard } from "./WizardProvider";
|
import { useWizard } from "./WizardProvider";
|
||||||
import { useMe } from "../lib/useMe";
|
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 }[] = [
|
const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [
|
||||||
{ id: "all", labelKey: "channels.filters.all" },
|
{ id: "all", labelKey: "channels.filters.all" },
|
||||||
{ id: "needs_full", labelKey: "channels.filters.needsFull" },
|
{ id: "needs_full", labelKey: "channels.filters.needsFull" },
|
||||||
{ id: "fully_synced", labelKey: "channels.filters.fullySynced" },
|
{ id: "fully_synced", labelKey: "channels.filters.fullySynced" },
|
||||||
{ id: "hidden", labelKey: "channels.filters.hidden" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const LAYOUT_ICON: Record<ChannelLayout, typeof Table> = {
|
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
|
// 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
|
// header's "go to full history" deep-link drives it; search/tag filtering + sort + pagination
|
||||||
// run over whatever the status chip leaves.
|
// run over whatever the status chip leaves. Hidden channels have their own tab, so they're
|
||||||
const channels = (channelsQuery.data ?? []).filter((c) =>
|
// excluded from the subscribed table (no more dimmed rows) rather than shown via a status chip.
|
||||||
statusFilter === "needs_full"
|
const hiddenChannels = (channelsQuery.data ?? []).filter((c) => c.hidden);
|
||||||
? !c.backfill_done
|
const channels = (channelsQuery.data ?? [])
|
||||||
: statusFilter === "fully_synced"
|
.filter((c) => !c.hidden)
|
||||||
? c.backfill_done
|
.filter((c) =>
|
||||||
: statusFilter === "hidden"
|
statusFilter === "needs_full"
|
||||||
? c.hidden
|
? !c.backfill_done
|
||||||
|
: statusFilter === "fully_synced"
|
||||||
|
? c.backfill_done
|
||||||
: true,
|
: true,
|
||||||
);
|
);
|
||||||
const q = search.trim().toLowerCase();
|
const q = search.trim().toLowerCase();
|
||||||
const visibleChannels = channels.filter((c) => {
|
const visibleChannels = channels.filter((c) => {
|
||||||
if (q && !`${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q)) return false;
|
if (q && !`${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q)) return false;
|
||||||
@@ -426,7 +427,7 @@ export default function Channels() {
|
|||||||
const tabs = (
|
const tabs = (
|
||||||
<div className="px-4 pt-4 max-w-384 mx-auto flex items-center justify-between gap-4 flex-wrap">
|
<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">
|
<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
|
<button
|
||||||
key={v}
|
key={v}
|
||||||
onClick={() => setView(v)}
|
onClick={() => setView(v)}
|
||||||
@@ -553,6 +554,42 @@ export default function Channels() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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
|
/* 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
|
`position: sticky` engages on the first scroll (the band's controls row above gives the
|
||||||
|
|||||||
@@ -27,18 +27,13 @@ import type { ChannelStatusFilter, ChannelsView } from "./Channels";
|
|||||||
// per-account persisted (localStorage); search / focus / the reset token are ephemeral. An account
|
// 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.
|
// switch does a full reload, so init-from-storage is all the hydration the persisted ones need.
|
||||||
|
|
||||||
const STATUS_FILTERS: readonly ChannelStatusFilter[] = [
|
const STATUS_FILTERS: readonly ChannelStatusFilter[] = ["needs_full", "fully_synced", "all"];
|
||||||
"needs_full",
|
|
||||||
"fully_synced",
|
|
||||||
"hidden",
|
|
||||||
"all",
|
|
||||||
];
|
|
||||||
|
|
||||||
function clampStatus(v: string | null): ChannelStatusFilter {
|
function clampStatus(v: string | null): ChannelStatusFilter {
|
||||||
return STATUS_FILTERS.includes(v as ChannelStatusFilter) ? (v as ChannelStatusFilter) : "all";
|
return STATUS_FILTERS.includes(v as ChannelStatusFilter) ? (v as ChannelStatusFilter) : "all";
|
||||||
}
|
}
|
||||||
function clampView(v: string | null): ChannelsView {
|
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 = {
|
type ChannelsState = {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"subscribed": "Subscriptions",
|
"subscribed": "Subscriptions",
|
||||||
"discovery": "Discover from playlists",
|
"discovery": "Discover from playlists",
|
||||||
|
"hidden": "Hidden",
|
||||||
"blocked": "Blocked"
|
"blocked": "Blocked"
|
||||||
},
|
},
|
||||||
"layoutLabel": "Layout",
|
"layoutLabel": "Layout",
|
||||||
@@ -40,8 +41,7 @@
|
|||||||
"filters": {
|
"filters": {
|
||||||
"all": "All",
|
"all": "All",
|
||||||
"needsFull": "Needs full history",
|
"needsFull": "Needs full history",
|
||||||
"fullySynced": "Fully synced",
|
"fullySynced": "Fully synced"
|
||||||
"hidden": "Hidden"
|
|
||||||
},
|
},
|
||||||
"tags": {
|
"tags": {
|
||||||
"yourTags": "Your tags",
|
"yourTags": "Your tags",
|
||||||
@@ -139,5 +139,11 @@
|
|||||||
"title": "Blocked channels",
|
"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."
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"subscribed": "Feliratkozások",
|
"subscribed": "Feliratkozások",
|
||||||
"discovery": "Felfedezés a lejátszási listákból",
|
"discovery": "Felfedezés a lejátszási listákból",
|
||||||
|
"hidden": "Elrejtett",
|
||||||
"blocked": "Letiltott"
|
"blocked": "Letiltott"
|
||||||
},
|
},
|
||||||
"layoutLabel": "Nézet",
|
"layoutLabel": "Nézet",
|
||||||
@@ -40,8 +41,7 @@
|
|||||||
"filters": {
|
"filters": {
|
||||||
"all": "Mind",
|
"all": "Mind",
|
||||||
"needsFull": "Hiányos előzmény",
|
"needsFull": "Hiányos előzmény",
|
||||||
"fullySynced": "Teljesen szinkronizálva",
|
"fullySynced": "Teljesen szinkronizálva"
|
||||||
"hidden": "Elrejtett"
|
|
||||||
},
|
},
|
||||||
"tags": {
|
"tags": {
|
||||||
"yourTags": "Címkéid",
|
"yourTags": "Címkéid",
|
||||||
@@ -139,5 +139,11 @@
|
|||||||
"title": "Tiltott csatornák",
|
"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."
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user