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
+2 -1
View File
@@ -51,7 +51,8 @@
"noneYet": "No tags yet — create one below.",
"create": "Create tag",
"createPlaceholder": "New tag…",
"createFailed": "Could not create the tag."
"createFailed": "Could not create the tag.",
"noTag": "No tag"
},
"loading": "Loading channels…",
"empty": "No channels.",
+1
View File
@@ -3,6 +3,7 @@
"label": "Filtered by",
"remove": "Remove filter: {{name}}",
"contentNone": "No content types",
"untagged": "Untagged",
"count_one": "{{count}} filter",
"count_other": "{{count}} filters",
"moreCount_one": "{{count}} more filter",
@@ -27,6 +27,7 @@
"clearDates": "clear dates",
"reshuffle": "Reshuffle",
"noMatchingTags": "No matching tags here",
"noTagOption": "No tag",
"manageTags": "Manage",
"widget": {
"savedviews": "Saved views",
+2 -1
View File
@@ -51,7 +51,8 @@
"noneYet": "Még nincs címke — hozz létre egyet lent.",
"create": "Címke létrehozása",
"createPlaceholder": "Új címke…",
"createFailed": "A címke létrehozása nem sikerült."
"createFailed": "A címke létrehozása nem sikerült.",
"noTag": "Nincs címke"
},
"loading": "Csatornák betöltése…",
"empty": "Nincsenek csatornák.",
+1
View File
@@ -3,6 +3,7 @@
"label": "Szűrve",
"remove": "Szűrő eltávolítása: {{name}}",
"contentNone": "Nincs tartalomtípus",
"untagged": "Címkézetlen",
"count_one": "{{count}} szűrő",
"count_other": "{{count}} szűrő",
"moreCount_one": "még {{count}} szűrő",
@@ -27,6 +27,7 @@
"clearDates": "dátumok törlése",
"reshuffle": "Újrakeverés",
"noMatchingTags": "Nincs ide illő címke",
"noTagOption": "Nincs címke",
"manageTags": "Kezelés",
"widget": {
"savedviews": "Mentett nézetek",
+7 -1
View File
@@ -32,7 +32,13 @@ export type * from "./api/setup";
import { feedApi } from "./api/feed";
export type * from "./api/feed";
export type * from "./api/tagModes";
export { DEFAULT_TAG_MODES, TAG_CATEGORIES, coerceTagModes } from "./api/tagModes";
export {
DEFAULT_TAG_MODES,
TAG_CATEGORIES,
NO_TAG_SENTINEL,
coerceTagModes,
coerceUntagged,
} from "./api/tagModes";
import { channelsApi } from "./api/channels";
export type * from "./api/channels";
import { syncApi } from "./api/sync";
+4
View File
@@ -71,6 +71,9 @@ export interface FeedFilters {
// Library (scope "all") only — provenance filter: "organic" (default) hides search-
// discovered videos, "all" mixes them in, "search" shows only them. Ignored for "my".
librarySource?: "organic" | "all" | "search" | "plex";
/** "No tag" filter (item B): keep only channels the user has NOT personally tagged in the channel
* manager (no user-owned ChannelTag). Auto/system tags don't count. */
untagged?: boolean;
/** OR-ed together: a video from ANY of these passes, and every other filter still applies. */
channelIds: string[];
maxAgeDays?: number;
@@ -89,6 +92,7 @@ function filterParams(f: FeedFilters): URLSearchParams {
for (const cat of TAG_CATEGORIES) {
if (f.tagModes[cat] === "and") p.set(`tag_mode_${cat}`, "and");
}
if (f.untagged) p.set("untagged", "true");
if (f.q) p.set("q", f.q);
p.set("scope", f.scope);
p.set("show_normal", String(f.includeNormal));
+13 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { coerceTagModes, DEFAULT_TAG_MODES } from "./tagModes";
import { coerceTagModes, coerceUntagged, DEFAULT_TAG_MODES } from "./tagModes";
// coerceTagModes runs on every persisted saved-view blob (SavedViewsProvider.asFilters), on the URL
// parse, and on the localStorage seed — the single migration point for the legacy global `tagMode`.
@@ -40,3 +40,15 @@ describe("coerceTagModes", () => {
expect(coerceTagModes({ tagModes: { language: 1 as unknown as "or" } }).language).toBe("or");
});
});
// coerceUntagged guards the "No tag" (item B) flag on the same persisted/URL/saved-view paths.
describe("coerceUntagged", () => {
it("is true only for an explicit untagged:true", () => {
expect(coerceUntagged({ untagged: true })).toBe(true);
expect(coerceUntagged(undefined)).toBe(false);
expect(coerceUntagged({})).toBe(false);
expect(coerceUntagged(null)).toBe(false);
expect(coerceUntagged({ untagged: "true" })).toBe(false);
expect(coerceUntagged({ untagged: ["other"] })).toBe(false);
});
});
+10
View File
@@ -10,6 +10,11 @@ export type TagModes = Record<TagCategory, TagMode>;
export const TAG_CATEGORIES: readonly TagCategory[] = ["language", "topic", "other"];
export const DEFAULT_TAG_MODES: TagModes = { language: "or", topic: "or", other: "or" };
/** UI sentinel for the "No tag" (item B) option — the feed's user-tags combobox and the channel
* manager's tag chip row both use it to mean "channels I haven't personally tagged". Not a real tag
* id (those are numeric strings), so it can never collide with one. */
export const NO_TAG_SENTINEL = "__none__";
/** Normalise a persisted/shared filter's tag match into the per-category shape, migrating the legacy
* global `tagMode` (which only ever affected topics) into the topic slot. */
export function coerceTagModes(raw: unknown): TagModes {
@@ -22,3 +27,8 @@ export function coerceTagModes(raw: unknown): TagModes {
};
return { language: pick("language"), topic: pick("topic"), other: pick("other") };
}
/** Normalise a persisted/shared filter's "No tag" (item B) flag to a boolean. */
export function coerceUntagged(raw: unknown): boolean {
return (raw as { untagged?: unknown } | null)?.untagged === true;
}
+8
View File
@@ -140,4 +140,12 @@ describe("filterParamUpdates", () => {
const back = paramsToFilters(new URLSearchParams("tagmode=and"), DEFAULTS);
expect(back.tagModes).toEqual({ language: "or", topic: "and", other: "or" });
});
it("round-trips the 'No tag' (untagged) flag; emitted only when on", () => {
expect(filtersToParams({ ...DEFAULTS, untagged: true }).get("notag")).toBe("1");
expect(filtersToParams({ ...DEFAULTS, untagged: false }).has("notag")).toBe(false);
expect(paramsToFilters(new URLSearchParams("notag=1"), DEFAULTS).untagged).toBe(true);
// Absent → the base value is left untouched (undefined here, i.e. off).
expect(paramsToFilters(new URLSearchParams(""), DEFAULTS).untagged).toBeUndefined();
});
});
+3
View File
@@ -20,6 +20,7 @@ const KEYS = [
"tagmode_topic",
"tagmode_other",
"tagmode", // legacy global (topic-only) — still recognised so old shared links resolve
"notag",
"channel",
"maxage",
"from",
@@ -44,6 +45,7 @@ export function filtersToParams(f: FeedFilters): URLSearchParams {
for (const cat of TAG_CATEGORIES) {
if (f.tagModes[cat] === "and") p.set(`tagmode_${cat}`, "and");
}
if (f.untagged) p.set("notag", "1");
if (f.channelIds.length) p.set("channel", f.channelIds.join(","));
if (f.maxAgeDays) p.set("maxage", String(f.maxAgeDays));
if (f.dateFrom) p.set("from", f.dateFrom);
@@ -95,6 +97,7 @@ export function paramsToFilters(params: URLSearchParams, base: FeedFilters): Fee
topic: params.get("tagmode_topic") === "and" || params.get("tagmode") === "and" ? "and" : "or",
other: params.get("tagmode_other") === "and" ? "and" : "or",
};
if (params.has("notag")) f.untagged = params.get("notag") === "1";
f.channelIds = (params.get("channel") ?? "").split(",").filter(Boolean);
f.maxAgeDays = num(params.get("maxage"));
f.dateFrom = params.get("from") || undefined;
+10
View File
@@ -15,6 +15,7 @@ export interface ActiveFilter {
const DEFAULT_SIDEBAR_FILTERS: Omit<FeedFilters, "q" | "scope"> = {
tags: [],
tagModes: DEFAULT_TAG_MODES,
untagged: false,
channelIds: [],
sort: "newest",
includeNormal: true,
@@ -66,6 +67,15 @@ export function useActiveFilters(
});
}
// "No tag" (item B): channels the user hasn't personally tagged. One removable chip.
if (filters.untagged) {
out.push({
key: "untagged",
label: t("feed.chips.untagged"),
remove: () => setFilters({ ...filters, untagged: false }),
});
}
if (filters.show !== "unwatched") {
out.push({
key: "show",