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
282cc978f6
6 changed files with 145 additions and 26 deletions
|
|
@ -67,6 +67,7 @@ def _filtered_query(
|
||||||
include_live: bool,
|
include_live: bool,
|
||||||
show: str,
|
show: str,
|
||||||
scope: str = "my",
|
scope: str = "my",
|
||||||
|
exclude_tag_category: str | None = None,
|
||||||
) -> tuple[Select, object]:
|
) -> tuple[Select, object]:
|
||||||
"""Build the feed query (joins + all WHERE filters), shared by /feed and /feed/count.
|
"""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.
|
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]] = {}
|
by_category: dict[str, list[int]] = {}
|
||||||
for tag_id, category in cat_rows:
|
for tag_id, category in cat_rows:
|
||||||
by_category.setdefault(category, []).append(tag_id)
|
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)
|
visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
|
||||||
for category, ids in by_category.items():
|
for category, ids in by_category.items():
|
||||||
if category == "topic" and tag_mode == "and" and len(ids) > 1:
|
if category == "topic" and tag_mode == "and" and len(ids) > 1:
|
||||||
|
|
@ -294,6 +300,48 @@ def get_feed_count(
|
||||||
return {"count": total or 0}
|
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")
|
@router.post("/videos/{video_id}/state")
|
||||||
def set_video_state(
|
def set_video_state(
|
||||||
video_id: str,
|
video_id: str,
|
||||||
|
|
|
||||||
|
|
@ -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,31 @@ 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. 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
|
// 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,47 +389,76 @@ 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
|
||||||
<button
|
className="flex items-center justify-between mb-1.5"
|
||||||
onClick={() =>
|
|
||||||
setFilters({ ...filters, tagMode: filters.tagMode === "or" ? "and" : "or" })
|
|
||||||
}
|
|
||||||
className="text-[10px] uppercase tracking-wide text-muted hover:text-accent"
|
|
||||||
title={t("sidebar.tagModeTooltip")}
|
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>
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const orderedAvailable = layout.order.filter((id) => available[id]);
|
const orderedAvailable = layout.order.filter((id) => available[id]);
|
||||||
const renderedIds = editing
|
const renderedIds = editing
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,13 @@
|
||||||
"any": "Beliebig",
|
"any": "Beliebig",
|
||||||
"all": "Alle",
|
"all": "Alle",
|
||||||
"tagModeTooltip": "Beliebige vs. alle ausgewählten Tags treffen",
|
"tagModeTooltip": "Beliebige vs. alle ausgewählten Tags treffen",
|
||||||
|
"match": "Treffer",
|
||||||
"custom": "Benutzerdefiniert",
|
"custom": "Benutzerdefiniert",
|
||||||
"from": "Von",
|
"from": "Von",
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,13 @@
|
||||||
"any": "Any",
|
"any": "Any",
|
||||||
"all": "All",
|
"all": "All",
|
||||||
"tagModeTooltip": "Match any vs all selected tags",
|
"tagModeTooltip": "Match any vs all selected tags",
|
||||||
|
"match": "Match",
|
||||||
"custom": "Custom",
|
"custom": "Custom",
|
||||||
"from": "From",
|
"from": "From",
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,13 @@
|
||||||
"any": "Bármelyik",
|
"any": "Bármelyik",
|
||||||
"all": "Összes",
|
"all": "Összes",
|
||||||
"tagModeTooltip": "Bármelyik vagy az összes kijelölt címke illeszkedjen",
|
"tagModeTooltip": "Bármelyik vagy az összes kijelölt címke illeszkedjen",
|
||||||
|
"match": "Egyezés",
|
||||||
"custom": "Egyéni",
|
"custom": "Egyéni",
|
||||||
"from": "Ettől",
|
"from": "Ettől",
|
||||||
"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