From 3e90fe2e5bd16d5fab7cbee6b62a66fe7f127da4 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 12:29:43 +0200 Subject: [PATCH 1/2] 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 e18be73d46eedda63aecfe2f8ad69096e45c89d0 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 12:29:51 +0200 Subject: [PATCH 2/2] 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 && (