feat(feed,channels): "No tag" filter for untagged channels (E8·S1)

Filter to channels you haven't personally tagged in the channel manager
(no user-owned ChannelTag; auto language/topic tags don't count):

- Feed: a "No tag" sentinel in the "Your tags" sidebar combobox (exclusive
  with real tag picks) + a removable "Untagged" chip; new `untagged` bool
  feed param / `notag` URL key, persisted + coerced on every filter path.
- Channel manager: a virtual "No tag" chip in the tag-chip row filters the
  table to untagged channels (client-side, shared NO_TAG_SENTINEL).

Backend `untagged` is a plain channel-level narrowing, so feed_summary keeps
it applied in the base pass — the count/facet lockstep holds with no mirror.

Note: item E (per-tag counts) and item D (Any/All toggle) were already
shipped; S1 delivers only item B.
This commit is contained in:
2026-08-25 01:44:34 +02:00
parent 5d7be687d3
commit 4ee0dab68f
22 changed files with 223 additions and 34 deletions
+16 -2
View File
@@ -4,7 +4,7 @@ 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 { api, NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { accountKey, LS } from "../lib/storage";
import { buildChannelColumns, applySyncFilter } from "./channelColumns";
@@ -217,11 +217,18 @@ export default function Channels() {
// funnel share ONE state and stay in sync (bidirectional). Toggling a chip rewrites `filters.tags`;
// filterRows / DataTable then applies it. Channel-name search stays separate (the shared top box).
const tagFilter = Array.isArray(filters.tags) ? filters.tags : [];
// Picking a real tag drops the "No tag" sentinel (item B) — they're mutually exclusive, as in the feed.
const toggleTagFilter = (tagId: number) => {
const id = String(tagId);
const next = tagFilter.includes(id) ? tagFilter.filter((x) => x !== id) : [...tagFilter, id];
const base = tagFilter.filter((x) => x !== NO_TAG_SENTINEL);
const next = base.includes(id) ? base.filter((x) => x !== id) : [...base, id];
setFilters({ ...filters, tags: next });
};
// The "No tag" chip: filter the table to channels you haven't personally tagged yet. Exclusive with
// real tag chips (clicking it drops them); the Tags column's test reads the sentinel (channelColumns).
const noTagOn = tagFilter.includes(NO_TAG_SENTINEL);
const toggleNoTagFilter = () =>
setFilters({ ...filters, tags: noTagOn ? [] : [NO_TAG_SENTINEL] });
// A focus-channel intent (header "without full history" / tag manager) seeds the search box.
useEffect(() => {
if (focusChannelName) setSearch(focusChannelName);
@@ -451,6 +458,13 @@ export default function Channels() {
{tg.name}
</button>
))}
<button
onClick={toggleNoTagFilter}
aria-pressed={noTagOn}
className={filterChipClass}
>
{t("channels.tags.noTag")}
</button>
{tagFilter.length > 0 && (
<button
onClick={() => setFilters({ ...filters, tags: [] })}