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 (
- - {t("feed.chips.label")} - - {filters.map((f) => ( - - ))} - {filters.length > 1 && ( - + +
+ + {/* Visible lane. */} + {layout.mode === "count" ? ( + + ) : ( + <> + + {t("feed.chips.label")} + + {filters.slice(0, layout.visible).map((f) => ( + + ))} + {layout.mode === "some" && hiddenCount > 0 && ( + + )} + {layout.mode === "all" && n > 1 && ( + + )} + )} ); 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 && ( +
+ {t("sidebar.match")} +
+ {(["or", "and"] as const).map((m) => ( + + ))} +
+
+ )} ); 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 ( + <> + + + +
+ {options.map((o) => { + const on = o.value === key || (o.value === "any" && key === ""); + return ( + pick(o.value)} + label={o.label} + className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${ + on ? "text-fg" : "text-muted" + }`} + > + + {o.label} + + ); + })} +
+ + {/* 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" && ( +
+ + + {(filters.dateFrom || filters.dateTo) && ( + + )} +
+ )} +
+ + ); +} diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 2d46c475..a4253c7d 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -13,6 +13,7 @@ import { Grid3x3, Info, LayoutGrid, + Library, List, RefreshCw, Rows3, @@ -36,6 +37,7 @@ import { type SortOption } from "../lib/columnSort"; import { FEED_VIEWS, type FeedView } from "../lib/feedView"; import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters"; import ActiveFilterChips from "./ActiveFilterChips"; +import SelectedViewChip from "./SelectedViewChip"; import { LoadingState, StateMessage } from "./QueryState"; import { PageToolbar } from "./PageShell"; import { useWizard } from "./WizardProvider"; @@ -568,6 +570,54 @@ export default function Feed({ : SORT_DEFAULT_DIR[k as Exclude], })); + // Sort + view mode are tightly bound to the grid below, so they live pinned to the right of the + // applied-filter row (directly above the cards), not up in the filter toolbar. Extracted here so the + // same controls render on both the feed and channel-scoped rows. + const sortView = ( + <> + { + 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" ? ( + + ) : undefined + } + /> +