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:
@@ -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={
|
||||
|
||||
Reference in New Issue
Block a user