From 241d132b4b3283b7adcd49e13261f2a676d405a8 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 11 Jun 2026 19:17:08 +0200 Subject: [PATCH] feat(ui): reflect filters/sort/search in the URL Serialize the active filters to a compact, readable query string (only non-default values) and parse them back on load, with URL taking precedence over localStorage. Uses history.replaceState so it never spams browser history. A pasted link now reproduces the exact view. --- frontend/src/App.tsx | 17 +++++++- frontend/src/lib/urlState.ts | 82 ++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 frontend/src/lib/urlState.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ce58cda..f75b329 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,7 @@ import { saveLocalTheme, type ThemePrefs, } from "./lib/theme"; +import { hasFilterParams, paramsToFilters, syncUrl } from "./lib/urlState"; import Login from "./components/Login"; import Header from "./components/Header"; import Sidebar from "./components/Sidebar"; @@ -27,7 +28,7 @@ const DEFAULT_FILTERS: FeedFilters = { const FILTERS_KEY = "subfeed.filters"; -function loadFilters(): FeedFilters { +function loadStoredFilters(): FeedFilters { try { return { ...DEFAULT_FILTERS, ...JSON.parse(localStorage.getItem(FILTERS_KEY) || "{}") }; } catch { @@ -35,18 +36,30 @@ function loadFilters(): FeedFilters { } } +// URL wins over localStorage so a pasted link reproduces the exact view. +function loadInitialFilters(): FeedFilters { + const params = new URLSearchParams(window.location.search); + if (hasFilterParams(params)) return paramsToFilters(params, DEFAULT_FILTERS); + return loadStoredFilters(); +} + export default function App() { const [theme, setThemeState] = useState(() => loadLocalTheme()); - const [filters, setFiltersState] = useState(loadFilters); + const [filters, setFiltersState] = useState(loadInitialFilters); const [view, setView] = useState<"grid" | "list">("grid"); function setFilters(next: FeedFilters) { setFiltersState(next); localStorage.setItem(FILTERS_KEY, JSON.stringify(next)); + syncUrl(next); } useEffect(() => applyTheme(theme), [theme]); + // Reflect the initial filters (from localStorage or URL) into the address bar + // so the URL is always shareable, even before the first filter change. + useEffect(() => syncUrl(filters), []); // eslint-disable-line react-hooks/exhaustive-deps + const meQuery = useQuery({ queryKey: ["me"], queryFn: api.me }); // On login, adopt server-stored preferences. diff --git a/frontend/src/lib/urlState.ts b/frontend/src/lib/urlState.ts new file mode 100644 index 0000000..3db19e2 --- /dev/null +++ b/frontend/src/lib/urlState.ts @@ -0,0 +1,82 @@ +// 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. +import type { FeedFilters } from "./api"; + +const KEYS = [ + "q", + "show", + "sort", + "normal", + "shorts", + "live", + "tags", + "tagmode", + "channel", + "maxage", + "from", + "to", + "mindur", + "maxdur", +] as const; + +export function filtersToParams(f: FeedFilters): URLSearchParams { + const p = new 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.includeNormal) p.set("normal", "0"); + if (f.includeShorts) p.set("shorts", "1"); + if (f.includeLive) p.set("live", "1"); + if (f.tags.length) p.set("tags", f.tags.join(",")); + if (f.tagMode === "and") p.set("tagmode", "and"); + if (f.channelId) p.set("channel", f.channelId); + if (f.maxAgeDays) p.set("maxage", String(f.maxAgeDays)); + if (f.dateFrom) p.set("from", f.dateFrom); + if (f.dateTo) p.set("to", f.dateTo); + if (f.minDuration != null) p.set("mindur", String(f.minDuration)); + if (f.maxDuration != null) p.set("maxdur", String(f.maxDuration)); + return p; +} + +/** Merge URL params onto a base filter set (typically the defaults). */ +export function paramsToFilters(params: URLSearchParams, base: FeedFilters): FeedFilters { + const f: FeedFilters = { ...base }; + const num = (v: string | null): number | undefined => { + if (v == null || v === "") return undefined; + const n = Number(v); + return Number.isFinite(n) ? n : undefined; + }; + + 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("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"; + if (params.has("tags")) + f.tags = (params.get("tags") ?? "") + .split(",") + .map((s) => Number(s)) + .filter((n) => Number.isInteger(n)); + if (params.has("tagmode")) f.tagMode = params.get("tagmode") === "and" ? "and" : "or"; + f.channelId = params.get("channel") || undefined; + f.maxAgeDays = num(params.get("maxage")); + f.dateFrom = params.get("from") || undefined; + f.dateTo = params.get("to") || undefined; + f.minDuration = num(params.get("mindur")); + f.maxDuration = num(params.get("maxdur")); + return f; +} + +/** True if the query string carries any filter param we recognize. */ +export function hasFilterParams(params: URLSearchParams): boolean { + return KEYS.some((k) => params.has(k)); +} + +/** Reflect the current filters into the address bar without adding history entries. */ +export function syncUrl(f: FeedFilters): void { + const qs = filtersToParams(f).toString(); + const url = qs ? `${window.location.pathname}?${qs}` : window.location.pathname; + window.history.replaceState(null, "", url); +}