Merge feature/s3-dynamic-facets: dynamic faceted filter chips

S3a:
- feat(feed): /api/facets endpoint for contextual per-tag channel counts
- feat(filters): dynamic chips (live counts, hide non-matching, sort by count)
- feat(filters): prominent topic Any/All match toggle
- fix(feed): conjunctive facet counts in AND mode
This commit is contained in:
npeter83 2026-06-15 12:25:10 +02:00
commit 581719401e
6 changed files with 145 additions and 26 deletions

View file

@ -67,6 +67,7 @@ def _filtered_query(
include_live: bool,
show: str,
scope: str = "my",
exclude_tag_category: str | None = None,
) -> tuple[Select, object]:
"""Build the feed query (joins + all WHERE filters), shared by /feed and /feed/count.
Returns the column-bearing select plus the watch-status expression for sorting.
@ -153,6 +154,11 @@ def _filtered_query(
by_category: dict[str, list[int]] = {}
for tag_id, category in cat_rows:
by_category.setdefault(category, []).append(tag_id)
# Facet counting drops the category being counted so its own chips don't zero each
# other out (standard drill-down faceting: a category's count ignores its own
# selections but still honours the other categories' filters).
if exclude_tag_category:
by_category.pop(exclude_tag_category, None)
visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
for category, ids in by_category.items():
if category == "topic" and tag_mode == "and" and len(ids) > 1:
@ -294,6 +300,48 @@ def get_feed_count(
return {"count": total or 0}
@router.get("/facets")
def get_facets(
params: dict = Depends(_feed_params),
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Per-tag channel counts for the *current* filter context, so the sidebar can show
live counts and drop chips that no longer match anything. Each category is counted with
every other filter applied (scope, channel, date, content type, search, watch state,
and the other category's tags) but its own selections ignored — standard drill-down
faceting, so selecting one topic doesn't zero out the rest of the topics.
The count is the number of distinct channels that have at least one video in the current
view, matching the existing channel-count chip semantics. JSON object keys are strings
(tag ids)."""
visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
counts: dict[int, int] = {}
for category in ("language", "topic"):
# Disjunctive (OR) facets drop the category's own selections so its chips keep
# independent counts (you can OR more of them in). Conjunctive (AND, topics only)
# keeps them applied, so each remaining chip narrows to channels that ALSO have all
# already-selected topics — and tags that can't co-occur drop to zero (hidden).
conjunctive = category == "topic" and params.get("tag_mode") == "and"
base, _status = _filtered_query(
db,
user,
**{**params, "exclude_tag_category": None if conjunctive else category},
)
channels = base.with_only_columns(Video.channel_id).distinct().subquery()
rows = db.execute(
select(ChannelTag.tag_id, func.count(func.distinct(ChannelTag.channel_id)))
.select_from(channels)
.join(ChannelTag, ChannelTag.channel_id == channels.c.channel_id)
.join(Tag, and_(Tag.id == ChannelTag.tag_id, Tag.category == category))
.where(visible)
.group_by(ChannelTag.tag_id)
).all()
for tag_id, count in rows:
counts[tag_id] = count
return {"counts": counts}
@router.post("/videos/{video_id}/state")
def set_video_state(
video_id: str,

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,31 @@ 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. Sorted by count (largest first), then name —
// so scanning top→bottom the smallest counts end up at the very bottom.
const visibleChips = (list: Tag[]): Tag[] => {
const shown = facetsReady
? list.filter((tg) => chipCount(tg) > 0 || filters.tags.includes(tg.id))
: list;
return [...shown].sort(
(a, b) => chipCount(b) - chipCount(a) || a.name.localeCompare(b.name)
);
};
// 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,47 +389,76 @@ 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">
<button
onClick={() =>
setFilters({ ...filters, tagMode: filters.tagMode === "or" ? "and" : "or" })
}
className="text-[10px] uppercase tracking-wide text-muted hover:text-accent"
<div
className="flex items-center justify-between mb-1.5"
title={t("sidebar.tagModeTooltip")}
>
{filters.tagMode === "or" ? t("sidebar.any") : t("sidebar.all")}
<span className="text-[10px] uppercase tracking-wide text-muted">
{t("sidebar.match")}
</span>
<div
className="flex items-center rounded-full border border-border bg-card p-0.5 text-[10px]"
role="group"
aria-label={t("sidebar.match")}
>
{(["or", "and"] as const).map((m) => (
<button
key={m}
onClick={() => setFilters({ ...filters, tagMode: m })}
aria-pressed={filters.tagMode === m}
className={`px-2 py-0.5 rounded-full transition ${
filters.tagMode === m
? "bg-accent text-accent-fg"
: "text-muted hover:text-fg"
}`}
>
{m === "or" ? t("sidebar.any") : t("sidebar.all")}
</button>
))}
</div>
</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>
</>
);
}
}
}
const orderedAvailable = layout.order.filter((id) => available[id]);
const renderedIds = editing

View file

@ -19,11 +19,13 @@
"any": "Beliebig",
"all": "Alle",
"tagModeTooltip": "Beliebige vs. alle ausgewählten Tags treffen",
"match": "Treffer",
"custom": "Benutzerdefiniert",
"from": "Von",
"to": "Bis",
"clearDates": "Daten löschen",
"reshuffle": "Neu mischen",
"noMatchingTags": "Keine passenden Tags",
"widget": {
"show": "Anzeigen",
"sort": "Sortierung",

View file

@ -19,11 +19,13 @@
"any": "Any",
"all": "All",
"tagModeTooltip": "Match any vs all selected tags",
"match": "Match",
"custom": "Custom",
"from": "From",
"to": "To",
"clearDates": "clear dates",
"reshuffle": "Reshuffle",
"noMatchingTags": "No matching tags here",
"widget": {
"show": "Show",
"sort": "Sort",

View file

@ -19,11 +19,13 @@
"any": "Bármelyik",
"all": "Összes",
"tagModeTooltip": "Bármelyik vagy az összes kijelölt címke illeszkedjen",
"match": "Egyezés",
"custom": "Egyéni",
"from": "Ettől",
"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",

View file

@ -152,13 +152,13 @@ async function req(url: string, opts: RequestInit = {}): Promise<any> {
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<string, number> }> =>
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) =>