feat(filters): dynamic faceted chips driven by /api/facets

Topic and language chips now show live channel counts for the current filter
context instead of the static global count, and chips that match nothing are
hidden (selected chips stay so they can be cleared). Selecting a channel (or any
filter) drops the now-irrelevant chips and updates the rest. Extract a shared
filterParams() so the feed and facets queries see identical filters; the facets
query is keyed on filters so it refetches as they change. Trilingual empty-state
string when a category has no matching tags.
This commit is contained in:
npeter83 2026-06-15 12:06:02 +02:00
parent 17e64156b8
commit d910bca5cf
5 changed files with 63 additions and 17 deletions

View file

@ -74,10 +74,12 @@ const DEFAULT_SIDEBAR_FILTERS: Omit<FeedFilters, "q" | "scope"> = {
function TagChip({
tag,
count,
active,
onClick,
}: {
tag: Tag;
count: number;
active: boolean;
onClick: () => void;
}) {
@ -85,7 +87,7 @@ function TagChip({
return (
<button
onClick={onClick}
title={t("sidebar.channelCount", { count: tag.channel_count })}
title={t("sidebar.channelCount", { count })}
className={`inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border shadow-sm hover:shadow active:translate-y-px transition ${
active
? "bg-accent text-accent-fg border-accent"
@ -98,7 +100,7 @@ function TagChip({
active ? "bg-accent-fg/20 text-accent-fg" : "bg-muted/15 text-muted"
}`}
>
{tag.channel_count}
{count}
</span>
</button>
);
@ -119,6 +121,26 @@ export default function Sidebar({
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
const tags = tagsQuery.data ?? [];
// Live per-tag channel counts for the current filter context (scope, channel, date,
// search, watch state, other category's tags). Lets us show contextual counts and hide
// chips that no longer match anything. Keyed on filters so it refetches as they change.
const facetsQuery = useQuery({
queryKey: ["facets", filters],
queryFn: () => api.facets(filters),
staleTime: 30_000,
});
const facetsReady = !!facetsQuery.data;
const facetCounts = facetsQuery.data?.counts ?? {};
// Before facets load, fall back to the static global count so chips don't flash empty.
const chipCount = (tag: Tag): number =>
facetsReady ? facetCounts[String(tag.id)] ?? 0 : tag.channel_count;
// Visible chips: hide zero-count ones once facets are in, but always keep selected ones
// so an active filter can still be cleared.
const visibleChips = (list: Tag[]): Tag[] =>
facetsReady
? list.filter((tg) => chipCount(tg) > 0 || filters.tags.includes(tg.id))
: list;
// After a page refresh the channel name isn't in the URL (only the id is), so
// filters.channelName is undefined and the chip would fall back to "This channel".
// Resolve the title from the (cached) channel list keyed by id. Only fetch when a
@ -362,20 +384,27 @@ export default function Sidebar({
/>
</>
);
case "language":
case "language": {
const chips = visibleChips(languages);
return (
<div className="flex flex-wrap gap-1.5">
{languages.map((t) => (
{chips.map((tg) => (
<TagChip
key={t.id}
tag={t}
active={filters.tags.includes(t.id)}
onClick={() => toggleTag(t.id)}
key={tg.id}
tag={tg}
count={chipCount(tg)}
active={filters.tags.includes(tg.id)}
onClick={() => toggleTag(tg.id)}
/>
))}
{facetsReady && chips.length === 0 && (
<span className="text-[11px] text-muted">{t("sidebar.noMatchingTags")}</span>
)}
</div>
);
case "topic":
}
case "topic": {
const chips = visibleChips(topics);
return (
<>
<div className="flex justify-end mb-1.5">
@ -390,17 +419,22 @@ export default function Sidebar({
</button>
</div>
<div className="flex flex-wrap gap-1.5">
{topics.map((t) => (
{chips.map((tg) => (
<TagChip
key={t.id}
tag={t}
active={filters.tags.includes(t.id)}
onClick={() => toggleTag(t.id)}
key={tg.id}
tag={tg}
count={chipCount(tg)}
active={filters.tags.includes(tg.id)}
onClick={() => toggleTag(tg.id)}
/>
))}
{facetsReady && chips.length === 0 && (
<span className="text-[11px] text-muted">{t("sidebar.noMatchingTags")}</span>
)}
</div>
</>
);
}
}
}