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,
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user