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) =>