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:
commit
581719401e
6 changed files with 145 additions and 26 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,45 +389,74 @@ 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"
|
||||
title={t("sidebar.tagModeTooltip")}
|
||||
<div
|
||||
className="flex items-center justify-between mb-1.5"
|
||||
title={t("sidebar.tagModeTooltip")}
|
||||
>
|
||||
<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")}
|
||||
>
|
||||
{filters.tagMode === "or" ? t("sidebar.any") : t("sidebar.all")}
|
||||
</button>
|
||||
{(["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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue