feat(feed): per-category any/all tag match, in each combobox's popover footer

The tag 'Match: Any/All' toggle was a single GLOBAL setting that only ever
affected topics, shown as its own row under the Topic widget. Make it
per-category — language, topic, and 'your tags' each get their own independent
any/all — and move it into each filter combobox's popover footer (like
SortControl's direction segment), reclaiming the standalone Match row.

Backend: the feed query already groups selected tags by category; replace the
single tag_mode with per-category tag_mode_language/topic/other params and apply
the conjunctive (HAVING all) branch per category. Mirrored across filtered_query,
feed_summary.passes(), and the /facets drill-down exclusion (kept in lockstep;
equivalence test updated to tag_mode_topic). No schema change.

Frontend: FeedFilters.tagMode -> tagModes {language,topic,other} (pure leaf
lib/api/tagModes with a coerceTagModes migration for the legacy global value);
serialized to the API and the shareable URL per category (legacy ?tagmode=and
still resolves into the topic slot); saved-view blobs migrate on read. Combobox
gains an optional matchMode/onMatchModeChange footer segment, shown once 2+
values are picked; the three tag comboboxes wire it to their category.

Verified: backend feed tests green (12, incl. the count/facets equivalence net);
live, the footer appears at 2+ selections, toggles aria-pressed, serializes
tagmode_topic=and to the URL, and the request carries tag_mode_topic=and (200).
374 vitest, tsc, ruff, eslint (0 err).
This commit is contained in:
2026-08-22 03:48:23 +02:00
parent 8f08073564
commit 829cadf5fb
15 changed files with 139 additions and 53 deletions
+2 -2
View File
@@ -27,7 +27,7 @@ import { useFeedFilters } from "./FeedFiltersProvider";
import { useConfirm } from "./ConfirmProvider";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { api, type FeedFilters, type Me, type ChannelDetail } from "../lib/api";
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Me, type ChannelDetail } from "../lib/api";
import type { FeedView } from "../lib/feedView";
import { channelYouTubeUrl, formatViews } from "../lib/format";
import { ChannelTagsControl } from "./ChannelTags";
@@ -231,7 +231,7 @@ export default function ChannelPage({
// the explored, per-user-private videos show — the backend gates them to this explorer).
const [filters, setFilters] = useState<FeedFilters>(() => ({
tags: [],
tagMode: "or",
tagModes: DEFAULT_TAG_MODES,
q: "",
sort: "newest",
scope: "all",
+33
View File
@@ -55,6 +55,11 @@ export interface ComboboxProps {
/** Chip/label text for a selected value whose option is not in the current (e.g. server-narrowed)
* list — the collection picker passes the stored title here so a reloaded filter still names itself. */
selectedLabels?: Record<string, string>;
/** Optional "any/all" match segment in the popover footer (like SortControl's direction segment):
* "or" = a match on ANY selected value, "and" = must match ALL. Provide BOTH to show it; it appears
* only once 2+ values are selected (below that, any and all are the same). */
matchMode?: "or" | "and";
onMatchModeChange?: (mode: "or" | "and") => void;
}
export default function Combobox({
@@ -73,6 +78,8 @@ export default function Combobox({
maxVisible = 50,
renderOption,
selectedLabels,
matchMode,
onMatchModeChange,
}: ComboboxProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
@@ -282,6 +289,32 @@ export default function Combobox({
</>
)}
</div>
{/* Any/all match segment (like SortControl's direction footer) — only meaningful, so only shown,
once 2+ values are picked. mousedown-preventDefault keeps the input focused so the list stays
open in the combobox's focus model. */}
{matchMode != null && onMatchModeChange && selected.length >= 2 && (
<div className="mt-1 flex items-center gap-2 border-t border-border px-1 pt-2">
<span className="text-2xs text-muted">{t("sidebar.match")}</span>
<div className="ml-auto flex gap-1">
{(["or", "and"] as const).map((m) => (
<button
key={m}
type="button"
aria-pressed={matchMode === m}
onMouseDown={(e) => e.preventDefault()}
onClick={() => onMatchModeChange(m)}
className={`rounded-md border px-2 py-0.5 text-2xs transition ${
matchMode === m
? "border-accent bg-accent/15 text-accent"
: "border-border text-muted hover:border-accent/50 hover:text-fg"
}`}
>
{t(m === "or" ? "sidebar.any" : "sidebar.all")}
</button>
))}
</div>
</div>
)}
</PopoverSurface>
</div>
);
@@ -9,7 +9,7 @@ import {
} from "react";
import { qk } from "../lib/queryKeys";
import { useQuery } from "@tanstack/react-query";
import { api, getActiveAccount, type FeedFilters } from "../lib/api";
import { api, getActiveAccount, DEFAULT_TAG_MODES, type FeedFilters } from "../lib/api";
import { filterParamUpdates, hasFilterParams, paramsToFilters } from "../lib/urlState";
import { useUrlMirror } from "../lib/useUrlMirror";
import { accountKey, LS, readJSON, readMerged, writeJSON } from "../lib/storage";
@@ -32,7 +32,7 @@ import { useNavigation } from "./NavigationProvider";
const DEFAULT_FILTERS: FeedFilters = {
tags: [],
tagMode: "or",
tagModes: DEFAULT_TAG_MODES,
channelIds: [],
q: "",
sort: "newest",
+9 -1
View File
@@ -18,7 +18,13 @@ import {
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { api, getActiveAccount, type FeedFilters, type SavedView } from "../lib/api";
import {
api,
getActiveAccount,
coerceTagModes,
type FeedFilters,
type SavedView,
} from "../lib/api";
import { filtersToParams } from "../lib/urlState";
import { notifyActionError } from "../lib/actionErrors";
import { accountKey, LS, writeJSON } from "../lib/storage";
@@ -37,6 +43,8 @@ const asFilters = (raw: FeedFilters): FeedFilters => ({
...raw,
tags: raw.tags ?? [],
channelIds: raw.channelIds ?? [],
// Migrate the legacy global `tagMode` (older blobs) into the per-category shape.
tagModes: coerceTagModes(raw),
});
function syncDefaultMirror(views: SavedView[]): void {
+17 -22
View File
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { Pencil, SlidersHorizontal } from "lucide-react";
import { api, type FeedFilters, type Tag } from "../lib/api";
import { api, type FeedFilters, type Tag, type TagCategory } from "../lib/api";
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
import { useDebounced } from "../lib/useDebounced";
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
@@ -14,7 +14,6 @@ import FilterRail from "./FilterRail";
import { type PanelItem } from "./PanelGroups";
import { type MediaSourceKind } from "../lib/mediaSource";
import ChannelPicker from "./ChannelPicker";
import SegmentedToggle from "./SegmentedToggle";
import FilterChipGroup from "./FilterChipGroup";
import Combobox, { type ComboboxOption } from "./Combobox";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
@@ -97,6 +96,9 @@ export default function Sidebar({
tags: [...filters.tags.filter((id) => !inList.has(id)), ...next.map(Number)],
});
}
// Each tag category (language/topic/other) has its OWN any/all match, set from its combobox footer.
const setTagMode = (category: TagCategory, mode: "or" | "and") =>
setFilters({ ...filters, tagModes: { ...filters.tagModes, [category]: mode } });
// The badge counts exactly what the chip row above the feed shows — same list, so they can't
// disagree about what's applied.
@@ -200,31 +202,22 @@ export default function Sidebar({
selected={selectedIn(languages)}
onChange={(next) => setTagSelection(languages, next)}
emptyText={t("sidebar.noMatchingTags")}
matchMode={filters.tagModes.language}
onMatchModeChange={(m) => setTagMode("language", m)}
/>
);
}
case "topic": {
return (
<div className="flex flex-col gap-2">
<SegmentedToggle
label={t("sidebar.match")}
title={t("sidebar.tagModeTooltip")}
ariaLabel={t("sidebar.match")}
options={[
{ value: "or", label: t("sidebar.any") },
{ value: "and", label: t("sidebar.all") },
]}
value={filters.tagMode}
onChange={(tagMode) => setFilters({ ...filters, tagMode })}
/>
<Combobox
label={t("sidebar.widget.topic")}
options={visibleChips(topics).map(tagOption)}
selected={selectedIn(topics)}
onChange={(next) => setTagSelection(topics, next)}
emptyText={t("sidebar.noMatchingTags")}
/>
</div>
<Combobox
label={t("sidebar.widget.topic")}
options={visibleChips(topics).map(tagOption)}
selected={selectedIn(topics)}
onChange={(next) => setTagSelection(topics, next)}
emptyText={t("sidebar.noMatchingTags")}
matchMode={filters.tagModes.topic}
onMatchModeChange={(m) => setTagMode("topic", m)}
/>
);
}
case "tags": {
@@ -236,6 +229,8 @@ export default function Sidebar({
selected={selectedIn(userTags)}
onChange={(next) => setTagSelection(userTags, next)}
emptyText={t("sidebar.noMatchingTags")}
matchMode={filters.tagModes.other}
onMatchModeChange={(m) => setTagMode("other", m)}
/>
<button
onClick={() => setTagManagerOpen(true)}
+2
View File
@@ -31,6 +31,8 @@ import { setupApi } from "./api/setup";
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";
import { channelsApi } from "./api/channels";
export type * from "./api/channels";
import { syncApi } from "./api/sync";
+7 -2
View File
@@ -2,6 +2,7 @@
// the shared FeedFilters into the query string the feed + facet endpoints both consume; they stay
// module-local (no consumer names them). Only touches core's req/beacon.
import { req, beacon } from "./core";
import { TAG_CATEGORIES, type TagModes } from "./tagModes";
// Per-user watch status stored server-side (VALID_STATES in routes/feed.py). "in_progress" and
// "unwatched" are NOT statuses — they're derived feed filters (status "new" + a resume position),
@@ -54,7 +55,8 @@ export interface FeedResponse {
export interface FeedFilters {
tags: number[];
tagMode: "or" | "and";
/** Per-category tag match (language / topic / other each independently any/all). */
tagModes: TagModes;
q: string;
sort: string;
// Re-roll token for the "shuffle" sort; bumped by the reshuffle button so the
@@ -83,7 +85,10 @@ export interface FeedFilters {
function filterParams(f: FeedFilters): URLSearchParams {
const p = new URLSearchParams();
f.tags.forEach((t) => p.append("tags", String(t)));
p.set("tag_mode", f.tagMode);
// Only emit the categories set to "and"; the backend defaults each to "or".
for (const cat of TAG_CATEGORIES) {
if (f.tagModes[cat] === "and") p.set(`tag_mode_${cat}`, "and");
}
if (f.q) p.set("q", f.q);
p.set("scope", f.scope);
p.set("show_normal", String(f.includeNormal));
+24
View File
@@ -0,0 +1,24 @@
// Per-category tag match — the small, dependency-free vocabulary shared by the feed filter model, the
// URL (de)serializer, the sidebar comboboxes, and the saved-view migration. Kept in its own leaf (no
// imports) so node-env tests and any module can pull it in without dragging the HTTP/i18n machinery.
/** A tag category's match: "or" = a channel with ANY selected tag passes, "and" = it must carry ALL. */
export type TagMode = "or" | "and";
/** The three tag categories, each with its OWN independent match mode. */
export type TagCategory = "language" | "topic" | "other";
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" };
/** 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 {
const r = (raw ?? {}) as { tagModes?: Partial<TagModes>; tagMode?: unknown };
const legacyTopic = r.tagMode === "and" ? "and" : undefined;
const pick = (cat: TagCategory): TagMode => {
const v = r.tagModes?.[cat];
if (v === "and" || v === "or") return v;
return cat === "topic" && legacyTopic ? "and" : "or";
};
return { language: pick("language"), topic: pick("topic"), other: pick("other") };
}
+1 -1
View File
@@ -12,7 +12,7 @@ const PRESETS: DatePreset[] = [
const base = (over: Partial<FeedFilters> = {}): FeedFilters => ({
tags: [],
tagMode: "or",
tagModes: { language: "or", topic: "or", other: "or" },
q: "",
sort: "date",
scope: "my",
+2 -2
View File
@@ -12,7 +12,7 @@ import {
filtersToParams,
paramsToFilters,
} from "./urlState";
import type { FeedFilters } from "./api";
import { DEFAULT_TAG_MODES, type FeedFilters } from "./api";
function setLocation(search: string) {
window.history.replaceState(null, "", `/app${search}`);
@@ -23,7 +23,7 @@ function queryOf(url: string): URLSearchParams {
const DEFAULTS: FeedFilters = {
tags: [],
tagMode: "or",
tagModes: DEFAULT_TAG_MODES,
channelIds: [],
q: "",
sort: "newest",
+14 -4
View File
@@ -4,7 +4,7 @@
// bookmarkable/shareable — the old "Share view" workaround (and the whole-query stripUrlParams) is
// retired. Only non-default values are emitted so URLs stay clean. NOTE: the browser TAB title stays
// the short module/channel name (NavigationProvider owns document.title) — the query never leaks into it.
import type { FeedFilters } from "./api";
import { TAG_CATEGORIES, type FeedFilters } from "./api";
const KEYS = [
"q",
@@ -16,7 +16,10 @@ const KEYS = [
"shorts",
"live",
"tags",
"tagmode",
"tagmode_language",
"tagmode_topic",
"tagmode_other",
"tagmode", // legacy global (topic-only) — still recognised so old shared links resolve
"channel",
"maxage",
"from",
@@ -38,7 +41,9 @@ export function filtersToParams(f: FeedFilters): URLSearchParams {
if (f.includeShorts) p.set("shorts", "1");
if (f.includeLive) p.set("live", "1");
if (f.tags.length) p.set("tags", f.tags.join(","));
if (f.tagMode === "and") p.set("tagmode", "and");
for (const cat of TAG_CATEGORIES) {
if (f.tagModes[cat] === "and") p.set(`tagmode_${cat}`, "and");
}
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);
@@ -84,7 +89,12 @@ export function paramsToFilters(params: URLSearchParams, base: FeedFilters): Fee
.split(",")
.map((s) => Number(s))
.filter((n) => Number.isInteger(n));
if (params.has("tagmode")) f.tagMode = params.get("tagmode") === "and" ? "and" : "or";
// Per-category tag match; a legacy global `tagmode=and` (topic-only) migrates into the topic slot.
f.tagModes = {
language: params.get("tagmode_language") === "and" ? "and" : "or",
topic: params.get("tagmode_topic") === "and" || params.get("tagmode") === "and" ? "and" : "or",
other: params.get("tagmode_other") === "and" ? "and" : "or",
};
f.channelIds = (params.get("channel") ?? "").split(",").filter(Boolean);
f.maxAgeDays = num(params.get("maxage"));
f.dateFrom = params.get("from") || undefined;
+2 -2
View File
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { qk } from "./queryKeys";
import { api, type FeedFilters } from "./api";
import { api, DEFAULT_TAG_MODES, type FeedFilters } from "./api";
import { parseSort } from "./feedSort";
/** One narrowing the user has applied, with the label to show and the way to undo just this one. */
@@ -14,7 +14,7 @@ export interface ActiveFilter {
/** What the feed looks like with nothing applied — the baseline every "active" check reads against. */
const DEFAULT_SIDEBAR_FILTERS: Omit<FeedFilters, "q" | "scope"> = {
tags: [],
tagMode: "or",
tagModes: DEFAULT_TAG_MODES,
channelIds: [],
sort: "newest",
includeNormal: true,