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:
2026-08-22 05:42:30 +02:00
parent 7ebbf45921
commit fca989c098
9 changed files with 368 additions and 185 deletions
@@ -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>
</>
);
}
+10 -1
View File
@@ -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}
>
<PanelGroups items={items} layout={layout} setLayout={setLayout} editing={editing} />
<PanelGroups
items={items}
layout={layout}
setLayout={setLayout}
editing={editing}
collapsible={collapsible}
/>
{children}
</SidePanel>
);
+51 -21
View File
@@ -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 (
<div
@@ -53,13 +67,27 @@ export default function PanelGroup({
<GripVertical className="w-4 h-4" />
</button>
)}
<button
onClick={editing ? undefined : onToggleCollapse}
disabled={editing}
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"
>
<span className="truncate">{title}</span>
</button>
{collapsible && !editing ? (
<button
onClick={onToggleCollapse}
className={`${titleCls} text-muted hover:text-fg transition`}
>
<span className="truncate">{title}</span>
</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 ? (
<button
onClick={onToggleHidden}
@@ -69,15 +97,17 @@ export default function PanelGroup({
{hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
) : (
<button
onClick={onToggleCollapse}
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" : ""}`}
/>
</button>
collapsible && (
<button
onClick={onToggleCollapse}
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" : ""}`}
/>
</button>
)
)}
</div>
{showBody && <div className="px-3 pb-3">{children}</div>}
+17 -1
View File
@@ -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}
</PanelGroup>
+31 -111
View File
@@ -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<string, boolean> = {
date: true,
@@ -125,75 +121,10 @@ export default function Sidebar({
switch (id) {
case "channel":
return <ChannelPicker filters={filters} setFilters={setFilters} />;
case "date": {
// Single-select across the presets + Custom; pure logic lives in lib/dateFilter.
const dateKey = deriveDateKey(filters, customDates, DATE_PRESETS);
case "date":
return (
<>
<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>
)}
</>
<DateFilterSelect filters={filters} setFilters={setFilters} presets={DATE_PRESETS} />
);
}
case "language": {
return (
<Combobox
@@ -221,26 +152,18 @@ export default function Sidebar({
);
}
case "tags": {
// The widget's own "Manage" trigger is its title (see the PanelItem onTitleClick below), so the
// body is just the picker.
return (
<div className="flex flex-col gap-2">
<Combobox
label={t("sidebar.widget.tags")}
options={visibleChips(userTags).map(tagOption)}
selected={selectedIn(userTags)}
onChange={(next) => setTagSelection(userTags, next)}
emptyText={t("sidebar.noMatchingTags")}
matchMode={filters.tagModes.other}
onMatchModeChange={(m) => setTagMode("other", m)}
/>
<button
onClick={() => setTagManagerOpen(true)}
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>
<Combobox
label={t("sidebar.widget.tags")}
options={visibleChips(userTags).map(tagOption)}
selected={selectedIn(userTags)}
onChange={(next) => 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 = (
<>
<button
onClick={clearAll}
disabled={!active}
className="text-xs text-muted enabled:hover:text-accent disabled:opacity-40 transition px-1 whitespace-nowrap"
>
{t("sidebar.clearAll")}
</button>
</>
);
.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 (
<FilterRail
@@ -277,7 +196,8 @@ export default function Sidebar({
layout={layout}
setLayout={setLayout}
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
// full manager modal. Hidden for the demo account (no per-user views). Feed rail only.
iconExpanded={
+11 -1
View File
@@ -67,9 +67,19 @@
"datePreset": {
"24h": "24h",
"1week": "1 week",
"2weeks": "2 weeks",
"1month": "1 month",
"3months": "3 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…",
"noChannelMatch": "No channel matches.",
+11 -1
View File
@@ -67,9 +67,19 @@
"datePreset": {
"24h": "24 óra",
"1week": "1 hét",
"2weeks": "2 hét",
"1month": "1 hónap",
"3months": "3 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…",
"noChannelMatch": "Nincs találat.",
+25 -23
View File
@@ -1,5 +1,5 @@
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";
const PRESETS: DatePreset[] = [
@@ -48,41 +48,43 @@ describe("deriveDateKey", () => {
});
});
describe("applyDateChip", () => {
describe("selectDateChip", () => {
it("picking a preset sets maxAgeDays and clears any custom range", () => {
const change = applyDateChip(
"7",
base({ dateFrom: "2026-01-01", dateTo: "2026-02-01" }),
false,
);
expect(change.customDates).toBeUndefined();
const change = selectDateChip("7", base({ dateFrom: "2026-01-01", dateTo: "2026-02-01" }));
expect(change.customOpen).toBe(false);
expect(change.filters).toMatchObject({ maxAgeDays: 7, dateFrom: undefined, dateTo: undefined });
});
it("re-picking the active preset toggles it off", () => {
const change = applyDateChip("7", base({ maxAgeDays: 7 }), false);
expect(change.filters?.maxAgeDays).toBeUndefined();
it("picking a preset always sets it (no toggle-off) even when it's already active", () => {
const change = selectDateChip("7", base({ maxAgeDays: 7 }));
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", () => {
const change = applyDateChip("7", base({ maxAgeDays: 7 }), true);
expect(change.filters?.maxAgeDays).toBe(7);
it("'any' clears the preset and any custom range", () => {
const change = selectDateChip("any", base({ maxAgeDays: 7, dateFrom: "2026-01-01" }));
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", () => {
const change = applyDateChip("custom", base({ maxAgeDays: 7 }), false);
expect(change.customDates).toBe(true);
expect(change.filters).toMatchObject({ maxAgeDays: undefined });
it("an empty key behaves like 'any'", () => {
const change = selectDateChip("", base({ maxAgeDays: 7 }));
expect(change.filters.maxAgeDays).toBeUndefined();
expect(change.customOpen).toBe(false);
});
it("closing Custom clears the flag and leaves filters untouched", () => {
const change = applyDateChip("custom", base({ maxAgeDays: 7 }), true);
expect(change.customDates).toBe(false);
expect(change.filters).toBeUndefined();
it("opening Custom sets the flag and clears a preset's maxAgeDays but keeps a typed range", () => {
const change = selectDateChip("custom", base({ maxAgeDays: 7, dateFrom: "2026-01-01" }));
expect(change.customOpen).toBe(true);
expect(change.filters).toMatchObject({ maxAgeDays: undefined, dateFrom: "2026-01-01" });
});
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 });
});
});
+24 -26
View File
@@ -1,51 +1,49 @@
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
// mounting Sidebar (same split as columnSort.ts for sort). The row is single-select across the day
// presets plus a "Custom" range chip.
// mounting Sidebar (same split as columnSort.ts for sort). The control is a single-select dropdown
// across an "Any time" clear, the day presets, and a "Custom range" (reveal-on-select from/to).
export interface DatePreset {
days: number;
key: string;
}
/** Which date chip is active: a preset's day-count (as a string), "custom", or "" (none). A custom
* range (the editor is open, or a from/to is set) owns the selection; otherwise a preset matches only
* when no custom range is active; a maxAge that matches no preset (e.g. a shared-link value) is none. */
/** Which date option is active: a preset's day-count (as a string), "custom", or "" (none / "Any
* time"). A custom range (the reveal is open, or a from/to is set) owns the selection; otherwise a
* 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(
filters: Pick<FeedFilters, "maxAgeDays" | "dateFrom" | "dateTo">,
customDates: boolean,
customOpen: boolean,
presets: readonly DatePreset[],
): 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);
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 active preset clears it) and clears any custom range; "custom" toggles the range editor and,
* when opened, clears a preset. Returns the next `filters` (only when they change) and/or the next
* `customDates` flag (only when it changes) so the caller applies exactly what moved. */
export function applyDateChip(
/** The state change when a date option is picked from the dropdown. Unlike the old chip row there is no
* toggle-off — "Any time" is the explicit clear. A preset sets maxAge and clears any custom range;
* "custom" opens the reveal and clears the preset (keeping any typed range); "any"/"" clears everything.
* Returns the next `filters` and the next `customOpen` flag so the caller applies both. */
export function selectDateChip(
key: string,
filters: FeedFilters,
customDates: boolean,
): { filters?: FeedFilters; customDates?: boolean } {
): { filters: FeedFilters; customOpen: boolean } {
if (key === "custom") {
const next = !customDates;
return next
? { customDates: true, filters: { ...filters, maxAgeDays: undefined } }
: { customDates: false };
return { filters: { ...filters, maxAgeDays: undefined }, customOpen: true };
}
if (key === "any" || key === "") {
return {
filters: { ...filters, maxAgeDays: undefined, dateFrom: undefined, dateTo: undefined },
customOpen: false,
};
}
const days = Number(key);
const activePreset = filters.maxAgeDays === days && !customDates;
return {
filters: {
...filters,
maxAgeDays: activePreset ? undefined : days,
dateFrom: undefined,
dateTo: undefined,
},
filters: { ...filters, maxAgeDays: days, dateFrom: undefined, dateTo: undefined },
customOpen: false,
};
}