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:
commit
95d0e4ae33
6 changed files with 61 additions and 12 deletions
|
|
@ -10,7 +10,7 @@ import {
|
||||||
saveLocalTheme,
|
saveLocalTheme,
|
||||||
type ThemePrefs,
|
type ThemePrefs,
|
||||||
} from "./lib/theme";
|
} from "./lib/theme";
|
||||||
import { hasFilterParams, paramsToFilters, readPage, syncUrl, type Page } from "./lib/urlState";
|
import { hasFilterParams, paramsToFilters, readPage, stripUrlParams, type Page } from "./lib/urlState";
|
||||||
import {
|
import {
|
||||||
loadLayout,
|
loadLayout,
|
||||||
normalizeLayout,
|
normalizeLayout,
|
||||||
|
|
@ -85,12 +85,10 @@ export default function App() {
|
||||||
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, page);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setPage(next: Page) {
|
function setPage(next: Page) {
|
||||||
setPageState(next);
|
setPageState(next);
|
||||||
syncUrl(filters, next);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSidebarLayout(next: SidebarLayout) {
|
function setSidebarLayout(next: SidebarLayout) {
|
||||||
|
|
@ -101,8 +99,16 @@ export default function App() {
|
||||||
|
|
||||||
useEffect(() => applyTheme(theme), [theme]);
|
useEffect(() => applyTheme(theme), [theme]);
|
||||||
|
|
||||||
// Reflect the initial filters + page into the address bar so the URL is always shareable.
|
// Filters live in localStorage, not the address bar. If we arrived via a "Share view"
|
||||||
useEffect(() => syncUrl(filters, page), []); // eslint-disable-line react-hooks/exhaustive-deps
|
// 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 });
|
const meQuery = useQuery({ queryKey: ["me"], queryFn: api.me });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
Pencil,
|
Pencil,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
|
Share2,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
|
|
@ -33,6 +34,8 @@ import {
|
||||||
type SidebarLayout,
|
type SidebarLayout,
|
||||||
type WidgetId,
|
type WidgetId,
|
||||||
} from "../lib/sidebarLayout";
|
} 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.
|
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
|
||||||
const SORT_IDS = [
|
const SORT_IDS = [
|
||||||
|
|
@ -196,6 +199,15 @@ export default function Sidebar({
|
||||||
(dateActive ? 1 : 0);
|
(dateActive ? 1 : 0);
|
||||||
const active = activeCount > 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() {
|
function clearAll() {
|
||||||
setCustomDates(false);
|
setCustomDates(false);
|
||||||
setFilters({ ...DEFAULT_SIDEBAR_FILTERS, q: filters.q, scope: filters.scope });
|
setFilters({ ...DEFAULT_SIDEBAR_FILTERS, q: filters.q, scope: filters.scope });
|
||||||
|
|
@ -486,6 +498,16 @@ export default function Sidebar({
|
||||||
{t("sidebar.clearAll")}
|
{t("sidebar.clearAll")}
|
||||||
</button>
|
</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 && (
|
{editing && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setLayout({ ...DEFAULT_LAYOUT })}
|
onClick={() => setLayout({ ...DEFAULT_LAYOUT })}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,9 @@
|
||||||
"clearDates": "Daten löschen",
|
"clearDates": "Daten löschen",
|
||||||
"reshuffle": "Neu mischen",
|
"reshuffle": "Neu mischen",
|
||||||
"noMatchingTags": "Keine passenden Tags",
|
"noMatchingTags": "Keine passenden Tags",
|
||||||
|
"shareView": "Link zu dieser Ansicht kopieren",
|
||||||
|
"shareCopied": "Ansichts-Link in die Zwischenablage kopiert",
|
||||||
|
"shareFailed": "Link konnte nicht kopiert werden",
|
||||||
"widget": {
|
"widget": {
|
||||||
"show": "Anzeigen",
|
"show": "Anzeigen",
|
||||||
"sort": "Sortierung",
|
"sort": "Sortierung",
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,9 @@
|
||||||
"clearDates": "clear dates",
|
"clearDates": "clear dates",
|
||||||
"reshuffle": "Reshuffle",
|
"reshuffle": "Reshuffle",
|
||||||
"noMatchingTags": "No matching tags here",
|
"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": {
|
"widget": {
|
||||||
"show": "Show",
|
"show": "Show",
|
||||||
"sort": "Sort",
|
"sort": "Sort",
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,9 @@
|
||||||
"clearDates": "dátumok törlése",
|
"clearDates": "dátumok törlése",
|
||||||
"reshuffle": "Újrakeverés",
|
"reshuffle": "Újrakeverés",
|
||||||
"noMatchingTags": "Nincs ide illő címke",
|
"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": {
|
"widget": {
|
||||||
"show": "Megjelenítés",
|
"show": "Megjelenítés",
|
||||||
"sort": "Rendezés",
|
"sort": "Rendezés",
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
// Serialize feed filters to/from a compact, human-readable URL query string, so a
|
// Serialize feed filters to/from a compact, human-readable URL query string. Filters are
|
||||||
// pasted URL reproduces exactly what's on screen (handy for "here's the URL, I see
|
// NOT kept in the address bar during normal use (localStorage is the single source of
|
||||||
// this bug"). Only non-default values are emitted to keep URLs clean.
|
// 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";
|
import type { FeedFilters } from "./api";
|
||||||
|
|
||||||
const KEYS = [
|
const KEYS = [
|
||||||
"q",
|
"q",
|
||||||
"show",
|
"show",
|
||||||
"sort",
|
"sort",
|
||||||
|
"scope",
|
||||||
"normal",
|
"normal",
|
||||||
"shorts",
|
"shorts",
|
||||||
"live",
|
"live",
|
||||||
|
|
@ -25,6 +27,7 @@ export function filtersToParams(f: FeedFilters): URLSearchParams {
|
||||||
if (f.q) p.set("q", f.q);
|
if (f.q) p.set("q", f.q);
|
||||||
if (f.show && f.show !== "unwatched") p.set("show", f.show);
|
if (f.show && f.show !== "unwatched") p.set("show", f.show);
|
||||||
if (f.sort && f.sort !== "newest") p.set("sort", f.sort);
|
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.includeNormal) p.set("normal", "0");
|
||||||
if (f.includeShorts) p.set("shorts", "1");
|
if (f.includeShorts) p.set("shorts", "1");
|
||||||
if (f.includeLive) p.set("live", "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("q")) f.q = params.get("q") ?? "";
|
||||||
if (params.has("show")) f.show = params.get("show") || "unwatched";
|
if (params.has("show")) f.show = params.get("show") || "unwatched";
|
||||||
if (params.has("sort")) f.sort = params.get("sort") || "newest";
|
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("normal")) f.includeNormal = params.get("normal") !== "0";
|
||||||
if (params.has("shorts")) f.includeShorts = params.get("shorts") === "1";
|
if (params.has("shorts")) f.includeShorts = params.get("shorts") === "1";
|
||||||
if (params.has("live")) f.includeLive = params.get("live") === "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";
|
return p === "channels" || p === "stats" ? p : "feed";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reflect the current filters + page into the address bar without adding history entries. */
|
/** Build a shareable absolute URL that reproduces the current filters (+ page). Used by the
|
||||||
export function syncUrl(f: FeedFilters, page: Page = "feed"): void {
|
* 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);
|
const params = filtersToParams(f);
|
||||||
if (page !== "feed") params.set("page", page);
|
if (page !== "feed") params.set("page", page);
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
const url = qs ? `${window.location.pathname}?${qs}` : window.location.pathname;
|
return `${window.location.origin}${window.location.pathname}${qs ? `?${qs}` : ""}`;
|
||||||
window.history.replaceState(null, "", url);
|
}
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue