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
+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={