Merge feature/s3b-deurl-filter-state: de-URL filter state + share-view link

S3b:
- refactor(filters): stop mirroring filters to the URL; localStorage canonical
- feat(filters): opt-in "Share view" link button (filters/sort/scope)
This commit is contained in:
npeter83 2026-06-15 12:30:17 +02:00
commit c69d02d01a
6 changed files with 61 additions and 12 deletions

View file

@ -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 });

View file

@ -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.<id>") 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")}
</button>
)}
{!editing && (
<button
onClick={shareView}
title={t("sidebar.shareView")}
aria-label={t("sidebar.shareView")}
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<Share2 className="w-4 h-4" />
</button>
)}
{editing && (
<button
onClick={() => setLayout({ ...DEFAULT_LAYOUT })}

View file

@ -26,6 +26,9 @@
"clearDates": "Daten löschen",
"reshuffle": "Neu mischen",
"noMatchingTags": "Keine passenden Tags",
"shareView": "Link zu dieser Ansicht kopieren",
"shareCopied": "Ansichts-Link in die Zwischenablage kopiert",
"shareFailed": "Link konnte nicht kopiert werden",
"widget": {
"show": "Anzeigen",
"sort": "Sortierung",

View file

@ -26,6 +26,9 @@
"clearDates": "clear dates",
"reshuffle": "Reshuffle",
"noMatchingTags": "No matching tags here",
"shareView": "Copy a link to this view",
"shareCopied": "View link copied to clipboard",
"shareFailed": "Couldn't copy the link",
"widget": {
"show": "Show",
"sort": "Sort",

View file

@ -26,6 +26,9 @@
"clearDates": "dátumok törlése",
"reshuffle": "Újrakeverés",
"noMatchingTags": "Nincs ide illő címke",
"shareView": "Hivatkozás másolása erre a nézetre",
"shareCopied": "Nézet-hivatkozás a vágólapra másolva",
"shareFailed": "Nem sikerült a hivatkozás másolása",
"widget": {
"show": "Megjelenítés",
"sort": "Rendezés",

View file

@ -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);
}
}