Admin → Diagnostics reads the server's stored log: one list, newest first, where a line opens in place to its full record (time, source and logger, account, request and job ids, version, message, attributes, stack). A request or job id narrows the list to that request or job with every other filter off, shown as a removable chip. Filters: level group, source, account, event, period and a free-text search; all of them live in the URL (`l*` keys, which setPage clears like the Plex and Channels keys), so a filtered view is a link and Back restores it. "Live" re-reads the newest page every few seconds while the tab is visible; paging into older rows turns it off. Copy puts the loaded lines on the clipboard as JSON lines. The facet counts now honour every filter but their own, as the feed's do: a request pivot narrows them, and a picked level does not zero its siblings. A value in use stays offered even when the counts drop it. "Saját napló" and the console draw a line with one shared component, `LogLine`, whose phone layout gives the message its own full-width line (sharing the code's line left it a few characters). The level groups moved to lib/log.ts for both. The console's own reads are logged at DEBUG on success, like the healthcheck: at INFO its live tail filled the log with itself.
194 lines
8.1 KiB
TypeScript
194 lines
8.1 KiB
TypeScript
// Serialize feed filters to/from a compact, human-readable URL query string, and build/patch the
|
|
// address-bar URL for the whole app's navigation. The query string IS the source of truth for the
|
|
// current view (page / channel / plex sub-view / playlist) and the feed filters, so every URL is
|
|
// bookmarkable/shareable — the old "Share view" workaround (and the whole-query stripUrlParams) is
|
|
// retired. Only non-default values are emitted so URLs stay clean. NOTE: the browser TAB title stays
|
|
// the short module/channel name (NavigationProvider owns document.title) — the query never leaks into it.
|
|
import { TAG_CATEGORIES, type FeedFilters } from "./api";
|
|
|
|
const KEYS = [
|
|
"q",
|
|
"show",
|
|
"sort",
|
|
"scope",
|
|
"source",
|
|
"normal",
|
|
"shorts",
|
|
"live",
|
|
"tags",
|
|
"tagmode_language",
|
|
"tagmode_topic",
|
|
"tagmode_other",
|
|
"tagmode", // legacy global (topic-only) — still recognised so old shared links resolve
|
|
"notag",
|
|
"channel",
|
|
"maxage",
|
|
"from",
|
|
"to",
|
|
"year",
|
|
"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.scope === "all") p.set("scope", "all");
|
|
// Library provenance filter (only meaningful in "all" scope); "organic" is the default.
|
|
if (f.scope === "all" && f.librarySource && f.librarySource !== "organic")
|
|
p.set("source", f.librarySource);
|
|
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(","));
|
|
for (const cat of TAG_CATEGORIES) {
|
|
if (f.tagModes[cat] === "and") p.set(`tagmode_${cat}`, "and");
|
|
}
|
|
if (f.untagged) p.set("notag", "1");
|
|
if (f.channelIds.length) p.set("channel", f.channelIds.join(","));
|
|
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.year) p.set("year", String(f.year));
|
|
if (f.minDuration != null) p.set("mindur", String(f.minDuration));
|
|
if (f.maxDuration != null) p.set("maxdur", String(f.maxDuration));
|
|
return p;
|
|
}
|
|
|
|
/** Build the address-bar delta for the feed filters: each recognized filter key mapped to its emitted
|
|
* value, or null to delete it (so switching a filter back to its default clears the key). Pass `null`
|
|
* to clear ALL filter keys — used when leaving the feed, where the localStorage copy still holds them.
|
|
* Feed the result into urlWithParams(). */
|
|
export function filterParamUpdates(f: FeedFilters | null): Record<string, string | null> {
|
|
const p = f ? filtersToParams(f) : new URLSearchParams();
|
|
const out: Record<string, string | null> = {};
|
|
for (const k of KEYS) out[k] = p.get(k);
|
|
return out;
|
|
}
|
|
|
|
/** 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("scope")) f.scope = params.get("scope") === "all" ? "all" : "my";
|
|
if (params.has("source")) {
|
|
const s = params.get("source");
|
|
f.librarySource = s === "all" || s === "search" || s === "plex" ? s : "organic";
|
|
}
|
|
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));
|
|
// Per-category tag match; a legacy global `tagmode=and` (topic-only) migrates into the topic slot.
|
|
f.tagModes = {
|
|
language: params.get("tagmode_language") === "and" ? "and" : "or",
|
|
topic: params.get("tagmode_topic") === "and" || params.get("tagmode") === "and" ? "and" : "or",
|
|
other: params.get("tagmode_other") === "and" ? "and" : "or",
|
|
};
|
|
if (params.has("notag")) f.untagged = params.get("notag") === "1";
|
|
f.channelIds = (params.get("channel") ?? "").split(",").filter(Boolean);
|
|
f.maxAgeDays = num(params.get("maxage"));
|
|
f.dateFrom = params.get("from") || undefined;
|
|
f.dateTo = params.get("to") || undefined;
|
|
f.year = num(params.get("year"));
|
|
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));
|
|
}
|
|
|
|
// The single source of truth for valid page ids; `Page` and the runtime validator both
|
|
// derive from it, so adding a page is a one-line change (no parallel allowlists to keep in
|
|
// sync). "feed" is the default/fallback.
|
|
const PAGES = [
|
|
"home",
|
|
"feed",
|
|
"channels",
|
|
"stats",
|
|
"playlists",
|
|
"settings",
|
|
"scheduler",
|
|
"config",
|
|
"users",
|
|
"audit",
|
|
"logs",
|
|
"notifications",
|
|
"messages",
|
|
"downloads",
|
|
"plex",
|
|
] as const;
|
|
|
|
export type Page = (typeof PAGES)[number];
|
|
|
|
/** Narrow an arbitrary string to a known Page (else null). */
|
|
export function isPage(p: string | null | undefined): p is Page {
|
|
return !!p && (PAGES as readonly string[]).includes(p);
|
|
}
|
|
|
|
export function readPage(): Page {
|
|
const p = new URLSearchParams(window.location.search).get("page");
|
|
return isPage(p) ? p : "feed";
|
|
}
|
|
|
|
/** Build a URL (path + query + fragment) from the CURRENT location with `updates` applied to the query —
|
|
* a null value deletes that key, any other string sets it. Lets each provider own its own nav key(s) —
|
|
* page / chan / v / pl / the feed-filter keys — and update the address bar without clobbering the others
|
|
* (read-modify-write on the live query). The caller decides pushState vs replaceState with the string.
|
|
*
|
|
* ⚠️ The FRAGMENT must be carried over. The one-time auth tokens ride it (`#verify=…` / `#reset=…`, SB3),
|
|
* and the providers that mirror nav params (useUrlMirror in FeedFilters/Playlists/Plex/Channels) sit ABOVE
|
|
* the lazily-loaded <App> in main.tsx — their mount effect replaceState's this URL before the auth card's
|
|
* chunk has even loaded. Dropping the hash here silently ate the token: the verification link opened a
|
|
* bare sign-in page and the account could never be verified. */
|
|
export function urlWithParams(updates: Record<string, string | null>): string {
|
|
const params = new URLSearchParams(window.location.search);
|
|
for (const [k, v] of Object.entries(updates)) {
|
|
if (v == null) params.delete(k);
|
|
else params.set(k, v);
|
|
}
|
|
const qs = params.toString();
|
|
return `${window.location.pathname}${qs ? `?${qs}` : ""}${window.location.hash}`;
|
|
}
|
|
|
|
/** Remove specific TRANSIENT params (one-time auth/admin links: ?link, ?oauth, ?admin, ?access, ?verify,
|
|
* ?reset) from the address bar once they've been read, WITHOUT touching the nav/filter params that now
|
|
* live there. Preserves history.state. Replaces the old whole-query stripUrlParams, which is unsafe now
|
|
* that the query is the source of truth for navigation. No-op when none of the keys are present. */
|
|
export function clearParams(...keys: string[]): void {
|
|
const params = new URLSearchParams(window.location.search);
|
|
let changed = false;
|
|
for (const k of keys) {
|
|
if (params.has(k)) {
|
|
params.delete(k);
|
|
changed = true;
|
|
}
|
|
}
|
|
if (!changed) return;
|
|
const qs = params.toString();
|
|
window.history.replaceState(
|
|
window.history.state,
|
|
"",
|
|
// Same fragment rule as urlWithParams: this strips QUERY keys only, so a `#verify=…`/`#reset=…`
|
|
// token that has not been consumed yet must survive. Welcome's own cleanup is what clears it.
|
|
`${window.location.pathname}${qs ? `?${qs}` : ""}${window.location.hash}`,
|
|
);
|
|
}
|