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:
@@ -0,0 +1,45 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X } from "lucide-react";
|
||||
import type { ActiveFilter } from "../lib/useActiveFilters";
|
||||
|
||||
// The applied-filter row above the feed: every narrowing currently in force, each removable on its
|
||||
// own. It exists because the filter panel now sits collapsed as a tab — without this you'd have to
|
||||
// open the panel just to see what you'd filtered by.
|
||||
export default function ActiveFilterChips({
|
||||
filters,
|
||||
onClearAll,
|
||||
}: {
|
||||
filters: ActiveFilter[];
|
||||
onClearAll: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (filters.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5 pb-3" aria-label={t("feed.chips.label")}>
|
||||
<span className="text-[11px] uppercase tracking-wider text-muted mr-0.5">
|
||||
{t("feed.chips.label")}
|
||||
</span>
|
||||
{filters.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
onClick={f.remove}
|
||||
title={t("feed.chips.remove", { name: f.label })}
|
||||
aria-label={t("feed.chips.remove", { name: f.label })}
|
||||
className="group inline-flex items-center gap-1 pl-2.5 pr-1.5 py-1 rounded-full border border-accent/40 bg-accent/10 text-accent text-xs hover:border-accent hover:bg-accent/20 transition"
|
||||
>
|
||||
<span className="max-w-[14rem] truncate">{f.label}</span>
|
||||
<X className="w-3 h-3 shrink-0 opacity-60 group-hover:opacity-100" />
|
||||
</button>
|
||||
))}
|
||||
{filters.length > 1 && (
|
||||
<button
|
||||
onClick={onClearAll}
|
||||
className="ml-0.5 text-xs text-muted hover:text-fg underline underline-offset-2 transition"
|
||||
>
|
||||
{t("sidebar.clearAll")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
SORT_KEYS,
|
||||
type SortKey,
|
||||
} from "../lib/feedSort";
|
||||
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
||||
import ActiveFilterChips from "./ActiveFilterChips";
|
||||
import VirtualFeed from "./VirtualFeed";
|
||||
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
|
||||
const PlayerModal = lazy(() => import("./PlayerModal"));
|
||||
@@ -84,6 +86,8 @@ export default function Feed({
|
||||
channelScoped?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// Same list the panel's badge counts — see lib/useActiveFilters.
|
||||
const activeFilters = useActiveFilters(filters, setFilters);
|
||||
const sortKeys = channelScoped
|
||||
? SORT_KEYS.filter((k) => k !== "subscribers" && k !== "priority")
|
||||
: SORT_KEYS;
|
||||
@@ -625,6 +629,7 @@ export default function Feed({
|
||||
return (
|
||||
<div className="p-4">
|
||||
{toolbar}
|
||||
<ActiveFilterChips filters={activeFilters} onClearAll={() => setFilters(clearedFilters(filters))} />
|
||||
{items.length === 0 ? (
|
||||
<div className="py-16 text-center text-muted">
|
||||
<p>{t("feed.noMatches")}</p>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
|
||||
import { shareUrl } from "../lib/urlState";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
||||
import TagManager from "./TagManager";
|
||||
import SavedViewsWidget from "./SavedViewsWidget";
|
||||
import SidePanel from "./SidePanel";
|
||||
@@ -21,18 +22,6 @@ const DATE_PRESETS: { days: number; key: string }[] = [
|
||||
{ days: 365, key: "1year" },
|
||||
];
|
||||
|
||||
// Filter values owned by the sidebar (everything except the header search `q` and the
|
||||
// `scope` mode, both preserved across a "Clear all").
|
||||
const DEFAULT_SIDEBAR_FILTERS: Omit<FeedFilters, "q" | "scope"> = {
|
||||
tags: [],
|
||||
tagMode: "or",
|
||||
sort: "newest",
|
||||
includeNormal: true,
|
||||
includeShorts: false,
|
||||
includeLive: false,
|
||||
show: "unwatched",
|
||||
};
|
||||
|
||||
function TagChip({
|
||||
tag,
|
||||
count,
|
||||
@@ -143,21 +132,9 @@ export default function Sidebar({
|
||||
});
|
||||
}
|
||||
|
||||
const dateActive = !!filters.maxAgeDays || !!filters.dateFrom || !!filters.dateTo;
|
||||
const contentChanged =
|
||||
!filters.includeNormal || filters.includeShorts || filters.includeLive;
|
||||
const scopeActive = filters.scope === "all";
|
||||
const sourceActive =
|
||||
filters.scope === "all" && !!filters.librarySource && filters.librarySource !== "organic";
|
||||
const activeCount =
|
||||
filters.tags.length +
|
||||
(filters.show !== "unwatched" ? 1 : 0) +
|
||||
(filters.sort !== "newest" && filters.sort !== "relevance" ? 1 : 0) +
|
||||
(contentChanged ? 1 : 0) +
|
||||
(filters.channelId ? 1 : 0) +
|
||||
(dateActive ? 1 : 0) +
|
||||
(scopeActive ? 1 : 0) +
|
||||
(sourceActive ? 1 : 0);
|
||||
// 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;
|
||||
|
||||
async function shareView() {
|
||||
@@ -171,7 +148,7 @@ export default function Sidebar({
|
||||
|
||||
function clearAll() {
|
||||
setCustomDates(false);
|
||||
setFilters({ ...DEFAULT_SIDEBAR_FILTERS, q: filters.q, scope: "my" });
|
||||
setFilters(clearedFilters(filters));
|
||||
}
|
||||
|
||||
const available: Record<string, boolean> = {
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
{
|
||||
"chips": {
|
||||
"label": "Filtered by",
|
||||
"remove": "Remove filter: {{name}}",
|
||||
"contentNone": "No content types",
|
||||
"maxAge_one": "Last {{count}} day",
|
||||
"maxAge_other": "Last {{count}} days"
|
||||
},
|
||||
"loading": "Loading feed…",
|
||||
"loadError": "Couldn't load the feed.",
|
||||
"emptyTitle": "Your feed is empty",
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
{
|
||||
"chips": {
|
||||
"label": "Szűrve",
|
||||
"remove": "Szűrő eltávolítása: {{name}}",
|
||||
"contentNone": "Nincs tartalomtípus",
|
||||
"maxAge_one": "Utolsó {{count}} nap",
|
||||
"maxAge_other": "Utolsó {{count}} nap"
|
||||
},
|
||||
"loading": "Hírfolyam betöltése…",
|
||||
"loadError": "Nem sikerült betölteni a hírfolyamot.",
|
||||
"emptyTitle": "A hírfolyamod üres",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user