From 79f53ccf59206dae2e483e28a4e95b14941a4058 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 12:06:02 +0200 Subject: [PATCH] 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. --- frontend/src/components/Sidebar.tsx | 62 ++++++++++++++++++----- frontend/src/i18n/locales/de/sidebar.json | 1 + frontend/src/i18n/locales/en/sidebar.json | 1 + frontend/src/i18n/locales/hu/sidebar.json | 1 + frontend/src/lib/api.ts | 15 ++++-- 5 files changed, 63 insertions(+), 17 deletions(-) diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 3ab6c97..1e5a452 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -74,10 +74,12 @@ const DEFAULT_SIDEBAR_FILTERS: Omit = { function TagChip({ tag, + count, active, onClick, }: { tag: Tag; + count: number; active: boolean; onClick: () => void; }) { @@ -85,7 +87,7 @@ function TagChip({ return ( ); @@ -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 (
- {languages.map((t) => ( + {chips.map((tg) => ( 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 && ( + {t("sidebar.noMatchingTags")} + )}
); - case "topic": + } + case "topic": { + const chips = visibleChips(topics); return ( <>
@@ -390,17 +419,22 @@ export default function Sidebar({
- {topics.map((t) => ( + {chips.map((tg) => ( 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 && ( + {t("sidebar.noMatchingTags")} + )}
); + } } } diff --git a/frontend/src/i18n/locales/de/sidebar.json b/frontend/src/i18n/locales/de/sidebar.json index 221aa2f..11ccab4 100644 --- a/frontend/src/i18n/locales/de/sidebar.json +++ b/frontend/src/i18n/locales/de/sidebar.json @@ -24,6 +24,7 @@ "to": "Bis", "clearDates": "Daten löschen", "reshuffle": "Neu mischen", + "noMatchingTags": "Keine passenden Tags", "widget": { "show": "Anzeigen", "sort": "Sortierung", diff --git a/frontend/src/i18n/locales/en/sidebar.json b/frontend/src/i18n/locales/en/sidebar.json index 152f7b9..23b21be 100644 --- a/frontend/src/i18n/locales/en/sidebar.json +++ b/frontend/src/i18n/locales/en/sidebar.json @@ -24,6 +24,7 @@ "to": "To", "clearDates": "clear dates", "reshuffle": "Reshuffle", + "noMatchingTags": "No matching tags here", "widget": { "show": "Show", "sort": "Sort", diff --git a/frontend/src/i18n/locales/hu/sidebar.json b/frontend/src/i18n/locales/hu/sidebar.json index 5c5cc0e..0c4cbab 100644 --- a/frontend/src/i18n/locales/hu/sidebar.json +++ b/frontend/src/i18n/locales/hu/sidebar.json @@ -24,6 +24,7 @@ "to": "Eddig", "clearDates": "dátumok törlése", "reshuffle": "Újrakeverés", + "noMatchingTags": "Nincs ide illő címke", "widget": { "show": "Megjelenítés", "sort": "Rendezés", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index e813069..52b0a97 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -152,13 +152,13 @@ async function req(url: string, opts: RequestInit = {}): Promise { return r.status === 204 ? null : r.json(); } -function feedQuery(f: FeedFilters, offset: number, limit: number): string { +// The filter half of the query (everything except paging/sort), shared by the feed and +// the facet-count endpoint so both see exactly the same filter context. +function filterParams(f: FeedFilters): URLSearchParams { const p = new URLSearchParams(); f.tags.forEach((t) => p.append("tags", String(t))); p.set("tag_mode", f.tagMode); if (f.q) p.set("q", f.q); - p.set("sort", f.sort); - if (f.sort === "shuffle" && f.seed) p.set("seed", String(f.seed)); p.set("scope", f.scope); p.set("show_normal", String(f.includeNormal)); p.set("include_shorts", String(f.includeShorts)); @@ -170,6 +170,13 @@ function feedQuery(f: FeedFilters, offset: number, limit: number): string { if (f.dateTo) p.set("published_before", f.dateTo); if (f.minDuration != null) p.set("min_duration", String(f.minDuration)); if (f.maxDuration != null) p.set("max_duration", String(f.maxDuration)); + return p; +} + +function feedQuery(f: FeedFilters, offset: number, limit: number): string { + const p = filterParams(f); + p.set("sort", f.sort); + if (f.sort === "shuffle" && f.seed) p.set("seed", String(f.seed)); p.set("offset", String(offset)); p.set("limit", String(limit)); return p.toString(); @@ -252,6 +259,8 @@ export const api = { req(`/api/feed?${feedQuery(f, offset, limit)}`), feedCount: (f: FeedFilters): Promise<{ count: number }> => req(`/api/feed/count?${feedQuery(f, 0, 0)}`), + facets: (f: FeedFilters): Promise<{ counts: Record }> => + req(`/api/facets?${filterParams(f).toString()}`), setState: (id: string, status: string) => req(`/api/videos/${id}/state`, { method: "POST", body: JSON.stringify({ status }) }), saveProgress: (id: string, positionSeconds: number, durationSeconds: number) =>