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:
parent
79e7694b24
commit
79f53ccf59
5 changed files with 63 additions and 17 deletions
|
|
@ -74,10 +74,12 @@ const DEFAULT_SIDEBAR_FILTERS: Omit<FeedFilters, "q" | "scope"> = {
|
||||||
|
|
||||||
function TagChip({
|
function TagChip({
|
||||||
tag,
|
tag,
|
||||||
|
count,
|
||||||
active,
|
active,
|
||||||
onClick,
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
tag: Tag;
|
tag: Tag;
|
||||||
|
count: number;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -85,7 +87,7 @@ function TagChip({
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
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 ${
|
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
|
active
|
||||||
? "bg-accent text-accent-fg border-accent"
|
? "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"
|
active ? "bg-accent-fg/20 text-accent-fg" : "bg-muted/15 text-muted"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tag.channel_count}
|
{count}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
@ -119,6 +121,26 @@ export default function Sidebar({
|
||||||
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
|
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
|
||||||
const tags = tagsQuery.data ?? [];
|
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
|
// 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".
|
// 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
|
// 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 (
|
return (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{languages.map((t) => (
|
{chips.map((tg) => (
|
||||||
<TagChip
|
<TagChip
|
||||||
key={t.id}
|
key={tg.id}
|
||||||
tag={t}
|
tag={tg}
|
||||||
active={filters.tags.includes(t.id)}
|
count={chipCount(tg)}
|
||||||
onClick={() => toggleTag(t.id)}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
case "topic":
|
}
|
||||||
|
case "topic": {
|
||||||
|
const chips = visibleChips(topics);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-end mb-1.5">
|
<div className="flex justify-end mb-1.5">
|
||||||
|
|
@ -390,17 +419,22 @@ export default function Sidebar({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{topics.map((t) => (
|
{chips.map((tg) => (
|
||||||
<TagChip
|
<TagChip
|
||||||
key={t.id}
|
key={tg.id}
|
||||||
tag={t}
|
tag={tg}
|
||||||
active={filters.tags.includes(t.id)}
|
count={chipCount(tg)}
|
||||||
onClick={() => toggleTag(t.id)}
|
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>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@
|
||||||
"to": "Bis",
|
"to": "Bis",
|
||||||
"clearDates": "Daten löschen",
|
"clearDates": "Daten löschen",
|
||||||
"reshuffle": "Neu mischen",
|
"reshuffle": "Neu mischen",
|
||||||
|
"noMatchingTags": "Keine passenden Tags",
|
||||||
"widget": {
|
"widget": {
|
||||||
"show": "Anzeigen",
|
"show": "Anzeigen",
|
||||||
"sort": "Sortierung",
|
"sort": "Sortierung",
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@
|
||||||
"to": "To",
|
"to": "To",
|
||||||
"clearDates": "clear dates",
|
"clearDates": "clear dates",
|
||||||
"reshuffle": "Reshuffle",
|
"reshuffle": "Reshuffle",
|
||||||
|
"noMatchingTags": "No matching tags here",
|
||||||
"widget": {
|
"widget": {
|
||||||
"show": "Show",
|
"show": "Show",
|
||||||
"sort": "Sort",
|
"sort": "Sort",
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@
|
||||||
"to": "Eddig",
|
"to": "Eddig",
|
||||||
"clearDates": "dátumok törlése",
|
"clearDates": "dátumok törlése",
|
||||||
"reshuffle": "Újrakeverés",
|
"reshuffle": "Újrakeverés",
|
||||||
|
"noMatchingTags": "Nincs ide illő címke",
|
||||||
"widget": {
|
"widget": {
|
||||||
"show": "Megjelenítés",
|
"show": "Megjelenítés",
|
||||||
"sort": "Rendezés",
|
"sort": "Rendezés",
|
||||||
|
|
|
||||||
|
|
@ -152,13 +152,13 @@ async function req(url: string, opts: RequestInit = {}): Promise<any> {
|
||||||
return r.status === 204 ? null : r.json();
|
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();
|
const p = new URLSearchParams();
|
||||||
f.tags.forEach((t) => p.append("tags", String(t)));
|
f.tags.forEach((t) => p.append("tags", String(t)));
|
||||||
p.set("tag_mode", f.tagMode);
|
p.set("tag_mode", f.tagMode);
|
||||||
if (f.q) p.set("q", f.q);
|
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("scope", f.scope);
|
||||||
p.set("show_normal", String(f.includeNormal));
|
p.set("show_normal", String(f.includeNormal));
|
||||||
p.set("include_shorts", String(f.includeShorts));
|
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.dateTo) p.set("published_before", f.dateTo);
|
||||||
if (f.minDuration != null) p.set("min_duration", String(f.minDuration));
|
if (f.minDuration != null) p.set("min_duration", String(f.minDuration));
|
||||||
if (f.maxDuration != null) p.set("max_duration", String(f.maxDuration));
|
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("offset", String(offset));
|
||||||
p.set("limit", String(limit));
|
p.set("limit", String(limit));
|
||||||
return p.toString();
|
return p.toString();
|
||||||
|
|
@ -252,6 +259,8 @@ export const api = {
|
||||||
req(`/api/feed?${feedQuery(f, offset, limit)}`),
|
req(`/api/feed?${feedQuery(f, offset, limit)}`),
|
||||||
feedCount: (f: FeedFilters): Promise<{ count: number }> =>
|
feedCount: (f: FeedFilters): Promise<{ count: number }> =>
|
||||||
req(`/api/feed/count?${feedQuery(f, 0, 0)}`),
|
req(`/api/feed/count?${feedQuery(f, 0, 0)}`),
|
||||||
|
facets: (f: FeedFilters): Promise<{ counts: Record<string, number> }> =>
|
||||||
|
req(`/api/facets?${filterParams(f).toString()}`),
|
||||||
setState: (id: string, status: string) =>
|
setState: (id: string, status: string) =>
|
||||||
req(`/api/videos/${id}/state`, { method: "POST", body: JSON.stringify({ status }) }),
|
req(`/api/videos/${id}/state`, { method: "POST", body: JSON.stringify({ status }) }),
|
||||||
saveProgress: (id: string, positionSeconds: number, durationSeconds: number) =>
|
saveProgress: (id: string, positionSeconds: number, durationSeconds: number) =>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue