feat(feed): flatten the filter rail — dropdown upload-date, non-collapsible widgets, title triggers
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.
This commit is contained in:
@@ -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<boolean>(!!(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 (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
ref={pop.refs.setReference}
|
||||||
|
{...pop.getReferenceProps()}
|
||||||
|
aria-label={`${t("sidebar.datePresetLabel")}: ${summary}`}
|
||||||
|
className={`glass-card glass-hover flex w-full items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-sm transition ${
|
||||||
|
open ? "border-accent" : "border-border"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CalendarDays className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
|
||||||
|
<span className="flex-1 truncate text-left text-fg">{summary}</span>
|
||||||
|
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<PopoverSurface
|
||||||
|
popover={pop}
|
||||||
|
ariaLabel={t("sidebar.datePresetLabel")}
|
||||||
|
className="glass-menu min-w-52 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
|
||||||
|
>
|
||||||
|
<div role="group" aria-label={t("sidebar.datePresetLabel")}>
|
||||||
|
{options.map((o) => {
|
||||||
|
const on = o.value === key || (o.value === "any" && key === "");
|
||||||
|
return (
|
||||||
|
<PopoverItem
|
||||||
|
key={o.value}
|
||||||
|
role="menuitemradio"
|
||||||
|
checked={on}
|
||||||
|
onSelect={() => 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"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Check className={`h-3.5 w-3.5 shrink-0 ${on ? "text-accent" : "opacity-0"}`} />
|
||||||
|
<span className="flex-1 truncate">{o.label}</span>
|
||||||
|
</PopoverItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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" && (
|
||||||
|
<div className="mt-1 flex flex-col gap-1.5 border-t border-border px-1 pt-2">
|
||||||
|
<label className="flex items-center justify-between gap-2 text-xs">
|
||||||
|
<span className="text-muted">{t("sidebar.from")}</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={filters.dateFrom ?? ""}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
|
}}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFilters({
|
||||||
|
...filters,
|
||||||
|
dateFrom: e.target.value || undefined,
|
||||||
|
maxAgeDays: undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={dateInputCls}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center justify-between gap-2 text-xs">
|
||||||
|
<span className="text-muted">{t("sidebar.to")}</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={filters.dateTo ?? ""}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
|
}}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFilters({
|
||||||
|
...filters,
|
||||||
|
dateTo: e.target.value || undefined,
|
||||||
|
maxAgeDays: undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={dateInputCls}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{(filters.dateFrom || filters.dateTo) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFilters({ ...filters, dateFrom: undefined, dateTo: undefined })}
|
||||||
|
className="text-2xs text-muted hover:text-accent self-end"
|
||||||
|
>
|
||||||
|
{t("sidebar.clearDates")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PopoverSurface>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ export default function FilterRail({
|
|||||||
layout,
|
layout,
|
||||||
setLayout,
|
setLayout,
|
||||||
items,
|
items,
|
||||||
|
collapsible = true,
|
||||||
iconExpanded,
|
iconExpanded,
|
||||||
onTitleClick,
|
onTitleClick,
|
||||||
children,
|
children,
|
||||||
@@ -36,6 +37,8 @@ export default function FilterRail({
|
|||||||
layout: PanelLayout;
|
layout: PanelLayout;
|
||||||
setLayout: (l: PanelLayout) => void;
|
setLayout: (l: PanelLayout) => void;
|
||||||
items: PanelItem[];
|
items: PanelItem[];
|
||||||
|
/** Passed to PanelGroups: false renders plain always-open label + control rows (no collapse). */
|
||||||
|
collapsible?: boolean;
|
||||||
iconExpanded?: ReactNode;
|
iconExpanded?: ReactNode;
|
||||||
onTitleClick?: () => void;
|
onTitleClick?: () => void;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
@@ -57,7 +60,13 @@ export default function FilterRail({
|
|||||||
iconExpanded={iconExpanded}
|
iconExpanded={iconExpanded}
|
||||||
onTitleClick={onTitleClick}
|
onTitleClick={onTitleClick}
|
||||||
>
|
>
|
||||||
<PanelGroups items={items} layout={layout} setLayout={setLayout} editing={editing} />
|
<PanelGroups
|
||||||
|
items={items}
|
||||||
|
layout={layout}
|
||||||
|
setLayout={setLayout}
|
||||||
|
editing={editing}
|
||||||
|
collapsible={collapsible}
|
||||||
|
/>
|
||||||
{children}
|
{children}
|
||||||
</SidePanel>
|
</SidePanel>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,29 +1,37 @@
|
|||||||
import { type ReactNode, type CSSProperties } from "react";
|
import { type ReactNode, type CSSProperties } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { useSortable } from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
|
||||||
// One collapsible "island" card inside a SidePanel: a titled glass-card with per-group collapse,
|
// One "island" card inside a SidePanel: a titled glass-card, and — in the panel's edit mode — a drag
|
||||||
// and — in the panel's edit mode — a drag handle (reorder) and a hide toggle. Generalized from
|
// handle (reorder) and a hide toggle. Generalized from the feed sidebar's WidgetCard so all three
|
||||||
// the feed sidebar's WidgetCard so all three panels share the same island look and behavior.
|
// 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({
|
export default function PanelGroup({
|
||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
editing,
|
editing,
|
||||||
|
collapsible = true,
|
||||||
collapsed,
|
collapsed,
|
||||||
hidden,
|
hidden,
|
||||||
onToggleCollapse,
|
onToggleCollapse,
|
||||||
onToggleHidden,
|
onToggleHidden,
|
||||||
|
onTitleClick,
|
||||||
|
titleActionLabel,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
editing: boolean;
|
editing: boolean;
|
||||||
|
collapsible?: boolean;
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
hidden: boolean;
|
hidden: boolean;
|
||||||
onToggleCollapse: () => void;
|
onToggleCollapse: () => void;
|
||||||
onToggleHidden: () => void;
|
onToggleHidden: () => void;
|
||||||
|
onTitleClick?: () => void;
|
||||||
|
titleActionLabel?: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -32,7 +40,13 @@ export default function PanelGroup({
|
|||||||
disabled: !editing,
|
disabled: !editing,
|
||||||
});
|
});
|
||||||
const style: CSSProperties = { transform: CSS.Transform.toString(transform), transition };
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -53,13 +67,27 @@ export default function PanelGroup({
|
|||||||
<GripVertical className="w-4 h-4" />
|
<GripVertical className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
{collapsible && !editing ? (
|
||||||
onClick={editing ? undefined : onToggleCollapse}
|
<button
|
||||||
disabled={editing}
|
onClick={onToggleCollapse}
|
||||||
className="flex-1 min-w-0 flex items-center text-left select-none text-sm uppercase tracking-wide text-muted enabled:hover:text-fg transition"
|
className={`${titleCls} text-muted hover:text-fg transition`}
|
||||||
>
|
>
|
||||||
<span className="truncate">{title}</span>
|
<span className="truncate">{title}</span>
|
||||||
</button>
|
</button>
|
||||||
|
) : titleIsTrigger ? (
|
||||||
|
<button
|
||||||
|
onClick={onTitleClick}
|
||||||
|
title={titleActionLabel}
|
||||||
|
className={`${titleCls} group text-muted hover:text-accent transition`}
|
||||||
|
>
|
||||||
|
<span className="truncate">{title}</span>
|
||||||
|
<Pencil className="w-3 h-3 shrink-0 text-muted/60 group-hover:text-accent" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className={`${titleCls} text-muted`}>
|
||||||
|
<span className="truncate">{title}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<button
|
<button
|
||||||
onClick={onToggleHidden}
|
onClick={onToggleHidden}
|
||||||
@@ -69,15 +97,17 @@ export default function PanelGroup({
|
|||||||
{hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
{hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
collapsible && (
|
||||||
onClick={onToggleCollapse}
|
<button
|
||||||
title={collapsed ? t("sidebar.expand") : t("sidebar.collapse")}
|
onClick={onToggleCollapse}
|
||||||
className="text-muted hover:text-fg"
|
title={collapsed ? t("sidebar.expand") : t("sidebar.collapse")}
|
||||||
>
|
className="text-muted hover:text-fg"
|
||||||
<ChevronDown
|
>
|
||||||
className={`w-4 h-4 transition-transform ${collapsed ? "-rotate-90" : ""}`}
|
<ChevronDown
|
||||||
/>
|
className={`w-4 h-4 transition-transform ${collapsed ? "-rotate-90" : ""}`}
|
||||||
</button>
|
/>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{showBody && <div className="px-3 pb-3">{children}</div>}
|
{showBody && <div className="px-3 pb-3">{children}</div>}
|
||||||
|
|||||||
@@ -17,7 +17,16 @@ import {
|
|||||||
import type { PanelLayout } from "../lib/panelLayout";
|
import type { PanelLayout } from "../lib/panelLayout";
|
||||||
import PanelGroup from "./PanelGroup";
|
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
|
// 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
|
// collapse driven by `layout` (see lib/panelLayout). `items` holds only the currently AVAILABLE
|
||||||
@@ -29,11 +38,15 @@ export default function PanelGroups({
|
|||||||
layout,
|
layout,
|
||||||
setLayout,
|
setLayout,
|
||||||
editing,
|
editing,
|
||||||
|
collapsible = true,
|
||||||
}: {
|
}: {
|
||||||
items: PanelItem[];
|
items: PanelItem[];
|
||||||
layout: PanelLayout;
|
layout: PanelLayout;
|
||||||
setLayout: (l: PanelLayout) => void;
|
setLayout: (l: PanelLayout) => void;
|
||||||
editing: boolean;
|
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(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||||||
@@ -76,10 +89,13 @@ export default function PanelGroups({
|
|||||||
id={id}
|
id={id}
|
||||||
title={byId.get(id)!.title}
|
title={byId.get(id)!.title}
|
||||||
editing={editing}
|
editing={editing}
|
||||||
|
collapsible={collapsible}
|
||||||
collapsed={!!layout.collapsed[id]}
|
collapsed={!!layout.collapsed[id]}
|
||||||
hidden={!!layout.hidden[id]}
|
hidden={!!layout.hidden[id]}
|
||||||
onToggleCollapse={() => toggleCollapse(id)}
|
onToggleCollapse={() => toggleCollapse(id)}
|
||||||
onToggleHidden={() => toggleHidden(id)}
|
onToggleHidden={() => toggleHidden(id)}
|
||||||
|
onTitleClick={byId.get(id)!.onTitleClick}
|
||||||
|
titleActionLabel={byId.get(id)!.titleActionLabel}
|
||||||
>
|
>
|
||||||
{byId.get(id)!.body}
|
{byId.get(id)!.body}
|
||||||
</PanelGroup>
|
</PanelGroup>
|
||||||
|
|||||||
@@ -2,12 +2,11 @@ import { type ReactNode, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||||
import { qk } from "../lib/queryKeys";
|
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 { api, type FeedFilters, type Tag, type TagCategory } from "../lib/api";
|
||||||
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
|
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
|
||||||
import { useDebounced } from "../lib/useDebounced";
|
import { useDebounced } from "../lib/useDebounced";
|
||||||
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
import { useActiveFilters } from "../lib/useActiveFilters";
|
||||||
import { applyDateChip, deriveDateKey } from "../lib/dateFilter";
|
|
||||||
import TagManager from "./TagManager";
|
import TagManager from "./TagManager";
|
||||||
import SavedViewsPopover from "./SavedViewsPopover";
|
import SavedViewsPopover from "./SavedViewsPopover";
|
||||||
import PersonalViewsModal from "./PersonalViewsModal";
|
import PersonalViewsModal from "./PersonalViewsModal";
|
||||||
@@ -16,7 +15,7 @@ import FilterRail from "./FilterRail";
|
|||||||
import { type PanelItem } from "./PanelGroups";
|
import { type PanelItem } from "./PanelGroups";
|
||||||
import { type MediaSourceKind } from "../lib/mediaSource";
|
import { type MediaSourceKind } from "../lib/mediaSource";
|
||||||
import ChannelPicker from "./ChannelPicker";
|
import ChannelPicker from "./ChannelPicker";
|
||||||
import FilterChipGroup from "./FilterChipGroup";
|
import DateFilterSelect from "./DateFilterSelect";
|
||||||
import Combobox, { type ComboboxOption } from "./Combobox";
|
import Combobox, { type ComboboxOption } from "./Combobox";
|
||||||
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
||||||
import { useChannelsActions } from "./ChannelsProvider";
|
import { useChannelsActions } from "./ChannelsProvider";
|
||||||
@@ -26,9 +25,13 @@ import { useMe } from "../lib/useMe";
|
|||||||
const DATE_PRESETS: { days: number; key: string }[] = [
|
const DATE_PRESETS: { days: number; key: string }[] = [
|
||||||
{ days: 1, key: "24h" },
|
{ days: 1, key: "24h" },
|
||||||
{ days: 7, key: "1week" },
|
{ days: 7, key: "1week" },
|
||||||
|
{ days: 14, key: "2weeks" },
|
||||||
{ days: 30, key: "1month" },
|
{ days: 30, key: "1month" },
|
||||||
|
{ days: 90, key: "3months" },
|
||||||
{ days: 180, key: "6months" },
|
{ days: 180, key: "6months" },
|
||||||
{ days: 365, key: "1year" },
|
{ days: 365, key: "1year" },
|
||||||
|
{ days: 730, key: "2years" },
|
||||||
|
{ days: 1825, key: "5years" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Sidebar({
|
export default function Sidebar({
|
||||||
@@ -77,7 +80,6 @@ export default function Sidebar({
|
|||||||
const languages = tags.filter((t) => t.category === "language");
|
const languages = tags.filter((t) => t.category === "language");
|
||||||
const topics = tags.filter((t) => t.category === "topic");
|
const topics = tags.filter((t) => t.category === "topic");
|
||||||
const userTags = tags.filter((t) => !t.system);
|
const userTags = tags.filter((t) => !t.system);
|
||||||
const [customDates, setCustomDates] = useState(false);
|
|
||||||
const [tagManagerOpen, setTagManagerOpen] = useState(false);
|
const [tagManagerOpen, setTagManagerOpen] = useState(false);
|
||||||
// This rail's media source (the feed). Names the abstraction the feed and Plex rails share; drives
|
// 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.
|
// 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
|
// The badge counts exactly what the chip row above the feed shows — same list, so they can't
|
||||||
// disagree about what's applied.
|
// disagree about what's applied.
|
||||||
const activeCount = useActiveFilters(filters, setFilters).length;
|
const activeCount = useActiveFilters(filters, setFilters).length;
|
||||||
const active = activeCount > 0;
|
|
||||||
|
|
||||||
function clearAll() {
|
|
||||||
setCustomDates(false);
|
|
||||||
setFilters(clearedFilters(filters));
|
|
||||||
}
|
|
||||||
|
|
||||||
const available: Record<string, boolean> = {
|
const available: Record<string, boolean> = {
|
||||||
date: true,
|
date: true,
|
||||||
@@ -125,75 +121,10 @@ export default function Sidebar({
|
|||||||
switch (id) {
|
switch (id) {
|
||||||
case "channel":
|
case "channel":
|
||||||
return <ChannelPicker filters={filters} setFilters={setFilters} />;
|
return <ChannelPicker filters={filters} setFilters={setFilters} />;
|
||||||
case "date": {
|
case "date":
|
||||||
// Single-select across the presets + Custom; pure logic lives in lib/dateFilter.
|
|
||||||
const dateKey = deriveDateKey(filters, customDates, DATE_PRESETS);
|
|
||||||
return (
|
return (
|
||||||
<>
|
<DateFilterSelect filters={filters} setFilters={setFilters} presets={DATE_PRESETS} />
|
||||||
<FilterChipGroup
|
|
||||||
ariaLabel={t("sidebar.datePresetLabel")}
|
|
||||||
value={dateKey}
|
|
||||||
options={[
|
|
||||||
...DATE_PRESETS.map((p) => ({
|
|
||||||
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) && (
|
|
||||||
<div className="flex flex-col gap-1.5 mt-2">
|
|
||||||
<label className="flex items-center justify-between gap-2 text-xs">
|
|
||||||
<span className="text-muted">{t("sidebar.from")}</span>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={filters.dateFrom ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFilters({
|
|
||||||
...filters,
|
|
||||||
dateFrom: e.target.value || undefined,
|
|
||||||
maxAgeDays: undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center justify-between gap-2 text-xs">
|
|
||||||
<span className="text-muted">{t("sidebar.to")}</span>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={filters.dateTo ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFilters({
|
|
||||||
...filters,
|
|
||||||
dateTo: e.target.value || undefined,
|
|
||||||
maxAgeDays: undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{(filters.dateFrom || filters.dateTo) && (
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
setFilters({ ...filters, dateFrom: undefined, dateTo: undefined })
|
|
||||||
}
|
|
||||||
className="text-2xs text-muted hover:text-accent self-end"
|
|
||||||
>
|
|
||||||
{t("sidebar.clearDates")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
case "language": {
|
case "language": {
|
||||||
return (
|
return (
|
||||||
<Combobox
|
<Combobox
|
||||||
@@ -221,26 +152,18 @@ export default function Sidebar({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "tags": {
|
case "tags": {
|
||||||
|
// The widget's own "Manage" trigger is its title (see the PanelItem onTitleClick below), so the
|
||||||
|
// body is just the picker.
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<Combobox
|
||||||
<Combobox
|
label={t("sidebar.widget.tags")}
|
||||||
label={t("sidebar.widget.tags")}
|
options={visibleChips(userTags).map(tagOption)}
|
||||||
options={visibleChips(userTags).map(tagOption)}
|
selected={selectedIn(userTags)}
|
||||||
selected={selectedIn(userTags)}
|
onChange={(next) => setTagSelection(userTags, next)}
|
||||||
onChange={(next) => setTagSelection(userTags, next)}
|
emptyText={t("sidebar.noMatchingTags")}
|
||||||
emptyText={t("sidebar.noMatchingTags")}
|
matchMode={filters.tagModes.other}
|
||||||
matchMode={filters.tagModes.other}
|
onMatchModeChange={(m) => setTagMode("other", m)}
|
||||||
onMatchModeChange={(m) => setTagMode("other", m)}
|
/>
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => setTagManagerOpen(true)}
|
|
||||||
title={t("sidebar.manageTags")}
|
|
||||||
className="inline-flex items-center gap-1 self-start text-xs px-2 py-1 rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
|
|
||||||
>
|
|
||||||
<Pencil className="w-3 h-3" />
|
|
||||||
{t("sidebar.manageTags")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
@@ -252,19 +175,15 @@ export default function Sidebar({
|
|||||||
// the actual render order is the user's saved layout (PanelGroups orders by it).
|
// the actual render order is the user's saved layout (PanelGroups orders by it).
|
||||||
const items: PanelItem[] = defaultLayout(source)
|
const items: PanelItem[] = defaultLayout(source)
|
||||||
.order.filter((id) => available[id])
|
.order.filter((id) => available[id])
|
||||||
.map((id) => ({ id, title: t("sidebar.widget." + id), body: widgetBody(id) }));
|
.map((id) => ({
|
||||||
|
id,
|
||||||
const actions = (
|
title: t("sidebar.widget." + id),
|
||||||
<>
|
body: widgetBody(id),
|
||||||
<button
|
// The widgets aren't collapsible, so a widget's title can act as its own trigger: "Your tags"
|
||||||
onClick={clearAll}
|
// opens the tag manager (replacing the old inline Manage button).
|
||||||
disabled={!active}
|
onTitleClick: id === "tags" ? () => setTagManagerOpen(true) : undefined,
|
||||||
className="text-xs text-muted enabled:hover:text-accent disabled:opacity-40 transition px-1 whitespace-nowrap"
|
titleActionLabel: id === "tags" ? t("sidebar.manageTags") : undefined,
|
||||||
>
|
}));
|
||||||
{t("sidebar.clearAll")}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FilterRail
|
<FilterRail
|
||||||
@@ -277,7 +196,8 @@ export default function Sidebar({
|
|||||||
layout={layout}
|
layout={layout}
|
||||||
setLayout={setLayout}
|
setLayout={setLayout}
|
||||||
items={items}
|
items={items}
|
||||||
actions={actions}
|
// The feed widgets are simple label + control rows, not collapsible islands.
|
||||||
|
collapsible={false}
|
||||||
// Saved ("personal") views: the header icon opens a quick-pick popover, the title opens the
|
// Saved ("personal") views: the header icon opens a quick-pick popover, the title opens the
|
||||||
// full manager modal. Hidden for the demo account (no per-user views). Feed rail only.
|
// full manager modal. Hidden for the demo account (no per-user views). Feed rail only.
|
||||||
iconExpanded={
|
iconExpanded={
|
||||||
|
|||||||
@@ -67,9 +67,19 @@
|
|||||||
"datePreset": {
|
"datePreset": {
|
||||||
"24h": "24h",
|
"24h": "24h",
|
||||||
"1week": "1 week",
|
"1week": "1 week",
|
||||||
|
"2weeks": "2 weeks",
|
||||||
"1month": "1 month",
|
"1month": "1 month",
|
||||||
|
"3months": "3 months",
|
||||||
"6months": "6 months",
|
"6months": "6 months",
|
||||||
"1year": "1 year"
|
"1year": "1 year",
|
||||||
|
"2years": "2 years",
|
||||||
|
"5years": "5 years"
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"anyTime": "Any time",
|
||||||
|
"customRange": "Custom range",
|
||||||
|
"fromShort": "From {{date}}",
|
||||||
|
"untilShort": "Until {{date}}"
|
||||||
},
|
},
|
||||||
"channelSearch": "Search channels…",
|
"channelSearch": "Search channels…",
|
||||||
"noChannelMatch": "No channel matches.",
|
"noChannelMatch": "No channel matches.",
|
||||||
|
|||||||
@@ -67,9 +67,19 @@
|
|||||||
"datePreset": {
|
"datePreset": {
|
||||||
"24h": "24 óra",
|
"24h": "24 óra",
|
||||||
"1week": "1 hét",
|
"1week": "1 hét",
|
||||||
|
"2weeks": "2 hét",
|
||||||
"1month": "1 hónap",
|
"1month": "1 hónap",
|
||||||
|
"3months": "3 hónap",
|
||||||
"6months": "6 hónap",
|
"6months": "6 hónap",
|
||||||
"1year": "1 év"
|
"1year": "1 év",
|
||||||
|
"2years": "2 év",
|
||||||
|
"5years": "5 év"
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"anyTime": "Bármikor",
|
||||||
|
"customRange": "Egyéni tartomány",
|
||||||
|
"fromShort": "{{date}}-től",
|
||||||
|
"untilShort": "{{date}}-ig"
|
||||||
},
|
},
|
||||||
"channelSearch": "Csatornák keresése…",
|
"channelSearch": "Csatornák keresése…",
|
||||||
"noChannelMatch": "Nincs találat.",
|
"noChannelMatch": "Nincs találat.",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { applyDateChip, deriveDateKey, type DatePreset } from "./dateFilter";
|
import { selectDateChip, deriveDateKey, type DatePreset } from "./dateFilter";
|
||||||
import { type FeedFilters } from "./api";
|
import { type FeedFilters } from "./api";
|
||||||
|
|
||||||
const PRESETS: DatePreset[] = [
|
const PRESETS: DatePreset[] = [
|
||||||
@@ -48,41 +48,43 @@ describe("deriveDateKey", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("applyDateChip", () => {
|
describe("selectDateChip", () => {
|
||||||
it("picking a preset sets maxAgeDays and clears any custom range", () => {
|
it("picking a preset sets maxAgeDays and clears any custom range", () => {
|
||||||
const change = applyDateChip(
|
const change = selectDateChip("7", base({ dateFrom: "2026-01-01", dateTo: "2026-02-01" }));
|
||||||
"7",
|
expect(change.customOpen).toBe(false);
|
||||||
base({ dateFrom: "2026-01-01", dateTo: "2026-02-01" }),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
expect(change.customDates).toBeUndefined();
|
|
||||||
expect(change.filters).toMatchObject({ maxAgeDays: 7, dateFrom: undefined, dateTo: undefined });
|
expect(change.filters).toMatchObject({ maxAgeDays: 7, dateFrom: undefined, dateTo: undefined });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("re-picking the active preset toggles it off", () => {
|
it("picking a preset always sets it (no toggle-off) even when it's already active", () => {
|
||||||
const change = applyDateChip("7", base({ maxAgeDays: 7 }), false);
|
const change = selectDateChip("7", base({ maxAgeDays: 7 }));
|
||||||
expect(change.filters?.maxAgeDays).toBeUndefined();
|
expect(change.filters.maxAgeDays).toBe(7);
|
||||||
|
expect(change.customOpen).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a preset is not 'active' while a custom range is open, so it sets rather than toggling off", () => {
|
it("'any' clears the preset and any custom range", () => {
|
||||||
const change = applyDateChip("7", base({ maxAgeDays: 7 }), true);
|
const change = selectDateChip("any", base({ maxAgeDays: 7, dateFrom: "2026-01-01" }));
|
||||||
expect(change.filters?.maxAgeDays).toBe(7);
|
expect(change.filters).toMatchObject({
|
||||||
|
maxAgeDays: undefined,
|
||||||
|
dateFrom: undefined,
|
||||||
|
dateTo: undefined,
|
||||||
|
});
|
||||||
|
expect(change.customOpen).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opening Custom sets the flag and clears a preset's maxAgeDays", () => {
|
it("an empty key behaves like 'any'", () => {
|
||||||
const change = applyDateChip("custom", base({ maxAgeDays: 7 }), false);
|
const change = selectDateChip("", base({ maxAgeDays: 7 }));
|
||||||
expect(change.customDates).toBe(true);
|
expect(change.filters.maxAgeDays).toBeUndefined();
|
||||||
expect(change.filters).toMatchObject({ maxAgeDays: undefined });
|
expect(change.customOpen).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("closing Custom clears the flag and leaves filters untouched", () => {
|
it("opening Custom sets the flag and clears a preset's maxAgeDays but keeps a typed range", () => {
|
||||||
const change = applyDateChip("custom", base({ maxAgeDays: 7 }), true);
|
const change = selectDateChip("custom", base({ maxAgeDays: 7, dateFrom: "2026-01-01" }));
|
||||||
expect(change.customDates).toBe(false);
|
expect(change.customOpen).toBe(true);
|
||||||
expect(change.filters).toBeUndefined();
|
expect(change.filters).toMatchObject({ maxAgeDays: undefined, dateFrom: "2026-01-01" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves unrelated filter fields when picking a preset", () => {
|
it("preserves unrelated filter fields when picking a preset", () => {
|
||||||
const change = applyDateChip("30", base({ q: "hello", tags: [1, 2] }), false);
|
const change = selectDateChip("30", base({ q: "hello", tags: [1, 2] }));
|
||||||
expect(change.filters).toMatchObject({ q: "hello", tags: [1, 2], maxAgeDays: 30 });
|
expect(change.filters).toMatchObject({ q: "hello", tags: [1, 2], maxAgeDays: 30 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,51 +1,49 @@
|
|||||||
import { type FeedFilters } from "./api";
|
import { type FeedFilters } from "./api";
|
||||||
|
|
||||||
// Pure date-filter selection logic for the feed sidebar's "Upload date" chip row, lifted out of the
|
// Pure date-filter selection logic for the feed sidebar's "Upload date" selector, lifted out of the
|
||||||
// component so the single-select derivation + the preset/custom dispatch can be unit-tested without
|
// component so the single-select derivation + the preset/custom dispatch can be unit-tested without
|
||||||
// mounting Sidebar (same split as columnSort.ts for sort). The row is single-select across the day
|
// mounting Sidebar (same split as columnSort.ts for sort). The control is a single-select dropdown
|
||||||
// presets plus a "Custom" range chip.
|
// across an "Any time" clear, the day presets, and a "Custom range" (reveal-on-select from/to).
|
||||||
|
|
||||||
export interface DatePreset {
|
export interface DatePreset {
|
||||||
days: number;
|
days: number;
|
||||||
key: string;
|
key: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Which date chip is active: a preset's day-count (as a string), "custom", or "" (none). A custom
|
/** Which date option is active: a preset's day-count (as a string), "custom", or "" (none / "Any
|
||||||
* range (the editor is open, or a from/to is set) owns the selection; otherwise a preset matches only
|
* time"). A custom range (the reveal is open, or a from/to is set) owns the selection; otherwise a
|
||||||
* when no custom range is active; a maxAge that matches no preset (e.g. a shared-link value) is none. */
|
* preset matches only when no custom range is active; a maxAge that matches no preset (e.g. a
|
||||||
|
* shared-link value) is none. */
|
||||||
export function deriveDateKey(
|
export function deriveDateKey(
|
||||||
filters: Pick<FeedFilters, "maxAgeDays" | "dateFrom" | "dateTo">,
|
filters: Pick<FeedFilters, "maxAgeDays" | "dateFrom" | "dateTo">,
|
||||||
customDates: boolean,
|
customOpen: boolean,
|
||||||
presets: readonly DatePreset[],
|
presets: readonly DatePreset[],
|
||||||
): string {
|
): string {
|
||||||
if (customDates || filters.dateFrom || filters.dateTo) return "custom";
|
if (customOpen || filters.dateFrom || filters.dateTo) return "custom";
|
||||||
const preset = presets.find((p) => filters.maxAgeDays === p.days);
|
const preset = presets.find((p) => filters.maxAgeDays === p.days);
|
||||||
return preset ? String(preset.days) : "";
|
return preset ? String(preset.days) : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The state change when a date chip is picked. A preset is single-select with toggle-off (re-picking
|
/** The state change when a date option is picked from the dropdown. Unlike the old chip row there is no
|
||||||
* the active preset clears it) and clears any custom range; "custom" toggles the range editor and,
|
* toggle-off — "Any time" is the explicit clear. A preset sets maxAge and clears any custom range;
|
||||||
* when opened, clears a preset. Returns the next `filters` (only when they change) and/or the next
|
* "custom" opens the reveal and clears the preset (keeping any typed range); "any"/"" clears everything.
|
||||||
* `customDates` flag (only when it changes) so the caller applies exactly what moved. */
|
* Returns the next `filters` and the next `customOpen` flag so the caller applies both. */
|
||||||
export function applyDateChip(
|
export function selectDateChip(
|
||||||
key: string,
|
key: string,
|
||||||
filters: FeedFilters,
|
filters: FeedFilters,
|
||||||
customDates: boolean,
|
): { filters: FeedFilters; customOpen: boolean } {
|
||||||
): { filters?: FeedFilters; customDates?: boolean } {
|
|
||||||
if (key === "custom") {
|
if (key === "custom") {
|
||||||
const next = !customDates;
|
return { filters: { ...filters, maxAgeDays: undefined }, customOpen: true };
|
||||||
return next
|
}
|
||||||
? { customDates: true, filters: { ...filters, maxAgeDays: undefined } }
|
if (key === "any" || key === "") {
|
||||||
: { customDates: false };
|
return {
|
||||||
|
filters: { ...filters, maxAgeDays: undefined, dateFrom: undefined, dateTo: undefined },
|
||||||
|
customOpen: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const days = Number(key);
|
const days = Number(key);
|
||||||
const activePreset = filters.maxAgeDays === days && !customDates;
|
|
||||||
return {
|
return {
|
||||||
filters: {
|
filters: { ...filters, maxAgeDays: days, dateFrom: undefined, dateTo: undefined },
|
||||||
...filters,
|
customOpen: false,
|
||||||
maxAgeDays: activePreset ? undefined : days,
|
|
||||||
dateFrom: undefined,
|
|
||||||
dateTo: undefined,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user