From fca989c0983848e758737efe3ee71102c2d789de Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 22 Aug 2026 05:42:30 +0200 Subject: [PATCH] =?UTF-8?q?feat(feed):=20flatten=20the=20filter=20rail=20?= =?UTF-8?q?=E2=80=94=20dropdown=20upload-date,=20non-collapsible=20widgets?= =?UTF-8?q?,=20title=20triggers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the feed filter rail into simpler label + control rows: - Remove the rail's own "Clear all" (the chip row above the cards already surfaces one whenever any filter is active). - Replace the Upload date chip group with a single-select dropdown (DateFilterSelect): an "Any time" clear, more presets (2 weeks / 3 months / 2 years / 5 years) and a "Custom range" that reveals from/to on select; the trigger then reads the chosen range. Selection logic is the pure lib/dateFilter (applyDateChip -> selectDateChip, no toggle-off; tests updated). - Widgets are no longer collapsible: PanelGroup gains `collapsible` (feed passes false) so groups render as always-open label + control rows; reorder + hide in edit mode stay. Other rails (Plex/Playlists) keep the collapsible islands. - A non-collapsible group's title can act as its own trigger (PanelItem onTitleClick): "Your tags" opens the tag manager, replacing the inline Manage button. --- frontend/src/components/DateFilterSelect.tsx | 188 +++++++++++++++++++ frontend/src/components/FilterRail.tsx | 11 +- frontend/src/components/PanelGroup.tsx | 72 ++++--- frontend/src/components/PanelGroups.tsx | 18 +- frontend/src/components/Sidebar.tsx | 142 +++----------- frontend/src/i18n/locales/en/sidebar.json | 12 +- frontend/src/i18n/locales/hu/sidebar.json | 12 +- frontend/src/lib/dateFilter.test.ts | 48 ++--- frontend/src/lib/dateFilter.ts | 50 +++-- 9 files changed, 368 insertions(+), 185 deletions(-) create mode 100644 frontend/src/components/DateFilterSelect.tsx diff --git a/frontend/src/components/DateFilterSelect.tsx b/frontend/src/components/DateFilterSelect.tsx new file mode 100644 index 00000000..12f3ba4b --- /dev/null +++ b/frontend/src/components/DateFilterSelect.tsx @@ -0,0 +1,188 @@ +import { useEffect, 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 a range is cleared elsewhere (the chip-row Clear all) while nothing was ever typed, drop the + // "custom" reveal so the trigger doesn't linger on "Custom range" with empty fields. + useEffect(() => { + if (!filters.dateFrom && !filters.dateTo && filters.maxAgeDays !== undefined) + setCustomOpen(false); + }, [filters.dateFrom, filters.dateTo, filters.maxAgeDays]); + + 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/FilterRail.tsx b/frontend/src/components/FilterRail.tsx index deeffdc5..1fd38cec 100644 --- a/frontend/src/components/FilterRail.tsx +++ b/frontend/src/components/FilterRail.tsx @@ -22,6 +22,7 @@ export default function FilterRail({ layout, setLayout, items, + collapsible = true, iconExpanded, onTitleClick, children, @@ -36,6 +37,8 @@ export default function FilterRail({ layout: PanelLayout; setLayout: (l: PanelLayout) => void; items: PanelItem[]; + /** Passed to PanelGroups: false renders plain always-open label + control rows (no collapse). */ + collapsible?: boolean; iconExpanded?: ReactNode; onTitleClick?: () => void; children?: ReactNode; @@ -57,7 +60,13 @@ export default function FilterRail({ iconExpanded={iconExpanded} onTitleClick={onTitleClick} > - + {children} ); 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 (
)} - + {collapsible && !editing ? ( + + ) : titleIsTrigger ? ( + + ) : ( + + {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/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 5d3082ef..aeb9d700 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -2,12 +2,11 @@ import { type ReactNode, useState } from "react"; import { useTranslation } from "react-i18next"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; -import { Pencil, SlidersHorizontal } from "lucide-react"; +import { SlidersHorizontal } from "lucide-react"; 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"; -import { applyDateChip, deriveDateKey } from "../lib/dateFilter"; +import { useActiveFilters } from "../lib/useActiveFilters"; import TagManager from "./TagManager"; import SavedViewsPopover from "./SavedViewsPopover"; import PersonalViewsModal from "./PersonalViewsModal"; @@ -16,7 +15,7 @@ import FilterRail from "./FilterRail"; import { type PanelItem } from "./PanelGroups"; import { type MediaSourceKind } from "../lib/mediaSource"; import ChannelPicker from "./ChannelPicker"; -import FilterChipGroup from "./FilterChipGroup"; +import DateFilterSelect from "./DateFilterSelect"; import Combobox, { type ComboboxOption } from "./Combobox"; import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; import { useChannelsActions } from "./ChannelsProvider"; @@ -26,9 +25,13 @@ import { useMe } from "../lib/useMe"; const DATE_PRESETS: { days: number; key: string }[] = [ { days: 1, key: "24h" }, { days: 7, key: "1week" }, + { days: 14, key: "2weeks" }, { days: 30, key: "1month" }, + { days: 90, key: "3months" }, { days: 180, key: "6months" }, { days: 365, key: "1year" }, + { days: 730, key: "2years" }, + { days: 1825, key: "5years" }, ]; export default function Sidebar({ @@ -77,7 +80,6 @@ export default function Sidebar({ const languages = tags.filter((t) => t.category === "language"); const topics = tags.filter((t) => t.category === "topic"); const userTags = tags.filter((t) => !t.system); - const [customDates, setCustomDates] = useState(false); const [tagManagerOpen, setTagManagerOpen] = useState(false); // This rail's media source (the feed). Names the abstraction the feed and Plex rails share; drives // the panel layout key. `FilterRail` owns the customize `editing` state now. @@ -106,12 +108,6 @@ export default function Sidebar({ // The badge counts exactly what the chip row above the feed shows — same list, so they can't // disagree about what's applied. const activeCount = useActiveFilters(filters, setFilters).length; - const active = activeCount > 0; - - function clearAll() { - setCustomDates(false); - setFilters(clearedFilters(filters)); - } const available: Record = { date: true, @@ -125,75 +121,10 @@ export default function Sidebar({ switch (id) { case "channel": return ; - case "date": { - // Single-select across the presets + Custom; pure logic lives in lib/dateFilter. - const dateKey = deriveDateKey(filters, customDates, DATE_PRESETS); + case "date": return ( - <> - ({ - value: String(p.days), - label: t("sidebar.datePreset." + p.key), - })), - { value: "custom", label: t("sidebar.custom") }, - ]} - onChange={(key) => { - const change = applyDateChip(key, filters, customDates); - if (change.customDates !== undefined) setCustomDates(change.customDates); - if (change.filters) setFilters(change.filters); - }} - /> - - {(customDates || filters.dateFrom || filters.dateTo) && ( -
- - - {(filters.dateFrom || filters.dateTo) && ( - - )} -
- )} - + ); - } case "language": { return ( - setTagSelection(userTags, next)} - emptyText={t("sidebar.noMatchingTags")} - matchMode={filters.tagModes.other} - onMatchModeChange={(m) => setTagMode("other", m)} - /> - - + setTagSelection(userTags, next)} + emptyText={t("sidebar.noMatchingTags")} + matchMode={filters.tagModes.other} + onMatchModeChange={(m) => setTagMode("other", m)} + /> ); } default: @@ -252,19 +175,15 @@ export default function Sidebar({ // the actual render order is the user's saved layout (PanelGroups orders by it). const items: PanelItem[] = defaultLayout(source) .order.filter((id) => available[id]) - .map((id) => ({ id, title: t("sidebar.widget." + id), body: widgetBody(id) })); - - const actions = ( - <> - - - ); + .map((id) => ({ + id, + title: t("sidebar.widget." + id), + body: widgetBody(id), + // The widgets aren't collapsible, so a widget's title can act as its own trigger: "Your tags" + // opens the tag manager (replacing the old inline Manage button). + onTitleClick: id === "tags" ? () => setTagManagerOpen(true) : undefined, + titleActionLabel: id === "tags" ? t("sidebar.manageTags") : undefined, + })); return (