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.
This commit is contained in:
parent
8b944504a9
commit
a595a33e36
2 changed files with 97 additions and 2 deletions
|
|
@ -8,6 +8,7 @@ import {
|
||||||
saveLocalTheme,
|
saveLocalTheme,
|
||||||
type ThemePrefs,
|
type ThemePrefs,
|
||||||
} from "./lib/theme";
|
} from "./lib/theme";
|
||||||
|
import { hasFilterParams, paramsToFilters, syncUrl } from "./lib/urlState";
|
||||||
import Login from "./components/Login";
|
import Login from "./components/Login";
|
||||||
import Header from "./components/Header";
|
import Header from "./components/Header";
|
||||||
import Sidebar from "./components/Sidebar";
|
import Sidebar from "./components/Sidebar";
|
||||||
|
|
@ -27,7 +28,7 @@ const DEFAULT_FILTERS: FeedFilters = {
|
||||||
|
|
||||||
const FILTERS_KEY = "subfeed.filters";
|
const FILTERS_KEY = "subfeed.filters";
|
||||||
|
|
||||||
function loadFilters(): FeedFilters {
|
function loadStoredFilters(): FeedFilters {
|
||||||
try {
|
try {
|
||||||
return { ...DEFAULT_FILTERS, ...JSON.parse(localStorage.getItem(FILTERS_KEY) || "{}") };
|
return { ...DEFAULT_FILTERS, ...JSON.parse(localStorage.getItem(FILTERS_KEY) || "{}") };
|
||||||
} catch {
|
} 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() {
|
export default function App() {
|
||||||
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
|
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
|
||||||
const [filters, setFiltersState] = useState<FeedFilters>(loadFilters);
|
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
|
||||||
const [view, setView] = useState<"grid" | "list">("grid");
|
const [view, setView] = useState<"grid" | "list">("grid");
|
||||||
|
|
||||||
function setFilters(next: FeedFilters) {
|
function setFilters(next: FeedFilters) {
|
||||||
setFiltersState(next);
|
setFiltersState(next);
|
||||||
localStorage.setItem(FILTERS_KEY, JSON.stringify(next));
|
localStorage.setItem(FILTERS_KEY, JSON.stringify(next));
|
||||||
|
syncUrl(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => applyTheme(theme), [theme]);
|
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 });
|
const meQuery = useQuery({ queryKey: ["me"], queryFn: api.me });
|
||||||
|
|
||||||
// On login, adopt server-stored preferences.
|
// On login, adopt server-stored preferences.
|
||||||
|
|
|
||||||
82
frontend/src/lib/urlState.ts
Normal file
82
frontend/src/lib/urlState.ts
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue