2026-06-15 12:29:43 +02:00
|
|
|
// 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.
|
2026-06-11 19:17:08 +02:00
|
|
|
import type { FeedFilters } from "./api";
|
|
|
|
|
|
|
|
|
|
const KEYS = [
|
|
|
|
|
"q",
|
|
|
|
|
"show",
|
|
|
|
|
"sort",
|
2026-06-15 12:29:43 +02:00
|
|
|
"scope",
|
2026-06-29 02:30:37 +02:00
|
|
|
"source",
|
2026-06-11 19:17:08 +02:00
|
|
|
"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);
|
2026-06-15 12:29:43 +02:00
|
|
|
if (f.scope === "all") p.set("scope", "all");
|
2026-06-29 02:30:37 +02:00
|
|
|
// 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);
|
2026-06-11 19:17:08 +02:00
|
|
|
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";
|
2026-06-15 12:29:43 +02:00
|
|
|
if (params.has("scope")) f.scope = params.get("scope") === "all" ? "all" : "my";
|
2026-06-29 02:30:37 +02:00
|
|
|
if (params.has("source")) {
|
|
|
|
|
const s = params.get("source");
|
2026-07-05 02:32:00 +02:00
|
|
|
f.librarySource = s === "all" || s === "search" || s === "plex" ? s : "organic";
|
2026-06-29 02:30:37 +02:00
|
|
|
}
|
2026-06-11 19:17:08 +02:00
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 03:20:17 +02:00
|
|
|
// 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.
|
|
|
|
|
export const PAGES = [
|
|
|
|
|
"feed",
|
|
|
|
|
"channels",
|
|
|
|
|
"stats",
|
|
|
|
|
"playlists",
|
|
|
|
|
"settings",
|
|
|
|
|
"scheduler",
|
2026-06-19 12:23:00 +02:00
|
|
|
"config",
|
2026-06-19 14:16:48 +02:00
|
|
|
"users",
|
2026-06-18 03:20:17 +02:00
|
|
|
"notifications",
|
2026-06-25 22:34:24 +02:00
|
|
|
"messages",
|
feat(downloads): M5 — Download Center frontend
- api.ts: download types + methods (profiles, enqueue, list/manage, file url, share,
usage, index, admin storage/quota)
- format.ts: formatBytes / formatSpeed
- i18n: en/hu/de downloads.json (trilingual)
- Downloads nav module (Download icon, active-count badge, hidden for demo) placed after
Messages; urlState page + App render + Header title
- DownloadButton: self-contained per-card/player affordance (demo-hidden, reflects the
per-user download index: downloaded / in-queue), opens the preset dialog
- DownloadDialog: preset picker + optional display name -> enqueue + 'View' toast
- ProfileEditor: manage built-in + custom formats (full spec form incl. SponsorBlock)
- DownloadCenter: Queue / Library / Shared / (admin) System tabs, live progress via
useLiveQuery, usage bar, pause/resume/cancel/delete, save-to-device, rename, share,
admin storage dashboard + per-user quota editor
- wired DownloadButton into VideoCard + PlayerModal
- lib/nav.ts: decoupled navigator so the download toast can jump to the page
tsc + vite build clean. Verified in a headless authed browser: page + tabs render, Library
shows a real download with usage bar + actions, no console errors.
2026-07-03 01:52:58 +02:00
|
|
|
"downloads",
|
2026-06-18 03:20:17 +02:00
|
|
|
] 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);
|
|
|
|
|
}
|
feat(m5a): channel manager, tabbed settings panel, per-user sync status
Backend: /api/channels (list + PATCH priority/hidden + attach/detach user tags),
user-tag CRUD on /api/tags, /api/sync/my-status (per-user channel sync counts).
Frontend: feed|channels page nav (URL-synced) from the account menu; a slide-in
tabbed Settings panel (Appearance, Notifications=6b sound+duration, Sync status +
actions + admin pause/resume, Account); a channel manager with priority, hide,
per-channel user tags, sync badges and 'view in feed'. Notifications now honor the
configurable sound + auto-dismiss settings.
2026-06-11 20:45:48 +02:00
|
|
|
|
|
|
|
|
export function readPage(): Page {
|
2026-06-12 02:47:55 +02:00
|
|
|
const p = new URLSearchParams(window.location.search).get("page");
|
2026-06-18 03:20:17 +02:00
|
|
|
return isPage(p) ? p : "feed";
|
feat(m5a): channel manager, tabbed settings panel, per-user sync status
Backend: /api/channels (list + PATCH priority/hidden + attach/detach user tags),
user-tag CRUD on /api/tags, /api/sync/my-status (per-user channel sync counts).
Frontend: feed|channels page nav (URL-synced) from the account menu; a slide-in
tabbed Settings panel (Appearance, Notifications=6b sound+duration, Sync status +
actions + admin pause/resume, Account); a channel manager with priority, hide,
per-channel user tags, sync badges and 'view in feed'. Notifications now honor the
configurable sound + auto-dismiss settings.
2026-06-11 20:45:48 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-15 12:29:43 +02:00
|
|
|
/** 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 {
|
feat(m5a): channel manager, tabbed settings panel, per-user sync status
Backend: /api/channels (list + PATCH priority/hidden + attach/detach user tags),
user-tag CRUD on /api/tags, /api/sync/my-status (per-user channel sync counts).
Frontend: feed|channels page nav (URL-synced) from the account menu; a slide-in
tabbed Settings panel (Appearance, Notifications=6b sound+duration, Sync status +
actions + admin pause/resume, Account); a channel manager with priority, hide,
per-channel user tags, sync badges and 'view in feed'. Notifications now honor the
configurable sound + auto-dismiss settings.
2026-06-11 20:45:48 +02:00
|
|
|
const params = filtersToParams(f);
|
|
|
|
|
if (page !== "feed") params.set("page", page);
|
|
|
|
|
const qs = params.toString();
|
2026-06-15 12:29:43 +02:00
|
|
|
return `${window.location.origin}${window.location.pathname}${qs ? `?${qs}` : ""}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 01:13:26 +02:00
|
|
|
/** Strip the query string from the address bar (after hydrating from a share/auth link),
|
|
|
|
|
* leaving a clean URL. Preserves the current history.state (e.g. the in-app `sfPage` stamp)
|
|
|
|
|
* so it's safe to call at any time, not just before that stamp is written. */
|
2026-06-15 12:29:43 +02:00
|
|
|
export function stripUrlParams(): void {
|
|
|
|
|
if (window.location.search) {
|
2026-06-26 01:13:26 +02:00
|
|
|
window.history.replaceState(window.history.state, "", window.location.pathname);
|
2026-06-15 12:29:43 +02:00
|
|
|
}
|
2026-06-11 19:17:08 +02:00
|
|
|
}
|