feat(feed): removable active-filter chips above the feed

With the filter panel now collapsed to a tab, there was no way to see what was
narrowing the feed without opening it. Every applied filter is now a chip you
can drop on its own, with a Clear all when more than one is on.

The panel's count badge and the chip row read the SAME list (lib/useActiveFilters),
so they can't disagree about what's applied; that list also owns the reset both
"Clear all"s share. Replaces the hand-summed activeCount in Sidebar.
This commit is contained in:
2026-07-15 22:11:44 +02:00
parent 837b48cbfa
commit 644d65a3fd
6 changed files with 212 additions and 28 deletions
+143
View File
@@ -0,0 +1,143 @@
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { api, type FeedFilters } from "./api";
import { parseSort } from "./feedSort";
/** One narrowing the user has applied, with the label to show and the way to undo just this one. */
export interface ActiveFilter {
key: string;
label: string;
remove: () => void;
}
/** What the feed looks like with nothing applied — the baseline every "active" check reads against. */
export const DEFAULT_SIDEBAR_FILTERS: Omit<FeedFilters, "q" | "scope"> = {
tags: [],
tagMode: "or",
sort: "newest",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "unwatched",
};
/**
* The reset behind every "Clear all" (the panel's and the chip row's), so they can't drift apart.
* The search term survives — clearing filters isn't the same as abandoning the search — and the
* view returns to your own subscriptions.
*/
export function clearedFilters(current: FeedFilters): FeedFilters {
return { ...DEFAULT_SIDEBAR_FILTERS, q: current.q, scope: "my" };
}
/**
* The single source of truth for "what is currently narrowing the feed" — it backs BOTH the side
* panel's active-count badge and the removable chip row above the feed, so the two can never
* disagree. Tags/channels come from the shared react-query caches, so callers need not thread them.
*
* A dimension counts as active when it differs from the feed's default: show=unwatched,
* sort=newest (relevance is search's implicit default, so it doesn't count either), normal-only
* content, scope=my, source=organic, and no tags/channel/date.
*/
export function useActiveFilters(
filters: FeedFilters,
setFilters: (f: FeedFilters) => void
): ActiveFilter[] {
const { t } = useTranslation();
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
const channelsQuery = useQuery({
queryKey: ["channels"],
queryFn: api.channels,
enabled: !!filters.channelId && !filters.channelName,
staleTime: 5 * 60_000,
});
const out: ActiveFilter[] = [];
// Tags: one chip each — removing one shouldn't drop the rest.
for (const id of filters.tags) {
const tag = (tagsQuery.data ?? []).find((x) => x.id === id);
out.push({
key: `tag:${id}`,
label: tag?.name ?? String(id),
remove: () => setFilters({ ...filters, tags: filters.tags.filter((x) => x !== id) }),
});
}
if (filters.show !== "unwatched") {
out.push({
key: "show",
label: t("sidebar.show." + filters.show),
remove: () => setFilters({ ...filters, show: "unwatched" }),
});
}
if (filters.sort !== "newest" && filters.sort !== "relevance") {
out.push({
key: "sort",
label: t("feed.sortKey." + parseSort(filters.sort).key),
remove: () => setFilters({ ...filters, sort: "newest", seed: undefined }),
});
}
if (!filters.includeNormal || filters.includeShorts || filters.includeLive) {
const on = [
filters.includeNormal && t("sidebar.content.normal"),
filters.includeShorts && t("sidebar.content.shorts"),
filters.includeLive && t("sidebar.content.live"),
].filter(Boolean);
out.push({
key: "content",
// Nothing selected is a legitimate (empty-feed) state — say so rather than render a bare label.
label: on.length ? on.join(" + ") : t("feed.chips.contentNone"),
remove: () =>
setFilters({ ...filters, includeNormal: true, includeShorts: false, includeLive: false }),
});
}
if (filters.channelId) {
const name =
filters.channelName ??
channelsQuery.data?.find((c) => c.id === filters.channelId)?.title ??
t("sidebar.thisChannel");
out.push({
key: "channel",
label: name,
remove: () => setFilters({ ...filters, channelId: undefined, channelName: undefined }),
});
}
if (filters.maxAgeDays || filters.dateFrom || filters.dateTo) {
const label = filters.maxAgeDays
? t("feed.chips.maxAge", { count: filters.maxAgeDays })
: [filters.dateFrom, filters.dateTo].filter(Boolean).join(" – ");
out.push({
key: "date",
label,
remove: () =>
setFilters({ ...filters, maxAgeDays: undefined, dateFrom: undefined, dateTo: undefined }),
});
}
if (filters.scope === "all") {
out.push({
key: "scope",
label: t("header.scope.all"),
// Source is a library-only concept, so it goes back to the default alongside the scope —
// otherwise it would linger as a chip with nothing to apply to.
remove: () => setFilters({ ...filters, scope: "my", librarySource: "organic" }),
});
}
if (filters.scope === "all" && filters.librarySource && filters.librarySource !== "organic") {
// The wire values and the label keys don't line up 1:1 ("search" reads as "only").
const LABEL = { all: "all", search: "only", plex: "plex" } as const;
out.push({
key: "source",
label: t("feed.source." + LABEL[filters.librarySource]),
remove: () => setFilters({ ...filters, librarySource: "organic" }),
});
}
return out;
}