Archilyzer · Source

archilyzer

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

commit 66cfeb0a8097798cb8466116ea11caaa8ba2478b
parent fb6dc5d7726a72a65ef466e132ec756c7b7de8cc
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sun, 26 Apr 2026 19:07:21 -0400

refactor: split into pnpm workspace with common/ and export/ packages

Carves the existing single Next.js app into two workspace packages with no
behavior change: common/ holds reusable lib code and React components,
export/ holds the static-export Next.js app that imports them via
yt-dlp-transcript-common (workspace:*).

- Adds tsconfig.base.json, common/{package,tsconfig}.json, export/{package,tsconfig}.json
- Moves lib/* into common/lib/ and reusable app/* into common/components/
- Moves Next.js entry (page/layout/globals/favicon), config files, public/,
  and scripts/build-index.ts into export/
- Adds transpilePackages: ["yt-dlp-transcript-common"] to export's next.config
- common/lib/settings.ts walks up to find the monorepo root so settings.json
  resolves regardless of cwd
- Root package.json becomes the workspace root with orchestration scripts
- create-archives.sh and .gitignore updated for the new layout

Verified pnpm --filter export run build produces identical output (13,344
transcripts across 14 pages, 7 channels) and dev mode renders correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Diffstat:
M.gitignore | 24++++++++++++------------
Dapp/PlayerProvider.tsx | 512-------------------------------------------------------------------------------
Dapp/TranscriptModal.tsx | 273-------------------------------------------------------------------------------
Dapp/TranscriptSearch.tsx | 737-------------------------------------------------------------------------------
Dapp/layout.tsx | 53-----------------------------------------------------
Dapp/page.tsx | 34----------------------------------
Dapp/searchPipeline.ts | 251-------------------------------------------------------------------------------
Dapp/summariesCache.ts | 82-------------------------------------------------------------------------------
Dapp/transcriptCache.ts | 99-------------------------------------------------------------------------------
Dapp/transcriptStore.ts | 147-------------------------------------------------------------------------------
Rapp/OdyseePlayer.tsx -> common/components/OdyseePlayer.tsx | 0
Acommon/components/PlayerProvider.tsx | 512+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rapp/QueryProvider.tsx -> common/components/QueryProvider.tsx | 0
Rapp/RumblePlayer.tsx -> common/components/RumblePlayer.tsx | 0
Acommon/components/TranscriptModal.tsx | 273+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/TranscriptSearch.tsx | 737+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rapp/badges.tsx -> common/components/badges.tsx | 0
Acommon/components/searchPipeline.ts | 251+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/summariesCache.ts | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/transcriptCache.ts | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/transcriptStore.ts | 147+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rapp/urlState.ts -> common/components/urlState.ts | 0
Rlib/format.ts -> common/lib/format.ts | 0
Rlib/manifest.ts -> common/lib/manifest.ts | 0
Acommon/lib/settings.ts | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rlib/transcripts-server.ts -> common/lib/transcripts-server.ts | 0
Rlib/transcripts.ts -> common/lib/transcripts.ts | 0
Rlib/vtt.ts -> common/lib/vtt.ts | 0
Rlib/whisper.ts -> common/lib/whisper.ts | 0
Acommon/package.json | 23+++++++++++++++++++++++
Acommon/tsconfig.json | 9+++++++++
Mcreate-archives.sh | 6+++---
Rapp/favicon.ico -> export/app/favicon.ico | 0
Rapp/globals.css -> export/app/globals.css | 0
Aexport/app/layout.tsx | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/app/page.tsx | 34++++++++++++++++++++++++++++++++++
Reslint.config.mjs -> export/eslint.config.mjs | 0
Aexport/next.config.ts | 11+++++++++++
Aexport/package.json | 40++++++++++++++++++++++++++++++++++++++++
Rpostcss.config.mjs -> export/postcss.config.mjs | 0
Rpublic/file.svg -> export/public/file.svg | 0
Rpublic/globe.svg -> export/public/globe.svg | 0
Rpublic/next.svg -> export/public/next.svg | 0
Rpublic/vercel.svg -> export/public/vercel.svg | 0
Rpublic/window.svg -> export/public/window.svg | 0
Aexport/scripts/build-index.ts | 672+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rserve.json -> export/serve.json | 0
Aexport/tsconfig.json | 20++++++++++++++++++++
Dlib/settings.ts | 49-------------------------------------------------
Dnext.config.ts | 10----------
Mpackage.json | 37+++++++------------------------------
Mpnpm-lock.yaml | 426++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Mpnpm-workspace.yaml | 5+++++
Dscripts/build-index.ts | 668-------------------------------------------------------------------------------
Atsconfig.base.json | 16++++++++++++++++
Dtsconfig.json | 34----------------------------------
56 files changed, 3302 insertions(+), 3184 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -1,7 +1,7 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies -/node_modules +**/node_modules /.pnp .pnp.* .yarn/* @@ -14,8 +14,8 @@ /coverage # next.js -/.next/ -/out/ +**/.next/ +**/out/ # production /build @@ -37,19 +37,19 @@ yarn-error.log* .vercel # typescript -*.tsbuildinfo -next-env.d.ts +**/*.tsbuildinfo +**/next-env.d.ts # nested transcripts repo /transcripts -# public downloadable repo archives (generate with create-archives.sh) -/public/code.tar.gz -/public/code.tar.xz -/public/transcripts.tar.gz -/public/transcripts.tar.xz +# downloadable repo archives (generate with create-archives.sh) +/export/public/code.tar.gz +/export/public/code.tar.xz +/export/public/transcripts.tar.gz +/export/public/transcripts.tar.xz # preprocessed transcript data (regenerate with `pnpm build:index`) -/public/summaries/ -/public/transcripts/ +/export/public/summaries/ +/export/public/transcripts/ /transcripts/index.mdb/ diff --git a/app/PlayerProvider.tsx b/app/PlayerProvider.tsx @@ -1,512 +0,0 @@ -"use client"; - -import dynamic from "next/dynamic"; -import { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import type ReactPlayerType from "react-player"; -import { fetchTranscript } from "./transcriptCache"; -import { useUrlParams, writeUrlParams } from "./urlState"; -import { formatDate, formatDuration } from "@/lib/format"; -import type { Platform } from "@/lib/transcripts"; -import type { RumblePlayerHandle } from "./RumblePlayer"; -import type { OdyseePlayerHandle } from "./OdyseePlayer"; - -const ReactPlayer = dynamic(() => import("react-player/youtube"), { - ssr: false, -}); - -const RumblePlayer = dynamic(() => import("./RumblePlayer"), { - ssr: false, -}); - -const OdyseePlayer = dynamic(() => import("./OdyseePlayer"), { - ssr: false, -}); - -type PlayerHandle = - | Pick<ReactPlayerType, "seekTo"> - | RumblePlayerHandle - | OdyseePlayerHandle; - -export type Cue = { start: number; end: number; text: string }; - -export type TranscriptData = { - slug: string; - id: string; - title: string; - uploadDate: string; - date: string; - duration: string; - channel: string; - description?: string; - isLivestream: boolean; - ageRestricted: boolean; - platform: Platform; - webpageUrl: string; - cues?: Cue[]; -}; - -type Status = "idle" | "loading" | "ready"; -export type DisplayMode = "modal" | "mini" | "hidden"; - -type Detail = { - slug: string; - id: string; - title: string; - uploadDate: string; - duration: number; - channel: string; - description?: string; - isLivestream: boolean; - ageRestricted: boolean; - platform: Platform; - webpageUrl: string; - cues?: Cue[]; -}; - -type ClipState = { - slug: string | null; - start: number | null; - end: number | null; -}; - -type PlayerState = { - activeSlug: string | null; - data: TranscriptData | null; - status: Status; - displayMode: DisplayMode; - modalOpen: boolean; - playing: boolean; - currentTime: number; - clipStart: number | null; - clipEnd: number | null; - openTranscript: (slug: string, start?: number) => void; - setDisplayMode: (mode: DisplayMode) => void; - closePlayer: () => void; - markClipStart: () => void; - markClipEnd: () => void; - clearClip: () => void; - copyDownloadCommand: () => Promise<boolean>; - copyShareUrl: () => Promise<boolean>; - openInPreservetube: () => void; - seekTo: (seconds: number) => void; -}; - -const Ctx = createContext<PlayerState | null>(null); - -export function usePlayer(): PlayerState { - const v = useContext(Ctx); - if (!v) throw new Error("usePlayer must be inside PlayerProvider"); - return v; -} - -function toHMS(seconds: number): string { - const s = Math.max(0, Math.floor(seconds)); - const h = Math.floor(s / 3600); - const m = Math.floor((s % 3600) / 60); - const sec = s % 60; - return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`; -} - -export function PlayerProvider({ - children, -}: { - children: React.ReactNode; -}) { - const { v: urlSlug, t: urlTime } = useUrlParams(); - const [detail, setDetail] = useState<Detail | null>(null); - const [displayState, setDisplayState] = useState<{ - slug: string; - mode: DisplayMode; - } | null>(null); - const [clip, setClip] = useState<ClipState>({ - slug: null, - start: null, - end: null, - }); - const [playing, setPlaying] = useState(false); - const [currentTime, setCurrentTime] = useState(0); - const playerRef = useRef<PlayerHandle | null>(null); - const pendingSeekRef = useRef<number | null>(null); - const readyForSlugRef = useRef<string | null>(null); - - const activeSlug = urlSlug; - const displayMode: DisplayMode = - displayState?.slug === activeSlug ? displayState.mode : "modal"; - const modalOpen = activeSlug !== null && displayMode === "modal"; - const detailMatches = detail?.slug === activeSlug; - const data: TranscriptData | null = useMemo(() => { - if (!detailMatches || !detail) return null; - return { - slug: detail.slug, - id: detail.id, - title: detail.title, - uploadDate: detail.uploadDate, - date: formatDate(detail.uploadDate), - duration: formatDuration(detail.duration), - channel: detail.channel, - description: detail.description, - isLivestream: detail.isLivestream, - ageRestricted: detail.ageRestricted, - platform: detail.platform, - webpageUrl: detail.webpageUrl, - cues: detail.cues, - }; - }, [detail, detailMatches]); - const status: Status = !activeSlug - ? "idle" - : detailMatches - ? "ready" - : "loading"; - - const clipStart = clip.slug && clip.slug === activeSlug ? clip.start : null; - const clipEnd = clip.slug && clip.slug === activeSlug ? clip.end : null; - - const openTranscript = useCallback( - (slug: string, start?: number) => { - setDisplayState({ slug, mode: "modal" }); - const t = typeof start === "number" ? Math.round(start) : null; - if (slug === activeSlug && t !== null && playerRef.current) { - playerRef.current.seekTo(t, "seconds"); - setCurrentTime(t); - setPlaying(true); - } - writeUrlParams({ v: slug, t }); - }, - [activeSlug], - ); - - const setDisplayMode = useCallback( - (mode: DisplayMode) => { - if (!activeSlug) return; - setDisplayState({ slug: activeSlug, mode }); - }, - [activeSlug], - ); - - const closePlayer = useCallback(() => { - setDisplayState(null); - writeUrlParams({ v: null, t: null }); - }, []); - - const markClipStart = useCallback(() => { - if (!activeSlug) return; - setClip((prev) => ({ - slug: activeSlug, - start: currentTime, - end: prev.slug === activeSlug ? prev.end : null, - })); - }, [activeSlug, currentTime]); - - const markClipEnd = useCallback(() => { - if (!activeSlug) return; - setClip((prev) => ({ - slug: activeSlug, - start: prev.slug === activeSlug ? prev.start : null, - end: currentTime, - })); - }, [activeSlug, currentTime]); - - const clearClip = useCallback(() => { - setClip({ slug: null, start: null, end: null }); - }, []); - - const copyDownloadCommand = useCallback(async (): Promise<boolean> => { - if (!data) return false; - const url = data.webpageUrl; - const sections = - clipStart !== null && clipEnd !== null - ? `--download-sections "*${toHMS(clipStart)}-${toHMS(clipEnd)}" ` - : ""; - const cmd = `yt-dlp ${sections}"${url}"`; - try { - await navigator.clipboard.writeText(cmd); - return true; - } catch { - return false; - } - }, [data, clipStart, clipEnd]); - - const copyShareUrl = useCallback(async (): Promise<boolean> => { - if (!data) return false; - const t = data.platform === "youtube" ? currentTime : (urlTime ?? 0); - const secs = Math.max(0, Math.floor(t)); - const params = new URLSearchParams(); - params.set("v", data.slug); - if (secs > 0) params.set("t", String(secs)); - const url = `${window.location.origin}${window.location.pathname}?${params.toString()}`; - try { - await navigator.clipboard.writeText(url); - return true; - } catch { - return false; - } - }, [data, currentTime, urlTime]); - - const openInPreservetube = useCallback(() => { - if (!data || data.platform !== "youtube") return; - window.open( - `https://preservetube.com/watch?v=${encodeURIComponent(data.id)}`, - "_blank", - "noopener,noreferrer", - ); - }, [data]); - - const seekTo = useCallback((seconds: number) => { - if (playerRef.current) { - playerRef.current.seekTo(seconds, "seconds"); - setPlaying(true); - } else { - pendingSeekRef.current = seconds; - } - setCurrentTime(seconds); - }, []); - - useEffect(() => { - if (!urlSlug) return; - readyForSlugRef.current = null; - pendingSeekRef.current = urlTime ?? 0; - let cancelled = false; - fetchTranscript(urlSlug) - .then((full) => { - if (cancelled) return; - setDetail({ - slug: urlSlug, - id: full.id, - title: full.title, - uploadDate: full.uploadDate, - duration: full.duration, - channel: full.channel, - description: full.description, - isLivestream: full.isLivestream, - ageRestricted: full.ageRestricted, - platform: full.platform, - webpageUrl: full.webpageUrl, - cues: full.cues, - }); - }) - .catch(() => { - if (cancelled) return; - setDetail(null); - }); - return () => { - cancelled = true; - }; - // urlTime intentionally omitted — t changes shouldn't refetch - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [urlSlug]); - - useEffect(() => { - if (urlTime === null) return; - if (readyForSlugRef.current !== urlSlug) return; - playerRef.current?.seekTo(urlTime, "seconds"); - setCurrentTime(urlTime); - }, [urlSlug, urlTime]); - - // Rumble / Odysee have no onReady or progress events; seed currentTime from - // the URL so clip-mark buttons and active-cue highlighting have a baseline, - // and mark the slug ready so later ?t= changes can seek via the imperative - // handle. - useEffect(() => { - if (detail?.platform === "rumble" || detail?.platform === "odysee") { - readyForSlugRef.current = detail.slug; - pendingSeekRef.current = null; - setCurrentTime(urlTime ?? 0); - } - // urlTime intentionally omitted — the urlTime effect above handles later changes. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [detail]); - - useEffect(() => { - if (!modalOpen) return; - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") setDisplayMode("mini"); - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [modalOpen, setDisplayMode]); - - const handleReady = () => { - if ( - readyForSlugRef.current !== activeSlug && - pendingSeekRef.current !== null && - playerRef.current - ) { - playerRef.current.seekTo(pendingSeekRef.current, "seconds"); - setPlaying(true); - pendingSeekRef.current = null; - readyForSlugRef.current = activeSlug; - } - }; - - const value: PlayerState = { - activeSlug, - data, - status, - displayMode, - modalOpen, - playing, - currentTime, - clipStart, - clipEnd, - openTranscript, - setDisplayMode, - closePlayer, - markClipStart, - markClipEnd, - clearClip, - copyDownloadCommand, - copyShareUrl, - openInPreservetube, - seekTo, - }; - - const showPlayer = activeSlug !== null && data !== null; - - let containerClass = ""; - if (displayMode === "modal") { - containerClass = - "fixed left-1/2 -translate-x-1/2 top-16 w-[min(92vw,960px)] aspect-video z-[60] rounded-lg overflow-hidden bg-black shadow-2xl"; - } else if (displayMode === "mini") { - containerClass = - "fixed bottom-4 right-4 w-72 sm:w-96 aspect-video z-[60] rounded-lg overflow-hidden bg-black shadow-2xl ring-1 ring-black/40"; - } else { - // hidden — keep iframe rendered with size so YT keeps playing, - // but pull it offscreen and remove from interaction. - containerClass = - "fixed -left-[10000px] -top-[10000px] w-80 aspect-video pointer-events-none"; - } - - return ( - <Ctx.Provider value={value}> - {children} - - {showPlayer && ( - <div className={containerClass} aria-hidden={displayMode === "hidden"}> - {data.platform === "rumble" ? ( - <RumblePlayer - key={data.id} - ref={(p: RumblePlayerHandle | null) => { - playerRef.current = p; - }} - videoId={data.id} - startSeconds={urlTime ?? 0} - /> - ) : data.platform === "odysee" ? ( - <OdyseePlayer - key={data.id} - ref={(p: OdyseePlayerHandle | null) => { - playerRef.current = p; - }} - videoId={data.id} - startSeconds={urlTime ?? 0} - /> - ) : ( - <ReactPlayer - ref={(p: ReactPlayerType | null) => { - playerRef.current = p; - }} - url={`https://www.youtube.com/watch?v=${data.id}`} - width="100%" - height="100%" - playing={playing} - controls - onReady={handleReady} - onPlay={() => setPlaying(true)} - onPause={() => setPlaying(false)} - onProgress={(s: { playedSeconds: number }) => - setCurrentTime(s.playedSeconds) - } - /> - )} - {displayMode === "mini" && ( - <button - type="button" - aria-label="Reopen transcript" - onClick={() => setDisplayMode("modal")} - 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> - <span className="opacity-70">click to reopen</span> - </button> - )} - </div> - )} - - {showPlayer && displayMode === "hidden" && ( - <HiddenControls - title={data.title} - onShow={() => setDisplayMode("modal")} - onMini={() => setDisplayMode("mini")} - onClose={closePlayer} - /> - )} - </Ctx.Provider> - ); -} - -function HiddenControls({ - title, - onShow, - onMini, - onClose, -}: { - title: string; - onShow: () => void; - onMini: () => void; - onClose: () => void; -}) { - return ( - <div className="fixed bottom-4 right-4 z-[60] flex items-center gap-2 rounded-full bg-zinc-900/95 text-white shadow-2xl ring-1 ring-white/10 px-3 py-2 max-w-[min(90vw,28rem)]"> - <span className="text-xs opacity-70 shrink-0">♪</span> - <span className="text-xs font-medium truncate flex-1 min-w-0"> - {title} - </span> - <ControlButton title="Show player" onClick={onShow} char="▢" /> - <ControlButton title="Collapse to mini-player" onClick={onMini} char="↘" /> - <ControlButton title="Close player" onClick={onClose} char="✕" /> - </div> - ); -} - -function ControlButton({ - title, - onClick, - char, - disabled, - highlight, -}: { - title: string; - onClick: () => void; - char: string; - disabled?: boolean; - highlight?: boolean; -}) { - return ( - <button - type="button" - title={title} - aria-label={title} - onClick={onClick} - disabled={disabled} - className={`shrink-0 inline-flex items-center justify-center w-8 h-8 rounded-md text-sm font-mono transition ${ - disabled - ? "bg-white/5 text-white/30 cursor-not-allowed" - : highlight - ? "bg-amber-500/30 hover:bg-amber-500/50 text-white ring-1 ring-amber-400/60" - : "bg-white/10 hover:bg-white/20 text-white" - }`} - > - {char} - </button> - ); -} - -export { ControlButton, toHMS }; diff --git a/app/TranscriptModal.tsx b/app/TranscriptModal.tsx @@ -1,273 +0,0 @@ -"use client"; - -import { useEffect, useRef, useState } from "react"; -import { ControlButton, toHMS, usePlayer } from "./PlayerProvider"; -import { AgeRestrictedBadge, LivestreamBadge } from "./badges"; -import { formatTimestamp } from "@/lib/vtt"; -import { formatDate } from "@/lib/format"; - -export default function TranscriptModal() { - const { - modalOpen, - activeSlug, - data, - status, - currentTime, - clipStart, - clipEnd, - setDisplayMode, - closePlayer, - markClipStart, - markClipEnd, - clearClip, - copyDownloadCommand, - copyShareUrl, - openInPreservetube, - seekTo, - } = usePlayer(); - const activeRef = useRef<HTMLLIElement | null>(null); - const scrollRef = useRef<HTMLDivElement | null>(null); - const [copied, setCopied] = useState(false); - const copyResetRef = useRef<number | null>(null); - const [shareCopied, setShareCopied] = useState(false); - const shareResetRef = useRef<number | null>(null); - - const cues = data?.cues ?? []; - const activeIndex = findActiveIndex(cues, currentTime); - - useEffect(() => { - if (!modalOpen) return; - activeRef.current?.scrollIntoView({ block: "nearest", behavior: "smooth" }); - }, [activeIndex, modalOpen]); - - useEffect(() => { - if (modalOpen) { - const prev = document.body.style.overflow; - document.body.style.overflow = "hidden"; - return () => { - document.body.style.overflow = prev; - }; - } - }, [modalOpen]); - - useEffect(() => { - return () => { - if (copyResetRef.current !== null) window.clearTimeout(copyResetRef.current); - if (shareResetRef.current !== null) window.clearTimeout(shareResetRef.current); - }; - }, []); - - if (!modalOpen || !activeSlug) return null; - - const canDownload = clipStart !== null && clipEnd !== null && clipEnd > clipStart; - const onCopy = async () => { - const ok = await copyDownloadCommand(); - if (!ok) return; - setCopied(true); - if (copyResetRef.current !== null) window.clearTimeout(copyResetRef.current); - copyResetRef.current = window.setTimeout(() => setCopied(false), 1800); - }; - const onShare = async () => { - const ok = await copyShareUrl(); - if (!ok) return; - setShareCopied(true); - if (shareResetRef.current !== null) window.clearTimeout(shareResetRef.current); - shareResetRef.current = window.setTimeout(() => setShareCopied(false), 1800); - }; - - return ( - <div className="fixed inset-0 z-50 flex flex-col"> - <button - type="button" - aria-label="Collapse to mini-player" - onClick={() => setDisplayMode("mini")} - className="absolute inset-0 bg-black/70 backdrop-blur-sm" - /> - <div className="relative flex flex-col items-center w-full h-full overflow-hidden pt-4 pointer-events-none"> - <div - className="w-[min(92vw,960px)] pointer-events-auto flex flex-col gap-3" - style={{ - marginTop: "calc(min(92vw, 960px) * 9 / 16 + 3rem)", - maxHeight: - "calc(100vh - (min(92vw, 960px) * 9 / 16) - 5rem)", - }} - > - <div className="flex items-center gap-2 flex-wrap rounded-lg bg-zinc-900/80 ring-1 ring-white/10 p-2"> - <ControlButton - title="Mark clip start at current time" - onClick={markClipStart} - char="[" - highlight={clipStart !== null} - /> - <ControlButton - title="Mark clip end at current time" - onClick={markClipEnd} - char="]" - highlight={clipEnd !== null} - /> - <ClipReadout - clipStart={clipStart} - clipEnd={clipEnd} - onClear={clearClip} - /> - <ControlButton - title={ - canDownload - ? copied - ? "Copied!" - : "Copy yt-dlp download command for this clip" - : "Mark both clip start and end first" - } - onClick={onCopy} - char={copied ? "✓" : "↓"} - disabled={!canDownload} - /> - <div className="flex-1" /> - <ControlButton - title={ - !data - ? "Loading…" - : shareCopied - ? "Copied!" - : "Copy share link at current time" - } - onClick={onShare} - char={shareCopied ? "✓" : "⤴"} - disabled={!data} - /> - {data?.platform === "youtube" && ( - <ControlButton - title="Open in Preservetube" - onClick={openInPreservetube} - char="⧉" - /> - )} - <ControlButton - title="Collapse to mini-player" - onClick={() => setDisplayMode("mini")} - char="↘" - /> - <ControlButton - title="Hide player (keep audio playing)" - onClick={() => setDisplayMode("hidden")} - char="—" - /> - <ControlButton - title="Close player" - onClick={closePlayer} - char="✕" - /> - </div> - - <div className="text-white min-w-0"> - <h2 className="text-lg font-semibold leading-tight truncate"> - {data?.title ?? "Loading…"} - </h2> - {data && ( - <p className="text-xs text-zinc-300 mt-0.5 flex items-center gap-1.5 flex-wrap"> - <span> - {formatDate(data.uploadDate)} - {data.channel && ` · ${data.channel}`} - </span> - {data.isLivestream && <LivestreamBadge />} - {data.ageRestricted && <AgeRestrictedBadge />} - </p> - )} - </div> - - <div - ref={scrollRef} - className="flex-1 overflow-y-auto rounded-lg border border-zinc-700 bg-zinc-900" - > - {status === "loading" && ( - <p className="text-sm text-zinc-400 p-4">Loading transcript…</p> - )} - {status === "ready" && cues.length === 0 && ( - <p className="text-sm text-zinc-400 p-4">No cues available.</p> - )} - {status === "ready" && cues.length > 0 && ( - <ol className="divide-y divide-zinc-800 text-zinc-100"> - {cues.map((cue, i) => { - const isActive = i === activeIndex; - return ( - <li - key={i} - ref={isActive ? activeRef : undefined} - className={ - isActive ? "bg-blue-950/50" : "" - } - > - <button - type="button" - onClick={() => seekTo(cue.start)} - className="w-full text-left flex gap-3 px-3 py-2 hover:bg-zinc-800" - > - <span className="text-xs font-mono text-zinc-400 shrink-0 w-16 pt-0.5"> - {formatTimestamp(cue.start)} - </span> - <span className="text-sm">{cue.text}</span> - </button> - </li> - ); - })} - </ol> - )} - </div> - </div> - </div> - </div> - ); -} - -function ClipReadout({ - clipStart, - clipEnd, - onClear, -}: { - clipStart: number | null; - clipEnd: number | null; - onClear: () => void; -}) { - if (clipStart === null && clipEnd === null) { - return ( - <span className="text-xs text-zinc-500 font-mono px-1"> - no clip set - </span> - ); - } - return ( - <span className="text-xs font-mono text-zinc-200 px-1 inline-flex items-center gap-1"> - <span>{clipStart !== null ? toHMS(clipStart) : "--:--:--"}</span> - <span className="opacity-60">→</span> - <span>{clipEnd !== null ? toHMS(clipEnd) : "--:--:--"}</span> - <button - type="button" - title="Clear clip" - onClick={onClear} - className="ml-1 text-zinc-400 hover:text-white" - > - × - </button> - </span> - ); -} - -function findActiveIndex( - cues: { start: number }[], - t: number, -): number { - if (!cues.length) return -1; - let lo = 0; - let hi = cues.length - 1; - let found = -1; - while (lo <= hi) { - const mid = (lo + hi) >> 1; - if (cues[mid].start <= t) { - found = mid; - lo = mid + 1; - } else { - hi = mid - 1; - } - } - return found; -} diff --git a/app/TranscriptSearch.tsx b/app/TranscriptSearch.tsx @@ -1,737 +0,0 @@ -"use client"; - -import { useEffect, useMemo, useRef, useState } from "react"; -import { usePlayer } from "./PlayerProvider"; -import { useSummaries } from "./summariesCache"; -import { - createSearchPipeline, - filterSlugs, - type Hit, - type PipelineController, -} from "./searchPipeline"; -import { useUrlParams, writeUrlParams } from "./urlState"; -import { AgeRestrictedBadge, LivestreamBadge } from "./badges"; -import type { DisplaySummary } from "@/lib/transcripts"; - -type Summary = DisplaySummary; - -type HitGroup = { - slug: string; - title: string; - channel: string; - date: string; - isLivestream: boolean; - ageRestricted: boolean; - hits: Hit[]; -}; - -const DEFAULT_MAX_HITS = 500; -const DEFAULT_FETCH_CONCURRENCY = 6; -const DEFAULT_FLUSH_INTERVAL_MS = 120; -const DEFAULT_GROUPS_PAGE_SIZE = 50; - -export default function TranscriptSearch() { - const { - q: query, - re: useRegex, - v: activeVideo, - t: activeTime, - ch: committedChannelList, - nov, - nol, - naa, - nar, - } = useUrlParams(); - // Content-based key so Set identity doesn't churn when other URL params - // (v/t) change — otherwise opening the player modal would restart the - // search pipeline from scratch. - const committedChannelsKey = committedChannelList - .slice() - .sort() - .join("\u0000"); - const committedChannels = useMemo( - () => new Set(committedChannelList), - // eslint-disable-next-line react-hooks/exhaustive-deps - [committedChannelsKey], - ); - const [input, setInput] = useState(query); - useEffect(() => { - setInput(query); - }, [query]); - - const [draftExcludedChannels, setDraftExcludedChannels] = useState< - Set<string> - >(() => new Set(committedChannelList)); - const [draftNov, setDraftNov] = useState(nov); - const [draftNol, setDraftNol] = useState(nol); - const [draftNaa, setDraftNaa] = useState(naa); - const [draftNar, setDraftNar] = useState(nar); - useEffect(() => { - setDraftExcludedChannels(new Set(committedChannelList)); - }, [committedChannelList]); - useEffect(() => setDraftNov(nov), [nov]); - useEffect(() => setDraftNol(nol), [nol]); - useEffect(() => setDraftNaa(naa), [naa]); - useEffect(() => setDraftNar(nar), [nar]); - - 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 { manifest, summaries, loadedPages, pageCount, summariesReady } = - useSummaries(); - const transcripts: Summary[] | null = summariesReady ? summaries : null; - - const [hitsBySlug, setHitsBySlug] = useState<Record<string, Hit[]>>({}); - const [totalHits, setTotalHits] = useState(0); - const [processed, setProcessed] = useState(0); - const [totalToProcess, setTotalToProcess] = useState(0); - const [pipelineDone, setPipelineDone] = useState(false); - const [capped, setCapped] = useState(false); - - // Advanced options — configurable from the UI, pre-filled with defaults. - const [hitBatchValue, setHitBatchValue] = useState(DEFAULT_MAX_HITS); - const [hitBatchUnlimited, setHitBatchUnlimited] = useState(false); - const [groupsBatchValue, setGroupsBatchValue] = useState( - DEFAULT_GROUPS_PAGE_SIZE, - ); - const [groupsBatchUnlimited, setGroupsBatchUnlimited] = useState(false); - const [fetchConcurrency, setFetchConcurrency] = useState( - DEFAULT_FETCH_CONCURRENCY, - ); - const [flushIntervalMs, setFlushIntervalMs] = useState( - DEFAULT_FLUSH_INTERVAL_MS, - ); - const hitBatchSize = hitBatchUnlimited ? Infinity : hitBatchValue; - const groupsBatchSize = groupsBatchUnlimited - ? Infinity - : groupsBatchValue; - - const [hitLimit, setHitLimit] = useState(DEFAULT_MAX_HITS); - const [groupsShown, setGroupsShown] = useState<number>( - DEFAULT_GROUPS_PAGE_SIZE, - ); - const pipelineRef = useRef<PipelineController | null>(null); - const { openTranscript } = usePlayer(); - - const trimmed = searchExecuted ? query.trim() : ""; - - // Reset the live caps on each new search. Uses the currently configured - // batch sizes so an advanced-options change only takes effect on the next - // search (no jarring mid-search restarts when tuning). - useEffect(() => { - setHitLimit(hitBatchSize); - setGroupsShown(groupsBatchSize); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [trimmed, useRegex]); - - const channelOptions = useMemo<string[]>(() => { - if (manifest?.channels.length) { - return manifest.channels - .map((c) => c.name) - .filter((n) => n) - .sort((a, b) => a.localeCompare(b)); - } - if (!transcripts) return []; - const names = new Set<string>(); - for (const t of transcripts) if (t.channel) names.add(t.channel); - return Array.from(names).sort((a, b) => a.localeCompare(b)); - }, [manifest, transcripts]); - - const passesFilter = useMemo(() => { - return (t: Summary) => { - if (committedChannels.has(t.channel)) return false; - if (t.isLivestream ? nol : nov) return false; - if (t.ageRestricted ? nar : naa) return false; - return true; - }; - }, [committedChannels, nov, nol, naa, nar]); - - // Stable signature of the filter inputs; used as a single dep in the search - // pipeline so reference-unstable upstream values (new `ch: []` array on every - // URL change) don't cancel an in-flight search when the modal opens. - const filterKey = `${committedChannelsKey}|${nov ? 1 : 0}|${nol ? 1 : 0}|${naa ? 1 : 0}|${nar ? 1 : 0}`; - - const regex = useMemo<{ re: RegExp | null; error: string | null }>(() => { - if (!trimmed || !useRegex) return { re: null, error: null }; - try { - return { re: new RegExp(trimmed, "i"), error: null }; - } catch (e) { - return { re: null, error: (e as Error).message }; - } - }, [trimmed, useRegex]); - - // Pipeline lifecycle: create a new pipeline when inputs change, and cancel - // the prior one. hitLimit is NOT a dep here — it's pushed into the live - // pipeline via setHitLimit so bumping the cap resumes rather than restarts. - useEffect(() => { - pipelineRef.current?.cancel(); - pipelineRef.current = null; - - if (!trimmed) { - setHitsBySlug({}); - setTotalHits(0); - setProcessed(0); - setTotalToProcess(0); - setPipelineDone(false); - setCapped(false); - return; - } - if (!transcripts) return; - if (useRegex && !regex.re) return; - - const slugs = filterSlugs(transcripts, passesFilter); - - const controller = createSearchPipeline({ - slugs, - query: trimmed, - useRegex, - regex: regex.re, - initialHitLimit: hitLimit, - concurrency: fetchConcurrency, - flushIntervalMs, - emit: (u) => { - setHitsBySlug(u.hitsBySlug); - setTotalHits(u.totalHits); - setProcessed(u.processed); - setTotalToProcess(u.totalToProcess); - setCapped(u.capped); - setPipelineDone(u.done); - }, - }); - pipelineRef.current = controller; - - return () => { - controller.cancel(); - if (pipelineRef.current === controller) pipelineRef.current = null; - }; - // hitLimit intentionally omitted — bumped via setHitLimit below. - // passesFilter captured via filterKey (content signature). - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [trimmed, transcripts, filterKey, useRegex, regex.re]); - - // Push hitLimit bumps into the live pipeline so "Show more videos" resumes - // the existing traversal instead of restarting from slug 0. - useEffect(() => { - pipelineRef.current?.setHitLimit(hitLimit); - }, [hitLimit]); - - const groups = useMemo<HitGroup[]>(() => { - if (!trimmed || !transcripts) return []; - const out: HitGroup[] = []; - for (const t of transcripts) { - const hits = hitsBySlug[t.slug]; - if (!hits?.length) continue; - out.push({ - slug: t.slug, - title: t.title, - channel: t.channel, - date: t.date, - isLivestream: t.isLivestream, - ageRestricted: t.ageRestricted, - hits, - }); - } - return out; - }, [trimmed, transcripts, hitsBySlug]); - - const indexLoading = Boolean(trimmed) && !summariesReady; - const searching = Boolean(trimmed) && totalToProcess > 0; - const pipelineActive = searching && !pipelineDone; - const indexProgress = - pageCount > 0 ? `${loadedPages}/${pageCount} pages` : ""; - - const filtersDirty = - !sameSet(draftExcludedChannels, committedChannels) || - draftNov !== nov || - draftNol !== nol || - draftNaa !== naa || - draftNar !== nar; - const queryDirty = input.trim() !== (searchExecuted ? query.trim() : ""); - const commitSearch = () => { - setSearchExecuted(true); - writeUrlParams({ - q: input.trim(), - ch: Array.from(draftExcludedChannels), - nov: draftNov, - nol: draftNol, - naa: draftNaa, - nar: draftNar, - }); - }; - - return ( - <div className="flex flex-col gap-6"> - <form - onSubmit={(e) => { - e.preventDefault(); - commitSearch(); - }} - className="flex flex-col gap-1" - > - <input - type="search" - value={input} - onChange={(e) => setInput(e.target.value)} - placeholder={ - useRegex - ? "Regex pattern (case-insensitive)…" - : "Search transcripts..." - } - className={`w-full rounded-lg border ${ - regex.error - ? "border-red-500" - : "border-zinc-300 dark:border-zinc-700" - } bg-white dark:bg-zinc-900 px-4 py-3 text-base outline-none focus:ring-2 focus:ring-blue-500 ${ - useRegex ? "font-mono" : "" - }`} - /> - <label className="flex items-center gap-2 text-sm text-zinc-600 dark:text-zinc-400 select-none"> - <input - type="checkbox" - checked={useRegex} - onChange={(e) => writeUrlParams({ re: e.target.checked })} - className="accent-blue-600" - /> - Regex - {regex.error && ( - <span className="text-red-600 font-mono text-xs"> - {regex.error} - </span> - )} - {(queryDirty || filtersDirty) && ( - <span className="text-xs text-zinc-400 ml-auto"> - Press Enter to search - </span> - )} - </label> - </form> - - {channelOptions.length > 0 && ( - <div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-zinc-600 dark:text-zinc-400 -mt-3"> - {channelOptions.length > 1 && ( - <div className="flex flex-wrap items-center gap-x-3 gap-y-1"> - <span className="text-xs uppercase tracking-wide text-zinc-500"> - Channels - </span> - {channelOptions.map((name) => { - const checked = !draftExcludedChannels.has(name); - return ( - <label - key={name} - className="flex items-center gap-1.5 select-none" - > - <input - type="checkbox" - checked={checked} - onChange={(e) => { - setDraftExcludedChannels((prev) => { - const next = new Set(prev); - if (e.target.checked) next.delete(name); - else next.add(name); - return next; - }); - }} - className="accent-blue-600" - /> - {name} - </label> - ); - })} - </div> - )} - <label className="flex items-center gap-1.5 select-none"> - <input - type="checkbox" - checked={!draftNov} - onChange={(e) => setDraftNov(!e.target.checked)} - className="accent-blue-600" - /> - Videos - </label> - <label className="flex items-center gap-1.5 select-none"> - <input - type="checkbox" - checked={!draftNol} - onChange={(e) => setDraftNol(!e.target.checked)} - className="accent-blue-600" - /> - Livestreams - </label> - <label className="flex items-center gap-1.5 select-none"> - <input - type="checkbox" - checked={!draftNaa} - onChange={(e) => setDraftNaa(!e.target.checked)} - className="accent-blue-600" - /> - All ages - </label> - <label className="flex items-center gap-1.5 select-none"> - <input - type="checkbox" - checked={!draftNar} - onChange={(e) => setDraftNar(!e.target.checked)} - className="accent-blue-600" - /> - Age-restricted - </label> - </div> - )} - - <details className="-mt-3 text-sm text-zinc-600 dark:text-zinc-400"> - <summary className="cursor-pointer select-none text-xs uppercase tracking-wide text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"> - Advanced options - </summary> - <div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 p-4 rounded-md border border-zinc-200 dark:border-zinc-800 bg-white/40 dark:bg-zinc-900/40"> - <LimitField - label="Max hits per batch" - hint="Pause after this many matches; “Show more” adds another batch." - value={hitBatchValue} - unlimited={hitBatchUnlimited} - defaultValue={DEFAULT_MAX_HITS} - onValueChange={setHitBatchValue} - onUnlimitedChange={setHitBatchUnlimited} - /> - <LimitField - label="Videos per page" - hint="How many matching videos to show at a time." - value={groupsBatchValue} - unlimited={groupsBatchUnlimited} - defaultValue={DEFAULT_GROUPS_PAGE_SIZE} - onValueChange={setGroupsBatchValue} - onUnlimitedChange={setGroupsBatchUnlimited} - /> - <NumberField - label="Fetch concurrency" - hint="Parallel transcript downloads while searching. Applies on next search." - value={fetchConcurrency} - defaultValue={DEFAULT_FETCH_CONCURRENCY} - min={1} - max={32} - onChange={setFetchConcurrency} - /> - <NumberField - label="Flush interval (ms)" - hint="UI update cadence during search. Applies on next search." - value={flushIntervalMs} - defaultValue={DEFAULT_FLUSH_INTERVAL_MS} - min={16} - max={2000} - onChange={setFlushIntervalMs} - /> - <div className="sm:col-span-2 flex flex-wrap gap-2 pt-1"> - <button - type="button" - onClick={() => { - setHitBatchUnlimited(true); - setGroupsBatchUnlimited(true); - setHitLimit(Infinity); - setGroupsShown(Infinity); - }} - className="px-3 py-1.5 rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 hover:bg-zinc-50 dark:hover:bg-zinc-800 text-xs" - > - Search through all transcripts - </button> - <button - type="button" - onClick={() => { - setHitBatchValue(DEFAULT_MAX_HITS); - setHitBatchUnlimited(false); - setGroupsBatchValue(DEFAULT_GROUPS_PAGE_SIZE); - setGroupsBatchUnlimited(false); - setFetchConcurrency(DEFAULT_FETCH_CONCURRENCY); - setFlushIntervalMs(DEFAULT_FLUSH_INTERVAL_MS); - }} - className="px-3 py-1.5 rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 hover:bg-zinc-50 dark:hover:bg-zinc-800 text-xs" - > - Reset to defaults - </button> - </div> - </div> - </details> - - {!trimmed && ( - <p className="text-sm text-zinc-500"> - Enter a query to search transcripts. - </p> - )} - - {trimmed && ( - <> - <section> - <h2 className="text-sm font-medium text-zinc-500 mb-2 flex items-center gap-2"> - <span> - Transcript mentions{" "} - {totalHits > 0 - ? `(${totalHits}${capped ? "+" : ""} mention${ - totalHits === 1 ? "" : "s" - } across ${groups.length} video${ - groups.length === 1 ? "" : "s" - })` - : "(0)"} - </span> - {indexLoading && ( - <span className="text-xs text-zinc-400 font-normal"> - loading index{indexProgress ? ` (${indexProgress})` : ""}… - </span> - )} - {searching && ( - <span className="text-xs text-zinc-400 font-normal"> - searched {processed}/{totalToProcess} - {pipelineActive ? "…" : ""} - </span> - )} - </h2> - {!indexLoading && groups.length === 0 && !pipelineActive && ( - <p className="text-sm text-zinc-500">No transcript mentions.</p> - )} - {groups.length > 0 && ( - <div className="flex flex-col gap-3"> - {groups.slice(0, groupsShown).map((g) => ( - <div - key={g.slug} - className="border border-zinc-200 dark:border-zinc-800 rounded-lg overflow-hidden bg-white dark:bg-zinc-900" - > - <button - type="button" - 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"> - {g.title} - </span> - {g.isLivestream && <LivestreamBadge />} - {g.ageRestricted && <AgeRestrictedBadge />} - <span className="text-xs text-zinc-500 shrink-0"> - {g.channel && `${g.channel} · `} - {g.date} - {` · ${g.hits.length} mention${g.hits.length === 1 ? "" : "s"}`} - </span> - </button> - <ul className="flex flex-col divide-y divide-zinc-200 dark:divide-zinc-800"> - {g.hits.map((h, i) => { - const isActive = - activeVideo === g.slug && activeTime === h.start; - return ( - <li key={i}> - <button - type="button" - 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 ${ - isActive - ? "bg-amber-100/70 dark:bg-amber-500/10 ring-1 ring-inset ring-amber-400/60" - : "" - }`} - > - <span className="text-xs font-mono text-zinc-500 shrink-0 w-16"> - {formatSeconds(h.start)} - </span> - <span className="flex-1 min-w-0 text-sm"> - {highlight(h.text, trimmed, useRegex)} - </span> - </button> - </li> - ); - })} - </ul> - </div> - ))} - </div> - )} - {(groups.length > groupsShown || capped) && ( - <div className="mt-3 flex justify-center"> - <button - type="button" - onClick={() => { - setGroupsShown((n) => n + groupsBatchSize); - if (capped) setHitLimit((l) => l + hitBatchSize); - }} - className="px-4 py-2 rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 hover:bg-zinc-50 dark:hover:bg-zinc-800 text-sm" - > - {groups.length > groupsShown - ? `Show more videos (${groups.length - groupsShown} loaded${capped ? ", more available" : ""})` - : "Show more videos"} - </button> - </div> - )} - </section> - </> - )} - </div> - ); -} - -function sameSet<T>(a: Set<T>, b: Set<T>): boolean { - if (a.size !== b.size) return false; - for (const v of a) if (!b.has(v)) return false; - return true; -} - -function NumberField({ - label, - hint, - value, - defaultValue, - min, - max, - onChange, - disabled, -}: { - label: string; - hint?: string; - value: number; - defaultValue: number; - min: number; - max: number; - onChange: (n: number) => void; - disabled?: boolean; -}) { - return ( - <label className="flex flex-col gap-1"> - <span className="text-xs font-medium text-zinc-700 dark:text-zinc-300"> - {label} - </span> - <input - type="number" - min={min} - max={max} - value={Number.isFinite(value) ? value : ""} - placeholder={String(defaultValue)} - disabled={disabled} - onChange={(e) => { - const raw = e.target.value; - if (raw === "") { - onChange(defaultValue); - return; - } - const n = Math.max(min, Math.min(max, Math.floor(Number(raw)))); - if (Number.isFinite(n)) onChange(n); - }} - className="w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm disabled:opacity-50" - /> - {hint && <span className="text-xs text-zinc-500">{hint}</span>} - </label> - ); -} - -function LimitField({ - label, - hint, - value, - unlimited, - defaultValue, - onValueChange, - onUnlimitedChange, -}: { - label: string; - hint?: string; - value: number; - unlimited: boolean; - defaultValue: number; - onValueChange: (n: number) => void; - onUnlimitedChange: (b: boolean) => void; -}) { - return ( - <div className="flex flex-col gap-1"> - <div className="flex items-baseline justify-between gap-2"> - <span className="text-xs font-medium text-zinc-700 dark:text-zinc-300"> - {label} - </span> - <label className="flex items-center gap-1 text-xs select-none"> - <input - type="checkbox" - checked={unlimited} - onChange={(e) => onUnlimitedChange(e.target.checked)} - className="accent-blue-600" - /> - No limit - </label> - </div> - <input - type="number" - min={1} - max={1_000_000} - value={unlimited ? "" : value} - placeholder={unlimited ? "∞" : String(defaultValue)} - disabled={unlimited} - onChange={(e) => { - const raw = e.target.value; - if (raw === "") { - onValueChange(defaultValue); - return; - } - const n = Math.max(1, Math.floor(Number(raw))); - if (Number.isFinite(n)) onValueChange(n); - }} - className="w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm disabled:opacity-50" - /> - {hint && <span className="text-xs text-zinc-500">{hint}</span>} - </div> - ); -} - -function formatSeconds(s: number): string { - const h = Math.floor(s / 3600); - const m = Math.floor((s % 3600) / 60); - const sec = s % 60; - const mm = String(m).padStart(2, "0"); - const ss = String(sec).padStart(2, "0"); - return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`; -} - -function highlight(text: string, q: string, useRegex = false) { - if (!q) return text; - const parts: React.ReactNode[] = []; - - if (useRegex) { - let re: RegExp; - try { - re = new RegExp(q, "gi"); - } catch { - return text; - } - let last = 0; - let m: RegExpExecArray | null; - let key = 0; - while ((m = re.exec(text)) !== null) { - if (m.index > last) parts.push(text.slice(last, m.index)); - parts.push( - <mark - key={key++} - className="bg-yellow-200 dark:bg-yellow-500/30 text-inherit" - > - {m[0]} - </mark>, - ); - last = m.index + m[0].length; - if (m[0].length === 0) re.lastIndex++; - } - if (last < text.length) parts.push(text.slice(last)); - return <>{parts}</>; - } - - const lower = text.toLowerCase(); - const ql = q.toLowerCase(); - let i = 0; - while (i < text.length) { - const idx = lower.indexOf(ql, i); - if (idx === -1) { - parts.push(text.slice(i)); - break; - } - if (idx > i) parts.push(text.slice(i, idx)); - parts.push( - <mark - key={idx} - className="bg-yellow-200 dark:bg-yellow-500/30 text-inherit" - > - {text.slice(idx, idx + q.length)} - </mark>, - ); - i = idx + q.length; - } - return <>{parts}</>; -} diff --git a/app/layout.tsx b/app/layout.tsx @@ -1,53 +0,0 @@ -import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; -import Link from "next/link"; -import { getSettings } from "@/lib/settings"; -import { QueryProvider } from "./QueryProvider"; -import "./globals.css"; - -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); - -export function generateMetadata(): Metadata { - const s = getSettings(); - return { - title: s.siteTitle, - description: s.siteDescription, - }; -} - -export default async function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { - const settings = getSettings(); - return ( - <html - lang="en" - className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} - > - <body className="min-h-full flex flex-col bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100 font-sans"> - <QueryProvider> - <header className="border-b border-zinc-200 dark:border-zinc-800 bg-white/70 dark:bg-zinc-900/70 backdrop-blur sticky top-0 z-20"> - <div className="max-w-6xl mx-auto px-4 py-3 flex items-center justify-between"> - <Link href="/" className="font-semibold tracking-tight"> - {settings.headerTitle} - </Link> - </div> - </header> - <main className="flex-1 w-full max-w-6xl mx-auto px-4 py-6"> - {children} - </main> - </QueryProvider> - </body> - </html> - ); -} diff --git a/app/page.tsx b/app/page.tsx @@ -1,34 +0,0 @@ -import { countTranscripts } from "@/lib/transcripts"; -import { getSettings } from "@/lib/settings"; -import { PlayerProvider } from "./PlayerProvider"; -import TranscriptModal from "./TranscriptModal"; -import TranscriptSearch from "./TranscriptSearch"; - -export default async function Home() { - const settings = getSettings(); - const count = await countTranscripts(); - - return ( - <PlayerProvider> - <div className="flex flex-col gap-6"> - <div> - <h1 className="text-2xl font-semibold">{count} transcripts</h1> - {settings.homeTagline && ( - <p className="text-sm text-zinc-500 mt-1">{settings.homeTagline}</p> - )} - </div> - <TranscriptSearch /> - <section className="border-t border-zinc-200 dark:border-zinc-800 pt-4 text-sm text-zinc-500 flex gap-4"> - <span>Downloads:</span> - <a - href="/code.tar.gz" - className="underline hover:text-zinc-900 dark:hover:text-zinc-100" - > - code.tar.gz - </a> - </section> - </div> - <TranscriptModal /> - </PlayerProvider> - ); -} diff --git a/app/searchPipeline.ts b/app/searchPipeline.ts @@ -1,251 +0,0 @@ -import { fetchTranscript } from "./transcriptCache"; -import type { DisplaySummary } from "@/lib/transcripts"; - -export type Hit = { start: number; text: string }; - -export type PipelineUpdate = { - hitsBySlug: Record<string, Hit[]>; - totalHits: number; - processed: number; - totalToProcess: number; - capped: boolean; - done: boolean; -}; - -type PipelineConfig = { - slugs: string[]; - query: string; - useRegex: boolean; - regex: RegExp | null; - initialHitLimit: number; - concurrency: number; - flushIntervalMs: number; - emit: (update: PipelineUpdate) => void; -}; - -export type PipelineController = { - cancel(): void; - setHitLimit(limit: number): void; -}; - -// Runs a streaming search over the given slugs. State is closure-captured so -// `setHitLimit` can raise the cap and re-spawn workers without restarting -// traversal — workers resume from the shared `idx` cursor. -export function createSearchPipeline( - config: PipelineConfig, -): PipelineController { - const { - slugs, - query, - useRegex, - regex, - initialHitLimit, - concurrency, - flushIntervalMs, - emit, - } = config; - - let cancelled = false; - // `idx` is the next-to-claim cursor (leading edge); `completed` counts - // slugs that workers have finished scanning (trailing edge). We report - // `completed` to the UI so progress reflects actual work done, not - // in-flight claims — and it never exceeds slugs.length. - let idx = 0; - let completed = 0; - let totalSoFar = 0; - let hitLimit = initialHitLimit; - let activeWorkers = 0; - let done = false; - const localHits: Record<string, Hit[]> = {}; - let flushTimer: number | null = null; - - const pushUpdate = (overrides: Partial<PipelineUpdate> = {}) => { - emit({ - hitsBySlug: { ...localHits }, - totalHits: totalSoFar, - processed: completed, - totalToProcess: slugs.length, - // `capped` keys off `idx` (claimed), not `completed` — raising the cap - // only helps if there are still unclaimed slugs a worker can pick up. - capped: totalSoFar >= hitLimit && idx < slugs.length, - done, - ...overrides, - }); - }; - - const scheduleFlush = () => { - if (flushTimer !== null || cancelled) return; - flushTimer = window.setTimeout(() => { - flushTimer = null; - if (cancelled) return; - pushUpdate(); - }, flushIntervalMs); - }; - - const finalize = () => { - if (done || cancelled) return; - done = true; - if (flushTimer !== null) { - window.clearTimeout(flushTimer); - flushTimer = null; - } - pushUpdate(); - }; - - const worker = async () => { - activeWorkers++; - try { - while (!cancelled) { - if (totalSoFar >= hitLimit) return; - if (idx >= slugs.length) return; - const my = idx++; - const slug = slugs[my]; - try { - const full = await fetchTranscript(slug); - if (cancelled) return; - if (full.cues && totalSoFar < hitLimit) { - const remaining = hitLimit - totalSoFar; - const hits = findHitsInCues( - full.cues, - query, - useRegex, - regex, - remaining, - ); - if (hits.length > 0) { - localHits[slug] = hits; - totalSoFar += hits.length; - } - } - } catch { - // ignore per-transcript failures - } - completed++; - scheduleFlush(); - } - } finally { - activeWorkers--; - if (activeWorkers === 0 && !cancelled) { - // Either we hit the cap or ran out of work — either way, settle. - finalize(); - } - } - }; - - const ensureWorkers = () => { - if (cancelled || done) return; - if (totalSoFar >= hitLimit) return; - if (idx >= slugs.length) return; - const needed = Math.min( - concurrency - activeWorkers, - slugs.length - idx, - ); - for (let i = 0; i < needed; i++) worker(); - }; - - // Emit initial snapshot synchronously so the UI clears previous results. - pushUpdate(); - ensureWorkers(); - - return { - cancel() { - cancelled = true; - if (flushTimer !== null) { - window.clearTimeout(flushTimer); - flushTimer = null; - } - }, - setHitLimit(limit: number) { - if (cancelled) return; - if (limit <= hitLimit) return; - hitLimit = limit; - // The prior run may have finalized because we were capped. Resume. - if (done) { - done = false; - pushUpdate(); - } - ensureWorkers(); - }, - }; -} - -function findHitsInCues( - cues: { start: number; text: string }[], - query: string, - useRegex: boolean, - regex: RegExp | null, - limit: number, -): Hit[] { - const hits: Hit[] = []; - for (let i = 0; i < cues.length && hits.length < limit; i++) { - const cur = cues[i]; - const prevText = i > 0 ? cues[i - 1].text : ""; - const nextText = i < cues.length - 1 ? cues[i + 1].text : ""; - const sep1 = prevText ? " " : ""; - const sep2 = nextText ? " " : ""; - const windowText = prevText + sep1 + cur.text + sep2 + nextText; - const curStart = prevText.length + sep1.length; - const curEnd = curStart + cur.text.length; - const m = findFirstMatchInRange( - windowText, - curStart, - curEnd, - query, - useRegex, - regex, - ); - if (!m) continue; - const crosses = m.idx < curStart || m.idx + m.length > curEnd; - hits.push({ - start: Math.round(cur.start), - text: crosses ? windowText : cur.text, - }); - } - return hits; -} - -function findFirstMatchInRange( - haystack: string, - rangeStart: number, - rangeEnd: number, - query: string, - useRegex: boolean, - regex: RegExp | null, -): { idx: number; length: number } | null { - if (useRegex) { - if (!regex) return null; - const flags = regex.flags.includes("g") ? regex.flags : regex.flags + "g"; - const re = new RegExp(regex.source, flags); - let m: RegExpExecArray | null; - while ((m = re.exec(haystack)) !== null) { - if (m.index >= rangeStart && m.index < rangeEnd) { - return { idx: m.index, length: m[0].length }; - } - if (m.index >= rangeEnd) return null; - if (m[0].length === 0) re.lastIndex++; - } - return null; - } - if (!query) return null; - const lower = haystack.toLowerCase(); - const ql = query.toLowerCase(); - let from = 0; - while (from <= haystack.length) { - const idx = lower.indexOf(ql, from); - if (idx === -1) return null; - if (idx >= rangeStart && idx < rangeEnd) return { idx, length: ql.length }; - if (idx >= rangeEnd) return null; - from = idx + 1; - } - return null; -} - -// Helper to build slug list from summaries + filter predicate. -export function filterSlugs( - summaries: DisplaySummary[], - passes: (t: DisplaySummary) => boolean, -): string[] { - const slugs: string[] = []; - for (const t of summaries) if (passes(t)) slugs.push(t.slug); - return slugs; -} diff --git a/app/summariesCache.ts b/app/summariesCache.ts @@ -1,82 +0,0 @@ -"use client"; - -import { useMemo } from "react"; -import { useQueries, useQuery } from "@tanstack/react-query"; -import type { DisplaySummary } from "@/lib/transcripts"; -import type { Manifest } from "@/lib/manifest"; -import { pageFileName } from "@/lib/manifest"; - -async function fetchJson<T>(url: string): Promise<T> { - const r = await fetch(url); - if (!r.ok) throw new Error(`Failed to fetch ${url}: ${r.status}`); - return (await r.json()) as T; -} - -export type SummariesState = { - manifest: Manifest | null; - summaries: DisplaySummary[]; - loadedPages: number; - pageCount: number; - summariesReady: boolean; - error: Error | null; -}; - -export function useManifest() { - return useQuery<Manifest>({ - queryKey: ["manifest"], - queryFn: () => fetchJson<Manifest>("/summaries/manifest.json"), - }); -} - -export function useSummaries(): SummariesState { - const manifestQuery = useManifest(); - const manifest = manifestQuery.data ?? null; - const pageCount = manifest?.pageCount ?? 0; - - const pageQueries = useQueries({ - queries: Array.from({ length: pageCount }, (_, i) => ({ - queryKey: ["summaries-page", i], - queryFn: () => - fetchJson<DisplaySummary[]>(`/summaries/${pageFileName(i)}`), - enabled: pageCount > 0, - })), - }); - - const loadedPages = pageQueries.filter((q) => q.data).length; - const summariesReady = pageCount > 0 && loadedPages === pageCount; - - // Concatenate available pages; sort once all pages are in. Pages may arrive - // out of order, so a late sort keeps the newest-first ordering stable - // regardless of arrival order. Matches the LMDB composite-key order - // [uploadDate, channelSlug, id] reversed. - const summaries = useMemo<DisplaySummary[]>(() => { - if (loadedPages === 0) return []; - const out: DisplaySummary[] = []; - for (const q of pageQueries) if (q.data) out.push(...q.data); - if (summariesReady) { - out.sort( - (a, b) => - b.uploadDate.localeCompare(a.uploadDate) || - a.channelSlug.localeCompare(b.channelSlug) || - a.id.localeCompare(b.id), - ); - } - return out; - // pageQueries identity changes every render; key off loadedPages + ready. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [loadedPages, summariesReady]); - - const error = - manifestQuery.error instanceof Error - ? manifestQuery.error - : (pageQueries.find((q) => q.error)?.error as Error | undefined) ?? null; - - return { - manifest, - summaries, - loadedPages, - pageCount, - summariesReady, - error, - }; -} diff --git a/app/transcriptCache.ts b/app/transcriptCache.ts @@ -1,99 +0,0 @@ -"use client"; - -import type { TranscriptDetail } from "@/lib/transcripts"; -import type { ChannelTranscriptsManifest } from "@/lib/manifest"; -import { transcriptPageFileName } from "@/lib/manifest"; -import { idbGet, idbPutBatch } from "./transcriptStore"; - -const resolved = new Map<string, TranscriptDetail>(); -const inFlight = new Map<string, Promise<TranscriptDetail>>(); -const channelManifests = new Map< - string, - Promise<ChannelTranscriptsManifest> ->(); -const pagePromises = new Map<string, Promise<TranscriptDetail[]>>(); - -export function fetchTranscript(slug: string): Promise<TranscriptDetail> { - const hit = resolved.get(slug); - if (hit) return Promise.resolve(hit); - const flying = inFlight.get(slug); - if (flying) return flying; - - const p = load(slug).then((detail) => { - resolved.set(slug, detail); - inFlight.delete(slug); - return detail; - }); - p.catch(() => { - inFlight.delete(slug); - }); - inFlight.set(slug, p); - return p; -} - -function fetchChannelManifest( - channelSlug: string, -): Promise<ChannelTranscriptsManifest> { - let p = channelManifests.get(channelSlug); - if (!p) { - p = fetch(`/transcripts/${channelSlug}/manifest.json`).then((r) => { - if (!r.ok) - throw new Error( - `Failed to fetch transcripts manifest for ${channelSlug}`, - ); - return r.json() as Promise<ChannelTranscriptsManifest>; - }); - p.catch(() => channelManifests.delete(channelSlug)); - channelManifests.set(channelSlug, p); - } - return p; -} - -function fetchPage( - channelSlug: string, - pageIndex: number, -): Promise<TranscriptDetail[]> { - const key = `${channelSlug}:${pageIndex}`; - let p = pagePromises.get(key); - if (!p) { - p = fetch( - `/transcripts/${channelSlug}/${transcriptPageFileName(pageIndex)}`, - ).then((r) => { - if (!r.ok) - throw new Error( - `Failed to fetch transcript page ${channelSlug}/${pageIndex}`, - ); - return r.json() as Promise<TranscriptDetail[]>; - }); - p.catch(() => pagePromises.delete(key)); - pagePromises.set(key, p); - } - return p; -} - -async function load(slug: string): Promise<TranscriptDetail> { - const stored = await idbGet(slug); - if (stored) return stored; - const slashIdx = slug.indexOf("/"); - if (slashIdx < 0) throw new Error(`Malformed transcript slug: ${slug}`); - const channelSlug = slug.slice(0, slashIdx); - const videoId = slug.slice(slashIdx + 1); - const manifest = await fetchChannelManifest(channelSlug); - const pageIndex = manifest.slugToPage[videoId]; - if (pageIndex === undefined) - throw new Error(`Unknown transcript slug: ${slug}`); - const page = await fetchPage(channelSlug, pageIndex); - let found: TranscriptDetail | undefined; - for (const entry of page) { - if (entry.slug === slug) found = entry; - // Opportunistically warm the per-slug memory + IDB caches so subsequent - // fetches for other videos in this page hit without a network round-trip. - // Huge win for searchPipeline which iterates many slugs from the same - // channel/page. - resolved.set(entry.slug, entry); - idbPutBatch(entry); - } - if (!found) - throw new Error(`Transcript ${slug} missing from page ${pageIndex}`); - return found; -} diff --git a/app/transcriptStore.ts b/app/transcriptStore.ts @@ -1,147 +0,0 @@ -"use client"; - -import type { TranscriptDetail } from "@/lib/transcripts"; - -const DB_NAME = "yt-dlp-transcript-browser"; -const DB_VERSION = 1; -const STORE = "transcripts"; - -type Mode = "pending" | "ok" | "unavailable"; - -let mode: Mode = "pending"; -let dbPromise: Promise<IDBDatabase | null> | null = null; - -function openDb(): Promise<IDBDatabase | null> { - if (typeof indexedDB === "undefined") { - mode = "unavailable"; - return Promise.resolve(null); - } - if (dbPromise) return dbPromise; - dbPromise = new Promise<IDBDatabase | null>((resolve) => { - let req: IDBOpenDBRequest; - try { - req = indexedDB.open(DB_NAME, DB_VERSION); - } catch { - downgrade("open threw"); - resolve(null); - return; - } - req.onupgradeneeded = () => { - const db = req.result; - if (!db.objectStoreNames.contains(STORE)) { - db.createObjectStore(STORE, { keyPath: "slug" }); - } - }; - req.onsuccess = () => { - mode = "ok"; - resolve(req.result); - }; - req.onerror = () => { - downgrade("open failed"); - resolve(null); - }; - req.onblocked = () => { - downgrade("open blocked"); - resolve(null); - }; - }); - return dbPromise; -} - -function downgrade(reason: string): void { - if (mode === "unavailable") return; - mode = "unavailable"; - console.warn( - `[transcriptStore] IndexedDB unavailable (${reason}); falling back to network-only.`, - ); -} - -export async function idbGet( - slug: string, -): Promise<TranscriptDetail | null> { - const db = await openDb(); - if (!db) return null; - return new Promise<TranscriptDetail | null>((resolve) => { - let req: IDBRequest<TranscriptDetail | undefined>; - try { - const tx = db.transaction(STORE, "readonly"); - req = tx.objectStore(STORE).get(slug) as IDBRequest< - TranscriptDetail | undefined - >; - } catch { - downgrade("read tx threw"); - resolve(null); - return; - } - req.onsuccess = () => resolve(req.result ?? null); - req.onerror = () => resolve(null); - }); -} - -let pending: TranscriptDetail[] = []; -let flushScheduled = false; -let flushInFlight: Promise<void> | null = null; - -export function idbPutBatch(detail: TranscriptDetail): void { - if (mode === "unavailable") return; - pending.push(detail); - if (flushScheduled) return; - flushScheduled = true; - queueMicrotask(() => { - flushScheduled = false; - void flush(); - }); -} - -async function flush(): Promise<void> { - if (flushInFlight) { - await flushInFlight; - } - if (pending.length === 0) return; - const batch = pending; - pending = []; - flushInFlight = writeBatch(batch).finally(() => { - flushInFlight = null; - if (pending.length > 0 && !flushScheduled) { - flushScheduled = true; - queueMicrotask(() => { - flushScheduled = false; - void flush(); - }); - } - }); - await flushInFlight; -} - -async function writeBatch(batch: TranscriptDetail[]): Promise<void> { - const db = await openDb(); - if (!db) return; - return new Promise<void>((resolve) => { - let tx: IDBTransaction; - try { - tx = db.transaction(STORE, "readwrite"); - } catch { - downgrade("write tx threw"); - resolve(); - return; - } - const store = tx.objectStore(STORE); - for (const entry of batch) { - try { - store.put(entry); - } catch { - // Per-entry errors (e.g. unclonable values) shouldn't fail the batch. - } - } - tx.oncomplete = () => resolve(); - tx.onerror = () => { - // Quota errors land here; degrade silently after one warn. - downgrade("write tx error"); - resolve(); - }; - tx.onabort = () => { - downgrade("write tx abort"); - resolve(); - }; - }); -} diff --git a/app/OdyseePlayer.tsx b/common/components/OdyseePlayer.tsx diff --git a/common/components/PlayerProvider.tsx b/common/components/PlayerProvider.tsx @@ -0,0 +1,512 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import type ReactPlayerType from "react-player"; +import { fetchTranscript } from "./transcriptCache"; +import { useUrlParams, writeUrlParams } from "./urlState"; +import { formatDate, formatDuration } from "../lib/format"; +import type { Platform } from "../lib/transcripts"; +import type { RumblePlayerHandle } from "./RumblePlayer"; +import type { OdyseePlayerHandle } from "./OdyseePlayer"; + +const ReactPlayer = dynamic(() => import("react-player/youtube"), { + ssr: false, +}); + +const RumblePlayer = dynamic(() => import("./RumblePlayer"), { + ssr: false, +}); + +const OdyseePlayer = dynamic(() => import("./OdyseePlayer"), { + ssr: false, +}); + +type PlayerHandle = + | Pick<ReactPlayerType, "seekTo"> + | RumblePlayerHandle + | OdyseePlayerHandle; + +export type Cue = { start: number; end: number; text: string }; + +export type TranscriptData = { + slug: string; + id: string; + title: string; + uploadDate: string; + date: string; + duration: string; + channel: string; + description?: string; + isLivestream: boolean; + ageRestricted: boolean; + platform: Platform; + webpageUrl: string; + cues?: Cue[]; +}; + +type Status = "idle" | "loading" | "ready"; +export type DisplayMode = "modal" | "mini" | "hidden"; + +type Detail = { + slug: string; + id: string; + title: string; + uploadDate: string; + duration: number; + channel: string; + description?: string; + isLivestream: boolean; + ageRestricted: boolean; + platform: Platform; + webpageUrl: string; + cues?: Cue[]; +}; + +type ClipState = { + slug: string | null; + start: number | null; + end: number | null; +}; + +type PlayerState = { + activeSlug: string | null; + data: TranscriptData | null; + status: Status; + displayMode: DisplayMode; + modalOpen: boolean; + playing: boolean; + currentTime: number; + clipStart: number | null; + clipEnd: number | null; + openTranscript: (slug: string, start?: number) => void; + setDisplayMode: (mode: DisplayMode) => void; + closePlayer: () => void; + markClipStart: () => void; + markClipEnd: () => void; + clearClip: () => void; + copyDownloadCommand: () => Promise<boolean>; + copyShareUrl: () => Promise<boolean>; + openInPreservetube: () => void; + seekTo: (seconds: number) => void; +}; + +const Ctx = createContext<PlayerState | null>(null); + +export function usePlayer(): PlayerState { + const v = useContext(Ctx); + if (!v) throw new Error("usePlayer must be inside PlayerProvider"); + return v; +} + +function toHMS(seconds: number): string { + const s = Math.max(0, Math.floor(seconds)); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`; +} + +export function PlayerProvider({ + children, +}: { + children: React.ReactNode; +}) { + const { v: urlSlug, t: urlTime } = useUrlParams(); + const [detail, setDetail] = useState<Detail | null>(null); + const [displayState, setDisplayState] = useState<{ + slug: string; + mode: DisplayMode; + } | null>(null); + const [clip, setClip] = useState<ClipState>({ + slug: null, + start: null, + end: null, + }); + const [playing, setPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const playerRef = useRef<PlayerHandle | null>(null); + const pendingSeekRef = useRef<number | null>(null); + const readyForSlugRef = useRef<string | null>(null); + + const activeSlug = urlSlug; + const displayMode: DisplayMode = + displayState?.slug === activeSlug ? displayState.mode : "modal"; + const modalOpen = activeSlug !== null && displayMode === "modal"; + const detailMatches = detail?.slug === activeSlug; + const data: TranscriptData | null = useMemo(() => { + if (!detailMatches || !detail) return null; + return { + slug: detail.slug, + id: detail.id, + title: detail.title, + uploadDate: detail.uploadDate, + date: formatDate(detail.uploadDate), + duration: formatDuration(detail.duration), + channel: detail.channel, + description: detail.description, + isLivestream: detail.isLivestream, + ageRestricted: detail.ageRestricted, + platform: detail.platform, + webpageUrl: detail.webpageUrl, + cues: detail.cues, + }; + }, [detail, detailMatches]); + const status: Status = !activeSlug + ? "idle" + : detailMatches + ? "ready" + : "loading"; + + const clipStart = clip.slug && clip.slug === activeSlug ? clip.start : null; + const clipEnd = clip.slug && clip.slug === activeSlug ? clip.end : null; + + const openTranscript = useCallback( + (slug: string, start?: number) => { + setDisplayState({ slug, mode: "modal" }); + const t = typeof start === "number" ? Math.round(start) : null; + if (slug === activeSlug && t !== null && playerRef.current) { + playerRef.current.seekTo(t, "seconds"); + setCurrentTime(t); + setPlaying(true); + } + writeUrlParams({ v: slug, t }); + }, + [activeSlug], + ); + + const setDisplayMode = useCallback( + (mode: DisplayMode) => { + if (!activeSlug) return; + setDisplayState({ slug: activeSlug, mode }); + }, + [activeSlug], + ); + + const closePlayer = useCallback(() => { + setDisplayState(null); + writeUrlParams({ v: null, t: null }); + }, []); + + const markClipStart = useCallback(() => { + if (!activeSlug) return; + setClip((prev) => ({ + slug: activeSlug, + start: currentTime, + end: prev.slug === activeSlug ? prev.end : null, + })); + }, [activeSlug, currentTime]); + + const markClipEnd = useCallback(() => { + if (!activeSlug) return; + setClip((prev) => ({ + slug: activeSlug, + start: prev.slug === activeSlug ? prev.start : null, + end: currentTime, + })); + }, [activeSlug, currentTime]); + + const clearClip = useCallback(() => { + setClip({ slug: null, start: null, end: null }); + }, []); + + const copyDownloadCommand = useCallback(async (): Promise<boolean> => { + if (!data) return false; + const url = data.webpageUrl; + const sections = + clipStart !== null && clipEnd !== null + ? `--download-sections "*${toHMS(clipStart)}-${toHMS(clipEnd)}" ` + : ""; + const cmd = `yt-dlp ${sections}"${url}"`; + try { + await navigator.clipboard.writeText(cmd); + return true; + } catch { + return false; + } + }, [data, clipStart, clipEnd]); + + const copyShareUrl = useCallback(async (): Promise<boolean> => { + if (!data) return false; + const t = data.platform === "youtube" ? currentTime : (urlTime ?? 0); + const secs = Math.max(0, Math.floor(t)); + const params = new URLSearchParams(); + params.set("v", data.slug); + if (secs > 0) params.set("t", String(secs)); + const url = `${window.location.origin}${window.location.pathname}?${params.toString()}`; + try { + await navigator.clipboard.writeText(url); + return true; + } catch { + return false; + } + }, [data, currentTime, urlTime]); + + const openInPreservetube = useCallback(() => { + if (!data || data.platform !== "youtube") return; + window.open( + `https://preservetube.com/watch?v=${encodeURIComponent(data.id)}`, + "_blank", + "noopener,noreferrer", + ); + }, [data]); + + const seekTo = useCallback((seconds: number) => { + if (playerRef.current) { + playerRef.current.seekTo(seconds, "seconds"); + setPlaying(true); + } else { + pendingSeekRef.current = seconds; + } + setCurrentTime(seconds); + }, []); + + useEffect(() => { + if (!urlSlug) return; + readyForSlugRef.current = null; + pendingSeekRef.current = urlTime ?? 0; + let cancelled = false; + fetchTranscript(urlSlug) + .then((full) => { + if (cancelled) return; + setDetail({ + slug: urlSlug, + id: full.id, + title: full.title, + uploadDate: full.uploadDate, + duration: full.duration, + channel: full.channel, + description: full.description, + isLivestream: full.isLivestream, + ageRestricted: full.ageRestricted, + platform: full.platform, + webpageUrl: full.webpageUrl, + cues: full.cues, + }); + }) + .catch(() => { + if (cancelled) return; + setDetail(null); + }); + return () => { + cancelled = true; + }; + // urlTime intentionally omitted — t changes shouldn't refetch + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [urlSlug]); + + useEffect(() => { + if (urlTime === null) return; + if (readyForSlugRef.current !== urlSlug) return; + playerRef.current?.seekTo(urlTime, "seconds"); + setCurrentTime(urlTime); + }, [urlSlug, urlTime]); + + // Rumble / Odysee have no onReady or progress events; seed currentTime from + // the URL so clip-mark buttons and active-cue highlighting have a baseline, + // and mark the slug ready so later ?t= changes can seek via the imperative + // handle. + useEffect(() => { + if (detail?.platform === "rumble" || detail?.platform === "odysee") { + readyForSlugRef.current = detail.slug; + pendingSeekRef.current = null; + setCurrentTime(urlTime ?? 0); + } + // urlTime intentionally omitted — the urlTime effect above handles later changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [detail]); + + useEffect(() => { + if (!modalOpen) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setDisplayMode("mini"); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [modalOpen, setDisplayMode]); + + const handleReady = () => { + if ( + readyForSlugRef.current !== activeSlug && + pendingSeekRef.current !== null && + playerRef.current + ) { + playerRef.current.seekTo(pendingSeekRef.current, "seconds"); + setPlaying(true); + pendingSeekRef.current = null; + readyForSlugRef.current = activeSlug; + } + }; + + const value: PlayerState = { + activeSlug, + data, + status, + displayMode, + modalOpen, + playing, + currentTime, + clipStart, + clipEnd, + openTranscript, + setDisplayMode, + closePlayer, + markClipStart, + markClipEnd, + clearClip, + copyDownloadCommand, + copyShareUrl, + openInPreservetube, + seekTo, + }; + + const showPlayer = activeSlug !== null && data !== null; + + let containerClass = ""; + if (displayMode === "modal") { + containerClass = + "fixed left-1/2 -translate-x-1/2 top-16 w-[min(92vw,960px)] aspect-video z-[60] rounded-lg overflow-hidden bg-black shadow-2xl"; + } else if (displayMode === "mini") { + containerClass = + "fixed bottom-4 right-4 w-72 sm:w-96 aspect-video z-[60] rounded-lg overflow-hidden bg-black shadow-2xl ring-1 ring-black/40"; + } else { + // hidden — keep iframe rendered with size so YT keeps playing, + // but pull it offscreen and remove from interaction. + containerClass = + "fixed -left-[10000px] -top-[10000px] w-80 aspect-video pointer-events-none"; + } + + return ( + <Ctx.Provider value={value}> + {children} + + {showPlayer && ( + <div className={containerClass} aria-hidden={displayMode === "hidden"}> + {data.platform === "rumble" ? ( + <RumblePlayer + key={data.id} + ref={(p: RumblePlayerHandle | null) => { + playerRef.current = p; + }} + videoId={data.id} + startSeconds={urlTime ?? 0} + /> + ) : data.platform === "odysee" ? ( + <OdyseePlayer + key={data.id} + ref={(p: OdyseePlayerHandle | null) => { + playerRef.current = p; + }} + videoId={data.id} + startSeconds={urlTime ?? 0} + /> + ) : ( + <ReactPlayer + ref={(p: ReactPlayerType | null) => { + playerRef.current = p; + }} + url={`https://www.youtube.com/watch?v=${data.id}`} + width="100%" + height="100%" + playing={playing} + controls + onReady={handleReady} + onPlay={() => setPlaying(true)} + onPause={() => setPlaying(false)} + onProgress={(s: { playedSeconds: number }) => + setCurrentTime(s.playedSeconds) + } + /> + )} + {displayMode === "mini" && ( + <button + type="button" + aria-label="Reopen transcript" + onClick={() => setDisplayMode("modal")} + 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> + <span className="opacity-70">click to reopen</span> + </button> + )} + </div> + )} + + {showPlayer && displayMode === "hidden" && ( + <HiddenControls + title={data.title} + onShow={() => setDisplayMode("modal")} + onMini={() => setDisplayMode("mini")} + onClose={closePlayer} + /> + )} + </Ctx.Provider> + ); +} + +function HiddenControls({ + title, + onShow, + onMini, + onClose, +}: { + title: string; + onShow: () => void; + onMini: () => void; + onClose: () => void; +}) { + return ( + <div className="fixed bottom-4 right-4 z-[60] flex items-center gap-2 rounded-full bg-zinc-900/95 text-white shadow-2xl ring-1 ring-white/10 px-3 py-2 max-w-[min(90vw,28rem)]"> + <span className="text-xs opacity-70 shrink-0">♪</span> + <span className="text-xs font-medium truncate flex-1 min-w-0"> + {title} + </span> + <ControlButton title="Show player" onClick={onShow} char="▢" /> + <ControlButton title="Collapse to mini-player" onClick={onMini} char="↘" /> + <ControlButton title="Close player" onClick={onClose} char="✕" /> + </div> + ); +} + +function ControlButton({ + title, + onClick, + char, + disabled, + highlight, +}: { + title: string; + onClick: () => void; + char: string; + disabled?: boolean; + highlight?: boolean; +}) { + return ( + <button + type="button" + title={title} + aria-label={title} + onClick={onClick} + disabled={disabled} + className={`shrink-0 inline-flex items-center justify-center w-8 h-8 rounded-md text-sm font-mono transition ${ + disabled + ? "bg-white/5 text-white/30 cursor-not-allowed" + : highlight + ? "bg-amber-500/30 hover:bg-amber-500/50 text-white ring-1 ring-amber-400/60" + : "bg-white/10 hover:bg-white/20 text-white" + }`} + > + {char} + </button> + ); +} + +export { ControlButton, toHMS }; diff --git a/app/QueryProvider.tsx b/common/components/QueryProvider.tsx diff --git a/app/RumblePlayer.tsx b/common/components/RumblePlayer.tsx diff --git a/common/components/TranscriptModal.tsx b/common/components/TranscriptModal.tsx @@ -0,0 +1,273 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { ControlButton, toHMS, usePlayer } from "./PlayerProvider"; +import { AgeRestrictedBadge, LivestreamBadge } from "./badges"; +import { formatTimestamp } from "../lib/vtt"; +import { formatDate } from "../lib/format"; + +export default function TranscriptModal() { + const { + modalOpen, + activeSlug, + data, + status, + currentTime, + clipStart, + clipEnd, + setDisplayMode, + closePlayer, + markClipStart, + markClipEnd, + clearClip, + copyDownloadCommand, + copyShareUrl, + openInPreservetube, + seekTo, + } = usePlayer(); + const activeRef = useRef<HTMLLIElement | null>(null); + const scrollRef = useRef<HTMLDivElement | null>(null); + const [copied, setCopied] = useState(false); + const copyResetRef = useRef<number | null>(null); + const [shareCopied, setShareCopied] = useState(false); + const shareResetRef = useRef<number | null>(null); + + const cues = data?.cues ?? []; + const activeIndex = findActiveIndex(cues, currentTime); + + useEffect(() => { + if (!modalOpen) return; + activeRef.current?.scrollIntoView({ block: "nearest", behavior: "smooth" }); + }, [activeIndex, modalOpen]); + + useEffect(() => { + if (modalOpen) { + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = prev; + }; + } + }, [modalOpen]); + + useEffect(() => { + return () => { + if (copyResetRef.current !== null) window.clearTimeout(copyResetRef.current); + if (shareResetRef.current !== null) window.clearTimeout(shareResetRef.current); + }; + }, []); + + if (!modalOpen || !activeSlug) return null; + + const canDownload = clipStart !== null && clipEnd !== null && clipEnd > clipStart; + const onCopy = async () => { + const ok = await copyDownloadCommand(); + if (!ok) return; + setCopied(true); + if (copyResetRef.current !== null) window.clearTimeout(copyResetRef.current); + copyResetRef.current = window.setTimeout(() => setCopied(false), 1800); + }; + const onShare = async () => { + const ok = await copyShareUrl(); + if (!ok) return; + setShareCopied(true); + if (shareResetRef.current !== null) window.clearTimeout(shareResetRef.current); + shareResetRef.current = window.setTimeout(() => setShareCopied(false), 1800); + }; + + return ( + <div className="fixed inset-0 z-50 flex flex-col"> + <button + type="button" + aria-label="Collapse to mini-player" + onClick={() => setDisplayMode("mini")} + className="absolute inset-0 bg-black/70 backdrop-blur-sm" + /> + <div className="relative flex flex-col items-center w-full h-full overflow-hidden pt-4 pointer-events-none"> + <div + className="w-[min(92vw,960px)] pointer-events-auto flex flex-col gap-3" + style={{ + marginTop: "calc(min(92vw, 960px) * 9 / 16 + 3rem)", + maxHeight: + "calc(100vh - (min(92vw, 960px) * 9 / 16) - 5rem)", + }} + > + <div className="flex items-center gap-2 flex-wrap rounded-lg bg-zinc-900/80 ring-1 ring-white/10 p-2"> + <ControlButton + title="Mark clip start at current time" + onClick={markClipStart} + char="[" + highlight={clipStart !== null} + /> + <ControlButton + title="Mark clip end at current time" + onClick={markClipEnd} + char="]" + highlight={clipEnd !== null} + /> + <ClipReadout + clipStart={clipStart} + clipEnd={clipEnd} + onClear={clearClip} + /> + <ControlButton + title={ + canDownload + ? copied + ? "Copied!" + : "Copy yt-dlp download command for this clip" + : "Mark both clip start and end first" + } + onClick={onCopy} + char={copied ? "✓" : "↓"} + disabled={!canDownload} + /> + <div className="flex-1" /> + <ControlButton + title={ + !data + ? "Loading…" + : shareCopied + ? "Copied!" + : "Copy share link at current time" + } + onClick={onShare} + char={shareCopied ? "✓" : "⤴"} + disabled={!data} + /> + {data?.platform === "youtube" && ( + <ControlButton + title="Open in Preservetube" + onClick={openInPreservetube} + char="⧉" + /> + )} + <ControlButton + title="Collapse to mini-player" + onClick={() => setDisplayMode("mini")} + char="↘" + /> + <ControlButton + title="Hide player (keep audio playing)" + onClick={() => setDisplayMode("hidden")} + char="—" + /> + <ControlButton + title="Close player" + onClick={closePlayer} + char="✕" + /> + </div> + + <div className="text-white min-w-0"> + <h2 className="text-lg font-semibold leading-tight truncate"> + {data?.title ?? "Loading…"} + </h2> + {data && ( + <p className="text-xs text-zinc-300 mt-0.5 flex items-center gap-1.5 flex-wrap"> + <span> + {formatDate(data.uploadDate)} + {data.channel && ` · ${data.channel}`} + </span> + {data.isLivestream && <LivestreamBadge />} + {data.ageRestricted && <AgeRestrictedBadge />} + </p> + )} + </div> + + <div + ref={scrollRef} + className="flex-1 overflow-y-auto rounded-lg border border-zinc-700 bg-zinc-900" + > + {status === "loading" && ( + <p className="text-sm text-zinc-400 p-4">Loading transcript…</p> + )} + {status === "ready" && cues.length === 0 && ( + <p className="text-sm text-zinc-400 p-4">No cues available.</p> + )} + {status === "ready" && cues.length > 0 && ( + <ol className="divide-y divide-zinc-800 text-zinc-100"> + {cues.map((cue, i) => { + const isActive = i === activeIndex; + return ( + <li + key={i} + ref={isActive ? activeRef : undefined} + className={ + isActive ? "bg-blue-950/50" : "" + } + > + <button + type="button" + onClick={() => seekTo(cue.start)} + className="w-full text-left flex gap-3 px-3 py-2 hover:bg-zinc-800" + > + <span className="text-xs font-mono text-zinc-400 shrink-0 w-16 pt-0.5"> + {formatTimestamp(cue.start)} + </span> + <span className="text-sm">{cue.text}</span> + </button> + </li> + ); + })} + </ol> + )} + </div> + </div> + </div> + </div> + ); +} + +function ClipReadout({ + clipStart, + clipEnd, + onClear, +}: { + clipStart: number | null; + clipEnd: number | null; + onClear: () => void; +}) { + if (clipStart === null && clipEnd === null) { + return ( + <span className="text-xs text-zinc-500 font-mono px-1"> + no clip set + </span> + ); + } + return ( + <span className="text-xs font-mono text-zinc-200 px-1 inline-flex items-center gap-1"> + <span>{clipStart !== null ? toHMS(clipStart) : "--:--:--"}</span> + <span className="opacity-60">→</span> + <span>{clipEnd !== null ? toHMS(clipEnd) : "--:--:--"}</span> + <button + type="button" + title="Clear clip" + onClick={onClear} + className="ml-1 text-zinc-400 hover:text-white" + > + × + </button> + </span> + ); +} + +function findActiveIndex( + cues: { start: number }[], + t: number, +): number { + if (!cues.length) return -1; + let lo = 0; + let hi = cues.length - 1; + let found = -1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (cues[mid].start <= t) { + found = mid; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + return found; +} diff --git a/common/components/TranscriptSearch.tsx b/common/components/TranscriptSearch.tsx @@ -0,0 +1,737 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { usePlayer } from "./PlayerProvider"; +import { useSummaries } from "./summariesCache"; +import { + createSearchPipeline, + filterSlugs, + type Hit, + type PipelineController, +} from "./searchPipeline"; +import { useUrlParams, writeUrlParams } from "./urlState"; +import { AgeRestrictedBadge, LivestreamBadge } from "./badges"; +import type { DisplaySummary } from "../lib/transcripts"; + +type Summary = DisplaySummary; + +type HitGroup = { + slug: string; + title: string; + channel: string; + date: string; + isLivestream: boolean; + ageRestricted: boolean; + hits: Hit[]; +}; + +const DEFAULT_MAX_HITS = 500; +const DEFAULT_FETCH_CONCURRENCY = 6; +const DEFAULT_FLUSH_INTERVAL_MS = 120; +const DEFAULT_GROUPS_PAGE_SIZE = 50; + +export default function TranscriptSearch() { + const { + q: query, + re: useRegex, + v: activeVideo, + t: activeTime, + ch: committedChannelList, + nov, + nol, + naa, + nar, + } = useUrlParams(); + // Content-based key so Set identity doesn't churn when other URL params + // (v/t) change — otherwise opening the player modal would restart the + // search pipeline from scratch. + const committedChannelsKey = committedChannelList + .slice() + .sort() + .join("\u0000"); + const committedChannels = useMemo( + () => new Set(committedChannelList), + // eslint-disable-next-line react-hooks/exhaustive-deps + [committedChannelsKey], + ); + const [input, setInput] = useState(query); + useEffect(() => { + setInput(query); + }, [query]); + + const [draftExcludedChannels, setDraftExcludedChannels] = useState< + Set<string> + >(() => new Set(committedChannelList)); + const [draftNov, setDraftNov] = useState(nov); + const [draftNol, setDraftNol] = useState(nol); + const [draftNaa, setDraftNaa] = useState(naa); + const [draftNar, setDraftNar] = useState(nar); + useEffect(() => { + setDraftExcludedChannels(new Set(committedChannelList)); + }, [committedChannelList]); + useEffect(() => setDraftNov(nov), [nov]); + useEffect(() => setDraftNol(nol), [nol]); + useEffect(() => setDraftNaa(naa), [naa]); + useEffect(() => setDraftNar(nar), [nar]); + + 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 { manifest, summaries, loadedPages, pageCount, summariesReady } = + useSummaries(); + const transcripts: Summary[] | null = summariesReady ? summaries : null; + + const [hitsBySlug, setHitsBySlug] = useState<Record<string, Hit[]>>({}); + const [totalHits, setTotalHits] = useState(0); + const [processed, setProcessed] = useState(0); + const [totalToProcess, setTotalToProcess] = useState(0); + const [pipelineDone, setPipelineDone] = useState(false); + const [capped, setCapped] = useState(false); + + // Advanced options — configurable from the UI, pre-filled with defaults. + const [hitBatchValue, setHitBatchValue] = useState(DEFAULT_MAX_HITS); + const [hitBatchUnlimited, setHitBatchUnlimited] = useState(false); + const [groupsBatchValue, setGroupsBatchValue] = useState( + DEFAULT_GROUPS_PAGE_SIZE, + ); + const [groupsBatchUnlimited, setGroupsBatchUnlimited] = useState(false); + const [fetchConcurrency, setFetchConcurrency] = useState( + DEFAULT_FETCH_CONCURRENCY, + ); + const [flushIntervalMs, setFlushIntervalMs] = useState( + DEFAULT_FLUSH_INTERVAL_MS, + ); + const hitBatchSize = hitBatchUnlimited ? Infinity : hitBatchValue; + const groupsBatchSize = groupsBatchUnlimited + ? Infinity + : groupsBatchValue; + + const [hitLimit, setHitLimit] = useState(DEFAULT_MAX_HITS); + const [groupsShown, setGroupsShown] = useState<number>( + DEFAULT_GROUPS_PAGE_SIZE, + ); + const pipelineRef = useRef<PipelineController | null>(null); + const { openTranscript } = usePlayer(); + + const trimmed = searchExecuted ? query.trim() : ""; + + // Reset the live caps on each new search. Uses the currently configured + // batch sizes so an advanced-options change only takes effect on the next + // search (no jarring mid-search restarts when tuning). + useEffect(() => { + setHitLimit(hitBatchSize); + setGroupsShown(groupsBatchSize); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [trimmed, useRegex]); + + const channelOptions = useMemo<string[]>(() => { + if (manifest?.channels.length) { + return manifest.channels + .map((c) => c.name) + .filter((n) => n) + .sort((a, b) => a.localeCompare(b)); + } + if (!transcripts) return []; + const names = new Set<string>(); + for (const t of transcripts) if (t.channel) names.add(t.channel); + return Array.from(names).sort((a, b) => a.localeCompare(b)); + }, [manifest, transcripts]); + + const passesFilter = useMemo(() => { + return (t: Summary) => { + if (committedChannels.has(t.channel)) return false; + if (t.isLivestream ? nol : nov) return false; + if (t.ageRestricted ? nar : naa) return false; + return true; + }; + }, [committedChannels, nov, nol, naa, nar]); + + // Stable signature of the filter inputs; used as a single dep in the search + // pipeline so reference-unstable upstream values (new `ch: []` array on every + // URL change) don't cancel an in-flight search when the modal opens. + const filterKey = `${committedChannelsKey}|${nov ? 1 : 0}|${nol ? 1 : 0}|${naa ? 1 : 0}|${nar ? 1 : 0}`; + + const regex = useMemo<{ re: RegExp | null; error: string | null }>(() => { + if (!trimmed || !useRegex) return { re: null, error: null }; + try { + return { re: new RegExp(trimmed, "i"), error: null }; + } catch (e) { + return { re: null, error: (e as Error).message }; + } + }, [trimmed, useRegex]); + + // Pipeline lifecycle: create a new pipeline when inputs change, and cancel + // the prior one. hitLimit is NOT a dep here — it's pushed into the live + // pipeline via setHitLimit so bumping the cap resumes rather than restarts. + useEffect(() => { + pipelineRef.current?.cancel(); + pipelineRef.current = null; + + if (!trimmed) { + setHitsBySlug({}); + setTotalHits(0); + setProcessed(0); + setTotalToProcess(0); + setPipelineDone(false); + setCapped(false); + return; + } + if (!transcripts) return; + if (useRegex && !regex.re) return; + + const slugs = filterSlugs(transcripts, passesFilter); + + const controller = createSearchPipeline({ + slugs, + query: trimmed, + useRegex, + regex: regex.re, + initialHitLimit: hitLimit, + concurrency: fetchConcurrency, + flushIntervalMs, + emit: (u) => { + setHitsBySlug(u.hitsBySlug); + setTotalHits(u.totalHits); + setProcessed(u.processed); + setTotalToProcess(u.totalToProcess); + setCapped(u.capped); + setPipelineDone(u.done); + }, + }); + pipelineRef.current = controller; + + return () => { + controller.cancel(); + if (pipelineRef.current === controller) pipelineRef.current = null; + }; + // hitLimit intentionally omitted — bumped via setHitLimit below. + // passesFilter captured via filterKey (content signature). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [trimmed, transcripts, filterKey, useRegex, regex.re]); + + // Push hitLimit bumps into the live pipeline so "Show more videos" resumes + // the existing traversal instead of restarting from slug 0. + useEffect(() => { + pipelineRef.current?.setHitLimit(hitLimit); + }, [hitLimit]); + + const groups = useMemo<HitGroup[]>(() => { + if (!trimmed || !transcripts) return []; + const out: HitGroup[] = []; + for (const t of transcripts) { + const hits = hitsBySlug[t.slug]; + if (!hits?.length) continue; + out.push({ + slug: t.slug, + title: t.title, + channel: t.channel, + date: t.date, + isLivestream: t.isLivestream, + ageRestricted: t.ageRestricted, + hits, + }); + } + return out; + }, [trimmed, transcripts, hitsBySlug]); + + const indexLoading = Boolean(trimmed) && !summariesReady; + const searching = Boolean(trimmed) && totalToProcess > 0; + const pipelineActive = searching && !pipelineDone; + const indexProgress = + pageCount > 0 ? `${loadedPages}/${pageCount} pages` : ""; + + const filtersDirty = + !sameSet(draftExcludedChannels, committedChannels) || + draftNov !== nov || + draftNol !== nol || + draftNaa !== naa || + draftNar !== nar; + const queryDirty = input.trim() !== (searchExecuted ? query.trim() : ""); + const commitSearch = () => { + setSearchExecuted(true); + writeUrlParams({ + q: input.trim(), + ch: Array.from(draftExcludedChannels), + nov: draftNov, + nol: draftNol, + naa: draftNaa, + nar: draftNar, + }); + }; + + return ( + <div className="flex flex-col gap-6"> + <form + onSubmit={(e) => { + e.preventDefault(); + commitSearch(); + }} + className="flex flex-col gap-1" + > + <input + type="search" + value={input} + onChange={(e) => setInput(e.target.value)} + placeholder={ + useRegex + ? "Regex pattern (case-insensitive)…" + : "Search transcripts..." + } + className={`w-full rounded-lg border ${ + regex.error + ? "border-red-500" + : "border-zinc-300 dark:border-zinc-700" + } bg-white dark:bg-zinc-900 px-4 py-3 text-base outline-none focus:ring-2 focus:ring-blue-500 ${ + useRegex ? "font-mono" : "" + }`} + /> + <label className="flex items-center gap-2 text-sm text-zinc-600 dark:text-zinc-400 select-none"> + <input + type="checkbox" + checked={useRegex} + onChange={(e) => writeUrlParams({ re: e.target.checked })} + className="accent-blue-600" + /> + Regex + {regex.error && ( + <span className="text-red-600 font-mono text-xs"> + {regex.error} + </span> + )} + {(queryDirty || filtersDirty) && ( + <span className="text-xs text-zinc-400 ml-auto"> + Press Enter to search + </span> + )} + </label> + </form> + + {channelOptions.length > 0 && ( + <div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-zinc-600 dark:text-zinc-400 -mt-3"> + {channelOptions.length > 1 && ( + <div className="flex flex-wrap items-center gap-x-3 gap-y-1"> + <span className="text-xs uppercase tracking-wide text-zinc-500"> + Channels + </span> + {channelOptions.map((name) => { + const checked = !draftExcludedChannels.has(name); + return ( + <label + key={name} + className="flex items-center gap-1.5 select-none" + > + <input + type="checkbox" + checked={checked} + onChange={(e) => { + setDraftExcludedChannels((prev) => { + const next = new Set(prev); + if (e.target.checked) next.delete(name); + else next.add(name); + return next; + }); + }} + className="accent-blue-600" + /> + {name} + </label> + ); + })} + </div> + )} + <label className="flex items-center gap-1.5 select-none"> + <input + type="checkbox" + checked={!draftNov} + onChange={(e) => setDraftNov(!e.target.checked)} + className="accent-blue-600" + /> + Videos + </label> + <label className="flex items-center gap-1.5 select-none"> + <input + type="checkbox" + checked={!draftNol} + onChange={(e) => setDraftNol(!e.target.checked)} + className="accent-blue-600" + /> + Livestreams + </label> + <label className="flex items-center gap-1.5 select-none"> + <input + type="checkbox" + checked={!draftNaa} + onChange={(e) => setDraftNaa(!e.target.checked)} + className="accent-blue-600" + /> + All ages + </label> + <label className="flex items-center gap-1.5 select-none"> + <input + type="checkbox" + checked={!draftNar} + onChange={(e) => setDraftNar(!e.target.checked)} + className="accent-blue-600" + /> + Age-restricted + </label> + </div> + )} + + <details className="-mt-3 text-sm text-zinc-600 dark:text-zinc-400"> + <summary className="cursor-pointer select-none text-xs uppercase tracking-wide text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"> + Advanced options + </summary> + <div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 p-4 rounded-md border border-zinc-200 dark:border-zinc-800 bg-white/40 dark:bg-zinc-900/40"> + <LimitField + label="Max hits per batch" + hint="Pause after this many matches; “Show more” adds another batch." + value={hitBatchValue} + unlimited={hitBatchUnlimited} + defaultValue={DEFAULT_MAX_HITS} + onValueChange={setHitBatchValue} + onUnlimitedChange={setHitBatchUnlimited} + /> + <LimitField + label="Videos per page" + hint="How many matching videos to show at a time." + value={groupsBatchValue} + unlimited={groupsBatchUnlimited} + defaultValue={DEFAULT_GROUPS_PAGE_SIZE} + onValueChange={setGroupsBatchValue} + onUnlimitedChange={setGroupsBatchUnlimited} + /> + <NumberField + label="Fetch concurrency" + hint="Parallel transcript downloads while searching. Applies on next search." + value={fetchConcurrency} + defaultValue={DEFAULT_FETCH_CONCURRENCY} + min={1} + max={32} + onChange={setFetchConcurrency} + /> + <NumberField + label="Flush interval (ms)" + hint="UI update cadence during search. Applies on next search." + value={flushIntervalMs} + defaultValue={DEFAULT_FLUSH_INTERVAL_MS} + min={16} + max={2000} + onChange={setFlushIntervalMs} + /> + <div className="sm:col-span-2 flex flex-wrap gap-2 pt-1"> + <button + type="button" + onClick={() => { + setHitBatchUnlimited(true); + setGroupsBatchUnlimited(true); + setHitLimit(Infinity); + setGroupsShown(Infinity); + }} + className="px-3 py-1.5 rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 hover:bg-zinc-50 dark:hover:bg-zinc-800 text-xs" + > + Search through all transcripts + </button> + <button + type="button" + onClick={() => { + setHitBatchValue(DEFAULT_MAX_HITS); + setHitBatchUnlimited(false); + setGroupsBatchValue(DEFAULT_GROUPS_PAGE_SIZE); + setGroupsBatchUnlimited(false); + setFetchConcurrency(DEFAULT_FETCH_CONCURRENCY); + setFlushIntervalMs(DEFAULT_FLUSH_INTERVAL_MS); + }} + className="px-3 py-1.5 rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 hover:bg-zinc-50 dark:hover:bg-zinc-800 text-xs" + > + Reset to defaults + </button> + </div> + </div> + </details> + + {!trimmed && ( + <p className="text-sm text-zinc-500"> + Enter a query to search transcripts. + </p> + )} + + {trimmed && ( + <> + <section> + <h2 className="text-sm font-medium text-zinc-500 mb-2 flex items-center gap-2"> + <span> + Transcript mentions{" "} + {totalHits > 0 + ? `(${totalHits}${capped ? "+" : ""} mention${ + totalHits === 1 ? "" : "s" + } across ${groups.length} video${ + groups.length === 1 ? "" : "s" + })` + : "(0)"} + </span> + {indexLoading && ( + <span className="text-xs text-zinc-400 font-normal"> + loading index{indexProgress ? ` (${indexProgress})` : ""}… + </span> + )} + {searching && ( + <span className="text-xs text-zinc-400 font-normal"> + searched {processed}/{totalToProcess} + {pipelineActive ? "…" : ""} + </span> + )} + </h2> + {!indexLoading && groups.length === 0 && !pipelineActive && ( + <p className="text-sm text-zinc-500">No transcript mentions.</p> + )} + {groups.length > 0 && ( + <div className="flex flex-col gap-3"> + {groups.slice(0, groupsShown).map((g) => ( + <div + key={g.slug} + className="border border-zinc-200 dark:border-zinc-800 rounded-lg overflow-hidden bg-white dark:bg-zinc-900" + > + <button + type="button" + 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"> + {g.title} + </span> + {g.isLivestream && <LivestreamBadge />} + {g.ageRestricted && <AgeRestrictedBadge />} + <span className="text-xs text-zinc-500 shrink-0"> + {g.channel && `${g.channel} · `} + {g.date} + {` · ${g.hits.length} mention${g.hits.length === 1 ? "" : "s"}`} + </span> + </button> + <ul className="flex flex-col divide-y divide-zinc-200 dark:divide-zinc-800"> + {g.hits.map((h, i) => { + const isActive = + activeVideo === g.slug && activeTime === h.start; + return ( + <li key={i}> + <button + type="button" + 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 ${ + isActive + ? "bg-amber-100/70 dark:bg-amber-500/10 ring-1 ring-inset ring-amber-400/60" + : "" + }`} + > + <span className="text-xs font-mono text-zinc-500 shrink-0 w-16"> + {formatSeconds(h.start)} + </span> + <span className="flex-1 min-w-0 text-sm"> + {highlight(h.text, trimmed, useRegex)} + </span> + </button> + </li> + ); + })} + </ul> + </div> + ))} + </div> + )} + {(groups.length > groupsShown || capped) && ( + <div className="mt-3 flex justify-center"> + <button + type="button" + onClick={() => { + setGroupsShown((n) => n + groupsBatchSize); + if (capped) setHitLimit((l) => l + hitBatchSize); + }} + className="px-4 py-2 rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 hover:bg-zinc-50 dark:hover:bg-zinc-800 text-sm" + > + {groups.length > groupsShown + ? `Show more videos (${groups.length - groupsShown} loaded${capped ? ", more available" : ""})` + : "Show more videos"} + </button> + </div> + )} + </section> + </> + )} + </div> + ); +} + +function sameSet<T>(a: Set<T>, b: Set<T>): boolean { + if (a.size !== b.size) return false; + for (const v of a) if (!b.has(v)) return false; + return true; +} + +function NumberField({ + label, + hint, + value, + defaultValue, + min, + max, + onChange, + disabled, +}: { + label: string; + hint?: string; + value: number; + defaultValue: number; + min: number; + max: number; + onChange: (n: number) => void; + disabled?: boolean; +}) { + return ( + <label className="flex flex-col gap-1"> + <span className="text-xs font-medium text-zinc-700 dark:text-zinc-300"> + {label} + </span> + <input + type="number" + min={min} + max={max} + value={Number.isFinite(value) ? value : ""} + placeholder={String(defaultValue)} + disabled={disabled} + onChange={(e) => { + const raw = e.target.value; + if (raw === "") { + onChange(defaultValue); + return; + } + const n = Math.max(min, Math.min(max, Math.floor(Number(raw)))); + if (Number.isFinite(n)) onChange(n); + }} + className="w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm disabled:opacity-50" + /> + {hint && <span className="text-xs text-zinc-500">{hint}</span>} + </label> + ); +} + +function LimitField({ + label, + hint, + value, + unlimited, + defaultValue, + onValueChange, + onUnlimitedChange, +}: { + label: string; + hint?: string; + value: number; + unlimited: boolean; + defaultValue: number; + onValueChange: (n: number) => void; + onUnlimitedChange: (b: boolean) => void; +}) { + return ( + <div className="flex flex-col gap-1"> + <div className="flex items-baseline justify-between gap-2"> + <span className="text-xs font-medium text-zinc-700 dark:text-zinc-300"> + {label} + </span> + <label className="flex items-center gap-1 text-xs select-none"> + <input + type="checkbox" + checked={unlimited} + onChange={(e) => onUnlimitedChange(e.target.checked)} + className="accent-blue-600" + /> + No limit + </label> + </div> + <input + type="number" + min={1} + max={1_000_000} + value={unlimited ? "" : value} + placeholder={unlimited ? "∞" : String(defaultValue)} + disabled={unlimited} + onChange={(e) => { + const raw = e.target.value; + if (raw === "") { + onValueChange(defaultValue); + return; + } + const n = Math.max(1, Math.floor(Number(raw))); + if (Number.isFinite(n)) onValueChange(n); + }} + className="w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm disabled:opacity-50" + /> + {hint && <span className="text-xs text-zinc-500">{hint}</span>} + </div> + ); +} + +function formatSeconds(s: number): string { + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + const mm = String(m).padStart(2, "0"); + const ss = String(sec).padStart(2, "0"); + return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`; +} + +function highlight(text: string, q: string, useRegex = false) { + if (!q) return text; + const parts: React.ReactNode[] = []; + + if (useRegex) { + let re: RegExp; + try { + re = new RegExp(q, "gi"); + } catch { + return text; + } + let last = 0; + let m: RegExpExecArray | null; + let key = 0; + while ((m = re.exec(text)) !== null) { + if (m.index > last) parts.push(text.slice(last, m.index)); + parts.push( + <mark + key={key++} + className="bg-yellow-200 dark:bg-yellow-500/30 text-inherit" + > + {m[0]} + </mark>, + ); + last = m.index + m[0].length; + if (m[0].length === 0) re.lastIndex++; + } + if (last < text.length) parts.push(text.slice(last)); + return <>{parts}</>; + } + + const lower = text.toLowerCase(); + const ql = q.toLowerCase(); + let i = 0; + while (i < text.length) { + const idx = lower.indexOf(ql, i); + if (idx === -1) { + parts.push(text.slice(i)); + break; + } + if (idx > i) parts.push(text.slice(i, idx)); + parts.push( + <mark + key={idx} + className="bg-yellow-200 dark:bg-yellow-500/30 text-inherit" + > + {text.slice(idx, idx + q.length)} + </mark>, + ); + i = idx + q.length; + } + return <>{parts}</>; +} diff --git a/app/badges.tsx b/common/components/badges.tsx diff --git a/common/components/searchPipeline.ts b/common/components/searchPipeline.ts @@ -0,0 +1,251 @@ +import { fetchTranscript } from "./transcriptCache"; +import type { DisplaySummary } from "../lib/transcripts"; + +export type Hit = { start: number; text: string }; + +export type PipelineUpdate = { + hitsBySlug: Record<string, Hit[]>; + totalHits: number; + processed: number; + totalToProcess: number; + capped: boolean; + done: boolean; +}; + +type PipelineConfig = { + slugs: string[]; + query: string; + useRegex: boolean; + regex: RegExp | null; + initialHitLimit: number; + concurrency: number; + flushIntervalMs: number; + emit: (update: PipelineUpdate) => void; +}; + +export type PipelineController = { + cancel(): void; + setHitLimit(limit: number): void; +}; + +// Runs a streaming search over the given slugs. State is closure-captured so +// `setHitLimit` can raise the cap and re-spawn workers without restarting +// traversal — workers resume from the shared `idx` cursor. +export function createSearchPipeline( + config: PipelineConfig, +): PipelineController { + const { + slugs, + query, + useRegex, + regex, + initialHitLimit, + concurrency, + flushIntervalMs, + emit, + } = config; + + let cancelled = false; + // `idx` is the next-to-claim cursor (leading edge); `completed` counts + // slugs that workers have finished scanning (trailing edge). We report + // `completed` to the UI so progress reflects actual work done, not + // in-flight claims — and it never exceeds slugs.length. + let idx = 0; + let completed = 0; + let totalSoFar = 0; + let hitLimit = initialHitLimit; + let activeWorkers = 0; + let done = false; + const localHits: Record<string, Hit[]> = {}; + let flushTimer: number | null = null; + + const pushUpdate = (overrides: Partial<PipelineUpdate> = {}) => { + emit({ + hitsBySlug: { ...localHits }, + totalHits: totalSoFar, + processed: completed, + totalToProcess: slugs.length, + // `capped` keys off `idx` (claimed), not `completed` — raising the cap + // only helps if there are still unclaimed slugs a worker can pick up. + capped: totalSoFar >= hitLimit && idx < slugs.length, + done, + ...overrides, + }); + }; + + const scheduleFlush = () => { + if (flushTimer !== null || cancelled) return; + flushTimer = window.setTimeout(() => { + flushTimer = null; + if (cancelled) return; + pushUpdate(); + }, flushIntervalMs); + }; + + const finalize = () => { + if (done || cancelled) return; + done = true; + if (flushTimer !== null) { + window.clearTimeout(flushTimer); + flushTimer = null; + } + pushUpdate(); + }; + + const worker = async () => { + activeWorkers++; + try { + while (!cancelled) { + if (totalSoFar >= hitLimit) return; + if (idx >= slugs.length) return; + const my = idx++; + const slug = slugs[my]; + try { + const full = await fetchTranscript(slug); + if (cancelled) return; + if (full.cues && totalSoFar < hitLimit) { + const remaining = hitLimit - totalSoFar; + const hits = findHitsInCues( + full.cues, + query, + useRegex, + regex, + remaining, + ); + if (hits.length > 0) { + localHits[slug] = hits; + totalSoFar += hits.length; + } + } + } catch { + // ignore per-transcript failures + } + completed++; + scheduleFlush(); + } + } finally { + activeWorkers--; + if (activeWorkers === 0 && !cancelled) { + // Either we hit the cap or ran out of work — either way, settle. + finalize(); + } + } + }; + + const ensureWorkers = () => { + if (cancelled || done) return; + if (totalSoFar >= hitLimit) return; + if (idx >= slugs.length) return; + const needed = Math.min( + concurrency - activeWorkers, + slugs.length - idx, + ); + for (let i = 0; i < needed; i++) worker(); + }; + + // Emit initial snapshot synchronously so the UI clears previous results. + pushUpdate(); + ensureWorkers(); + + return { + cancel() { + cancelled = true; + if (flushTimer !== null) { + window.clearTimeout(flushTimer); + flushTimer = null; + } + }, + setHitLimit(limit: number) { + if (cancelled) return; + if (limit <= hitLimit) return; + hitLimit = limit; + // The prior run may have finalized because we were capped. Resume. + if (done) { + done = false; + pushUpdate(); + } + ensureWorkers(); + }, + }; +} + +function findHitsInCues( + cues: { start: number; text: string }[], + query: string, + useRegex: boolean, + regex: RegExp | null, + limit: number, +): Hit[] { + const hits: Hit[] = []; + for (let i = 0; i < cues.length && hits.length < limit; i++) { + const cur = cues[i]; + const prevText = i > 0 ? cues[i - 1].text : ""; + const nextText = i < cues.length - 1 ? cues[i + 1].text : ""; + const sep1 = prevText ? " " : ""; + const sep2 = nextText ? " " : ""; + const windowText = prevText + sep1 + cur.text + sep2 + nextText; + const curStart = prevText.length + sep1.length; + const curEnd = curStart + cur.text.length; + const m = findFirstMatchInRange( + windowText, + curStart, + curEnd, + query, + useRegex, + regex, + ); + if (!m) continue; + const crosses = m.idx < curStart || m.idx + m.length > curEnd; + hits.push({ + start: Math.round(cur.start), + text: crosses ? windowText : cur.text, + }); + } + return hits; +} + +function findFirstMatchInRange( + haystack: string, + rangeStart: number, + rangeEnd: number, + query: string, + useRegex: boolean, + regex: RegExp | null, +): { idx: number; length: number } | null { + if (useRegex) { + if (!regex) return null; + const flags = regex.flags.includes("g") ? regex.flags : regex.flags + "g"; + const re = new RegExp(regex.source, flags); + let m: RegExpExecArray | null; + while ((m = re.exec(haystack)) !== null) { + if (m.index >= rangeStart && m.index < rangeEnd) { + return { idx: m.index, length: m[0].length }; + } + if (m.index >= rangeEnd) return null; + if (m[0].length === 0) re.lastIndex++; + } + return null; + } + if (!query) return null; + const lower = haystack.toLowerCase(); + const ql = query.toLowerCase(); + let from = 0; + while (from <= haystack.length) { + const idx = lower.indexOf(ql, from); + if (idx === -1) return null; + if (idx >= rangeStart && idx < rangeEnd) return { idx, length: ql.length }; + if (idx >= rangeEnd) return null; + from = idx + 1; + } + return null; +} + +// Helper to build slug list from summaries + filter predicate. +export function filterSlugs( + summaries: DisplaySummary[], + passes: (t: DisplaySummary) => boolean, +): string[] { + const slugs: string[] = []; + for (const t of summaries) if (passes(t)) slugs.push(t.slug); + return slugs; +} diff --git a/common/components/summariesCache.ts b/common/components/summariesCache.ts @@ -0,0 +1,82 @@ +"use client"; + +import { useMemo } from "react"; +import { useQueries, useQuery } from "@tanstack/react-query"; +import type { DisplaySummary } from "../lib/transcripts"; +import type { Manifest } from "../lib/manifest"; +import { pageFileName } from "../lib/manifest"; + +async function fetchJson<T>(url: string): Promise<T> { + const r = await fetch(url); + if (!r.ok) throw new Error(`Failed to fetch ${url}: ${r.status}`); + return (await r.json()) as T; +} + +export type SummariesState = { + manifest: Manifest | null; + summaries: DisplaySummary[]; + loadedPages: number; + pageCount: number; + summariesReady: boolean; + error: Error | null; +}; + +export function useManifest() { + return useQuery<Manifest>({ + queryKey: ["manifest"], + queryFn: () => fetchJson<Manifest>("/summaries/manifest.json"), + }); +} + +export function useSummaries(): SummariesState { + const manifestQuery = useManifest(); + const manifest = manifestQuery.data ?? null; + const pageCount = manifest?.pageCount ?? 0; + + const pageQueries = useQueries({ + queries: Array.from({ length: pageCount }, (_, i) => ({ + queryKey: ["summaries-page", i], + queryFn: () => + fetchJson<DisplaySummary[]>(`/summaries/${pageFileName(i)}`), + enabled: pageCount > 0, + })), + }); + + const loadedPages = pageQueries.filter((q) => q.data).length; + const summariesReady = pageCount > 0 && loadedPages === pageCount; + + // Concatenate available pages; sort once all pages are in. Pages may arrive + // out of order, so a late sort keeps the newest-first ordering stable + // regardless of arrival order. Matches the LMDB composite-key order + // [uploadDate, channelSlug, id] reversed. + const summaries = useMemo<DisplaySummary[]>(() => { + if (loadedPages === 0) return []; + const out: DisplaySummary[] = []; + for (const q of pageQueries) if (q.data) out.push(...q.data); + if (summariesReady) { + out.sort( + (a, b) => + b.uploadDate.localeCompare(a.uploadDate) || + a.channelSlug.localeCompare(b.channelSlug) || + a.id.localeCompare(b.id), + ); + } + return out; + // pageQueries identity changes every render; key off loadedPages + ready. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [loadedPages, summariesReady]); + + const error = + manifestQuery.error instanceof Error + ? manifestQuery.error + : (pageQueries.find((q) => q.error)?.error as Error | undefined) ?? null; + + return { + manifest, + summaries, + loadedPages, + pageCount, + summariesReady, + error, + }; +} diff --git a/common/components/transcriptCache.ts b/common/components/transcriptCache.ts @@ -0,0 +1,99 @@ +"use client"; + +import type { TranscriptDetail } from "../lib/transcripts"; +import type { ChannelTranscriptsManifest } from "../lib/manifest"; +import { transcriptPageFileName } from "../lib/manifest"; +import { idbGet, idbPutBatch } from "./transcriptStore"; + +const resolved = new Map<string, TranscriptDetail>(); +const inFlight = new Map<string, Promise<TranscriptDetail>>(); +const channelManifests = new Map< + string, + Promise<ChannelTranscriptsManifest> +>(); +const pagePromises = new Map<string, Promise<TranscriptDetail[]>>(); + +export function fetchTranscript(slug: string): Promise<TranscriptDetail> { + const hit = resolved.get(slug); + if (hit) return Promise.resolve(hit); + const flying = inFlight.get(slug); + if (flying) return flying; + + const p = load(slug).then((detail) => { + resolved.set(slug, detail); + inFlight.delete(slug); + return detail; + }); + p.catch(() => { + inFlight.delete(slug); + }); + inFlight.set(slug, p); + return p; +} + +function fetchChannelManifest( + channelSlug: string, +): Promise<ChannelTranscriptsManifest> { + let p = channelManifests.get(channelSlug); + if (!p) { + p = fetch(`/transcripts/${channelSlug}/manifest.json`).then((r) => { + if (!r.ok) + throw new Error( + `Failed to fetch transcripts manifest for ${channelSlug}`, + ); + return r.json() as Promise<ChannelTranscriptsManifest>; + }); + p.catch(() => channelManifests.delete(channelSlug)); + channelManifests.set(channelSlug, p); + } + return p; +} + +function fetchPage( + channelSlug: string, + pageIndex: number, +): Promise<TranscriptDetail[]> { + const key = `${channelSlug}:${pageIndex}`; + let p = pagePromises.get(key); + if (!p) { + p = fetch( + `/transcripts/${channelSlug}/${transcriptPageFileName(pageIndex)}`, + ).then((r) => { + if (!r.ok) + throw new Error( + `Failed to fetch transcript page ${channelSlug}/${pageIndex}`, + ); + return r.json() as Promise<TranscriptDetail[]>; + }); + p.catch(() => pagePromises.delete(key)); + pagePromises.set(key, p); + } + return p; +} + +async function load(slug: string): Promise<TranscriptDetail> { + const stored = await idbGet(slug); + if (stored) return stored; + const slashIdx = slug.indexOf("/"); + if (slashIdx < 0) throw new Error(`Malformed transcript slug: ${slug}`); + const channelSlug = slug.slice(0, slashIdx); + const videoId = slug.slice(slashIdx + 1); + const manifest = await fetchChannelManifest(channelSlug); + const pageIndex = manifest.slugToPage[videoId]; + if (pageIndex === undefined) + throw new Error(`Unknown transcript slug: ${slug}`); + const page = await fetchPage(channelSlug, pageIndex); + let found: TranscriptDetail | undefined; + for (const entry of page) { + if (entry.slug === slug) found = entry; + // Opportunistically warm the per-slug memory + IDB caches so subsequent + // fetches for other videos in this page hit without a network round-trip. + // Huge win for searchPipeline which iterates many slugs from the same + // channel/page. + resolved.set(entry.slug, entry); + idbPutBatch(entry); + } + if (!found) + throw new Error(`Transcript ${slug} missing from page ${pageIndex}`); + return found; +} diff --git a/common/components/transcriptStore.ts b/common/components/transcriptStore.ts @@ -0,0 +1,147 @@ +"use client"; + +import type { TranscriptDetail } from "../lib/transcripts"; + +const DB_NAME = "yt-dlp-transcript-browser"; +const DB_VERSION = 1; +const STORE = "transcripts"; + +type Mode = "pending" | "ok" | "unavailable"; + +let mode: Mode = "pending"; +let dbPromise: Promise<IDBDatabase | null> | null = null; + +function openDb(): Promise<IDBDatabase | null> { + if (typeof indexedDB === "undefined") { + mode = "unavailable"; + return Promise.resolve(null); + } + if (dbPromise) return dbPromise; + dbPromise = new Promise<IDBDatabase | null>((resolve) => { + let req: IDBOpenDBRequest; + try { + req = indexedDB.open(DB_NAME, DB_VERSION); + } catch { + downgrade("open threw"); + resolve(null); + return; + } + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(STORE)) { + db.createObjectStore(STORE, { keyPath: "slug" }); + } + }; + req.onsuccess = () => { + mode = "ok"; + resolve(req.result); + }; + req.onerror = () => { + downgrade("open failed"); + resolve(null); + }; + req.onblocked = () => { + downgrade("open blocked"); + resolve(null); + }; + }); + return dbPromise; +} + +function downgrade(reason: string): void { + if (mode === "unavailable") return; + mode = "unavailable"; + console.warn( + `[transcriptStore] IndexedDB unavailable (${reason}); falling back to network-only.`, + ); +} + +export async function idbGet( + slug: string, +): Promise<TranscriptDetail | null> { + const db = await openDb(); + if (!db) return null; + return new Promise<TranscriptDetail | null>((resolve) => { + let req: IDBRequest<TranscriptDetail | undefined>; + try { + const tx = db.transaction(STORE, "readonly"); + req = tx.objectStore(STORE).get(slug) as IDBRequest< + TranscriptDetail | undefined + >; + } catch { + downgrade("read tx threw"); + resolve(null); + return; + } + req.onsuccess = () => resolve(req.result ?? null); + req.onerror = () => resolve(null); + }); +} + +let pending: TranscriptDetail[] = []; +let flushScheduled = false; +let flushInFlight: Promise<void> | null = null; + +export function idbPutBatch(detail: TranscriptDetail): void { + if (mode === "unavailable") return; + pending.push(detail); + if (flushScheduled) return; + flushScheduled = true; + queueMicrotask(() => { + flushScheduled = false; + void flush(); + }); +} + +async function flush(): Promise<void> { + if (flushInFlight) { + await flushInFlight; + } + if (pending.length === 0) return; + const batch = pending; + pending = []; + flushInFlight = writeBatch(batch).finally(() => { + flushInFlight = null; + if (pending.length > 0 && !flushScheduled) { + flushScheduled = true; + queueMicrotask(() => { + flushScheduled = false; + void flush(); + }); + } + }); + await flushInFlight; +} + +async function writeBatch(batch: TranscriptDetail[]): Promise<void> { + const db = await openDb(); + if (!db) return; + return new Promise<void>((resolve) => { + let tx: IDBTransaction; + try { + tx = db.transaction(STORE, "readwrite"); + } catch { + downgrade("write tx threw"); + resolve(); + return; + } + const store = tx.objectStore(STORE); + for (const entry of batch) { + try { + store.put(entry); + } catch { + // Per-entry errors (e.g. unclonable values) shouldn't fail the batch. + } + } + tx.oncomplete = () => resolve(); + tx.onerror = () => { + // Quota errors land here; degrade silently after one warn. + downgrade("write tx error"); + resolve(); + }; + tx.onabort = () => { + downgrade("write tx abort"); + resolve(); + }; + }); +} diff --git a/app/urlState.ts b/common/components/urlState.ts diff --git a/lib/format.ts b/common/lib/format.ts diff --git a/lib/manifest.ts b/common/lib/manifest.ts diff --git a/common/lib/settings.ts b/common/lib/settings.ts @@ -0,0 +1,60 @@ +import fs from "node:fs"; +import path from "node:path"; + +function findMonorepoRoot(): string { + let dir = process.cwd(); + for (let i = 0; i < 8; i++) { + if (fs.existsSync(path.join(dir, "pnpm-workspace.yaml"))) return dir; + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return process.cwd(); +} + +export type SiteSettings = { + siteTitle: string; + siteDescription: string; + headerTitle: string; + homeTagline: string; + maxTranscriptPageBytes: number; +}; + +export const TRANSCRIPT_PAGE_HARD_CAP_BYTES = 20 * 1024 * 1024; +export const TRANSCRIPT_PAGE_MIN_BYTES = 256 * 1024; +export const TRANSCRIPT_PAGE_DEFAULT_BYTES = 8 * 1024 * 1024; + +const DEFAULTS: SiteSettings = { + siteTitle: "Transcript Browser", + siteDescription: "Browse and search video transcripts", + headerTitle: "Transcript Browser", + homeTagline: "", + maxTranscriptPageBytes: TRANSCRIPT_PAGE_DEFAULT_BYTES, +}; + +let cached: SiteSettings | null = null; + +export function getSettings(): SiteSettings { + if (cached) return cached; + const file = path.join(findMonorepoRoot(), "settings.json"); + let parsed: Partial<SiteSettings> = {}; + try { + parsed = JSON.parse(fs.readFileSync(file, "utf8")) as Partial<SiteSettings>; + } catch { + parsed = {}; + } + const merged = { ...DEFAULTS, ...parsed }; + merged.maxTranscriptPageBytes = clampPageBytes(merged.maxTranscriptPageBytes); + cached = merged; + return cached; +} + +function clampPageBytes(value: unknown): number { + const n = + typeof value === "number" && Number.isFinite(value) + ? value + : TRANSCRIPT_PAGE_DEFAULT_BYTES; + if (n < TRANSCRIPT_PAGE_MIN_BYTES) return TRANSCRIPT_PAGE_MIN_BYTES; + if (n > TRANSCRIPT_PAGE_HARD_CAP_BYTES) return TRANSCRIPT_PAGE_HARD_CAP_BYTES; + return Math.floor(n); +} diff --git a/lib/transcripts-server.ts b/common/lib/transcripts-server.ts diff --git a/lib/transcripts.ts b/common/lib/transcripts.ts diff --git a/lib/vtt.ts b/common/lib/vtt.ts diff --git a/lib/whisper.ts b/common/lib/whisper.ts diff --git a/common/package.json b/common/package.json @@ -0,0 +1,23 @@ +{ + "name": "yt-dlp-transcript-common", + "version": "0.1.0", + "private": true, + "type": "module", + "dependencies": { + "@tanstack/react-query": "^5.99.1", + "fs-extra": "^11.3.4", + "react-player": "^2.16.1" + }, + "peerDependencies": { + "next": "16.2.3", + "react": "19.2.4", + "react-dom": "19.2.4" + }, + "devDependencies": { + "@types/fs-extra": "^11.0.4", + "@types/node": "^20.19.39", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "typescript": "^5.9.3" + } +} diff --git a/common/tsconfig.json b/common/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "jsx": "preserve", + "types": ["node"] + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/create-archives.sh b/create-archives.sh @@ -1,11 +1,11 @@ #!/bin/bash -git archive -9 --format tar.gz -o "./public/code.tar.gz" main +git archive -9 --format tar.gz -o "./export/public/code.tar.gz" main # Transcript archives are too big for CF pages (25mb) # The comments are kept since they'll likely be useful later # cd transcripts # # 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 > ../export/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 +# git archive -9 --format tar.gz -o "../export/public/transcripts.tar.gz" main diff --git a/app/favicon.ico b/export/app/favicon.ico Binary files differ. diff --git a/app/globals.css b/export/app/globals.css diff --git a/export/app/layout.tsx b/export/app/layout.tsx @@ -0,0 +1,53 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import Link from "next/link"; +import { getSettings } from "yt-dlp-transcript-common/lib/settings"; +import { QueryProvider } from "yt-dlp-transcript-common/components/QueryProvider"; +import "./globals.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export function generateMetadata(): Metadata { + const s = getSettings(); + return { + title: s.siteTitle, + description: s.siteDescription, + }; +} + +export default async function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + const settings = getSettings(); + return ( + <html + lang="en" + className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} + > + <body className="min-h-full flex flex-col bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100 font-sans"> + <QueryProvider> + <header className="border-b border-zinc-200 dark:border-zinc-800 bg-white/70 dark:bg-zinc-900/70 backdrop-blur sticky top-0 z-20"> + <div className="max-w-6xl mx-auto px-4 py-3 flex items-center justify-between"> + <Link href="/" className="font-semibold tracking-tight"> + {settings.headerTitle} + </Link> + </div> + </header> + <main className="flex-1 w-full max-w-6xl mx-auto px-4 py-6"> + {children} + </main> + </QueryProvider> + </body> + </html> + ); +} diff --git a/export/app/page.tsx b/export/app/page.tsx @@ -0,0 +1,34 @@ +import { countTranscripts } from "yt-dlp-transcript-common/lib/transcripts"; +import { getSettings } from "yt-dlp-transcript-common/lib/settings"; +import { PlayerProvider } from "yt-dlp-transcript-common/components/PlayerProvider"; +import TranscriptModal from "yt-dlp-transcript-common/components/TranscriptModal"; +import TranscriptSearch from "yt-dlp-transcript-common/components/TranscriptSearch"; + +export default async function Home() { + const settings = getSettings(); + const count = await countTranscripts(); + + return ( + <PlayerProvider> + <div className="flex flex-col gap-6"> + <div> + <h1 className="text-2xl font-semibold">{count} transcripts</h1> + {settings.homeTagline && ( + <p className="text-sm text-zinc-500 mt-1">{settings.homeTagline}</p> + )} + </div> + <TranscriptSearch /> + <section className="border-t border-zinc-200 dark:border-zinc-800 pt-4 text-sm text-zinc-500 flex gap-4"> + <span>Downloads:</span> + <a + href="/code.tar.gz" + className="underline hover:text-zinc-900 dark:hover:text-zinc-100" + > + code.tar.gz + </a> + </section> + </div> + <TranscriptModal /> + </PlayerProvider> + ); +} diff --git a/eslint.config.mjs b/export/eslint.config.mjs diff --git a/export/next.config.ts b/export/next.config.ts @@ -0,0 +1,11 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + output: "export", + trailingSlash: true, + images: { unoptimized: true }, + staticPageGenerationTimeout: 600, // Increase timeout to 600 seconds + transpilePackages: ["yt-dlp-transcript-common"], +}; + +export default nextConfig; diff --git a/export/package.json b/export/package.json @@ -0,0 +1,40 @@ +{ + "name": "export", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "next dev", + "build:index": "tsx scripts/build-index.ts", + "prebuild": "pnpm run build:index", + "build": "next build", + "start": "serve out", + "lint": "eslint" + }, + "dependencies": { + "@sindresorhus/slugify": "^3.0.0", + "@tanstack/react-query": "^5.99.1", + "execa": "^9.6.1", + "lmdb": "^3.5.4", + "next": "16.2.3", + "p-limit": "^7.3.0", + "react": "19.2.4", + "react-dom": "19.2.4", + "react-player": "^2.16.1", + "serve": "^14.2.6", + "yt-dlp-transcript-common": "workspace:*" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.2.2", + "@types/fs-extra": "^11.0.4", + "@types/node": "^20.19.39", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "eslint": "^9.39.4", + "eslint-config-next": "16.2.3", + "fs-extra": "^11.3.4", + "tailwindcss": "^4.2.2", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/postcss.config.mjs b/export/postcss.config.mjs diff --git a/public/file.svg b/export/public/file.svg diff --git a/public/globe.svg b/export/public/globe.svg diff --git a/public/next.svg b/export/public/next.svg diff --git a/public/vercel.svg b/export/public/vercel.svg diff --git a/public/window.svg b/export/public/window.svg diff --git a/export/scripts/build-index.ts b/export/scripts/build-index.ts @@ -0,0 +1,672 @@ +#!/usr/bin/env tsx +// Preprocess transcripts/channels/<channelSlug>/data/<videoDir>/ into: +// - LMDB cache at transcripts/index.mdb (for incremental rebuilds) +// - public/summaries/{manifest,page-NNNN}.json (paginated cross-channel +// summaries index for browse/search) +// - public/transcripts/<channelSlug>/{manifest,page-NNNN}.json +// (per-channel paginated transcript detail + cues, oldest-first so +// adding a newer video only dirties the last page) +// +// Per-channel config.json selects the transcript parser ("youtube" → VTT, +// "transcribe" → whisper.cpp JSON). Short-circuits when mtimes already match. + +import path from "node:path"; +import { createHash } from "node:crypto"; +import { + mkdir, + readdir, + readFile, + rename, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import type { Dirent } from "node:fs"; +import { open } from "lmdb"; +import { fileURLToPath } from "node:url"; +import { parseVtt, type Cue } from "yt-dlp-transcript-common/lib/vtt"; +import { parseWhisper } from "yt-dlp-transcript-common/lib/whisper"; +import { + summarize, + toDisplaySummary, + type RawMetadata, +} from "yt-dlp-transcript-common/lib/transcripts-server"; +import type { + TranscriptSummary, + TranscriptDetail, + DisplaySummary, +} from "yt-dlp-transcript-common/lib/transcripts"; +import type { + Manifest, + ChannelEntry, + ChannelTranscriptsManifest, +} from "yt-dlp-transcript-common/lib/manifest"; +import { + MANIFEST_VERSION, + SUMMARIES_PAGE_SIZE, + TRANSCRIPTS_MANIFEST_VERSION, + pageFileName, + transcriptPageFileName, +} from "yt-dlp-transcript-common/lib/manifest"; +import { getSettings } from "yt-dlp-transcript-common/lib/settings"; + +// Self-locating: the script lives at <repoRoot>/export/scripts/build-index.ts, +// so the repo root is two levels up regardless of the cwd it was invoked from. +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, "..", ".."); +const CHANNELS_DIR = path.join(ROOT, "transcripts", "channels"); +const DB_PATH = path.join(ROOT, "transcripts", "index.mdb"); +const PUBLIC_DIR = path.join(ROOT, "export", "public"); +const SUMMARIES_DIR = path.join(PUBLIC_DIR, "summaries"); +const TRANSCRIPTS_DIR = path.join(PUBLIC_DIR, "transcripts"); +const MANIFEST_PATH = path.join(SUMMARIES_DIR, "manifest.json"); + +const SCHEMA_VERSION = 5; + +type Handling = "youtube" | "transcribe"; +type ChannelConfig = { handling: Handling; name?: string }; + +// Primary sort/lookup key: [uploadDate, channelSlug, id]. Reverse iteration +// yields newest-first directly (uploadDate is YYYYMMDD). +type IndexKey = [string, string, string]; +// Per-channel forward-scan index: [channelSlug, uploadDate, id]. +type ChannelKey = [string, string, string]; +// Page-hash key: [channelSlug, pageIndex]. +type PageHashKey = [string, number]; +// Path key: [channelSlug, videoDir]. Stable regardless of metadata contents, +// so diffing by mtime doesn't require parsing metadata.info.json. +type PathKey = [string, string]; + +type MtimeRecord = { + metaMs: number; + transcriptMs: number | null; + indexKey: IndexKey; +}; + +type PageHashRecord = { + hash: string; + entryCount: number; + sizeBytes: number; +}; + +function indexToChannelKey(k: IndexKey): ChannelKey { + return [k[1], k[0], k[2]]; +} + +type LiveEntry = { + channelSlug: string; + handling: Handling; + configName: string | undefined; + videoDir: string; + metaPath: string; + metaMs: number; + transcriptPath: string; + transcriptMs: number | null; +}; + +async function exists(p: string): Promise<boolean> { + try { + await stat(p); + return true; + } catch { + return false; + } +} + +async function readChannelConfig(dir: string): Promise<ChannelConfig | null> { + try { + const raw = await readFile(path.join(dir, "config.json"), "utf8"); + const parsed = JSON.parse(raw) as ChannelConfig; + if (parsed.handling !== "youtube" && parsed.handling !== "transcribe") { + return null; + } + return parsed; + } catch { + return null; + } +} + +async function scanSource(): Promise<{ + live: LiveEntry[]; + channels: Map<string, ChannelConfig>; +}> { + const channels = new Map<string, ChannelConfig>(); + const live: LiveEntry[] = []; + const channelEntries = await readdir(CHANNELS_DIR, { withFileTypes: true }); + for (const ch of channelEntries) { + if (!ch.isDirectory()) continue; + const channelDir = path.join(CHANNELS_DIR, ch.name); + const cfg = await readChannelConfig(channelDir); + if (!cfg) { + console.warn( + `Skipping channel ${ch.name}: missing or invalid config.json`, + ); + continue; + } + channels.set(ch.name, cfg); + const dataDir = path.join(channelDir, "data"); + let videoEntries: Dirent[]; + try { + videoEntries = await readdir(dataDir, { withFileTypes: true }); + } catch { + continue; + } + const transcriptName = + cfg.handling === "youtube" ? "transcript.en.vtt" : "transcript.json"; + for (const v of videoEntries) { + if (!v.isDirectory()) continue; + const videoDir = v.name; + const metaPath = path.join(dataDir, videoDir, "metadata.info.json"); + const transcriptPath = path.join(dataDir, videoDir, transcriptName); + let metaMs: number; + try { + metaMs = (await stat(metaPath)).mtimeMs; + } catch { + continue; + } + let transcriptMs: number | null = null; + try { + transcriptMs = (await stat(transcriptPath)).mtimeMs; + } catch { + transcriptMs = null; + } + live.push({ + channelSlug: ch.name, + handling: cfg.handling, + configName: cfg.name, + videoDir, + metaPath, + metaMs, + transcriptPath, + transcriptMs, + }); + } + } + return { live, channels }; +} + +function pathKeyId(k: PathKey): string { + return `${k[0]}\x00${k[1]}`; +} + +function indexKeysEqual(a: IndexKey, b: IndexKey): boolean { + return a[0] === b[0] && a[1] === b[1] && a[2] === b[2]; +} + +async function writeJsonAtomic( + filePath: string, + value: unknown, +): Promise<void> { + const tmp = `${filePath}.tmp-${process.pid}`; + await writeFile(tmp, JSON.stringify(value)); + await rename(tmp, filePath); +} + +async function main(): Promise<void> { + const t0 = Date.now(); + await mkdir(path.dirname(DB_PATH), { recursive: true }); + await mkdir(SUMMARIES_DIR, { recursive: true }); + await mkdir(TRANSCRIPTS_DIR, { recursive: true }); + + const root = open({ + path: DB_PATH, + maxDbs: 8, + compression: true, + }); + const sums = root.openDB<TranscriptSummary, IndexKey>({ + name: "sums", + encoding: "msgpack", + }); + const cues = root.openDB<Cue[], IndexKey>({ + name: "cues", + encoding: "msgpack", + }); + const mtimes = root.openDB<MtimeRecord, PathKey>({ + name: "mtimes", + encoding: "msgpack", + }); + // Per-channel forward index: lets us stream [channelSlug, uploadDate, id] + // in oldest-first order for size-based page packing without loading every + // channel's entries into memory. + const byChannel = root.openDB<number, ChannelKey>({ + name: "byChannel", + encoding: "msgpack", + }); + // Content hashes of emitted transcript pages, so unchanged pages skip the + // write. Since page 1 = oldest videos, adding a new newest-upload video + // only dirties the last page's hash. + const pageHashes = root.openDB<PageHashRecord, PageHashKey>({ + name: "pageHashes", + encoding: "msgpack", + }); + const meta = root.openDB<unknown, string>({ + name: "meta", + encoding: "msgpack", + }); + + const storedSchema = meta.get("schema") as number | undefined; + const schemaBumped = storedSchema !== SCHEMA_VERSION; + if (schemaBumped) { + console.log( + `Schema change (${storedSchema ?? "<none>"} -> ${SCHEMA_VERSION}); invalidating LMDB cache.`, + ); + await sums.clearAsync(); + await cues.clearAsync(); + await mtimes.clearAsync(); + await byChannel.clearAsync(); + await pageHashes.clearAsync(); + await meta.put("schema", SCHEMA_VERSION); + } + + const { live, channels: channelConfigs } = await scanSource(); + const livePathIds = new Set<string>(); + const liveByPathId = new Map<string, LiveEntry>(); + for (const s of live) { + const id = pathKeyId([s.channelSlug, s.videoDir]); + livePathIds.add(id); + liveByPathId.set(id, s); + } + + // Diff against prior mtimes (path-keyed — no metadata reads required). + const added: LiveEntry[] = []; + const changed: LiveEntry[] = []; + const removed: { pathKey: PathKey; indexKey: IndexKey }[] = []; + + for (const s of live) { + const pk: PathKey = [s.channelSlug, s.videoDir]; + const prev = mtimes.get(pk); + if (!prev) { + added.push(s); + } else if ( + prev.metaMs !== s.metaMs || + prev.transcriptMs !== s.transcriptMs + ) { + changed.push(s); + } + } + for (const { key, value } of mtimes.getRange()) { + const k = key as PathKey; + if (!livePathIds.has(pathKeyId(k))) { + removed.push({ pathKey: k, indexKey: (value as MtimeRecord).indexKey }); + } + } + + const anyMutations = + added.length > 0 || changed.length > 0 || removed.length > 0; + + // Short-circuit: nothing changed AND public/ is intact AND the configured + // max page size matches what each channel was last paginated with. + const { maxTranscriptPageBytes: configuredMaxPageBytes } = getSettings(); + if (!anyMutations && !schemaBumped) { + const manifestRaw = await readFile(MANIFEST_PATH, "utf8").catch(() => null); + if (manifestRaw) { + try { + const parsed = JSON.parse(manifestRaw) as Manifest; + if ( + parsed.version === MANIFEST_VERSION && + parsed.totalCount === live.length && + parsed.pageSize === SUMMARIES_PAGE_SIZE + ) { + const firstPage = path.join(SUMMARIES_DIR, pageFileName(0)); + const lastPage = path.join( + SUMMARIES_DIR, + pageFileName(Math.max(0, parsed.pageCount - 1)), + ); + let transcriptsIntact = true; + for (const channelSlug of channelConfigs.keys()) { + const mPath = path.join( + TRANSCRIPTS_DIR, + channelSlug, + "manifest.json", + ); + const raw = await readFile(mPath, "utf8").catch(() => null); + if (!raw) { + transcriptsIntact = false; + break; + } + try { + const cm = JSON.parse(raw) as ChannelTranscriptsManifest; + if ( + cm.version !== TRANSCRIPTS_MANIFEST_VERSION || + cm.maxPageBytes !== configuredMaxPageBytes + ) { + transcriptsIntact = false; + break; + } + } catch { + transcriptsIntact = false; + break; + } + } + if ( + transcriptsIntact && + (await exists(firstPage)) && + (await exists(lastPage)) + ) { + console.log( + `Index up to date (${live.length} transcripts, ${parsed.pageCount} pages). Skipping.`, + ); + await root.close(); + return; + } + } + } catch { + // fall through + } + } + } + + console.log( + `Diff: +${added.length} added, ~${changed.length} changed, -${removed.length} removed, ${live.length} total.`, + ); + + // Ensure per-channel public/transcripts/<channelSlug>/ subdirs exist. + for (const channelSlug of channelConfigs.keys()) { + await mkdir(path.join(TRANSCRIPTS_DIR, channelSlug), { recursive: true }); + } + + // Process mutations. + const toProcess = [...added, ...changed]; + let processed = 0; + const BATCH = 200; + for (let i = 0; i < toProcess.length; i += BATCH) { + const slice = toProcess.slice(i, i + BATCH); + await Promise.all( + slice.map(async (s) => { + try { + const metaRaw = await readFile(s.metaPath, "utf8"); + const parsedMeta = JSON.parse(metaRaw) as RawMetadata; + const summary = summarize( + s.channelSlug, + s.videoDir, + parsedMeta, + s.configName, + ); + if (!summary.uploadDate) { + console.warn( + `Skipping ${s.channelSlug}/${s.videoDir}: no upload_date`, + ); + return; + } + const indexKey: IndexKey = [ + summary.uploadDate, + s.channelSlug, + summary.id, + ]; + let cueList: Cue[] | undefined; + if (s.transcriptMs !== null) { + try { + const raw = await readFile(s.transcriptPath, "utf8"); + cueList = + s.handling === "youtube" ? parseVtt(raw) : parseWhisper(raw); + } catch { + cueList = undefined; + } + } + + // If upload_date shifted (metadata edit), the old composite key is + // stale — drop it before writing the new one. + const pk: PathKey = [s.channelSlug, s.videoDir]; + const prev = mtimes.get(pk); + if (prev && !indexKeysEqual(prev.indexKey, indexKey)) { + sums.remove(prev.indexKey); + cues.remove(prev.indexKey); + byChannel.remove(indexToChannelKey(prev.indexKey)); + } + + sums.put(indexKey, summary); + if (cueList) cues.put(indexKey, cueList); + else cues.remove(indexKey); + byChannel.put(indexToChannelKey(indexKey), 1); + mtimes.put(pk, { + metaMs: s.metaMs, + transcriptMs: s.transcriptMs, + indexKey, + }); + } catch (err) { + console.warn( + `Failed to process ${s.channelSlug}/${s.videoDir}:`, + err, + ); + } + }), + ); + processed += slice.length; + if (toProcess.length > BATCH) { + process.stdout.write(` processed ${processed}/${toProcess.length}\r`); + } + } + if (toProcess.length > BATCH) process.stdout.write("\n"); + + // Process removals. + for (const { pathKey, indexKey } of removed) { + sums.remove(indexKey); + cues.remove(indexKey); + byChannel.remove(indexToChannelKey(indexKey)); + mtimes.remove(pathKey); + } + + await sums.flushed; + await cues.flushed; + await byChannel.flushed; + await mtimes.flushed; + + // Emit per-channel paginated transcript pages. Pages are size-packed + // oldest-first, so newer videos land on the last page and older pages + // hash-match from build to build. + const maxTranscriptPageBytes = configuredMaxPageBytes; + const generatedAt = new Date().toISOString(); + let pagesWritten = 0; + let pagesSkipped = 0; + let pagesDeleted = 0; + + type PendingEntry = { encoded: string; id: string }; + + const writePage = async ( + channelSlug: string, + idx: number, + entries: PendingEntry[], + ): Promise<void> => { + const body = `[${entries.map((e) => e.encoded).join(",")}]`; + const hash = createHash("sha1").update(body).digest("hex"); + const prev = pageHashes.get([channelSlug, idx]); + const outPath = path.join( + TRANSCRIPTS_DIR, + channelSlug, + transcriptPageFileName(idx), + ); + if (prev?.hash === hash && (await exists(outPath))) { + pagesSkipped++; + return; + } + const sizeBytes = Buffer.byteLength(body, "utf8"); + const tmp = `${outPath}.tmp-${process.pid}`; + await writeFile(tmp, body); + await rename(tmp, outPath); + pageHashes.put([channelSlug, idx], { + hash, + entryCount: entries.length, + sizeBytes, + }); + pagesWritten++; + }; + + for (const channelSlug of Array.from(channelConfigs.keys()).sort()) { + const channelDir = path.join(TRANSCRIPTS_DIR, channelSlug); + await mkdir(channelDir, { recursive: true }); + + let pageIdx = 0; + let buffer: PendingEntry[] = []; + // Running payload size excluding the outer `[` and `]` (accounted at + // flush time). Includes the leading commas between elements. + let payloadBytes = 0; + const slugToPage: Record<string, number> = {}; + + for (const { key } of byChannel.getRange({ + start: [channelSlug], + end: [channelSlug, "￿"], + })) { + const ck = key as ChannelKey; + if (ck[0] !== channelSlug) continue; + const indexKey: IndexKey = [ck[1], ck[0], ck[2]]; + const summary = sums.get(indexKey); + if (!summary) continue; + const cueList = cues.get(indexKey); + const detail: TranscriptDetail = { ...summary, cues: cueList }; + const encoded = JSON.stringify(detail); + const entryBytes = Buffer.byteLength(encoded, "utf8"); + const commaBytes = buffer.length === 0 ? 0 : 1; + const delta = entryBytes + commaBytes; + + // Close current page if this entry would push it past the limit. + // `+ 2` accounts for the outer brackets. + if (buffer.length > 0 && payloadBytes + delta + 2 > maxTranscriptPageBytes) { + await writePage(channelSlug, pageIdx, buffer); + pageIdx++; + buffer = []; + payloadBytes = 0; + } + + if (entryBytes + 2 > maxTranscriptPageBytes) { + console.warn( + ` ${summary.slug}: entry (${entryBytes} bytes) exceeds page budget; emitting solo page.`, + ); + } + + slugToPage[summary.id] = pageIdx; + buffer.push({ encoded, id: summary.id }); + payloadBytes += delta; + } + + if (buffer.length > 0) { + await writePage(channelSlug, pageIdx, buffer); + pageIdx++; + } + + const pageCount = pageIdx; + + // Prune stale per-channel page files + drop pageHashes beyond pageCount. + const keep = new Set<string>(["manifest.json"]); + for (let i = 0; i < pageCount; i++) keep.add(transcriptPageFileName(i)); + const existing = await readdir(channelDir).catch(() => [] as string[]); + for (const name of existing) { + if (keep.has(name)) continue; + await rm(path.join(channelDir, name), { force: true }); + pagesDeleted++; + } + // pageHashes may still have records for page indexes >= pageCount + // (channel shrank). Drop them. + for (const { key } of pageHashes.getRange({ + start: [channelSlug, pageCount], + end: [channelSlug, Number.MAX_SAFE_INTEGER], + })) { + pageHashes.remove(key as PageHashKey); + } + + const channelManifest: ChannelTranscriptsManifest = { + version: TRANSCRIPTS_MANIFEST_VERSION, + channelSlug, + pageCount, + maxPageBytes: maxTranscriptPageBytes, + generatedAt, + slugToPage, + }; + await writeJsonAtomic( + path.join(channelDir, "manifest.json"), + channelManifest, + ); + } + + await pageHashes.flushed; + + // Prune channel directories for channels no longer in config, and any + // legacy per-video `<id>.json` files that leaked in from pre-schema-4 + // builds at the top level of transcripts/ (pre-channel-subdir layout). + const topEntries = await readdir(TRANSCRIPTS_DIR, { + withFileTypes: true, + }).catch(() => [] as Dirent[]); + for (const e of topEntries) { + if (e.isDirectory()) { + if (!channelConfigs.has(e.name)) { + await rm(path.join(TRANSCRIPTS_DIR, e.name), { + recursive: true, + force: true, + }); + } + } else if (e.isFile()) { + await rm(path.join(TRANSCRIPTS_DIR, e.name), { force: true }); + } + } + + console.log( + `Transcript pages: ${pagesWritten} written, ${pagesSkipped} unchanged, ${pagesDeleted} stale removed.`, + ); + + // Stream LMDB (reverse composite-key order = newest-date first) to emit + // paginated summaries. + const pageSize = SUMMARIES_PAGE_SIZE; + const channelCounts = new Map<string, number>(); + // Seed channel list from config so empty channels still appear in the UI. + for (const cfg of channelConfigs.values()) { + if (cfg.name) channelCounts.set(cfg.name, 0); + } + let pageIndex = 0; + let buffer: DisplaySummary[] = []; + let total = 0; + + const flushPage = async () => { + if (buffer.length === 0) return; + const p = path.join(SUMMARIES_DIR, pageFileName(pageIndex)); + await writeJsonAtomic(p, buffer); + buffer = []; + pageIndex++; + }; + + for (const { value } of sums.getRange({ reverse: true })) { + const s = value as TranscriptSummary; + if (s.channel) { + channelCounts.set(s.channel, (channelCounts.get(s.channel) ?? 0) + 1); + } + buffer.push(toDisplaySummary(s)); + total++; + if (buffer.length >= pageSize) await flushPage(); + } + await flushPage(); + + // Prune stale page files. + const expectedPages = new Set<string>(); + for (let i = 0; i < pageIndex; i++) expectedPages.add(pageFileName(i)); + const existing = await readdir(SUMMARIES_DIR).catch(() => [] as string[]); + for (const name of existing) { + if (!name.startsWith("page-")) continue; + if (!expectedPages.has(name)) { + await rm(path.join(SUMMARIES_DIR, name), { force: true }); + } + } + + const channelList: ChannelEntry[] = Array.from(channelCounts.entries()) + .map(([name, count]) => ({ name, count })) + .sort((a, b) => a.name.localeCompare(b.name)); + + const manifest: Manifest = { + version: MANIFEST_VERSION, + totalCount: total, + pageSize, + pageCount: pageIndex, + generatedAt: new Date().toISOString(), + channels: channelList, + }; + await writeJsonAtomic(MANIFEST_PATH, manifest); + + await root.close(); + + const secs = ((Date.now() - t0) / 1000).toFixed(2); + console.log( + `Done in ${secs}s. ${total} transcripts across ${pageIndex} pages; ${channelList.length} channels.`, + ); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/serve.json b/export/serve.json diff --git a/export/tsconfig.json b/export/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "jsx": "preserve", + "plugins": [ + { + "name": "next" + } + ] + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "**/*.mts" + ], + "exclude": ["node_modules"] +} diff --git a/lib/settings.ts b/lib/settings.ts @@ -1,49 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; - -export type SiteSettings = { - siteTitle: string; - siteDescription: string; - headerTitle: string; - homeTagline: string; - maxTranscriptPageBytes: number; -}; - -export const TRANSCRIPT_PAGE_HARD_CAP_BYTES = 20 * 1024 * 1024; -export const TRANSCRIPT_PAGE_MIN_BYTES = 256 * 1024; -export const TRANSCRIPT_PAGE_DEFAULT_BYTES = 8 * 1024 * 1024; - -const DEFAULTS: SiteSettings = { - siteTitle: "Transcript Browser", - siteDescription: "Browse and search video transcripts", - headerTitle: "Transcript Browser", - homeTagline: "", - maxTranscriptPageBytes: TRANSCRIPT_PAGE_DEFAULT_BYTES, -}; - -let cached: SiteSettings | null = null; - -export function getSettings(): SiteSettings { - if (cached) return cached; - const file = path.join(process.cwd(), "settings.json"); - let parsed: Partial<SiteSettings> = {}; - try { - parsed = JSON.parse(fs.readFileSync(file, "utf8")) as Partial<SiteSettings>; - } catch { - parsed = {}; - } - const merged = { ...DEFAULTS, ...parsed }; - merged.maxTranscriptPageBytes = clampPageBytes(merged.maxTranscriptPageBytes); - cached = merged; - return cached; -} - -function clampPageBytes(value: unknown): number { - const n = - typeof value === "number" && Number.isFinite(value) - ? value - : TRANSCRIPT_PAGE_DEFAULT_BYTES; - if (n < TRANSCRIPT_PAGE_MIN_BYTES) return TRANSCRIPT_PAGE_MIN_BYTES; - if (n > TRANSCRIPT_PAGE_HARD_CAP_BYTES) return TRANSCRIPT_PAGE_HARD_CAP_BYTES; - return Math.floor(n); -} diff --git a/next.config.ts b/next.config.ts @@ -1,10 +0,0 @@ -import type { NextConfig } from "next"; - -const nextConfig: NextConfig = { - output: "export", - trailingSlash: true, - images: { unoptimized: true }, - staticPageGenerationTimeout: 600, // Increase timeout to 600 seconds -}; - -export default nextConfig; diff --git a/package.json b/package.json @@ -1,39 +1,16 @@ { - "name": "yt-dlp-transcript-browser", + "name": "yt-dlp-transcript-browser-monorepo", "version": "0.1.0", "private": true, "type": "module", "scripts": { - "dev": "next dev", - "build:index": "tsx scripts/build-index.ts", - "prebuild": "pnpm build:index", - "build": "next build", - "start": "serve out", - "lint": "eslint" - }, - "dependencies": { - "@sindresorhus/slugify": "^3.0.0", - "@tanstack/react-query": "^5.99.1", - "execa": "^9.6.1", - "lmdb": "^3.5.4", - "next": "16.2.3", - "p-limit": "^7.3.0", - "react": "19.2.4", - "react-dom": "19.2.4", - "react-player": "^2.16.1", - "serve": "^14.2.6" + "build:index": "pnpm --filter export run build:index", + "build:export": "pnpm --filter export run build", + "build": "pnpm --filter export run build", + "start:export": "pnpm --filter export run start", + "lint": "pnpm --filter export run lint" }, "devDependencies": { - "@tailwindcss/postcss": "^4.2.2", - "@types/fs-extra": "^11.0.4", - "@types/node": "^20.19.39", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", - "eslint": "^9.39.4", - "eslint-config-next": "16.2.3", - "fs-extra": "^11.3.4", - "tailwindcss": "^4.2.2", - "tsx": "^4.21.0", - "typescript": "^5.9.3" + "tsx": "^4.21.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml @@ -7,13 +7,56 @@ settings: importers: .: + devDependencies: + tsx: + specifier: ^4.21.0 + version: 4.21.0 + + common: + dependencies: + '@tanstack/react-query': + specifier: ^5.99.1 + version: 5.100.5(react@19.2.4) + fs-extra: + specifier: ^11.3.4 + version: 11.3.4 + next: + specifier: 16.2.3 + version: 16.2.3(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: + specifier: 19.2.4 + version: 19.2.4 + react-dom: + specifier: 19.2.4 + version: 19.2.4(react@19.2.4) + react-player: + specifier: ^2.16.1 + version: 2.16.1(react@19.2.4) + devDependencies: + '@types/fs-extra': + specifier: ^11.0.4 + version: 11.0.4 + '@types/node': + specifier: ^20.19.39 + version: 20.19.39 + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + export: dependencies: '@sindresorhus/slugify': specifier: ^3.0.0 version: 3.0.0 '@tanstack/react-query': specifier: ^5.99.1 - version: 5.99.1(react@19.2.4) + version: 5.100.5(react@19.2.4) execa: specifier: ^9.6.1 version: 9.6.1 @@ -38,10 +81,13 @@ importers: serve: specifier: ^14.2.6 version: 14.2.6 + yt-dlp-transcript-common: + specifier: workspace:* + version: link:../common devDependencies: '@tailwindcss/postcss': specifier: ^4.2.2 - version: 4.2.2 + version: 4.2.4 '@types/fs-extra': specifier: ^11.0.4 version: 11.0.4 @@ -59,13 +105,13 @@ importers: version: 9.39.4(jiti@2.6.1) eslint-config-next: specifier: 16.2.3 - version: 16.2.3(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + version: 16.2.3(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) fs-extra: specifier: ^11.3.4 version: 11.3.4 tailwindcss: specifier: ^4.2.2 - version: 4.2.2 + version: 4.2.4 tsx: specifier: ^4.21.0 version: 4.21.0 @@ -704,69 +750,69 @@ packages: '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} - '@tailwindcss/node@4.2.2': - resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} + '@tailwindcss/node@4.2.4': + resolution: {integrity: sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==} - '@tailwindcss/oxide-android-arm64@4.2.2': - resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==} + '@tailwindcss/oxide-android-arm64@4.2.4': + resolution: {integrity: sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==} engines: {node: '>= 20'} cpu: [arm64] os: [android] - '@tailwindcss/oxide-darwin-arm64@4.2.2': - resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==} + '@tailwindcss/oxide-darwin-arm64@4.2.4': + resolution: {integrity: sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==} engines: {node: '>= 20'} cpu: [arm64] os: [darwin] - '@tailwindcss/oxide-darwin-x64@4.2.2': - resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==} + '@tailwindcss/oxide-darwin-x64@4.2.4': + resolution: {integrity: sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==} engines: {node: '>= 20'} cpu: [x64] os: [darwin] - '@tailwindcss/oxide-freebsd-x64@4.2.2': - resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==} + '@tailwindcss/oxide-freebsd-x64@4.2.4': + resolution: {integrity: sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==} engines: {node: '>= 20'} cpu: [x64] os: [freebsd] - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': - resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==} + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4': + resolution: {integrity: sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==} engines: {node: '>= 20'} cpu: [arm] os: [linux] - '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': - resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==} + '@tailwindcss/oxide-linux-arm64-gnu@4.2.4': + resolution: {integrity: sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-arm64-musl@4.2.2': - resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} + '@tailwindcss/oxide-linux-arm64-musl@4.2.4': + resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [musl] - '@tailwindcss/oxide-linux-x64-gnu@4.2.2': - resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} + '@tailwindcss/oxide-linux-x64-gnu@4.2.4': + resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==} engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-x64-musl@4.2.2': - resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} + '@tailwindcss/oxide-linux-x64-musl@4.2.4': + resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==} engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [musl] - '@tailwindcss/oxide-wasm32-wasi@4.2.2': - resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} + '@tailwindcss/oxide-wasm32-wasi@4.2.4': + resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==} engines: {node: '>=14.0.0'} cpu: [wasm32] bundledDependencies: @@ -777,30 +823,30 @@ packages: - '@emnapi/wasi-threads' - tslib - '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': - resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==} + '@tailwindcss/oxide-win32-arm64-msvc@4.2.4': + resolution: {integrity: sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==} engines: {node: '>= 20'} cpu: [arm64] os: [win32] - '@tailwindcss/oxide-win32-x64-msvc@4.2.2': - resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==} + '@tailwindcss/oxide-win32-x64-msvc@4.2.4': + resolution: {integrity: sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==} engines: {node: '>= 20'} cpu: [x64] os: [win32] - '@tailwindcss/oxide@4.2.2': - resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==} + '@tailwindcss/oxide@4.2.4': + resolution: {integrity: sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==} engines: {node: '>= 20'} - '@tailwindcss/postcss@4.2.2': - resolution: {integrity: sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==} + '@tailwindcss/postcss@4.2.4': + resolution: {integrity: sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg==} - '@tanstack/query-core@5.99.1': - resolution: {integrity: sha512-5E8xwxyWvr22yt7zvzP3KOZ5TUElOdVA45NP3/Ao1m9mvc9i18NLTDe9m3M00BH2DR5J20cv7xckMPlhKNs+vQ==} + '@tanstack/query-core@5.100.5': + resolution: {integrity: sha512-t20KrhKkf0HXzqQkPbJ5erhFesup68BAbwFgYmTrS7bxMF7O5MdmL8jUkik4thsG7Hg00fblz30h6yF1d5TxGg==} - '@tanstack/react-query@5.99.1': - resolution: {integrity: sha512-akg5GdwW70lvJvCqVHZ7tizGyc+TATjUzKX9RuF1xknhEe/1leofXk7YLYbrbRsuhNbHJBAayaQUMvvOFZ5L5g==} + '@tanstack/react-query@5.100.5': + resolution: {integrity: sha512-aNwj1mi2v2bQ9IxkyR1grLOUkv3BYWoykHy9KDyLNbjC3tsahbOHJibK+Wjtr1wRhG59/AvJhiJG5OlthaCgJA==} peerDependencies: react: ^18 || ^19 @@ -833,63 +879,63 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} - '@typescript-eslint/eslint-plugin@8.58.2': - resolution: {integrity: sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==} + '@typescript-eslint/eslint-plugin@8.59.0': + resolution: {integrity: sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.58.2 + '@typescript-eslint/parser': ^8.59.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.58.2': - resolution: {integrity: sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==} + '@typescript-eslint/parser@8.59.0': + resolution: {integrity: sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.58.2': - resolution: {integrity: sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==} + '@typescript-eslint/project-service@8.59.0': + resolution: {integrity: sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.58.2': - resolution: {integrity: sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==} + '@typescript-eslint/scope-manager@8.59.0': + resolution: {integrity: sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.58.2': - resolution: {integrity: sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==} + '@typescript-eslint/tsconfig-utils@8.59.0': + resolution: {integrity: sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.58.2': - resolution: {integrity: sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==} + '@typescript-eslint/type-utils@8.59.0': + resolution: {integrity: sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.58.2': - resolution: {integrity: sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==} + '@typescript-eslint/types@8.59.0': + resolution: {integrity: sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.58.2': - resolution: {integrity: sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==} + '@typescript-eslint/typescript-estree@8.59.0': + resolution: {integrity: sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.58.2': - resolution: {integrity: sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==} + '@typescript-eslint/utils@8.59.0': + resolution: {integrity: sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.58.2': - resolution: {integrity: sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==} + '@typescript-eslint/visitor-keys@8.59.0': + resolution: {integrity: sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@unrs/resolver-binding-android-arm-eabi@1.11.1': @@ -1008,8 +1054,8 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - ajv@6.14.0: - resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} @@ -1104,8 +1150,8 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.10.20: - resolution: {integrity: sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==} + baseline-browser-mapping@2.10.23: + resolution: {integrity: sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g==} engines: {node: '>=6.0.0'} hasBin: true @@ -1157,8 +1203,8 @@ packages: resolution: {integrity: sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==} engines: {node: '>=14.16'} - caniuse-lite@1.0.30001788: - resolution: {integrity: sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==} + caniuse-lite@1.0.30001791: + resolution: {integrity: sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==} chalk-template@0.4.0: resolution: {integrity: sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==} @@ -1289,8 +1335,8 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - electron-to-chromium@1.5.340: - resolution: {integrity: sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA==} + electron-to-chromium@1.5.344: + resolution: {integrity: sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -1298,8 +1344,8 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - enhanced-resolve@5.20.1: - resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} + enhanced-resolve@5.21.0: + resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==} engines: {node: '>=10.13.0'} es-abstract@1.24.2: @@ -1858,8 +1904,8 @@ packages: engines: {node: '>=6'} hasBin: true - jsonfile@6.2.0: - resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} @@ -2083,8 +2129,8 @@ packages: resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} hasBin: true - node-releases@2.0.37: - resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} + node-releases@2.0.38: + resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} npm-run-path@4.0.1: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} @@ -2205,8 +2251,8 @@ packages: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} - postcss@8.5.10: - resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} + postcss@8.5.12: + resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==} engines: {node: ^10 || ^12 || >=14} prelude-ls@1.2.1: @@ -2293,8 +2339,8 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - safe-array-concat@1.1.3: - resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} + safe-array-concat@1.1.4: + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} engines: {node: '>=0.4'} safe-buffer@5.2.1: @@ -2466,11 +2512,11 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - tailwindcss@4.2.2: - resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} + tailwindcss@4.2.4: + resolution: {integrity: sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==} - tapable@2.3.2: - resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} tinyglobby@0.2.16: @@ -2522,8 +2568,8 @@ packages: resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} engines: {node: '>= 0.4'} - typescript-eslint@8.58.2: - resolution: {integrity: sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ==} + typescript-eslint@8.59.0: + resolution: {integrity: sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -2851,7 +2897,7 @@ snapshots: '@eslint/eslintrc@3.3.5': dependencies: - ajv: 6.14.0 + ajv: 6.15.0 debug: 4.4.3 espree: 10.4.0 globals: 14.0.0 @@ -3113,80 +3159,80 @@ snapshots: dependencies: tslib: 2.8.1 - '@tailwindcss/node@4.2.2': + '@tailwindcss/node@4.2.4': dependencies: '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.20.1 + enhanced-resolve: 5.21.0 jiti: 2.6.1 lightningcss: 1.32.0 magic-string: 0.30.21 source-map-js: 1.2.1 - tailwindcss: 4.2.2 + tailwindcss: 4.2.4 - '@tailwindcss/oxide-android-arm64@4.2.2': + '@tailwindcss/oxide-android-arm64@4.2.4': optional: true - '@tailwindcss/oxide-darwin-arm64@4.2.2': + '@tailwindcss/oxide-darwin-arm64@4.2.4': optional: true - '@tailwindcss/oxide-darwin-x64@4.2.2': + '@tailwindcss/oxide-darwin-x64@4.2.4': optional: true - '@tailwindcss/oxide-freebsd-x64@4.2.2': + '@tailwindcss/oxide-freebsd-x64@4.2.4': optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4': optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + '@tailwindcss/oxide-linux-arm64-gnu@4.2.4': optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + '@tailwindcss/oxide-linux-arm64-musl@4.2.4': optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + '@tailwindcss/oxide-linux-x64-gnu@4.2.4': optional: true - '@tailwindcss/oxide-linux-x64-musl@4.2.2': + '@tailwindcss/oxide-linux-x64-musl@4.2.4': optional: true - '@tailwindcss/oxide-wasm32-wasi@4.2.2': + '@tailwindcss/oxide-wasm32-wasi@4.2.4': optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + '@tailwindcss/oxide-win32-arm64-msvc@4.2.4': optional: true - '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + '@tailwindcss/oxide-win32-x64-msvc@4.2.4': optional: true - '@tailwindcss/oxide@4.2.2': + '@tailwindcss/oxide@4.2.4': optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.2.2 - '@tailwindcss/oxide-darwin-arm64': 4.2.2 - '@tailwindcss/oxide-darwin-x64': 4.2.2 - '@tailwindcss/oxide-freebsd-x64': 4.2.2 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.2 - '@tailwindcss/oxide-linux-arm64-gnu': 4.2.2 - '@tailwindcss/oxide-linux-arm64-musl': 4.2.2 - '@tailwindcss/oxide-linux-x64-gnu': 4.2.2 - '@tailwindcss/oxide-linux-x64-musl': 4.2.2 - '@tailwindcss/oxide-wasm32-wasi': 4.2.2 - '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 - '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 - - '@tailwindcss/postcss@4.2.2': + '@tailwindcss/oxide-android-arm64': 4.2.4 + '@tailwindcss/oxide-darwin-arm64': 4.2.4 + '@tailwindcss/oxide-darwin-x64': 4.2.4 + '@tailwindcss/oxide-freebsd-x64': 4.2.4 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.4 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.4 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.4 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.4 + '@tailwindcss/oxide-linux-x64-musl': 4.2.4 + '@tailwindcss/oxide-wasm32-wasi': 4.2.4 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.4 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.4 + + '@tailwindcss/postcss@4.2.4': dependencies: '@alloc/quick-lru': 5.2.0 - '@tailwindcss/node': 4.2.2 - '@tailwindcss/oxide': 4.2.2 - postcss: 8.5.10 - tailwindcss: 4.2.2 + '@tailwindcss/node': 4.2.4 + '@tailwindcss/oxide': 4.2.4 + postcss: 8.5.12 + tailwindcss: 4.2.4 - '@tanstack/query-core@5.99.1': {} + '@tanstack/query-core@5.100.5': {} - '@tanstack/react-query@5.99.1(react@19.2.4)': + '@tanstack/react-query@5.100.5(react@19.2.4)': dependencies: - '@tanstack/query-core': 5.99.1 + '@tanstack/query-core': 5.100.5 react: 19.2.4 '@tybys/wasm-util@0.10.1': @@ -3221,14 +3267,14 @@ snapshots: dependencies: csstype: 3.2.3 - '@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.59.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.58.2 - '@typescript-eslint/type-utils': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.58.2 + '@typescript-eslint/parser': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.59.0 + '@typescript-eslint/type-utils': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.0 eslint: 9.39.4(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 @@ -3237,41 +3283,41 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.58.2 - '@typescript-eslint/types': 8.58.2 - '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.58.2 + '@typescript-eslint/scope-manager': 8.59.0 + '@typescript-eslint/types': 8.59.0 + '@typescript-eslint/typescript-estree': 8.59.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.0 debug: 4.4.3 eslint: 9.39.4(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.58.2(typescript@5.9.3)': + '@typescript-eslint/project-service@8.59.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.58.2(typescript@5.9.3) - '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/tsconfig-utils': 8.59.0(typescript@5.9.3) + '@typescript-eslint/types': 8.59.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.58.2': + '@typescript-eslint/scope-manager@8.59.0': dependencies: - '@typescript-eslint/types': 8.58.2 - '@typescript-eslint/visitor-keys': 8.58.2 + '@typescript-eslint/types': 8.59.0 + '@typescript-eslint/visitor-keys': 8.59.0 - '@typescript-eslint/tsconfig-utils@8.58.2(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.59.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.58.2 - '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) - '@typescript-eslint/utils': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.59.0 + '@typescript-eslint/typescript-estree': 8.59.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 eslint: 9.39.4(jiti@2.6.1) ts-api-utils: 2.5.0(typescript@5.9.3) @@ -3279,14 +3325,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.58.2': {} + '@typescript-eslint/types@8.59.0': {} - '@typescript-eslint/typescript-estree@8.58.2(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.59.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.58.2(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.58.2(typescript@5.9.3) - '@typescript-eslint/types': 8.58.2 - '@typescript-eslint/visitor-keys': 8.58.2 + '@typescript-eslint/project-service': 8.59.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.59.0(typescript@5.9.3) + '@typescript-eslint/types': 8.59.0 + '@typescript-eslint/visitor-keys': 8.59.0 debug: 4.4.3 minimatch: 10.2.5 semver: 7.7.4 @@ -3296,20 +3342,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.58.2 - '@typescript-eslint/types': 8.58.2 - '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.59.0 + '@typescript-eslint/types': 8.59.0 + '@typescript-eslint/typescript-estree': 8.59.0(typescript@5.9.3) eslint: 9.39.4(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.58.2': + '@typescript-eslint/visitor-keys@8.59.0': dependencies: - '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/types': 8.59.0 eslint-visitor-keys: 5.0.1 '@unrs/resolver-binding-android-arm-eabi@1.11.1': @@ -3379,7 +3425,7 @@ snapshots: acorn@8.16.0: {} - ajv@6.14.0: + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 fast-json-stable-stringify: 2.1.0 @@ -3498,7 +3544,7 @@ snapshots: balanced-match@4.0.4: {} - baseline-browser-mapping@2.10.20: {} + baseline-browser-mapping@2.10.23: {} boxen@7.0.0: dependencies: @@ -3526,10 +3572,10 @@ snapshots: browserslist@4.28.2: dependencies: - baseline-browser-mapping: 2.10.20 - caniuse-lite: 1.0.30001788 - electron-to-chromium: 1.5.340 - node-releases: 2.0.37 + baseline-browser-mapping: 2.10.23 + caniuse-lite: 1.0.30001791 + electron-to-chromium: 1.5.344 + node-releases: 2.0.38 update-browserslist-db: 1.2.3(browserslist@4.28.2) bytes@3.0.0: {} @@ -3557,7 +3603,7 @@ snapshots: camelcase@7.0.1: {} - caniuse-lite@1.0.30001788: {} + caniuse-lite@1.0.30001791: {} chalk-template@0.4.0: dependencies: @@ -3680,16 +3726,16 @@ snapshots: eastasianwidth@0.2.0: {} - electron-to-chromium@1.5.340: {} + electron-to-chromium@1.5.344: {} emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} - enhanced-resolve@5.20.1: + enhanced-resolve@5.21.0: dependencies: graceful-fs: 4.2.11 - tapable: 2.3.2 + tapable: 2.3.3 es-abstract@1.24.2: dependencies: @@ -3733,7 +3779,7 @@ snapshots: object.assign: 4.1.7 own-keys: 1.0.1 regexp.prototype.flags: 1.5.4 - safe-array-concat: 1.1.3 + safe-array-concat: 1.1.4 safe-push-apply: 1.0.0 safe-regex-test: 1.1.0 set-proto: 1.0.0 @@ -3827,18 +3873,18 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-next@16.2.3(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + eslint-config-next@16.2.3(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): dependencies: '@next/eslint-plugin-next': 16.2.3 eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.10 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react-hooks: 7.1.1(eslint@9.39.4(jiti@2.6.1)) globals: 16.4.0 - typescript-eslint: 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + typescript-eslint: 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -3866,22 +3912,22 @@ snapshots: tinyglobby: 0.2.16 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.10 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -3892,7 +3938,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) hasown: 2.0.3 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -3904,7 +3950,7 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -3987,7 +4033,7 @@ snapshots: '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 '@types/estree': 1.0.8 - ajv: 6.14.0 + ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 debug: 4.4.3 @@ -4114,7 +4160,7 @@ snapshots: fs-extra@11.3.4: dependencies: graceful-fs: 4.2.11 - jsonfile: 6.2.0 + jsonfile: 6.2.1 universalify: 2.0.1 fsevents@2.3.3: @@ -4411,7 +4457,7 @@ snapshots: json5@2.2.3: {} - jsonfile@6.2.0: + jsonfile@6.2.1: dependencies: universalify: 2.0.1 optionalDependencies: @@ -4590,8 +4636,8 @@ snapshots: dependencies: '@next/env': 16.2.3 '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.10.20 - caniuse-lite: 1.0.30001788 + baseline-browser-mapping: 2.10.23 + caniuse-lite: 1.0.30001791 postcss: 8.4.31 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) @@ -4623,7 +4669,7 @@ snapshots: dependencies: detect-libc: 2.1.2 - node-releases@2.0.37: {} + node-releases@2.0.38: {} npm-run-path@4.0.1: dependencies: @@ -4743,7 +4789,7 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - postcss@8.5.10: + postcss@8.5.12: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 @@ -4844,7 +4890,7 @@ snapshots: dependencies: queue-microtask: 1.2.3 - safe-array-concat@1.1.3: + safe-array-concat@1.1.4: dependencies: call-bind: 1.0.9 call-bound: 1.0.4 @@ -5091,9 +5137,9 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - tailwindcss@4.2.2: {} + tailwindcss@4.2.4: {} - tapable@2.3.2: {} + tapable@2.3.3: {} tinyglobby@0.2.16: dependencies: @@ -5163,12 +5209,12 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.58.2(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) - '@typescript-eslint/utils': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.59.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.59.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.4(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml @@ -1,3 +1,8 @@ +packages: + - common + - editor + - export + ignoredBuiltDependencies: - sharp - unrs-resolver diff --git a/scripts/build-index.ts b/scripts/build-index.ts @@ -1,668 +0,0 @@ -#!/usr/bin/env tsx -// Preprocess transcripts/channels/<channelSlug>/data/<videoDir>/ into: -// - LMDB cache at transcripts/index.mdb (for incremental rebuilds) -// - public/summaries/{manifest,page-NNNN}.json (paginated cross-channel -// summaries index for browse/search) -// - public/transcripts/<channelSlug>/{manifest,page-NNNN}.json -// (per-channel paginated transcript detail + cues, oldest-first so -// adding a newer video only dirties the last page) -// -// Per-channel config.json selects the transcript parser ("youtube" → VTT, -// "transcribe" → whisper.cpp JSON). Short-circuits when mtimes already match. - -import path from "node:path"; -import { createHash } from "node:crypto"; -import { - mkdir, - readdir, - readFile, - rename, - rm, - stat, - writeFile, -} from "node:fs/promises"; -import type { Dirent } from "node:fs"; -import { open } from "lmdb"; -import { parseVtt, type Cue } from "../lib/vtt"; -import { parseWhisper } from "../lib/whisper"; -import { - summarize, - toDisplaySummary, - type RawMetadata, -} from "../lib/transcripts-server"; -import type { - TranscriptSummary, - TranscriptDetail, - DisplaySummary, -} from "../lib/transcripts"; -import type { - Manifest, - ChannelEntry, - ChannelTranscriptsManifest, -} from "../lib/manifest"; -import { - MANIFEST_VERSION, - SUMMARIES_PAGE_SIZE, - TRANSCRIPTS_MANIFEST_VERSION, - pageFileName, - transcriptPageFileName, -} from "../lib/manifest"; -import { getSettings } from "../lib/settings"; - -const ROOT = process.cwd(); -const CHANNELS_DIR = path.join(ROOT, "transcripts", "channels"); -const DB_PATH = path.join(ROOT, "transcripts", "index.mdb"); -const PUBLIC_DIR = path.join(ROOT, "public"); -const SUMMARIES_DIR = path.join(PUBLIC_DIR, "summaries"); -const TRANSCRIPTS_DIR = path.join(PUBLIC_DIR, "transcripts"); -const MANIFEST_PATH = path.join(SUMMARIES_DIR, "manifest.json"); - -const SCHEMA_VERSION = 5; - -type Handling = "youtube" | "transcribe"; -type ChannelConfig = { handling: Handling; name?: string }; - -// Primary sort/lookup key: [uploadDate, channelSlug, id]. Reverse iteration -// yields newest-first directly (uploadDate is YYYYMMDD). -type IndexKey = [string, string, string]; -// Per-channel forward-scan index: [channelSlug, uploadDate, id]. -type ChannelKey = [string, string, string]; -// Page-hash key: [channelSlug, pageIndex]. -type PageHashKey = [string, number]; -// Path key: [channelSlug, videoDir]. Stable regardless of metadata contents, -// so diffing by mtime doesn't require parsing metadata.info.json. -type PathKey = [string, string]; - -type MtimeRecord = { - metaMs: number; - transcriptMs: number | null; - indexKey: IndexKey; -}; - -type PageHashRecord = { - hash: string; - entryCount: number; - sizeBytes: number; -}; - -function indexToChannelKey(k: IndexKey): ChannelKey { - return [k[1], k[0], k[2]]; -} - -type LiveEntry = { - channelSlug: string; - handling: Handling; - configName: string | undefined; - videoDir: string; - metaPath: string; - metaMs: number; - transcriptPath: string; - transcriptMs: number | null; -}; - -async function exists(p: string): Promise<boolean> { - try { - await stat(p); - return true; - } catch { - return false; - } -} - -async function readChannelConfig(dir: string): Promise<ChannelConfig | null> { - try { - const raw = await readFile(path.join(dir, "config.json"), "utf8"); - const parsed = JSON.parse(raw) as ChannelConfig; - if (parsed.handling !== "youtube" && parsed.handling !== "transcribe") { - return null; - } - return parsed; - } catch { - return null; - } -} - -async function scanSource(): Promise<{ - live: LiveEntry[]; - channels: Map<string, ChannelConfig>; -}> { - const channels = new Map<string, ChannelConfig>(); - const live: LiveEntry[] = []; - const channelEntries = await readdir(CHANNELS_DIR, { withFileTypes: true }); - for (const ch of channelEntries) { - if (!ch.isDirectory()) continue; - const channelDir = path.join(CHANNELS_DIR, ch.name); - const cfg = await readChannelConfig(channelDir); - if (!cfg) { - console.warn( - `Skipping channel ${ch.name}: missing or invalid config.json`, - ); - continue; - } - channels.set(ch.name, cfg); - const dataDir = path.join(channelDir, "data"); - let videoEntries: Dirent[]; - try { - videoEntries = await readdir(dataDir, { withFileTypes: true }); - } catch { - continue; - } - const transcriptName = - cfg.handling === "youtube" ? "transcript.en.vtt" : "transcript.json"; - for (const v of videoEntries) { - if (!v.isDirectory()) continue; - const videoDir = v.name; - const metaPath = path.join(dataDir, videoDir, "metadata.info.json"); - const transcriptPath = path.join(dataDir, videoDir, transcriptName); - let metaMs: number; - try { - metaMs = (await stat(metaPath)).mtimeMs; - } catch { - continue; - } - let transcriptMs: number | null = null; - try { - transcriptMs = (await stat(transcriptPath)).mtimeMs; - } catch { - transcriptMs = null; - } - live.push({ - channelSlug: ch.name, - handling: cfg.handling, - configName: cfg.name, - videoDir, - metaPath, - metaMs, - transcriptPath, - transcriptMs, - }); - } - } - return { live, channels }; -} - -function pathKeyId(k: PathKey): string { - return `${k[0]}\x00${k[1]}`; -} - -function indexKeysEqual(a: IndexKey, b: IndexKey): boolean { - return a[0] === b[0] && a[1] === b[1] && a[2] === b[2]; -} - -async function writeJsonAtomic( - filePath: string, - value: unknown, -): Promise<void> { - const tmp = `${filePath}.tmp-${process.pid}`; - await writeFile(tmp, JSON.stringify(value)); - await rename(tmp, filePath); -} - -async function main(): Promise<void> { - const t0 = Date.now(); - await mkdir(path.dirname(DB_PATH), { recursive: true }); - await mkdir(SUMMARIES_DIR, { recursive: true }); - await mkdir(TRANSCRIPTS_DIR, { recursive: true }); - - const root = open({ - path: DB_PATH, - maxDbs: 8, - compression: true, - }); - const sums = root.openDB<TranscriptSummary, IndexKey>({ - name: "sums", - encoding: "msgpack", - }); - const cues = root.openDB<Cue[], IndexKey>({ - name: "cues", - encoding: "msgpack", - }); - const mtimes = root.openDB<MtimeRecord, PathKey>({ - name: "mtimes", - encoding: "msgpack", - }); - // Per-channel forward index: lets us stream [channelSlug, uploadDate, id] - // in oldest-first order for size-based page packing without loading every - // channel's entries into memory. - const byChannel = root.openDB<number, ChannelKey>({ - name: "byChannel", - encoding: "msgpack", - }); - // Content hashes of emitted transcript pages, so unchanged pages skip the - // write. Since page 1 = oldest videos, adding a new newest-upload video - // only dirties the last page's hash. - const pageHashes = root.openDB<PageHashRecord, PageHashKey>({ - name: "pageHashes", - encoding: "msgpack", - }); - const meta = root.openDB<unknown, string>({ - name: "meta", - encoding: "msgpack", - }); - - const storedSchema = meta.get("schema") as number | undefined; - const schemaBumped = storedSchema !== SCHEMA_VERSION; - if (schemaBumped) { - console.log( - `Schema change (${storedSchema ?? "<none>"} -> ${SCHEMA_VERSION}); invalidating LMDB cache.`, - ); - await sums.clearAsync(); - await cues.clearAsync(); - await mtimes.clearAsync(); - await byChannel.clearAsync(); - await pageHashes.clearAsync(); - await meta.put("schema", SCHEMA_VERSION); - } - - const { live, channels: channelConfigs } = await scanSource(); - const livePathIds = new Set<string>(); - const liveByPathId = new Map<string, LiveEntry>(); - for (const s of live) { - const id = pathKeyId([s.channelSlug, s.videoDir]); - livePathIds.add(id); - liveByPathId.set(id, s); - } - - // Diff against prior mtimes (path-keyed — no metadata reads required). - const added: LiveEntry[] = []; - const changed: LiveEntry[] = []; - const removed: { pathKey: PathKey; indexKey: IndexKey }[] = []; - - for (const s of live) { - const pk: PathKey = [s.channelSlug, s.videoDir]; - const prev = mtimes.get(pk); - if (!prev) { - added.push(s); - } else if ( - prev.metaMs !== s.metaMs || - prev.transcriptMs !== s.transcriptMs - ) { - changed.push(s); - } - } - for (const { key, value } of mtimes.getRange()) { - const k = key as PathKey; - if (!livePathIds.has(pathKeyId(k))) { - removed.push({ pathKey: k, indexKey: (value as MtimeRecord).indexKey }); - } - } - - const anyMutations = - added.length > 0 || changed.length > 0 || removed.length > 0; - - // Short-circuit: nothing changed AND public/ is intact AND the configured - // max page size matches what each channel was last paginated with. - const { maxTranscriptPageBytes: configuredMaxPageBytes } = getSettings(); - if (!anyMutations && !schemaBumped) { - const manifestRaw = await readFile(MANIFEST_PATH, "utf8").catch(() => null); - if (manifestRaw) { - try { - const parsed = JSON.parse(manifestRaw) as Manifest; - if ( - parsed.version === MANIFEST_VERSION && - parsed.totalCount === live.length && - parsed.pageSize === SUMMARIES_PAGE_SIZE - ) { - const firstPage = path.join(SUMMARIES_DIR, pageFileName(0)); - const lastPage = path.join( - SUMMARIES_DIR, - pageFileName(Math.max(0, parsed.pageCount - 1)), - ); - let transcriptsIntact = true; - for (const channelSlug of channelConfigs.keys()) { - const mPath = path.join( - TRANSCRIPTS_DIR, - channelSlug, - "manifest.json", - ); - const raw = await readFile(mPath, "utf8").catch(() => null); - if (!raw) { - transcriptsIntact = false; - break; - } - try { - const cm = JSON.parse(raw) as ChannelTranscriptsManifest; - if ( - cm.version !== TRANSCRIPTS_MANIFEST_VERSION || - cm.maxPageBytes !== configuredMaxPageBytes - ) { - transcriptsIntact = false; - break; - } - } catch { - transcriptsIntact = false; - break; - } - } - if ( - transcriptsIntact && - (await exists(firstPage)) && - (await exists(lastPage)) - ) { - console.log( - `Index up to date (${live.length} transcripts, ${parsed.pageCount} pages). Skipping.`, - ); - await root.close(); - return; - } - } - } catch { - // fall through - } - } - } - - console.log( - `Diff: +${added.length} added, ~${changed.length} changed, -${removed.length} removed, ${live.length} total.`, - ); - - // Ensure per-channel public/transcripts/<channelSlug>/ subdirs exist. - for (const channelSlug of channelConfigs.keys()) { - await mkdir(path.join(TRANSCRIPTS_DIR, channelSlug), { recursive: true }); - } - - // Process mutations. - const toProcess = [...added, ...changed]; - let processed = 0; - const BATCH = 200; - for (let i = 0; i < toProcess.length; i += BATCH) { - const slice = toProcess.slice(i, i + BATCH); - await Promise.all( - slice.map(async (s) => { - try { - const metaRaw = await readFile(s.metaPath, "utf8"); - const parsedMeta = JSON.parse(metaRaw) as RawMetadata; - const summary = summarize( - s.channelSlug, - s.videoDir, - parsedMeta, - s.configName, - ); - if (!summary.uploadDate) { - console.warn( - `Skipping ${s.channelSlug}/${s.videoDir}: no upload_date`, - ); - return; - } - const indexKey: IndexKey = [ - summary.uploadDate, - s.channelSlug, - summary.id, - ]; - let cueList: Cue[] | undefined; - if (s.transcriptMs !== null) { - try { - const raw = await readFile(s.transcriptPath, "utf8"); - cueList = - s.handling === "youtube" ? parseVtt(raw) : parseWhisper(raw); - } catch { - cueList = undefined; - } - } - - // If upload_date shifted (metadata edit), the old composite key is - // stale — drop it before writing the new one. - const pk: PathKey = [s.channelSlug, s.videoDir]; - const prev = mtimes.get(pk); - if (prev && !indexKeysEqual(prev.indexKey, indexKey)) { - sums.remove(prev.indexKey); - cues.remove(prev.indexKey); - byChannel.remove(indexToChannelKey(prev.indexKey)); - } - - sums.put(indexKey, summary); - if (cueList) cues.put(indexKey, cueList); - else cues.remove(indexKey); - byChannel.put(indexToChannelKey(indexKey), 1); - mtimes.put(pk, { - metaMs: s.metaMs, - transcriptMs: s.transcriptMs, - indexKey, - }); - } catch (err) { - console.warn( - `Failed to process ${s.channelSlug}/${s.videoDir}:`, - err, - ); - } - }), - ); - processed += slice.length; - if (toProcess.length > BATCH) { - process.stdout.write(` processed ${processed}/${toProcess.length}\r`); - } - } - if (toProcess.length > BATCH) process.stdout.write("\n"); - - // Process removals. - for (const { pathKey, indexKey } of removed) { - sums.remove(indexKey); - cues.remove(indexKey); - byChannel.remove(indexToChannelKey(indexKey)); - mtimes.remove(pathKey); - } - - await sums.flushed; - await cues.flushed; - await byChannel.flushed; - await mtimes.flushed; - - // Emit per-channel paginated transcript pages. Pages are size-packed - // oldest-first, so newer videos land on the last page and older pages - // hash-match from build to build. - const maxTranscriptPageBytes = configuredMaxPageBytes; - const generatedAt = new Date().toISOString(); - let pagesWritten = 0; - let pagesSkipped = 0; - let pagesDeleted = 0; - - type PendingEntry = { encoded: string; id: string }; - - const writePage = async ( - channelSlug: string, - idx: number, - entries: PendingEntry[], - ): Promise<void> => { - const body = `[${entries.map((e) => e.encoded).join(",")}]`; - const hash = createHash("sha1").update(body).digest("hex"); - const prev = pageHashes.get([channelSlug, idx]); - const outPath = path.join( - TRANSCRIPTS_DIR, - channelSlug, - transcriptPageFileName(idx), - ); - if (prev?.hash === hash && (await exists(outPath))) { - pagesSkipped++; - return; - } - const sizeBytes = Buffer.byteLength(body, "utf8"); - const tmp = `${outPath}.tmp-${process.pid}`; - await writeFile(tmp, body); - await rename(tmp, outPath); - pageHashes.put([channelSlug, idx], { - hash, - entryCount: entries.length, - sizeBytes, - }); - pagesWritten++; - }; - - for (const channelSlug of Array.from(channelConfigs.keys()).sort()) { - const channelDir = path.join(TRANSCRIPTS_DIR, channelSlug); - await mkdir(channelDir, { recursive: true }); - - let pageIdx = 0; - let buffer: PendingEntry[] = []; - // Running payload size excluding the outer `[` and `]` (accounted at - // flush time). Includes the leading commas between elements. - let payloadBytes = 0; - const slugToPage: Record<string, number> = {}; - - for (const { key } of byChannel.getRange({ - start: [channelSlug], - end: [channelSlug, "￿"], - })) { - const ck = key as ChannelKey; - if (ck[0] !== channelSlug) continue; - const indexKey: IndexKey = [ck[1], ck[0], ck[2]]; - const summary = sums.get(indexKey); - if (!summary) continue; - const cueList = cues.get(indexKey); - const detail: TranscriptDetail = { ...summary, cues: cueList }; - const encoded = JSON.stringify(detail); - const entryBytes = Buffer.byteLength(encoded, "utf8"); - const commaBytes = buffer.length === 0 ? 0 : 1; - const delta = entryBytes + commaBytes; - - // Close current page if this entry would push it past the limit. - // `+ 2` accounts for the outer brackets. - if (buffer.length > 0 && payloadBytes + delta + 2 > maxTranscriptPageBytes) { - await writePage(channelSlug, pageIdx, buffer); - pageIdx++; - buffer = []; - payloadBytes = 0; - } - - if (entryBytes + 2 > maxTranscriptPageBytes) { - console.warn( - ` ${summary.slug}: entry (${entryBytes} bytes) exceeds page budget; emitting solo page.`, - ); - } - - slugToPage[summary.id] = pageIdx; - buffer.push({ encoded, id: summary.id }); - payloadBytes += delta; - } - - if (buffer.length > 0) { - await writePage(channelSlug, pageIdx, buffer); - pageIdx++; - } - - const pageCount = pageIdx; - - // Prune stale per-channel page files + drop pageHashes beyond pageCount. - const keep = new Set<string>(["manifest.json"]); - for (let i = 0; i < pageCount; i++) keep.add(transcriptPageFileName(i)); - const existing = await readdir(channelDir).catch(() => [] as string[]); - for (const name of existing) { - if (keep.has(name)) continue; - await rm(path.join(channelDir, name), { force: true }); - pagesDeleted++; - } - // pageHashes may still have records for page indexes >= pageCount - // (channel shrank). Drop them. - for (const { key } of pageHashes.getRange({ - start: [channelSlug, pageCount], - end: [channelSlug, Number.MAX_SAFE_INTEGER], - })) { - pageHashes.remove(key as PageHashKey); - } - - const channelManifest: ChannelTranscriptsManifest = { - version: TRANSCRIPTS_MANIFEST_VERSION, - channelSlug, - pageCount, - maxPageBytes: maxTranscriptPageBytes, - generatedAt, - slugToPage, - }; - await writeJsonAtomic( - path.join(channelDir, "manifest.json"), - channelManifest, - ); - } - - await pageHashes.flushed; - - // Prune channel directories for channels no longer in config, and any - // legacy per-video `<id>.json` files that leaked in from pre-schema-4 - // builds at the top level of transcripts/ (pre-channel-subdir layout). - const topEntries = await readdir(TRANSCRIPTS_DIR, { - withFileTypes: true, - }).catch(() => [] as Dirent[]); - for (const e of topEntries) { - if (e.isDirectory()) { - if (!channelConfigs.has(e.name)) { - await rm(path.join(TRANSCRIPTS_DIR, e.name), { - recursive: true, - force: true, - }); - } - } else if (e.isFile()) { - await rm(path.join(TRANSCRIPTS_DIR, e.name), { force: true }); - } - } - - console.log( - `Transcript pages: ${pagesWritten} written, ${pagesSkipped} unchanged, ${pagesDeleted} stale removed.`, - ); - - // Stream LMDB (reverse composite-key order = newest-date first) to emit - // paginated summaries. - const pageSize = SUMMARIES_PAGE_SIZE; - const channelCounts = new Map<string, number>(); - // Seed channel list from config so empty channels still appear in the UI. - for (const cfg of channelConfigs.values()) { - if (cfg.name) channelCounts.set(cfg.name, 0); - } - let pageIndex = 0; - let buffer: DisplaySummary[] = []; - let total = 0; - - const flushPage = async () => { - if (buffer.length === 0) return; - const p = path.join(SUMMARIES_DIR, pageFileName(pageIndex)); - await writeJsonAtomic(p, buffer); - buffer = []; - pageIndex++; - }; - - for (const { value } of sums.getRange({ reverse: true })) { - const s = value as TranscriptSummary; - if (s.channel) { - channelCounts.set(s.channel, (channelCounts.get(s.channel) ?? 0) + 1); - } - buffer.push(toDisplaySummary(s)); - total++; - if (buffer.length >= pageSize) await flushPage(); - } - await flushPage(); - - // Prune stale page files. - const expectedPages = new Set<string>(); - for (let i = 0; i < pageIndex; i++) expectedPages.add(pageFileName(i)); - const existing = await readdir(SUMMARIES_DIR).catch(() => [] as string[]); - for (const name of existing) { - if (!name.startsWith("page-")) continue; - if (!expectedPages.has(name)) { - await rm(path.join(SUMMARIES_DIR, name), { force: true }); - } - } - - const channelList: ChannelEntry[] = Array.from(channelCounts.entries()) - .map(([name, count]) => ({ name, count })) - .sort((a, b) => a.name.localeCompare(b.name)); - - const manifest: Manifest = { - version: MANIFEST_VERSION, - totalCount: total, - pageSize, - pageCount: pageIndex, - generatedAt: new Date().toISOString(), - channels: channelList, - }; - await writeJsonAtomic(MANIFEST_PATH, manifest); - - await root.close(); - - const secs = ((Date.now() - t0) / 1000).toFixed(2); - console.log( - `Done in ${secs}s. ${total} transcripts across ${pageIndex} pages; ${channelList.length} channels.`, - ); -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/tsconfig.base.json b/tsconfig.base.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "incremental": true + } +} diff --git a/tsconfig.json b/tsconfig.json @@ -1,34 +0,0 @@ -{ - "compilerOptions": { - "target": "ESNext", - "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, - "skipLibCheck": true, - "strict": true, - "noEmit": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "react-jsx", - "incremental": true, - "plugins": [ - { - "name": "next" - } - ], - "paths": { - "@/*": ["./*"] - } - }, - "include": [ - "next-env.d.ts", - "**/*.ts", - "**/*.tsx", - ".next/types/**/*.ts", - ".next/dev/types/**/*.ts", - "**/*.mts" - ], - "exclude": ["node_modules"] -}