commit feab13bfc416ac727c7df24ea5d10af9f8d164b4
parent 6bc63ab481d0d2700b7ebd5838c960b0de02922d
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 4 Jul 2026 08:43:13 -0400
Merge perf: memoize search result cards & hit rows
Diffstat:
3 files changed, 72 insertions(+), 37 deletions(-)
diff --git a/common/components/TranscriptSearch.tsx b/common/components/TranscriptSearch.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useWindowVirtualizer } from "@tanstack/react-virtual";
import { usePlayer } from "./PlayerProvider";
import { VirtualRow } from "./VirtualRow";
@@ -1274,10 +1274,13 @@ export default function TranscriptSearch() {
[],
);
- const openWithMode = (slug: string, hit?: LayerHit) => {
- const modalMode = hit?.scope === "chat" ? "chat" : "transcript";
- openTranscript(slug, hit?.start, { mode: modalMode });
- };
+ const openWithMode = useCallback(
+ (slug: string, hit?: LayerHit) => {
+ const modalMode = hit?.scope === "chat" ? "chat" : "transcript";
+ openTranscript(slug, hit?.start, { mode: modalMode });
+ },
+ [openTranscript],
+ );
// Map of leafId → leaf (and its query) so result-row rendering can look
// up the original search terms for `<mark>` highlighting.
@@ -1969,7 +1972,7 @@ function VirtualResultList({
);
}
-function ResultCard({
+const ResultCard = memo(function ResultCard({
group,
leavesById,
orderedLeafIds,
@@ -2059,36 +2062,19 @@ function ResultCard({
</span>
</div>
<ul className="flex flex-col divide-y divide-border/60">
- {hits.map((h, i) => {
- const isActive =
- activeVideo === group.slug && activeTime === h.start;
- return (
- <li key={i}>
- <Button
- type="button"
- variant="ghost"
- onClick={() => openWithMode(group.slug, h)}
- className={`w-full h-auto justify-start text-left flex gap-3 px-4 py-2 rounded-none font-normal whitespace-normal ${
- isActive
- ? "bg-warning-soft ring-1 ring-inset ring-warning/60"
- : ""
- }`}
- >
- <span className="text-xs font-mono text-muted-foreground shrink-0 w-16">
- {h.scope === "metadata"
- ? "—"
- : formatSeconds(h.start)}
- </span>
- {h.track && h.track !== "live_chat" && (
- <TrackBadge track={h.track} />
- )}
- <span className="flex-1 min-w-0 text-sm">
- {highlight(h.text, leafInfo.query, leafInfo.useRegex)}
- </span>
- </Button>
- </li>
- );
- })}
+ {hits.map((h, i) => (
+ <HitRow
+ key={i}
+ slug={group.slug}
+ hit={h}
+ query={leafInfo.query}
+ useRegex={leafInfo.useRegex}
+ isActive={
+ activeVideo === group.slug && activeTime === h.start
+ }
+ onOpen={openWithMode}
+ />
+ ))}
</ul>
</li>
);
@@ -2096,7 +2082,52 @@ function ResultCard({
</ul>
</div>
);
-}
+});
+ResultCard.displayName = "ResultCard";
+
+// One hit row inside a card. Memoized so that (a) scroll-frame re-renders of
+// the parent list don't re-run highlight() for every hit, and (b) when the
+// modal target changes only the previously-active and newly-active rows
+// re-render — every other row bails out on shallow-equal props.
+const HitRow = memo(function HitRow({
+ slug,
+ hit,
+ query,
+ useRegex,
+ isActive,
+ onOpen,
+}: {
+ slug: string;
+ hit: LayerHit;
+ query: string;
+ useRegex: boolean;
+ isActive: boolean;
+ onOpen: (slug: string, hit?: LayerHit) => void;
+}) {
+ return (
+ <li>
+ <Button
+ type="button"
+ variant="ghost"
+ onClick={() => onOpen(slug, hit)}
+ className={`w-full h-auto justify-start text-left flex gap-3 px-4 py-2 rounded-none font-normal whitespace-normal ${
+ isActive ? "bg-warning-soft ring-1 ring-inset ring-warning/60" : ""
+ }`}
+ >
+ <span className="text-xs font-mono text-muted-foreground shrink-0 w-16">
+ {hit.scope === "metadata" ? "—" : formatSeconds(hit.start)}
+ </span>
+ {hit.track && hit.track !== "live_chat" && (
+ <TrackBadge track={hit.track} />
+ )}
+ <span className="flex-1 min-w-0 text-sm">
+ {highlight(hit.text, query, useRegex)}
+ </span>
+ </Button>
+ </li>
+ );
+});
+HitRow.displayName = "HitRow";
function TrackBadge({ track }: { track: string }) {
const label = track === "live_chat" ? "live chat" : track;
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **Search results scroll smoothly again on large result sets.** The results list windows one card per matching video (only the on-screen cards are mounted), but each visible card and every one of its hit rows was re-rendering on *every* scroll frame — and each hit row re-ran its `<mark>` highlighting, so a single video with hundreds of hits meant hundreds of redundant highlight passes per frame while scrolling. The result cards and individual hit rows are now memoized so an unchanged card/row is skipped during scroll, and opening the modal on a hit only re-renders the two rows whose highlight state actually changes. No visible/behavioral change — same DOM, same results, just far less work per frame. See `common/components/TranscriptSearch.tsx` (`ResultCard`/`HitRow` memoization, `openWithMode` stabilized via `useCallback`).
- **The monitor widget gains a needs-work channel list, more interaction buttons, and an in-place settings gear.** Three additions, all driveable from the widget builder. **(1) A "Needs work" list** (URL flag `act=1`) — a compact, per-channel worklist of videos to download (`↓ N`) or transcribe (`✎ N`), reusing the same `loadActionableSummary` that powers the `/actionable` page via a new `/api/widget/actionable` route; it polls on a 15s floor (the backlog changes on job completions, not seconds) and caps at 6 channels with a `+N more` line. **(2) More interactions** behind the existing `controls=1` switch: each needs-work row gains the same per-channel **Download missing** / **Transcribe pending** buttons as the actionable page (reusing `InlineActionButton`), and the controls row adds **Retry all failed** alongside Pause/Resume + Drain. **(3) An in-place settings gear** (on by default; URL flag `gear=0` to hide, or a **Show settings gear** builder checkbox) — clicking it opens the builder's own form *inside the widget window*, so a pinned widget can be reconfigured live without opening the builder page; edits apply immediately and mirror into the address bar via `history.replaceState`, so a reload preserves them and the link stays copyable. The builder form is extracted into a shared `WidgetConfigForm` used by both the builder and the overlay, and the widget's poller now fetches immediately on (re)subscribe instead of after one interval, so newly-enabled sections render at once. Existing links render unchanged (the two new flags default to their old behavior; the gear is the one new default-visible affordance and is read-only — it mutates no server state). See `editor/app/widget/lib/config.ts`, the new `editor/app/widget/components/WidgetConfigForm.tsx` and `editor/app/api/widget/actionable/route.ts`, `editor/app/widget/components/{MonitorWidget,WidgetControls}.tsx`, `editor/app/widget/builder/components/WidgetBuilder.tsx`, and `editor/e2e/widget.spec.ts`.
- **Every site build now bundles downloadable transcript & live-chat archive zips.** The archive builders (per-channel `<slug>.zip`, combined `all-transcripts.zip` / `all-live-chat.zip`) previously only ran as standalone actions that wrote to a non-served directory; now `compose-site` generates them for the site's own channels straight into the served `public/archives/` and writes a `manifest.json` (sizes + counts) that the site's new **Downloads** page reads. **`zip` is now the default archive format** everywhere (was `tar.gz`), and the Build page's format help text tracks the selected format. Generation is **on by default with three opt-out levels**: a global **Generate archive zips on build** toggle in Settings, a per-site **Generate archive zips** toggle (plus an optional **Archive size cap (MB)**) on the site's page, and a per-build **Skip archive zips** checkbox on the Build and Build & Deploy controls (`BUILD_ARCHIVES=0`). Because a single file over ~25 MB breaks a Cloudflare Pages deploy, any archive over the cap (default 25 MB; `0` = no cap) is dropped from what's served and flagged `oversize` in the manifest so the deploy still succeeds and the Downloads page shows it as unavailable rather than a dead link. See `common/bin/compose-site.ts` (`composeArchives`), `common/controller/archive{Transcripts,LiveChat}.ts` (new `outDir` option), `common/lib/archiveOptions.ts` (default + manifest types), `common/lib/{site,settings}.ts` (opt-out flags), and `editor/app/{deploy/buildDeployCore.ts,build/buildAction.ts,deploy/components/Build{Export,Deploy}Button.tsx,sites/components/SiteForm.tsx,settings/components/SettingsForm.tsx}`.
- **You can now change a channel's slug (its id) — deliberately, from the Danger zone.** A channel's slug *is* its on-disk directory name (`transcripts/channels/<slug>/`), so it used to be fixed at creation ("Slug is fixed once a channel is created"). A new **Rename** form in the channel's Danger zone lifts that: enter a new slug and **type the current slug to confirm** (same friction as delete), and the rename is blocked while the channel has running/queued jobs (the in-memory registry keys by slug). Because the slug is a directory name, the rename does a **full migration** of every slug-keyed store so nothing silently breaks: it moves the channel dir (config, data, playlist, snapshot, shards, failed lists) **and** the saved-video store dir — rewriting each `saved-video.json` pointer's absolute `dir` so persisted source videos still resolve — then retargets every site.json membership, the sync scheduler's per-channel backoff state, and any job bookmarks. The two filesystem moves run first and roll back on failure; the metadata updates that follow are atomic and best-effort (surfaced as warnings). Renaming **changes the channel's public URL** (the old one 404s), which the form warns about. The slug grammar is also now validated on create. See `common/controller/renameChannel.ts`, `common/controller/channels.ts` (`isValidChannelSlug`), `common/lib/savedVideo-server.ts` (`rewriteSavedVideoDir`), `common/jobs/bookmarks.ts` (`renameChannelInBookmarks`), `editor/app/channels/{actions.ts,components/RenameChannelForm.tsx,[slug]/page.tsx}`, and `editor/e2e/channel-rename.spec.ts`.
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -1,5 +1,8 @@
# Changelog
+## [Unreleased]
+- **Search results scroll smoothly again on large result sets.** The results list already mounts only the on-screen video cards, but each visible card and every one of its hit rows re-rendered on *every* scroll frame — and each hit row re-ran its `<mark>` highlighting, so a video with hundreds of hits meant hundreds of redundant highlight passes per frame while scrolling. Cards and hit rows are now memoized so unchanged ones are skipped during scroll (and opening a hit only re-renders the two rows whose highlight state changes). Same results, same layout — just far less work per frame.
+
## [0.6.1] - 2026-07-04
- **A new Downloads page lets you take the whole archive with you.** Reachable from the header nav and footer (shown only when a build actually produced archives), `/downloads` lists the site's transcript and live-chat bundles as `.zip` downloads — the whole site up top, then per channel — each printing its video count and file size. A bundle too large to host (over the build's size cap) is shown as unavailable with the reason instead of a broken link. The zips are regenerated on every build.
- **Download a single video's transcript or live chat as a file.** The player toolbar has a new download control (`⤓`) that saves whatever you're viewing — the transcript, or the live chat — as `.txt`, `.srt`, or `.json`, generated in your browser from the already-loaded cues (no download of the full archive needed).