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: [] })}
@@ -13,6 +13,7 @@ import {
api,
getActiveAccount,
coerceTagModes,
coerceUntagged,
DEFAULT_TAG_MODES,
type FeedFilters,
} from "../lib/api";
@@ -39,6 +40,7 @@ import { useNavigation } from "./NavigationProvider";
const DEFAULT_FILTERS: FeedFilters = {
tags: [],
tagModes: DEFAULT_TAG_MODES,
untagged: false,
channelIds: [],
q: "",
sort: "newest",
@@ -74,7 +76,12 @@ export function loadAccountFilters(id: number | null): FeedFilters {
// (all "or"), which coerceTagModes prefers over the legacy scalar and would mask it.
if (hasStored && fKey) {
const raw = readJSON<Partial<FeedFilters>>(fKey, {});
return { ...DEFAULT_FILTERS, ...raw, tagModes: coerceTagModes(raw) };
return {
...DEFAULT_FILTERS,
...raw,
tagModes: coerceTagModes(raw),
untagged: coerceUntagged(raw),
};
}
return { ...DEFAULT_FILTERS };
}
@@ -14,6 +14,7 @@ import {
api,
coercePlexFilters,
coerceTagModes,
coerceUntagged,
getActiveAccount,
plexFiltersSignature,
type FeedFilters,
@@ -42,6 +43,7 @@ export const asFilters = (raw: FeedFilters): FeedFilters => ({
tags: raw.tags ?? [],
channelIds: raw.channelIds ?? [],
tagModes: coerceTagModes(raw),
untagged: coerceUntagged(raw),
});
type ViewSource = "feed" | "plex";
+41 -19
View File
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { SlidersHorizontal } from "lucide-react";
import { api, type FeedFilters, type Tag, type TagCategory } from "../lib/api";
import { api, NO_TAG_SENTINEL, type FeedFilters, type Tag, type TagCategory } from "../lib/api";
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
import { useDebounced } from "../lib/useDebounced";
import { useActiveFilters } from "../lib/useActiveFilters";
@@ -104,6 +104,41 @@ export default function Sidebar({
const setTagMode = (category: TagCategory, mode: "or" | "and") =>
setFilters({ ...filters, tagModes: { ...filters.tagModes, [category]: mode } });
// The "Your tags" combobox carries the "No tag" sentinel (item B) at the top: selecting it filters to
// channels you HAVEN'T personally tagged in the channel manager (`filters.untagged`). It's mutually
// exclusive with picking real user tags — choosing it clears your tag picks, and picking a tag clears
// it. Language/Topic are auto/system tags, so "No tag" is meaningless there and only lives here.
function userTagsCombo(): ReactNode {
const noneOn = !!filters.untagged;
const inList = (id: number) => userTags.some((tg) => tg.id === id);
const onChange = (next: string[]) => {
if (next.includes(NO_TAG_SENTINEL) && !noneOn) {
setFilters({ ...filters, tags: filters.tags.filter((id) => !inList(id)), untagged: true });
} else {
const reals = next.filter((v) => v !== NO_TAG_SENTINEL).map(Number);
setFilters({
...filters,
tags: [...filters.tags.filter((id) => !inList(id)), ...reals],
untagged: false,
});
}
};
return (
<Combobox
label={t("sidebar.widget.tags")}
options={[
{ value: NO_TAG_SENTINEL, label: t("sidebar.noTagOption") },
...visibleChips(userTags).map(tagOption),
]}
selected={noneOn ? [NO_TAG_SENTINEL] : selectedIn(userTags)}
onChange={onChange}
emptyText={t("sidebar.noMatchingTags")}
matchMode={filters.tagModes.other}
onMatchModeChange={(m) => setTagMode("other", m)}
/>
);
}
// The badge counts exactly what the chip row above the feed shows — same list, so they can't
// disagree about what's applied.
const activeCount = useActiveFilters(filters, setFilters).length;
@@ -124,7 +159,7 @@ export default function Sidebar({
return (
<DateFilterSelect filters={filters} setFilters={setFilters} presets={DATE_PRESETS} />
);
case "language": {
case "language":
return (
<Combobox
label={t("sidebar.widget.language")}
@@ -136,8 +171,7 @@ export default function Sidebar({
onMatchModeChange={(m) => setTagMode("language", m)}
/>
);
}
case "topic": {
case "topic":
return (
<Combobox
label={t("sidebar.widget.topic")}
@@ -149,22 +183,10 @@ export default function Sidebar({
onMatchModeChange={(m) => setTagMode("topic", m)}
/>
);
}
case "tags": {
case "tags":
// The widget's own "Manage" trigger is its title (see the PanelItem onTitleClick below), so the
// body is just the picker.
return (
<Combobox
label={t("sidebar.widget.tags")}
options={visibleChips(userTags).map(tagOption)}
selected={selectedIn(userTags)}
onChange={(next) => setTagSelection(userTags, next)}
emptyText={t("sidebar.noMatchingTags")}
matchMode={filters.tagModes.other}
onMatchModeChange={(m) => setTagMode("other", m)}
/>
);
}
// body is just the picker — user tags plus the "No tag" sentinel.
return userTagsCombo();
default:
return null;
}
@@ -5,8 +5,8 @@
// "fully" | "needsFull" | "" vocab + predicate — asserted here so a regression can't silently change
// which channels the top chips show.
import { describe, it, expect } from "vitest";
import { applySyncFilter } from "./channelColumns";
import { type ManagedChannel } from "../lib/api";
import { applySyncFilter, tagsColumnTest } from "./channelColumns";
import { NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
const ch = (over: Partial<ManagedChannel>): ManagedChannel => ({
id: "c",
@@ -51,3 +51,23 @@ describe("applySyncFilter", () => {
expect(applySyncFilter(rows, "needsFull").map((r) => r.id)).toEqual(["recentOnly", "none"]);
});
});
describe("tagsColumnTest (item B — 'No tag')", () => {
const tagged = ch({ id: "tagged", tag_ids: [1, 2] });
const untagged = ch({ id: "untagged", tag_ids: [] });
it("the No-tag sentinel keeps only channels with no user tag", () => {
expect(tagsColumnTest(tagged, [NO_TAG_SENTINEL])).toBe(false);
expect(tagsColumnTest(untagged, [NO_TAG_SENTINEL])).toBe(true);
});
it("real tag values match ANY selected id (sentinel absent)", () => {
expect(tagsColumnTest(tagged, ["2"])).toBe(true);
expect(tagsColumnTest(tagged, ["3"])).toBe(false);
expect(tagsColumnTest(untagged, ["1"])).toBe(false);
});
it("the sentinel wins if it somehow co-exists with real ids", () => {
expect(tagsColumnTest(tagged, [NO_TAG_SENTINEL, "1"])).toBe(false);
});
});
+10 -2
View File
@@ -2,7 +2,7 @@ import type { ComponentProps } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Check, Eye, EyeOff, History, UserMinus } from "lucide-react";
import type { ManagedChannel } from "../lib/api";
import { NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
import { formatCountOrDash, formatTotalHours, relativeTime } from "../lib/format";
import type { Column } from "./DataTable";
import Tooltip from "./Tooltip";
@@ -42,6 +42,14 @@ export function applySyncFilter(rows: ManagedChannel[], value: unknown): Managed
return rows;
}
/** The Tags column filter predicate (item B): the "No tag" sentinel keeps channels the user hasn't
* personally tagged (empty `tag_ids`); it's exclusive with real tag picks (the chip row enforces that),
* so when present it wins. Otherwise a channel passes if it carries ANY selected tag id. */
export function tagsColumnTest(c: ManagedChannel, values: string[]): boolean {
if (values.includes(NO_TAG_SENTINEL)) return c.tag_ids.length === 0;
return values.some((v) => c.tag_ids.includes(Number(v)));
}
// The Stored count, with a "fully synced" ✓ charm to the left of the number when recent uploads and
// full history are both in — folding the retired Sync column's at-a-glance status into the value it
// describes. Full detail stays in the hover tooltip.
@@ -289,7 +297,7 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
filter: {
kind: "multi",
options: userTags.map((tg) => ({ value: String(tg.id), label: tg.name })),
test: (c, values) => values.some((v) => c.tag_ids.includes(Number(v))),
test: tagsColumnTest,
},
render: (c) => (
<ChannelTagsControl