diff --git a/backend/app/feedquery.py b/backend/app/feedquery.py
index 2703583e..e3bb5254 100644
--- a/backend/app/feedquery.py
+++ b/backend/app/feedquery.py
@@ -117,7 +117,7 @@ def filtered_query(
user: User,
*,
tags: list[int],
- tag_mode: str,
+ tag_modes: dict[str, str],
channel_ids: list[str],
q: str | None,
min_duration: int | None,
@@ -293,9 +293,10 @@ def filtered_query(
query = query.where(or_(func.unaccent(Video.title).ilike(like), channel_match))
if tags:
- # AND across tag categories (e.g. language AND topic narrows), OR within a category; the any/all
- # toggle controls multiple topic tags. ⚠️ feed_summary.passes() reimplements this exact rule in
- # Python for the sidebar count/facets — keep the two in lockstep (equivalence-tested by
+ # AND across tag categories (e.g. language AND topic narrows), OR within a category — unless that
+ # category's mode is "and" (its per-category any/all toggle), which requires a channel to carry
+ # ALL its selected tags. ⚠️ feed_summary.passes() reimplements this exact rule in Python for the
+ # sidebar count/facets — keep the two in lockstep (equivalence-tested by
# test_feed_summary_matches_count_plus_facets).
by_category = tags_by_category(db, tags)
# Facet counting drops the category being counted so its own chips don't zero each
@@ -305,7 +306,7 @@ def filtered_query(
by_category.pop(exclude_tag_category, None)
visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
for category, ids in by_category.items():
- if category == "topic" and tag_mode == "and" and len(ids) > 1:
+ if tag_modes.get(category, "or") == "and" and len(ids) > 1:
sub = (
select(ChannelTag.channel_id)
.where(ChannelTag.tag_id.in_(ids), visible)
@@ -363,8 +364,8 @@ def feed_summary(db: Session, user: User, **params) -> dict:
(the tag's own category dropped, unless it's a conjunctive topic-AND — mirroring the
old /facets exclude_tag_category logic exactly).
Tag matching is channel-based, identical to filtered_query's tag block (AND across categories, OR
- within, topic-AND requires ALL selected topics)."""
- tag_mode = params.get("tag_mode", "or")
+ within, a category in "and" mode requires ALL its selected tags)."""
+ tag_modes = params.get("tag_modes", {})
# ONE pass: per-channel video counts for the base filter, tags stripped so every candidate channel
# is present to facet over.
@@ -401,8 +402,8 @@ def feed_summary(db: Session, user: User, **params) -> dict:
for cat, sel in selected_by_cat.items():
if cat == exclude_cat:
continue
- if cat == "topic" and tag_mode == "and" and len(sel) > 1:
- if not sel <= ch_tags: # conjunctive: channel must carry ALL selected topics
+ if tag_modes.get(cat, "or") == "and" and len(sel) > 1:
+ if not sel <= ch_tags: # conjunctive: channel must carry ALL this category's selected tags
return False
elif not (sel & ch_tags): # disjunctive: channel must carry ANY selected tag
return False
@@ -416,7 +417,7 @@ def feed_summary(db: Session, user: User, **params) -> dict:
for category in ("language", "topic", "other"):
# Drop this category's own selections so its chips keep independent counts; a conjunctive
# topic-AND keeps them (each remaining chip narrows to channels that ALSO have the selected).
- exclude = None if (category == "topic" and tag_mode == "and") else category
+ exclude = None if tag_modes.get(category, "or") == "and" else category
for cid, ch_tags in channel_tags.items():
if not passes(ch_tags, exclude):
continue
diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py
index c3ec31d4..75676c18 100644
--- a/backend/app/routes/feed.py
+++ b/backend/app/routes/feed.py
@@ -48,7 +48,11 @@ FINISH_MARGIN_SECONDS = 10
# Shared query parameters for /feed and /feed/count.
def _feed_params(
tags: list[int] = Query(default=[]),
- tag_mode: str = "or",
+ # Per-category tag match: each of language / topic / other independently ORs (any) or ANDs (all)
+ # its own selected tags. Defaults to "or". (Superseded the single global `tag_mode`.)
+ tag_mode_language: str = "or",
+ tag_mode_topic: str = "or",
+ tag_mode_other: str = "or",
channel_ids: list[str] = Query(default=[]),
q: str | None = None,
min_duration: int | None = None,
@@ -65,7 +69,11 @@ def _feed_params(
) -> dict:
return {
"tags": tags,
- "tag_mode": tag_mode,
+ "tag_modes": {
+ "language": tag_mode_language,
+ "topic": tag_mode_topic,
+ "other": tag_mode_other,
+ },
"channel_ids": channel_ids,
"q": q,
"min_duration": min_duration,
@@ -162,7 +170,7 @@ def get_facets(
# independent counts (you can OR more of them in). Conjunctive (AND, topics only)
# keeps them applied, so each remaining chip narrows to channels that ALSO have all
# already-selected topics — and tags that can't co-occur drop to zero (hidden).
- conjunctive = category == "topic" and params.get("tag_mode") == "and"
+ conjunctive = params.get("tag_modes", {}).get(category) == "and"
base, _rank = filtered_query(
db,
user,
diff --git a/backend/tests/test_feed_views.py b/backend/tests/test_feed_views.py
index 1a3eefd4..c5b10338 100644
--- a/backend/tests/test_feed_views.py
+++ b/backend/tests/test_feed_views.py
@@ -96,6 +96,7 @@ def _seed_tagged(db, make_user):
"T1": Tag(user_id=None, name="Tech", category="topic"),
"T2": Tag(user_id=None, name="Gaming", category="topic"),
"O1": Tag(user_id=None, name="Favourite", category="other"),
+ "O2": Tag(user_id=None, name="Docs", category="other"),
# A USER-owned tag (+ a user-owned ChannelTag link below) so the visibility branch
# `ChannelTag.user_id == user.id` is exercised, not only the system `user_id IS NULL` one.
"U1": Tag(user_id=user.id, name="MyPin", category="other"),
@@ -104,8 +105,17 @@ def _seed_tagged(db, make_user):
chans = {c: Channel(id=c, title=c, subscriber_count=10) for c in ("chA", "chB", "chC", "chD")}
db.add_all(chans.values())
db.flush()
- # channel -> (system) tag keys
- links = {"chA": ["L1", "T1"], "chB": ["L1", "T2"], "chC": ["L2", "T1", "O1"], "chD": []}
+ # channel -> (system) tag keys. chA carries two languages (L1+L2); chC two "other" tags (O1+O2)
+ # AND chA carries O1 too — so the per-category any/all match narrows NON-TRIVIALLY (AND != OR) for
+ # language and other, not only topic: e.g. other {O1,O2} → OR {chA,chC} vs AND {chC}; language
+ # {L1,L2} → OR {chA,chB,chC} vs AND {chA}. (For topic, chA and chB each carry a DIFFERENT topic tag
+ # — T1 vs T2 — so topic {T1,T2} → OR {chA,chB,chC} vs AND {} empty.)
+ links = {
+ "chA": ["L1", "L2", "T1", "O1"],
+ "chB": ["L1", "T2"],
+ "chC": ["L2", "T1", "O1", "O2"],
+ "chD": [],
+ }
for cid, tkeys in links.items():
db.add(Subscription(user_id=user.id, channel_id=cid, priority=0, hidden=False))
for tk in tkeys:
@@ -127,8 +137,12 @@ _SUMMARY_CASES = [
"show=all&tags={L1}",
"show=all&tags={T1}",
"show=all&tags={L1}&tags={T1}",
- "show=all&tags={T1}&tags={T2}&tag_mode=or",
- "show=all&tags={T1}&tags={T2}&tag_mode=and",
+ "show=all&tags={T1}&tags={T2}&tag_mode_topic=or",
+ "show=all&tags={T1}&tags={T2}&tag_mode_topic=and",
+ # Per-category any/all is NOT topic-only — language and other must drill down identically too.
+ "show=all&tags={L1}&tags={L2}&tag_mode_language=or",
+ "show=all&tags={L1}&tags={L2}&tag_mode_language=and",
+ "show=all&tags={O1}&tags={O2}&tag_mode_other=and",
"show=all&tags={U1}", # user-owned tag (visibility == user.id branch)
"show=all&tags={U1}&tags={L1}", # user tag AND a system tag, cross-category
"show=all&q=clip",
diff --git a/frontend/src/components/ActiveFilterChips.tsx b/frontend/src/components/ActiveFilterChips.tsx
index 5268a59c..cfc909c7 100644
--- a/frontend/src/components/ActiveFilterChips.tsx
+++ b/frontend/src/components/ActiveFilterChips.tsx
@@ -1,51 +1,258 @@
+import { useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
-import { X } from "lucide-react";
+import { Filter, X } from "lucide-react";
import type { ActiveFilter } from "../lib/useActiveFilters";
+import { decideChipLayout } from "../lib/chipOverflow";
+import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
// The applied-filter row above the feed: every narrowing currently in force, each removable on its
// own. It exists because the filter panel now sits collapsed as a tab — without this you'd have to
// open the panel just to see what you'd filtered by.
+//
+// The row is a single flex line that shares its width with the Sort/View controls pinned to its right,
+// so the chips must never push those off-screen. When they'd overflow it trims responsively:
+// - wide -> show as many chips as fit, then a "+N" pill whose popover holds the rest;
+// - narrow -> collapse every chip into one "N filters" pill whose popover holds them all.
+// Fit is measured off a hidden mirror of the full chip set (so slicing the visible lane can't feed
+// back into the measurement and oscillate).
+
+// Below this lane width an overflow collapses all the way to a single count pill (mobile), instead of
+// the "fit some + N more" treatment used on wider lanes.
+const NARROW_PX = 420;
+
+const chipCls =
+ "chip group inline-flex items-center gap-1 pl-2.5 pr-1.5 py-1 text-sm whitespace-nowrap hover:border-accent hover:bg-accent/20 transition";
+
+function ChipButton({ f, testId }: { f: ActiveFilter; testId?: string }) {
+ const { t } = useTranslation();
+ return (
+
+ );
+}
+
+// The "+N" / "N filters" pill and its popover listing the (overflowed / all) chips, each removable.
+function OverflowPopover({
+ label,
+ ariaLabel,
+ chips,
+ onClearAll,
+ icon,
+}: {
+ label: string;
+ /** Accessible name for the trigger + popover — describes what's inside (e.g. "3 more filters"),
+ * since the visible label can be a bare "+3". Defaults to `label`. */
+ ariaLabel?: string;
+ chips: ActiveFilter[];
+ onClearAll: () => void;
+ icon?: boolean;
+}) {
+ const { t } = useTranslation();
+ const [open, setOpen] = useState(false);
+ const pop = useAnchoredPopover({
+ open,
+ onOpenChange: setOpen,
+ mode: "menu",
+ placement: "bottom-end",
+ });
+ const name = ariaLabel ?? label;
+ return (
+ <>
+
+
+
+ {chips.map((f) => (
+
+ {f.label}
+
+
+ ))}
+
+
+
+
+
+ >
+ );
+}
+
export default function ActiveFilterChips({
filters,
onClearAll,
+ className,
}: {
filters: ActiveFilter[];
onClearAll: () => void;
+ className?: string;
}) {
const { t } = useTranslation();
- if (filters.length === 0) return null;
+ const laneRef = useRef(null);
+ const labelRef = useRef(null);
+ const chipRefs = useRef>([]);
+ const plusRef = useRef(null);
+ const clearRef = useRef(null);
+ // How the row is currently laid out: all chips, a fit-count + "+N", or a single count pill.
+ const [layout, setLayout] = useState<{ mode: "all" | "some" | "count"; visible: number }>({
+ mode: "all",
+ visible: filters.length,
+ });
+
+ const n = filters.length;
+ // Re-measure whenever the chip set changes (labels or count) — captures both in one dep.
+ const chipsKey = filters.map((f) => f.key + f.label).join("|");
+
+ useLayoutEffect(() => {
+ const lane = laneRef.current;
+ const labelEl = labelRef.current;
+ if (!lane || !labelEl || n === 0) return;
+
+ const measure = () => {
+ const rightOf = (el: HTMLElement | null | undefined) =>
+ el ? el.offsetLeft + el.offsetWidth : 0;
+ const chips = chipRefs.current.slice(0, n);
+ const plusEl = plusRef.current;
+ // Measure the real rendered gap off the box between the last chip and the "+N" sample, so it
+ // scales with the text-size slider (gap-1.5 is rem) rather than being frozen in px.
+ const lastChip = chips[n - 1];
+ const gap = plusEl && lastChip ? Math.max(0, plusEl.offsetLeft - rightOf(lastChip)) : 6;
+ // "all" mode also renders a Clear all button after the chips (when n > 1), so reserve its width.
+ const clearW = n > 1 ? gap + (clearRef.current?.offsetWidth ?? 0) : 0;
+
+ setLayout(
+ decideChipLayout({
+ n,
+ labelRight: rightOf(labelEl),
+ chipRights: chips.map((c) => rightOf(c)),
+ plusW: plusEl?.offsetWidth ?? 0,
+ clearW,
+ avail: lane.clientWidth,
+ gap,
+ narrowPx: NARROW_PX,
+ }),
+ );
+ };
+
+ measure();
+ const ro = new ResizeObserver(measure);
+ ro.observe(lane);
+ return () => ro.disconnect();
+ }, [n, chipsKey]);
+
+ if (n === 0) return null;
+
+ const hiddenCount = n - layout.visible;
+ const overflowChips = filters.slice(layout.visible);
return (
);
diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx
index b3516082..b23e49af 100644
--- a/frontend/src/components/ChannelPage.tsx
+++ b/frontend/src/components/ChannelPage.tsx
@@ -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(() => ({
tags: [],
- tagMode: "or",
+ tagModes: DEFAULT_TAG_MODES,
q: "",
sort: "newest",
scope: "all",
diff --git a/frontend/src/components/Combobox.dom.test.tsx b/frontend/src/components/Combobox.dom.test.tsx
index 7fb2a690..19401cc5 100644
--- a/frontend/src/components/Combobox.dom.test.tsx
+++ b/frontend/src/components/Combobox.dom.test.tsx
@@ -64,6 +64,19 @@ describe("Combobox", () => {
expect(document.getElementById(controls!)?.getAttribute("role")).toBe("listbox");
});
+ it("focuses the input when the field chrome is pressed (merged ref, not a dead inputRef)", () => {
+ // Regression: the input carried only floating-ui's ref, so `inputRef` stayed null. The field's
+ // onMouseDown then preventDefault()-ed every press (its `e.target !== inputRef.current` guard was
+ // always true) and its compensating inputRef.current?.focus() was a no-op → the box couldn't be
+ // typed into with the mouse. A merged ref feeds the node to both, so pressing the chrome focuses it.
+ wrap();
+ const input = screen.getByRole("combobox");
+ const field = input.parentElement as HTMLElement;
+ expect(document.activeElement).not.toBe(input);
+ fireEvent.mouseDown(field); // press the field padding, not the input itself
+ expect(document.activeElement).toBe(input);
+ });
+
it("keeps the option list unmounted until the field is focused", () => {
wrap();
expect(screen.queryByRole("option", { hidden: true })).toBeNull();
@@ -192,6 +205,31 @@ describe("Combobox", () => {
expect(onChange).toHaveBeenCalledWith(["id1"]); // single-select replaces
});
+ it("shows the any/all match footer only at 2+ selections and reports a mode change", () => {
+ // The per-category any/all toggle: meaningless below 2 selections (any === all), so it appears
+ // only at >= 2, and clicking 'all' reports the mode change out.
+ const onMatchModeChange = vi.fn();
+ const props = {
+ label: "Topic",
+ options: OPTIONS,
+ onChange: vi.fn(),
+ matchMode: "or" as const,
+ onMatchModeChange,
+ };
+ // One selection: no footer.
+ const { rerender } = wrap();
+ fireEvent.focus(screen.getByRole("combobox"));
+ expect(screen.queryByText("sidebar.all")).toBeNull();
+
+ // Two selections: footer appears with an any/all segment.
+ rerender({});
+ fireEvent.focus(screen.getByRole("combobox"));
+ const all = screen.getByText("sidebar.all");
+ expect(all).toBeTruthy();
+ fireEvent.click(all);
+ expect(onMatchModeChange).toHaveBeenCalledWith("and");
+ });
+
it("single-select: picking replaces the value and closes the list", () => {
const onChange = vi.fn();
wrap(
diff --git a/frontend/src/components/Combobox.tsx b/frontend/src/components/Combobox.tsx
index fe459da5..4325b3ac 100644
--- a/frontend/src/components/Combobox.tsx
+++ b/frontend/src/components/Combobox.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Check, ChevronDown, X } from "lucide-react";
import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./AnchoredPopover";
@@ -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;
+ /** 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);
@@ -83,7 +90,7 @@ export default function Combobox({
if (queryProp === undefined) setQueryState(q);
onQueryChange?.(q);
};
- const inputRef = useRef(null);
+ const inputRef = useRef(null);
const fade = useScrollFade();
// Remember each value's display label as options flow through, so a selected chip can name itself
@@ -100,6 +107,19 @@ export default function Combobox({
onOpenChange?.(next);
};
const pop = useAnchoredPopover({ open, onOpenChange: setOpenAnd, mode: "combobox" });
+ // The input is the combobox reference for floating-ui AND our own focus target (the field's
+ // click-to-focus handler, and commit() re-focusing after a pick). A merged callback ref feeds the
+ // node to BOTH — without it `inputRef` stays null, so the field's onMouseDown guard (`e.target !==
+ // inputRef.current`) is always true and preventDefault() cancels the input's own click-focus, making
+ // the field impossible to type into with the mouse. Memoised so floating-ui's ref isn't re-attached
+ // every render.
+ const setInputRef = useCallback(
+ (node: HTMLInputElement | null) => {
+ pop.refs.setReference(node);
+ inputRef.current = node;
+ },
+ [pop.refs],
+ );
// `highlightIndex` = the row the user actually SEES highlighted (pointer hover, else keyboard); Enter
// commits THAT, not the bare keyboard `activeIndex` — otherwise arrow-then-hover-then-Enter would toggle
// a different option than the highlighted one, and a hover with no prior arrow (activeIndex still null)
@@ -129,11 +149,14 @@ export default function Combobox({
onChange(
selected.includes(value) ? selected.filter((x) => x !== value) : [...selected, value],
);
+ // Keep the field focused so the user can keep picking / typing; the list stays open.
+ inputRef.current?.focus();
} else {
+ // Single-select is final: close and DON'T re-focus — a refocus fires onFocus, which would
+ // immediately re-open the list (now that the merged ref makes focus() actually work).
onChange([value]);
setOpenAnd(false);
}
- inputRef.current?.focus();
};
return (
@@ -172,7 +195,7 @@ export default function Combobox({
))}
{
setQuery((e.target as HTMLInputElement).value);
@@ -266,6 +289,32 @@ export default function Combobox({
>
)}
+ {/* 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 && (
+
+ )}
);
diff --git a/frontend/src/components/DateFilterSelect.tsx b/frontend/src/components/DateFilterSelect.tsx
new file mode 100644
index 00000000..9df1cd33
--- /dev/null
+++ b/frontend/src/components/DateFilterSelect.tsx
@@ -0,0 +1,191 @@
+import { useLayoutEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { CalendarDays, Check, ChevronDown } from "lucide-react";
+import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
+import { type FeedFilters } from "../lib/api";
+import { deriveDateKey, selectDateChip, type DatePreset } from "../lib/dateFilter";
+
+// The feed rail's "Upload date" control: a single-select dropdown (the SortControl/FilterSelect pill,
+// but full-width for the rail) over "Any time" + the day presets + a "Custom range". Picking Custom
+// reveals the from/to fields in the popover footer (reveal-on-select) and the trigger then reads the
+// chosen range; the presets and Any time close on pick. Selection logic is the pure lib/dateFilter so
+// it stays testable without mounting this. The popover hosts free-text (date) inputs, so it opens as a
+// `hostsInput` menu and the inputs stop non-Escape keys from reaching the list navigation.
+
+// yyyy-mm-dd -> a local Date at midnight (split so it isn't parsed as UTC, which would shift a day).
+function parseDay(s: string): Date | null {
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
+ if (!m) return null;
+ return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
+}
+
+export default function DateFilterSelect({
+ filters,
+ setFilters,
+ presets,
+}: {
+ filters: FeedFilters;
+ setFilters: (f: FeedFilters) => void;
+ presets: readonly DatePreset[];
+}) {
+ const { t, i18n } = useTranslation();
+ const [open, setOpen] = useState(false);
+ const [customOpen, setCustomOpen] = useState(!!(filters.dateFrom || filters.dateTo));
+
+ // If the range is cleared elsewhere (chip-row Clear all / removing the date chip) while this popover
+ // is closed, drop the custom reveal so the trigger reverts to "Any time" instead of lingering on
+ // "Custom range" with empty fields. Guarded on !open so it never closes a freshly-picked Custom the
+ // user hasn't filled in yet — that stays open until they close it (handled by onOpenChange below).
+ // useLayoutEffect (not useEffect) so the reset commits BEFORE paint — otherwise the trigger flashes
+ // one stale "Custom range" frame between the external clear and this reset.
+ useLayoutEffect(() => {
+ if (!open && customOpen && !filters.dateFrom && !filters.dateTo) setCustomOpen(false);
+ }, [open, customOpen, filters.dateFrom, filters.dateTo]);
+
+ const key = deriveDateKey(filters, customOpen, presets);
+
+ const fmt = (s: string) => {
+ const d = parseDay(s);
+ return d
+ ? new Intl.DateTimeFormat(i18n.language, { month: "short", day: "numeric" }).format(d)
+ : s;
+ };
+ const rangeSummary = () => {
+ const { dateFrom: f, dateTo: to } = filters;
+ if (f && to) return `${fmt(f)} – ${fmt(to)}`;
+ if (f) return t("sidebar.date.fromShort", { date: fmt(f) });
+ if (to) return t("sidebar.date.untilShort", { date: fmt(to) });
+ return t("sidebar.date.customRange");
+ };
+ const summary =
+ key === ""
+ ? t("sidebar.date.anyTime")
+ : key === "custom"
+ ? rangeSummary()
+ : t("sidebar.datePreset." + presets.find((p) => String(p.days) === key)!.key);
+
+ const options = [
+ { value: "any", label: t("sidebar.date.anyTime") },
+ ...presets.map((p) => ({ value: String(p.days), label: t("sidebar.datePreset." + p.key) })),
+ { value: "custom", label: t("sidebar.date.customRange") },
+ ];
+
+ const pop = useAnchoredPopover({
+ open,
+ onOpenChange: (next) => {
+ setOpen(next);
+ // Opened Custom but committed no range → drop the reveal on close so it doesn't stick.
+ if (!next && customOpen && !filters.dateFrom && !filters.dateTo) setCustomOpen(false);
+ },
+ mode: "menu",
+ placement: "bottom-start",
+ hostsInput: true,
+ });
+
+ const pick = (value: string) => {
+ const change = selectDateChip(value, filters);
+ setCustomOpen(change.customOpen);
+ setFilters(change.filters);
+ if (value !== "custom") setOpen(false); // Custom stays open to reveal the range fields
+ };
+
+ const dateInputCls =
+ "bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40";
+
+ return (
+ <>
+
+
+ {summary}
+
+
+
+
+
+
+ {/* Reveal-on-select: the from/to fields appear once Custom is the chosen option. The inputs
+ stop non-Escape keys so the date-segment arrows act on the field, not the menu's list nav. */}
+ {key === "custom" && (
+
- {
- if (!next) return; // the feed is always sorted — no "no sort" option is offered
- setFilters({
- ...filters,
- sort: buildSort(next.key as SortKey, next.dir),
- seed: next.key === "shuffle" ? rollSeed() : undefined,
- });
- }}
- label={t("feed.sortLabel")}
- ascLabel={t("feed.dirAsc")}
- descLabel={t("feed.dirDesc")}
- trailing={
- sortKey === "shuffle" ? (
- setFilters({ ...filters, seed: rollSeed() })}
- title={t("sidebar.reshuffle")}
- aria-label={t("sidebar.reshuffle")}
- className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
- >
-
-
- ) : undefined
- }
- />
-
- ({
- id,
- label: t("feed.view." + id),
- icon: VIEW_ICON[id],
- }))}
- onChange={setView}
- label={t("feed.viewLabel")}
- // This toolbar is packed — show/content chips, source, count, sort — so the mode's name
- // only earns its place once the window is wide.
- labelClassName="hidden xl:inline"
- />
-
);
@@ -675,17 +682,44 @@ export default function Feed({
{toolbar}
- {!channelScoped && (
- setFilters(clearedFilters(filters))}
- />
- )}
+ {/* The controls row sits directly above the grid: the applied-filter chips flow from the left
+ (overflowing to a "+N"/count pill so they never crowd the controls), while Sort + View stay
+ pinned to the right. Channel-scoped feeds show no chips (their baseline isn't a "filter you
+ applied"), but still get the right-pinned Sort/View. */}
+
+ {!channelScoped && (
+ <>
+ {/* The active personal view (if any) leads the applied-filter row. */}
+
+ setFilters(clearedFilters(filters))}
+ className="flex-1"
+ />
+ >
+ )}
+
{sortView}
+
{items.length === 0 ? (
{t("feed.noMatches")}
+ {/* A channel filter under the "Mine" scope returns nothing when that channel is hidden from
+ your subscriptions (or you don't subscribe to it) — its videos only live in the shared
+ Library. Point there rather than leaving a bare "no matches". */}
+ {filters.channelIds.length > 0 && filters.scope === "my" && (
+
+ )}
{filters.q.trim() && !isDemo && (
onYtSearch(filters.q.trim())}
diff --git a/frontend/src/components/FeedFiltersProvider.dom.test.ts b/frontend/src/components/FeedFiltersProvider.dom.test.ts
new file mode 100644
index 00000000..2d54581c
--- /dev/null
+++ b/frontend/src/components/FeedFiltersProvider.dom.test.ts
@@ -0,0 +1,45 @@
+// @vitest-environment jsdom
+//
+// loadAccountFilters is the localStorage seed path for the feed filters. It must migrate a persisted
+// legacy global `tagMode` (older builds wrote it) into the per-category `tagModes` — the same coercion
+// the saved-view and URL paths do — so an upgrading user's topic any/all match isn't silently lost.
+import { describe, it, expect, beforeEach } from "vitest";
+import { loadAccountFilters } from "./FeedFiltersProvider";
+import { accountKey, LS } from "../lib/storage";
+
+const ID = 7;
+
+beforeEach(() => localStorage.clear());
+
+describe("loadAccountFilters — legacy tagMode migration on seed", () => {
+ it("migrates a persisted legacy tagMode:'and' from the last-session blob into topic", () => {
+ localStorage.setItem(accountKey(LS.filters, ID)!, JSON.stringify({ q: "hi", tagMode: "and" }));
+ const f = loadAccountFilters(ID);
+ expect(f.tagModes).toEqual({ language: "or", topic: "and", other: "or" });
+ expect(f.q).toBe("hi"); // other fields preserved through the merge
+ });
+
+ it("migrates a legacy tagMode:'and' from the default-view mirror when no last-session blob exists", () => {
+ localStorage.setItem(
+ accountKey(LS.defaultViewFilters, ID)!,
+ JSON.stringify({ tagMode: "and" }),
+ );
+ expect(loadAccountFilters(ID).tagModes.topic).toBe("and");
+ });
+
+ it("leaves tagModes at defaults when nothing is stored", () => {
+ expect(loadAccountFilters(ID).tagModes).toEqual({ language: "or", topic: "or", other: "or" });
+ });
+
+ it("preserves an already-migrated per-category tagModes blob", () => {
+ localStorage.setItem(
+ accountKey(LS.filters, ID)!,
+ JSON.stringify({ tagModes: { language: "and", topic: "or", other: "and" } }),
+ );
+ expect(loadAccountFilters(ID).tagModes).toEqual({
+ language: "and",
+ topic: "or",
+ other: "and",
+ });
+ });
+});
diff --git a/frontend/src/components/FeedFiltersProvider.tsx b/frontend/src/components/FeedFiltersProvider.tsx
index 3156533a..2a0d9ad3 100644
--- a/frontend/src/components/FeedFiltersProvider.tsx
+++ b/frontend/src/components/FeedFiltersProvider.tsx
@@ -9,10 +9,16 @@ 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,
+ coerceTagModes,
+ 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";
+import { accountKey, LS, readJSON, writeJSON } from "../lib/storage";
import { useNavigation } from "./NavigationProvider";
// Owns the GLOBAL feed-filter state (the feed's scope/tags/content/sort/show), lifted out of the App
@@ -32,7 +38,7 @@ import { useNavigation } from "./NavigationProvider";
const DEFAULT_FILTERS: FeedFilters = {
tags: [],
- tagMode: "or",
+ tagModes: DEFAULT_TAG_MODES,
channelIds: [],
q: "",
sort: "newest",
@@ -49,7 +55,7 @@ const FILTERS_KEY = LS.filters;
// default saved view wins over the last-session filters (SavedViewsWidget mirrors it here). When
// the account isn't known yet (first load, before the tab is pinned), start from defaults; the
// post-login effect loads the real ones once the id arrives.
-function loadAccountFilters(id: number | null): FeedFilters {
+export function loadAccountFilters(id: number | null): FeedFilters {
// Your last-applied filters win, so a reload keeps whatever view you're on (a saved view you
// picked, or manual tweaks) — setFilters persists them under LS.filters on every change. The
// starred default view only SEEDS a fresh account that has never stored filters: it drives the
@@ -62,10 +68,19 @@ function loadAccountFilters(id: number | null): FeedFilters {
} catch {
/* localStorage may be unavailable */
}
- if (hasStored && fKey) return readMerged(fKey, DEFAULT_FILTERS);
+ // Migrate a persisted legacy global `tagMode` into the per-category `tagModes` on every seed path,
+ // matching what the saved-view (asFilters) and URL (paramsToFilters) paths already do. coerceTagModes
+ // MUST run on the RAW stored blob, not the object merged over DEFAULT_FILTERS: the merge injects
+ // DEFAULT_TAG_MODES (all "or"), which coerceTagModes prefers over the legacy scalar and would mask it.
+ if (hasStored && fKey) {
+ const raw = readJSON>(fKey, {});
+ return { ...DEFAULT_FILTERS, ...raw, tagModes: coerceTagModes(raw) };
+ }
const dvKey = accountKey(LS.defaultViewFilters, id);
- const def = dvKey ? readJSON(dvKey, null) : null;
- if (def) return { ...DEFAULT_FILTERS, ...def };
+ const def = dvKey ? readJSON | null>(dvKey, null) : null;
+ if (def) {
+ return { ...DEFAULT_FILTERS, ...def, tagModes: coerceTagModes(def) };
+ }
return { ...DEFAULT_FILTERS };
}
diff --git a/frontend/src/components/FeedScopeToggle.tsx b/frontend/src/components/FeedScopeToggle.tsx
new file mode 100644
index 00000000..b943f19c
--- /dev/null
+++ b/frontend/src/components/FeedScopeToggle.tsx
@@ -0,0 +1,39 @@
+import { useTranslation } from "react-i18next";
+import { Library, User } from "lucide-react";
+import SegmentedToggle from "./SegmentedToggle";
+import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
+
+// The feed's Mine/Library scope, relocated from the filter sidebar to sit just right of the header
+// search box — mirroring PlexScopeToggle's placement, so the primary "what am I browsing" control is
+// always in reach instead of buried in the (possibly collapsed) filter rail. Same `filters.scope`
+// field, so behaviour is unchanged. The `pointer-events-auto`/`shrink-0` wrapper matches the other
+// header pills: the header band is `pointer-events-none`, and the pill must not collapse in the flex row.
+export default function FeedScopeToggle() {
+ const { t } = useTranslation();
+ const filters = useFeedFilters();
+ const { setFilters } = useFeedFiltersActions();
+ return (
+
+ );
+}
diff --git a/frontend/src/components/FilterRail.tsx b/frontend/src/components/FilterRail.tsx
index 2bc59b07..420e705c 100644
--- a/frontend/src/components/FilterRail.tsx
+++ b/frontend/src/components/FilterRail.tsx
@@ -8,10 +8,9 @@ import PanelGroups, { type PanelItem } from "./PanelGroups";
// chrome around a PanelGroups body: the feed Sidebar, the PlexSidebar, and the PlaylistsRail. It owns
// the customize (reorder/hide) `editing` state and the fixed title-bar wiring (edit toggle, reset to
// the layout default, edit hint); each rail supplies only what actually differs — its identity
-// (title/icon), the active-filter count + Clear-all action, its `items`, and any content above the
-// groups (`top`, e.g. the feed's scope toggle) or below (`children`, e.g. the feed's tag manager).
-// `kind` is the PanelId (also the layout key), so `top`/`children` default to nothing for the rails
-// that need neither.
+// (title/icon), the active-filter count + Clear-all action, its `items`, and any content below the
+// groups (`children`, e.g. the feed's tag manager). `kind` is the PanelId (also the layout key), so
+// `children` defaults to nothing for the rails that need none.
export default function FilterRail({
kind,
title,
@@ -23,7 +22,10 @@ export default function FilterRail({
layout,
setLayout,
items,
- top,
+ collapsible = true,
+ iconExpanded,
+ onTitleClick,
+ titleActionLabel,
children,
}: {
kind: PanelId;
@@ -36,7 +38,11 @@ export default function FilterRail({
layout: PanelLayout;
setLayout: (l: PanelLayout) => void;
items: PanelItem[];
- top?: ReactNode;
+ /** Passed to PanelGroups: false renders plain always-open label + control rows (no collapse). */
+ collapsible?: boolean;
+ iconExpanded?: ReactNode;
+ onTitleClick?: () => void;
+ titleActionLabel?: string;
children?: ReactNode;
}) {
const { t } = useTranslation();
@@ -53,9 +59,17 @@ export default function FilterRail({
onReset={() => setLayout(defaultLayout(kind))}
editHint={t("sidebar.editHint")}
actions={actions}
+ iconExpanded={iconExpanded}
+ onTitleClick={onTitleClick}
+ titleActionLabel={titleActionLabel}
>
- {top}
-
+
{children}
);
diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx
index d373b7ae..7618accc 100644
--- a/frontend/src/components/Header.tsx
+++ b/frontend/src/components/Header.tsx
@@ -5,6 +5,7 @@ import type { Me } from "../lib/api";
import SearchBar from "./SearchBar";
import PlexSearchBox from "./PlexSearchBox";
import PlexScopeToggle from "./PlexScopeToggle";
+import FeedScopeToggle from "./FeedScopeToggle";
import SyncStatus from "./SyncStatus";
import { useNavigation } from "./NavigationProvider";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
@@ -98,6 +99,9 @@ const Header = forwardRef<
className="pointer-events-none absolute top-3 left-4 right-[calc(1rem+var(--sbw))] mx-auto max-w-[var(--content-mw,none)] z-chrome flex flex-wrap items-center justify-center gap-x-3 gap-y-2"
>
{search}
+ {/* Mine/Library scope sits right of the feed search box — relocated here from the filter sidebar
+ so the primary "what am I browsing" control is always in reach (mirrors Plex's scope toggle). */}
+ {page === "feed" && }
{/* Library scope (movie/show/both) sits right of the search box on Plex — relocated here from
the filter sidebar (S3) so the primary "what am I browsing" control is always in reach. */}
{page === "plex" && me.plex_enabled && }
diff --git a/frontend/src/components/PanelGroup.tsx b/frontend/src/components/PanelGroup.tsx
index d0db5dcc..361d64d8 100644
--- a/frontend/src/components/PanelGroup.tsx
+++ b/frontend/src/components/PanelGroup.tsx
@@ -1,29 +1,37 @@
import { type ReactNode, type CSSProperties } from "react";
import { useTranslation } from "react-i18next";
-import { ChevronDown, Eye, EyeOff, GripVertical } from "lucide-react";
+import { ChevronDown, Eye, EyeOff, GripVertical, Pencil } from "lucide-react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
-// One collapsible "island" card inside a SidePanel: a titled glass-card with per-group collapse,
-// and — in the panel's edit mode — a drag handle (reorder) and a hide toggle. Generalized from
-// the feed sidebar's WidgetCard so all three panels share the same island look and behavior.
+// One "island" card inside a SidePanel: a titled glass-card, and — in the panel's edit mode — a drag
+// handle (reorder) and a hide toggle. Generalized from the feed sidebar's WidgetCard so all three
+// panels share the same island look and behavior. `collapsible` groups (the default) get a per-group
+// collapse chevron; non-collapsible groups render as a plain always-open label + control row, and
+// their title can instead act as a trigger via `onTitleClick` (e.g. feed "Your tags" -> tag manager).
export default function PanelGroup({
id,
title,
editing,
+ collapsible = true,
collapsed,
hidden,
onToggleCollapse,
onToggleHidden,
+ onTitleClick,
+ titleActionLabel,
children,
}: {
id: string;
title: string;
editing: boolean;
+ collapsible?: boolean;
collapsed: boolean;
hidden: boolean;
onToggleCollapse: () => void;
onToggleHidden: () => void;
+ onTitleClick?: () => void;
+ titleActionLabel?: string;
children: ReactNode;
}) {
const { t } = useTranslation();
@@ -32,7 +40,13 @@ export default function PanelGroup({
disabled: !editing,
});
const style: CSSProperties = { transform: CSS.Transform.toString(transform), transition };
- const showBody = !editing && !collapsed;
+ // Non-collapsible groups are always open (outside edit mode); collapsible ones honour `collapsed`.
+ const showBody = !editing && (!collapsible || !collapsed);
+ // A non-collapsible group's title acts as a trigger when one is provided.
+ const titleIsTrigger = !editing && !collapsible && !!onTitleClick;
+
+ const titleCls =
+ "flex-1 min-w-0 flex items-center gap-1 text-left select-none text-sm uppercase tracking-wide";
return (
)}
-
- {title}
-
+ {collapsible && !editing ? (
+
+ {title}
+
+ ) : titleIsTrigger ? (
+
+ {title}
+
+
+ ) : (
+
+ {title}
+
+ )}
{editing ? (
: }
) : (
-
-
-
+ collapsible && (
+
+
+
+ )
)}
{showBody &&
{children}
}
diff --git a/frontend/src/components/PanelGroups.tsx b/frontend/src/components/PanelGroups.tsx
index 1b997d49..93892e77 100644
--- a/frontend/src/components/PanelGroups.tsx
+++ b/frontend/src/components/PanelGroups.tsx
@@ -17,7 +17,16 @@ import {
import type { PanelLayout } from "../lib/panelLayout";
import PanelGroup from "./PanelGroup";
-export type PanelItem = { id: string; title: string; body: ReactNode };
+export type PanelItem = {
+ id: string;
+ title: string;
+ body: ReactNode;
+ /** When set (and the panel is non-collapsible), the group's title becomes a button firing this —
+ * e.g. the feed's "Your tags" opens the tag manager. */
+ onTitleClick?: () => void;
+ /** Accessible name / tooltip for the title action (what onTitleClick does). */
+ titleActionLabel?: string;
+};
// Renders a panel's islands in the user's saved order, with drag-to-reorder + hide + per-group
// collapse driven by `layout` (see lib/panelLayout). `items` holds only the currently AVAILABLE
@@ -29,11 +38,15 @@ export default function PanelGroups({
layout,
setLayout,
editing,
+ collapsible = true,
}: {
items: PanelItem[];
layout: PanelLayout;
setLayout: (l: PanelLayout) => void;
editing: boolean;
+ /** When false, groups render as plain (always-open) label + control rows — no collapse chevron.
+ * Reorder + hide (in edit mode) stay. Default true (the collapsible-island look). */
+ collapsible?: boolean;
}) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
@@ -76,10 +89,13 @@ export default function PanelGroups({
id={id}
title={byId.get(id)!.title}
editing={editing}
+ collapsible={collapsible}
collapsed={!!layout.collapsed[id]}
hidden={!!layout.hidden[id]}
onToggleCollapse={() => toggleCollapse(id)}
onToggleHidden={() => toggleHidden(id)}
+ onTitleClick={byId.get(id)!.onTitleClick}
+ titleActionLabel={byId.get(id)!.titleActionLabel}
>
{byId.get(id)!.body}
diff --git a/frontend/src/components/PersonalViewsModal.tsx b/frontend/src/components/PersonalViewsModal.tsx
new file mode 100644
index 00000000..e4106c67
--- /dev/null
+++ b/frontend/src/components/PersonalViewsModal.tsx
@@ -0,0 +1,222 @@
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { BookmarkPlus, GripVertical, Play, Star, Trash2 } from "lucide-react";
+import {
+ closestCenter,
+ DndContext,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ type DragEndEvent,
+} from "@dnd-kit/core";
+import {
+ arrayMove,
+ SortableContext,
+ useSortable,
+ verticalListSortingStrategy,
+} from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
+import type { SavedView } from "../lib/api";
+import Modal from "./Modal";
+import { useSavedViews, asFilters } from "./SavedViewsProvider";
+import { useActiveFilters } from "../lib/useActiveFilters";
+
+// Master–detail manager for saved ("personal") views: the left column lists the views (drag to
+// reorder) plus a "save the current filters" input; the right column shows the selected view's name,
+// what filters it captures, its default flag, apply, and delete. Opened from the filter panel's title
+// or the saved-views popover; all mutations flow through SavedViewsProvider.
+function ViewRow({
+ view,
+ selected,
+ active,
+ onSelect,
+}: {
+ view: SavedView;
+ selected: boolean;
+ active: boolean;
+ onSelect: () => void;
+}) {
+ const { t } = useTranslation();
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
+ id: view.id,
+ });
+ return (
+
+ setTagSelection(topics, next)}
+ emptyText={t("sidebar.noMatchingTags")}
+ matchMode={filters.tagModes.topic}
+ onMatchModeChange={(m) => setTagMode("topic", m)}
+ />
);
}
case "tags": {
+ // The widget's own "Manage" trigger is its title (see the PanelItem onTitleClick below), so the
+ // body is just the picker.
return (
-