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
+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,
};
}