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:
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