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
+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)}