Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit b87bd2b87a99a17e7053546969973d146e24582a
parent 22c311f84e61c2b394a921cf9b62ff0f8239707f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu, 16 Apr 2026 13:21:13 -0400

better URL state

Diffstat:
Mapp/PlayerProvider.tsx | 103++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mapp/TranscriptSearch.tsx | 123++++++++++++++++++++++++++++---------------------------------------------------
Aapp/urlState.ts | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcreate-archives.sh | 2+-
4 files changed, 191 insertions(+), 115 deletions(-)

diff --git a/app/PlayerProvider.tsx b/app/PlayerProvider.tsx @@ -12,6 +12,7 @@ import { } from "react"; import type ReactPlayerType from "react-player"; import { fetchTranscript } from "./transcriptCache"; +import { useUrlParams, writeUrlParams } from "./urlState"; const ReactPlayer = dynamic(() => import("react-player/youtube"), { ssr: false, @@ -36,6 +37,12 @@ export type TranscriptData = TranscriptSummary & { type Status = "idle" | "loading" | "ready"; +type Detail = { + slug: string; + description?: string; + cues?: Cue[]; +}; + type PlayerState = { activeSlug: string | null; data: TranscriptData | null; @@ -63,10 +70,9 @@ export function PlayerProvider({ children: React.ReactNode; transcripts: TranscriptSummary[]; }) { - const [activeSlug, setActiveSlug] = useState<string | null>(null); - const [data, setData] = useState<TranscriptData | null>(null); - const [status, setStatus] = useState<Status>("idle"); - const [modalOpen, setModalOpen] = useState(false); + const { v: urlSlug, t: urlTime } = useUrlParams(); + const [detail, setDetail] = useState<Detail | null>(null); + const [dismissedSlug, setDismissedSlug] = useState<string | null>(null); const [playing, setPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); const playerRef = useRef<ReactPlayerType | null>(null); @@ -79,42 +85,37 @@ export function PlayerProvider({ return m; }, [transcripts]); + const activeSlug = urlSlug; + const modalOpen = activeSlug !== null && dismissedSlug !== activeSlug; + const summary = activeSlug ? bySlug.get(activeSlug) ?? null : null; + const detailMatches = detail?.slug === activeSlug; + const data: TranscriptData | null = summary + ? detailMatches + ? { ...summary, description: detail.description, cues: detail.cues } + : { ...summary } + : null; + const status: Status = !activeSlug + ? "idle" + : detailMatches + ? "ready" + : "loading"; + const openTranscript = useCallback( (slug: string, start?: number) => { - setModalOpen(true); - if (slug === activeSlug) { - if (typeof start === "number" && playerRef.current) { - playerRef.current.seekTo(start, "seconds"); - setPlaying(true); - } - return; + setDismissedSlug(null); + const t = typeof start === "number" ? Math.round(start) : null; + if (slug === activeSlug && t !== null && playerRef.current) { + playerRef.current.seekTo(t, "seconds"); + setPlaying(true); } - const summary = bySlug.get(slug); - if (!summary) return; - setActiveSlug(slug); - setData({ ...summary }); - setStatus("loading"); - readyForSlugRef.current = null; - pendingSeekRef.current = typeof start === "number" ? start : 0; - fetchTranscript(slug).then((full) => { - setActiveSlug((current) => { - if (current !== slug) return current; - setData({ ...summary, description: full.description, cues: full.cues }); - setStatus("ready"); - return current; - }); - }).catch(() => { - setActiveSlug((current) => { - if (current !== slug) return current; - setStatus("ready"); - return current; - }); - }); + writeUrlParams({ v: slug, t }); }, - [activeSlug, bySlug], + [activeSlug], ); - const closeModal = useCallback(() => setModalOpen(false), []); + const closeModal = useCallback(() => { + setDismissedSlug(activeSlug); + }, [activeSlug]); const seekTo = useCallback((seconds: number) => { if (playerRef.current) { @@ -126,6 +127,38 @@ export function PlayerProvider({ }, []); useEffect(() => { + if (!urlSlug) return; + if (!bySlug.has(urlSlug)) return; + readyForSlugRef.current = null; + pendingSeekRef.current = urlTime ?? 0; + let cancelled = false; + fetchTranscript(urlSlug) + .then((full) => { + if (cancelled) return; + setDetail({ + slug: urlSlug, + description: full.description, + cues: full.cues, + }); + }) + .catch(() => { + if (cancelled) return; + setDetail({ slug: urlSlug }); + }); + return () => { + cancelled = true; + }; + // urlTime intentionally omitted — t changes shouldn't refetch + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [urlSlug, bySlug]); + + useEffect(() => { + if (urlTime === null) return; + if (readyForSlugRef.current !== urlSlug) return; + playerRef.current?.seekTo(urlTime, "seconds"); + }, [urlSlug, urlTime]); + + useEffect(() => { if (!modalOpen) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") closeModal(); @@ -197,7 +230,7 @@ export function PlayerProvider({ <button type="button" aria-label="Reopen transcript" - onClick={() => setModalOpen(true)} + onClick={() => setDismissedSlug(null)} className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent text-white text-xs px-3 py-1.5 text-left hover:from-black/95" > <span className="font-medium line-clamp-1">{data.title}</span> diff --git a/app/TranscriptSearch.tsx b/app/TranscriptSearch.tsx @@ -1,8 +1,9 @@ "use client"; -import { useEffect, useMemo, useState, useSyncExternalStore } from "react"; +import { useEffect, useMemo, useState } from "react"; import { usePlayer } from "./PlayerProvider"; import { fetchTranscript } from "./transcriptCache"; +import { useUrlParams, writeUrlParams } from "./urlState"; import type { TranscriptDetail } from "@/lib/transcripts"; type Summary = { @@ -30,61 +31,26 @@ type HitGroup = { const MAX_HITS = 500; -const urlListeners = new Set<() => void>(); -function subscribeUrl(cb: () => void) { - urlListeners.add(cb); - window.addEventListener("popstate", cb); - return () => { - urlListeners.delete(cb); - window.removeEventListener("popstate", cb); - }; -} -function getUrlSnapshot() { - return window.location.search; -} -function getServerUrlSnapshot() { - return ""; -} -function writeUrl(query: string, useRegex: boolean) { - const params = new URLSearchParams(window.location.search); - if (query) params.set("q", query); - else params.delete("q"); - if (useRegex) params.set("re", "1"); - else params.delete("re"); - 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 urlListeners) l(); -} - export default function TranscriptSearch({ transcripts, }: { transcripts: Summary[]; }) { - const search = useSyncExternalStore( - subscribeUrl, - getUrlSnapshot, - getServerUrlSnapshot, - ); - const { query, useRegex } = useMemo(() => { - const params = new URLSearchParams(search); - return { - query: params.get("q") ?? "", - useRegex: params.get("re") === "1", - }; - }, [search]); - const setQuery = (q: string) => writeUrl(q, useRegex); - const setUseRegex = (r: boolean) => writeUrl(query, r); - const [lastHit, setLastHit] = useState<{ - slug: string; - start: number; - } | null>(null); + const { q: query, re: useRegex, v: activeVideo, t: activeTime } = useUrlParams(); + const [input, setInput] = useState(query); + useEffect(() => { + setInput(query); + }, [query]); + const [searchExecuted, setSearchExecuted] = useState(() => { + if (typeof window === "undefined") return true; + const params = new URLSearchParams(window.location.search); + return !(params.has("v") && (params.get("q") ?? "") !== ""); + }); + const [fetched, setFetched] = useState<Record<string, TranscriptDetail>>({}); const { openTranscript } = usePlayer(); - const trimmed = query.trim(); + const trimmed = searchExecuted ? query.trim() : ""; useEffect(() => { if (!trimmed) return; @@ -177,11 +143,18 @@ export default function TranscriptSearch({ return ( <div className="flex flex-col gap-6"> - <div className="flex flex-col gap-1"> + <form + onSubmit={(e) => { + e.preventDefault(); + setSearchExecuted(true); + writeUrlParams({ q: input.trim() }); + }} + className="flex flex-col gap-1" + > <input type="search" - value={query} - onChange={(e) => setQuery(e.target.value)} + value={input} + onChange={(e) => setInput(e.target.value)} placeholder={ useRegex ? "Regex pattern (case-insensitive)…" @@ -199,7 +172,7 @@ export default function TranscriptSearch({ <input type="checkbox" checked={useRegex} - onChange={(e) => setUseRegex(e.target.checked)} + onChange={(e) => writeUrlParams({ re: e.target.checked })} className="accent-blue-600" /> Regex @@ -208,17 +181,19 @@ export default function TranscriptSearch({ {regex.error} </span> )} + {input.trim() !== (searchExecuted ? query.trim() : "") && ( + <span className="text-xs text-zinc-400 ml-auto"> + Press Enter to search + </span> + )} </label> - </div> + </form> {!trimmed && ( <TranscriptGrid items={transcripts} - onOpen={(slug) => { - setLastHit({ slug, start: 0 }); - openTranscript(slug); - }} - lastSlug={lastHit?.slug ?? null} + onOpen={(slug) => openTranscript(slug)} + activeSlug={activeVideo} /> )} @@ -231,11 +206,8 @@ export default function TranscriptSearch({ {titleMatches.length > 0 ? ( <TranscriptGrid items={titleMatches} - onOpen={(slug) => { - setLastHit({ slug, start: 0 }); - openTranscript(slug); - }} - lastSlug={lastHit?.slug ?? null} + onOpen={(slug) => openTranscript(slug)} + activeSlug={activeVideo} /> ) : ( <p className="text-sm text-zinc-500">No title matches.</p> @@ -272,10 +244,7 @@ export default function TranscriptSearch({ > <button type="button" - onClick={() => { - setLastHit({ slug: g.slug, start: 0 }); - openTranscript(g.slug); - }} + onClick={() => openTranscript(g.slug)} className="w-full text-left px-4 py-2 bg-zinc-50 dark:bg-zinc-900/60 border-b border-zinc-200 dark:border-zinc-800 hover:bg-zinc-100 dark:hover:bg-zinc-800 flex items-baseline gap-2" > <span className="font-medium truncate flex-1 min-w-0"> @@ -289,19 +258,15 @@ export default function TranscriptSearch({ </button> <ul className="flex flex-col divide-y divide-zinc-200 dark:divide-zinc-800"> {g.hits.map((h, i) => { - const isLast = - lastHit?.slug === g.slug && - lastHit?.start === h.start; + const isActive = + activeVideo === g.slug && activeTime === h.start; return ( <li key={i}> <button type="button" - onClick={() => { - setLastHit({ slug: g.slug, start: h.start }); - openTranscript(g.slug, h.start); - }} + onClick={() => openTranscript(g.slug, h.start)} className={`w-full text-left flex gap-3 px-4 py-2 hover:bg-zinc-50 dark:hover:bg-zinc-800 ${ - isLast + isActive ? "bg-amber-100/70 dark:bg-amber-500/10 ring-1 ring-inset ring-amber-400/60" : "" }`} @@ -331,23 +296,23 @@ export default function TranscriptSearch({ function TranscriptGrid({ items, onOpen, - lastSlug, + activeSlug, }: { items: Summary[]; onOpen: (slug: string) => void; - lastSlug?: string | null; + activeSlug?: string | null; }) { return ( <ul className="flex flex-col divide-y divide-zinc-200 dark:divide-zinc-800 border border-zinc-200 dark:border-zinc-800 rounded-lg overflow-hidden bg-white dark:bg-zinc-900"> {items.map((t) => { - const isLast = lastSlug === t.slug; + const isActive = activeSlug === t.slug; return ( <li key={t.slug}> <button type="button" onClick={() => onOpen(t.slug)} className={`w-full text-left flex items-baseline gap-3 px-4 py-2 hover:bg-zinc-50 dark:hover:bg-zinc-800 ${ - isLast + isActive ? "bg-amber-100/70 dark:bg-amber-500/10 ring-1 ring-inset ring-amber-400/60" : "" }`} diff --git a/app/urlState.ts b/app/urlState.ts @@ -0,0 +1,78 @@ +"use client"; + +import { useSyncExternalStore } from "react"; + +export type UrlParams = { + q: string; + re: boolean; + v: string | null; + t: number | null; +}; + +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; + return { + q: p.get("q") ?? "", + re: p.get("re") === "1", + v: p.get("v"), + t: t !== null && Number.isFinite(t) ? t : null, + }; +} + +export function useUrlParams(): UrlParams { + const search = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + return parse(search); +} + +type Patch = Partial<{ + q: string; + re: boolean; + v: string | null; + t: number | null; +}>; + +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"); + } + 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(); +} diff --git a/create-archives.sh b/create-archives.sh @@ -5,6 +5,6 @@ cd transcripts # Transcript archives are too big for CF pages (25mb) # Commands commented and kept just in case # use xz over gz to get the file below 25MB -# git archive --format tar main | xz -9 > ../public/transcripts.tar.xz +git archive --format tar main | xz -9 > ../public/transcripts.tar.xz # Uncomment this line to use .tar.gz for compatability and compression speed, but at a significantly higher file size # git archive -9 --format tar.gz -o "../public/transcripts.tar.gz" main