From 17e64156b8847bda785ca6d74c9a0a6e092466ab Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 12:05:53 +0200 Subject: [PATCH 1/8] feat(feed): /api/facets endpoint for contextual tag counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a facets endpoint that returns per-tag channel counts for the current filter context (scope, channel, date, content type, search, watch state, and the other category's tags). Each category is counted with its own selections ignored — standard drill-down faceting — by a new exclude_tag_category param threaded into _filtered_query, so selecting one topic doesn't zero out the other topics. Count is distinct channels with a matching video, keeping the channel-count chip semantics. Reuses the feed's filter query so both stay in lockstep. --- backend/app/routes/feed.py | 41 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index fc0b8b3..08f2ecc 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,41 @@ 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"): + base, _status = _filtered_query( + db, user, **{**params, "exclude_tag_category": 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, From d910bca5cf86de79c766cf2e4d8cfcb547144989 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 12:06:02 +0200 Subject: [PATCH 2/8] 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. --- frontend/src/components/Sidebar.tsx | 62 ++++++++++++++++++----- frontend/src/i18n/locales/de/sidebar.json | 1 + frontend/src/i18n/locales/en/sidebar.json | 1 + frontend/src/i18n/locales/hu/sidebar.json | 1 + frontend/src/lib/api.ts | 15 ++++-- 5 files changed, 63 insertions(+), 17 deletions(-) diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 3ab6c97..1e5a452 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -74,10 +74,12 @@ const DEFAULT_SIDEBAR_FILTERS: Omit = { function TagChip({ tag, + count, active, onClick, }: { tag: Tag; + count: number; active: boolean; onClick: () => void; }) { @@ -85,7 +87,7 @@ function TagChip({ return ( ); @@ -119,6 +121,26 @@ 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. + 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 // 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,20 +384,27 @@ 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 ( <>
@@ -390,17 +419,22 @@ export default function Sidebar({
- {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")} + )}
); + } } } diff --git a/frontend/src/i18n/locales/de/sidebar.json b/frontend/src/i18n/locales/de/sidebar.json index 221aa2f..11ccab4 100644 --- a/frontend/src/i18n/locales/de/sidebar.json +++ b/frontend/src/i18n/locales/de/sidebar.json @@ -24,6 +24,7 @@ "to": "Bis", "clearDates": "Daten löschen", "reshuffle": "Neu mischen", + "noMatchingTags": "Keine passenden Tags", "widget": { "show": "Anzeigen", "sort": "Sortierung", diff --git a/frontend/src/i18n/locales/en/sidebar.json b/frontend/src/i18n/locales/en/sidebar.json index 152f7b9..23b21be 100644 --- a/frontend/src/i18n/locales/en/sidebar.json +++ b/frontend/src/i18n/locales/en/sidebar.json @@ -24,6 +24,7 @@ "to": "To", "clearDates": "clear dates", "reshuffle": "Reshuffle", + "noMatchingTags": "No matching tags here", "widget": { "show": "Show", "sort": "Sort", diff --git a/frontend/src/i18n/locales/hu/sidebar.json b/frontend/src/i18n/locales/hu/sidebar.json index 5c5cc0e..0c4cbab 100644 --- a/frontend/src/i18n/locales/hu/sidebar.json +++ b/frontend/src/i18n/locales/hu/sidebar.json @@ -24,6 +24,7 @@ "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", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index e813069..52b0a97 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -152,13 +152,13 @@ async function req(url: string, opts: RequestInit = {}): Promise { 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 }> => + 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) => From 6e42e010ddcc02e7230bc74c6501f94fe6c1bcbd Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 12:10:09 +0200 Subject: [PATCH 3/8] feat(filters): sort facet chips by count, then name Order topic/language chips by their (contextual) count descending, name as the tiebreaker, so the most-populated tags sit at the top and the smallest counts fall to the bottom as you scan down. --- frontend/src/components/Sidebar.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 1e5a452..48a67b5 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -135,11 +135,16 @@ export default function Sidebar({ 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 + // 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". From c44721ed9820f5259cf90d98179bf7b6ef87278b Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 12:20:08 +0200 Subject: [PATCH 4/8] fix(feed): conjunctive facet counts when topic match mode is AND MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In AND ("All") topic mode the facet endpoint still excluded the topic selections when counting topic chips, so every topic kept its full count and none dropped out as you narrowed — e.g. picking Comedy left Cooking visible even though no channel has both. Count topics conjunctively in AND mode (keep the selected topics applied) so each remaining chip reflects channels that ALSO have all already-selected topics; non-co-occurring tags fall to zero and hide. OR mode stays disjunctive. Verified: Comedy selected narrows topic chips 21 -> 6. --- backend/app/routes/feed.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index 08f2ecc..b743b92 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -318,8 +318,15 @@ def get_facets( 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": category} + db, + user, + **{**params, "exclude_tag_category": None if conjunctive else category}, ) channels = base.with_only_columns(Video.channel_id).distinct().subquery() rows = db.execute( From 2866baa3e8f1815224f02be71986f257e7f9e595 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 12:20:17 +0200 Subject: [PATCH 5/8] feat(filters): make the topic Any/All match toggle prominent The AND/OR ("Any"/"All") control for topic chips was a single faint corner link that was easy to miss. Replace it with a labelled segmented control ("Match: [Any][All]") so the AND option is discoverable. New trilingual 'match' label. --- frontend/src/components/Sidebar.tsx | 35 +++++++++++++++++------ frontend/src/i18n/locales/de/sidebar.json | 1 + frontend/src/i18n/locales/en/sidebar.json | 1 + frontend/src/i18n/locales/hu/sidebar.json | 1 + 4 files changed, 29 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 48a67b5..260134b 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -412,16 +412,33 @@ export default function Sidebar({ const chips = visibleChips(topics); return ( <> -
- + {(["or", "and"] as const).map((m) => ( + + ))} +
{chips.map((tg) => ( diff --git a/frontend/src/i18n/locales/de/sidebar.json b/frontend/src/i18n/locales/de/sidebar.json index 11ccab4..6fa35b8 100644 --- a/frontend/src/i18n/locales/de/sidebar.json +++ b/frontend/src/i18n/locales/de/sidebar.json @@ -19,6 +19,7 @@ "any": "Beliebig", "all": "Alle", "tagModeTooltip": "Beliebige vs. alle ausgewählten Tags treffen", + "match": "Treffer", "custom": "Benutzerdefiniert", "from": "Von", "to": "Bis", diff --git a/frontend/src/i18n/locales/en/sidebar.json b/frontend/src/i18n/locales/en/sidebar.json index 23b21be..5d8a473 100644 --- a/frontend/src/i18n/locales/en/sidebar.json +++ b/frontend/src/i18n/locales/en/sidebar.json @@ -19,6 +19,7 @@ "any": "Any", "all": "All", "tagModeTooltip": "Match any vs all selected tags", + "match": "Match", "custom": "Custom", "from": "From", "to": "To", diff --git a/frontend/src/i18n/locales/hu/sidebar.json b/frontend/src/i18n/locales/hu/sidebar.json index 0c4cbab..72780a2 100644 --- a/frontend/src/i18n/locales/hu/sidebar.json +++ b/frontend/src/i18n/locales/hu/sidebar.json @@ -19,6 +19,7 @@ "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", From eef64ef8112ba2a347149523b43f176ea8db5a80 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 12:29:43 +0200 Subject: [PATCH 6/8] refactor(filters): stop mirroring filters into the URL; localStorage is canonical Filters/sort/search/scope were written to the address bar on every change (a leftover from sharing reproducible examples), giving two sources of truth. Make localStorage the single source: drop the automatic syncUrl from setFilters/setPage. A "Share view" link still hydrates filters on first load, after which the query is stripped from the URL (stripUrlParams) so it stays clean. syncUrl is replaced by shareUrl (builds the link on demand); the serializer now also round-trips scope. --- frontend/src/App.tsx | 16 +++++++++++----- frontend/src/lib/urlState.ts | 26 +++++++++++++++++++------- 2 files changed, 30 insertions(+), 12 deletions(-) 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/lib/urlState.ts b/frontend/src/lib/urlState.ts index 8498865..4b745f3 100644 --- a/frontend/src/lib/urlState.ts +++ b/frontend/src/lib/urlState.ts @@ -1,12 +1,14 @@ -// Serialize feed filters to/from a compact, human-readable URL query string, so a -// pasted URL reproduces exactly what's on screen (handy for "here's the URL, I see -// this bug"). Only non-default values are emitted to keep URLs clean. +// Serialize feed filters to/from a compact, human-readable URL query string. Filters are +// NOT kept in the address bar during normal use (localStorage is the single source of +// truth); this serializer powers the explicit "Share view" link and the one-time hydration +// when someone opens such a link. Only non-default values are emitted to keep URLs clean. import type { FeedFilters } from "./api"; const KEYS = [ "q", "show", "sort", + "scope", "normal", "shorts", "live", @@ -25,6 +27,7 @@ export function filtersToParams(f: FeedFilters): URLSearchParams { if (f.q) p.set("q", f.q); if (f.show && f.show !== "unwatched") p.set("show", f.show); if (f.sort && f.sort !== "newest") p.set("sort", f.sort); + if (f.scope === "all") p.set("scope", "all"); if (!f.includeNormal) p.set("normal", "0"); if (f.includeShorts) p.set("shorts", "1"); if (f.includeLive) p.set("live", "1"); @@ -51,6 +54,7 @@ export function paramsToFilters(params: URLSearchParams, base: FeedFilters): Fee if (params.has("q")) f.q = params.get("q") ?? ""; if (params.has("show")) f.show = params.get("show") || "unwatched"; if (params.has("sort")) f.sort = params.get("sort") || "newest"; + if (params.has("scope")) f.scope = params.get("scope") === "all" ? "all" : "my"; if (params.has("normal")) f.includeNormal = params.get("normal") !== "0"; if (params.has("shorts")) f.includeShorts = params.get("shorts") === "1"; if (params.has("live")) f.includeLive = params.get("live") === "1"; @@ -81,11 +85,19 @@ export function readPage(): Page { return p === "channels" || p === "stats" ? p : "feed"; } -/** Reflect the current filters + page into the address bar without adding history entries. */ -export function syncUrl(f: FeedFilters, page: Page = "feed"): void { +/** Build a shareable absolute URL that reproduces the current filters (+ page). Used by the + * opt-in "Share view" action — filters are not otherwise written to the address bar. */ +export function shareUrl(f: FeedFilters, page: Page = "feed"): string { const params = filtersToParams(f); if (page !== "feed") params.set("page", page); const qs = params.toString(); - const url = qs ? `${window.location.pathname}?${qs}` : window.location.pathname; - window.history.replaceState(null, "", url); + return `${window.location.origin}${window.location.pathname}${qs ? `?${qs}` : ""}`; +} + +/** Strip any filter/page query params from the address bar (after hydrating from a share + * link), leaving a clean URL without touching history. */ +export function stripUrlParams(): void { + if (window.location.search) { + window.history.replaceState(null, "", window.location.pathname); + } } From 78182f3ba64b0b0e2825a8537c5a62ad752d0972 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 12:29:51 +0200 Subject: [PATCH 7/8] feat(filters): "Share view" link button in the sidebar Add a share button next to Clear all that copies a link reproducing the current filter view (filters, sort, scope) to the clipboard, with a confirmation toast. This is the opt-in replacement for the old always-on URL mirroring. Trilingual. --- frontend/src/components/Sidebar.tsx | 22 ++++++++++++++++++++++ frontend/src/i18n/locales/de/sidebar.json | 3 +++ frontend/src/i18n/locales/en/sidebar.json | 3 +++ frontend/src/i18n/locales/hu/sidebar.json | 3 +++ 4 files changed, 31 insertions(+) diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 260134b..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 = [ @@ -196,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 }); @@ -486,6 +498,16 @@ export default function Sidebar({ {t("sidebar.clearAll")} )} + {!editing && ( + + )} {editing && (