"use client"; import { useMemo, useSyncExternalStore } from "react"; // SearchMode is defined in `lib/searchQuery.ts`, which interprets it. // Re-exported here so every existing `from "./urlState"` import site is // unchanged. export type { SearchMode } from "../lib/searchQuery"; import type { SearchMode } from "../lib/searchQuery"; import { isTagId } from "../lib/curatedTags"; // Per-video modal content mode. Independent from the search page's `mode` so // the modal can be toggled without disturbing search state. Absence on the // URL means "transcript" — every other value is persisted verbatim. // // "digest" and NOT "summary", deliberately: `DisplaySummary` is already a video // LISTING CARD (common/lib/transcripts.ts) and `/summaries/` is already the // browse-index page tree the search index serves. A third meaning of "summary" // is exactly the naming hazard PLAN.md warns about. "digest" matches the // artifact, the generation stage, the settings section and the page tree. export type ModalMode = "transcript" | "chat" | "post" | "digest"; export type UrlParams = { q: string; re: boolean; v: string | null; t: number | null; vm: ModalMode; // The transcript TRACK the reader shows (lib/captionTracks.ts) when it is // not the record's primary — a viewer's choice, never a data change. Absent // (null) for the primary, which is the default and stays off the URL. vt: string | null; ch: string[]; nov: boolean; nol: boolean; naa: boolean; nar: boolean; nav: boolean; nd: boolean; nu: boolean; mode: SearchMode; // Subs-mode track exclusion filter; empty array means "all tracks" (no // exclusions). Mirrors the channel-exclusion convention. Persisted on the // URL as repeated `tk=` params (e.g. ?m=subs&tk=live_chat to hide live // chat results). tracks: string[]; // Curated tag ids selected in the filter panel, as repeated `tg=` params // (e.g. ?tg=eva-collab&tg=eva-in-chat). ORed: a video carrying ANY of them // passes. `tg` and not `tag`, following `tk`'s two-letter convention, and // deliberately nowhere near the `tags` SEARCH SCOPE, which is yt-dlp // keywords — a different thing wearing the same English word (see // lib/curatedTags.ts). // // NOT a share-v1 key: unlike ch/nov/…, `tg` IS the live representation of // the committed selection, so stripAllFilterParamsFromUrl leaves it alone // and it round-trips through a reload on its own. tags: string[]; }; const listeners = new Set<() => void>(); function subscribe(cb: () => void) { listeners.add(cb); window.addEventListener("popstate", cb); return () => { listeners.delete(cb); window.removeEventListener("popstate", cb); }; } function getSnapshot() { return window.location.search; } function getServerSnapshot() { return ""; } function parse(search: string): UrlParams { const p = new URLSearchParams(search); const tRaw = p.get("t"); const t = tRaw !== null && tRaw !== "" ? Number(tRaw) : null; const modeRaw = p.get("m"); const mode: SearchMode = modeRaw === "subs" ? "subs" : modeRaw === "posts" ? "posts" : "transcripts"; const vmRaw = p.get("vm"); const vm: ModalMode = vmRaw === "chat" ? "chat" : vmRaw === "post" ? "post" : vmRaw === "digest" ? "digest" : "transcript"; return { q: p.get("q") ?? "", re: p.get("re") === "1", v: p.get("v"), t: t !== null && Number.isFinite(t) ? t : null, vm, vt: p.get("vt") || null, ch: p.getAll("ch"), nov: p.get("nov") === "1", nol: p.get("nol") === "1", naa: p.get("naa") === "1", nar: p.get("nar") === "1", nav: p.get("nav") === "1", nd: p.get("nd") === "1", nu: p.get("nu") === "1", mode, tracks: p.getAll("tk"), // Validated against TAG_ID_RE, so this entry point agrees with the MCP's // parseSearchArgs: a lowercase [a-z0-9._-] id or nothing. A malformed // token is DROPPED rather than carried, because the only thing a // never-matching id can do downstream is turn a filter into a silent // empty result. (A well-formed id this site does not publish is a // different case and is kept — the chip row renders it so it can be // cleared.) tags: p.getAll("tg").filter((t) => isTagId(t)), }; } export function useUrlParams(): UrlParams { const search = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); return useMemo(() => parse(search), [search]); } type Patch = Partial<{ q: string; re: boolean; v: string | null; t: number | null; vm: ModalMode; vt: string | null; ch: string[]; nov: boolean; nol: boolean; naa: boolean; nar: boolean; nav: boolean; nd: boolean; nu: boolean; mode: SearchMode; tracks: string[]; tags: string[]; }>; export function writeUrlParams(patch: Patch) { const params = new URLSearchParams(window.location.search); if (patch.q !== undefined) { if (patch.q) params.set("q", patch.q); else params.delete("q"); } if (patch.re !== undefined) { if (patch.re) params.set("re", "1"); else params.delete("re"); } if (patch.v !== undefined) { if (patch.v) params.set("v", patch.v); else params.delete("v"); } if (patch.t !== undefined) { if (patch.t !== null) params.set("t", String(patch.t)); else params.delete("t"); } if (patch.vm !== undefined) { if (patch.vm === "chat") params.set("vm", "chat"); else if (patch.vm === "post") params.set("vm", "post"); else if (patch.vm === "digest") params.set("vm", "digest"); // "transcript" is the fallthrough and DELETES the param rather than // setting vm=transcript — the default must stay absent from the URL. else params.delete("vm"); } if (patch.vt !== undefined) { if (patch.vt) params.set("vt", patch.vt); else params.delete("vt"); } if (patch.ch !== undefined) { params.delete("ch"); for (const v of patch.ch) params.append("ch", v); } if (patch.nov !== undefined) { if (patch.nov) params.set("nov", "1"); else params.delete("nov"); } if (patch.nol !== undefined) { if (patch.nol) params.set("nol", "1"); else params.delete("nol"); } if (patch.naa !== undefined) { if (patch.naa) params.set("naa", "1"); else params.delete("naa"); } if (patch.nar !== undefined) { if (patch.nar) params.set("nar", "1"); else params.delete("nar"); } if (patch.nav !== undefined) { if (patch.nav) params.set("nav", "1"); else params.delete("nav"); } if (patch.nd !== undefined) { if (patch.nd) params.set("nd", "1"); else params.delete("nd"); } if (patch.nu !== undefined) { if (patch.nu) params.set("nu", "1"); else params.delete("nu"); } if (patch.mode !== undefined) { if (patch.mode === "subs") params.set("m", "subs"); else if (patch.mode === "posts") params.set("m", "posts"); else params.delete("m"); } if (patch.tracks !== undefined) { params.delete("tk"); for (const v of patch.tracks) params.append("tk", v); } if (patch.tags !== undefined) { params.delete("tg"); for (const v of patch.tags) if (isTagId(v)) params.append("tg", v); } const qs = params.toString(); const next = `${window.location.pathname}${qs ? `?${qs}` : ""}`; if (next === window.location.pathname + window.location.search) return; window.history.replaceState(window.history.state, "", next); for (const l of listeners) l(); }