diff --git a/VERSION b/VERSION index 6e8bf73..0ea3a94 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0 +0.2.0 diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index fc0b8b3..b743b92 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -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, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ff3dfcf..719da76 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,7 +10,7 @@ import { saveLocalTheme, type ThemePrefs, } from "./lib/theme"; -import { hasFilterParams, paramsToFilters, readPage, syncUrl, type Page } from "./lib/urlState"; +import { hasFilterParams, paramsToFilters, readPage, stripUrlParams, type Page } from "./lib/urlState"; import { loadLayout, normalizeLayout, @@ -85,12 +85,10 @@ export default function App() { function setFilters(next: FeedFilters) { setFiltersState(next); localStorage.setItem(FILTERS_KEY, JSON.stringify(next)); - syncUrl(next, page); } function setPage(next: Page) { setPageState(next); - syncUrl(filters, next); } function setSidebarLayout(next: SidebarLayout) { @@ -101,8 +99,16 @@ export default function App() { useEffect(() => applyTheme(theme), [theme]); - // Reflect the initial filters + page into the address bar so the URL is always shareable. - useEffect(() => syncUrl(filters, page), []); // eslint-disable-line react-hooks/exhaustive-deps + // Filters live in localStorage, not the address bar. If we arrived via a "Share view" + // link, its params have already hydrated the initial state — persist them and strip the + // query so the URL stays clean from here on. + useEffect(() => { + const params = new URLSearchParams(window.location.search); + if (hasFilterParams(params) || params.has("page")) { + localStorage.setItem(FILTERS_KEY, JSON.stringify(filters)); + stripUrlParams(); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps const meQuery = useQuery({ queryKey: ["me"], queryFn: api.me }); diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 3ab6c97..95a8980 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -10,6 +10,7 @@ import { Pencil, RefreshCw, RotateCcw, + Share2, X, } from "lucide-react"; import { @@ -33,6 +34,8 @@ import { type SidebarLayout, type WidgetId, } from "../lib/sidebarLayout"; +import { shareUrl } from "../lib/urlState"; +import { notify } from "../lib/notifications"; // Filter ids; display labels resolved at render time via t("sidebar.sort.") etc. const SORT_IDS = [ @@ -74,10 +77,12 @@ const DEFAULT_SIDEBAR_FILTERS: Omit = { function TagChip({ tag, + count, active, onClick, }: { tag: Tag; + count: number; active: boolean; onClick: () => void; }) { @@ -85,7 +90,7 @@ function TagChip({ return ( ); @@ -119,6 +124,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 @@ -169,6 +199,15 @@ export default function Sidebar({ (dateActive ? 1 : 0); const active = activeCount > 0; + async function shareView() { + try { + await navigator.clipboard.writeText(shareUrl(filters)); + notify({ message: t("sidebar.shareCopied") }); + } catch { + notify({ level: "warning", message: t("sidebar.shareFailed") }); + } + } + function clearAll() { setCustomDates(false); setFilters({ ...DEFAULT_SIDEBAR_FILTERS, q: filters.q, scope: filters.scope }); @@ -362,45 +401,74 @@ 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 ( <> -
- + {(["or", "and"] as const).map((m) => ( + + ))} +
- {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")} + )}
); + } } } @@ -430,6 +498,16 @@ export default function Sidebar({ {t("sidebar.clearAll")} )} + {!editing && ( + + )} {editing && (