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