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:
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user