Archilyzer · Source

archilyzer

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

commit c0f0236f81e2e42fa935aa6391aa87ae45cae2e7
parent 91735e8fb9a20e6522b39555f6c7901fa64e952d
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sat,  4 Jul 2026 13:34:50 -0400

Merge main into feat/archive-downloads (changelog union)

# Conflicts:
#	editor/CHANGELOG.md
#	export/CHANGELOG.md

Diffstat:
Mcommon/components/TranscriptSearch.tsx | 105+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Meditor/CHANGELOG.md | 2++
Aeditor/app/api/widget/actionable/route.ts | 39+++++++++++++++++++++++++++++++++++++++
Meditor/app/widget/builder/components/WidgetBuilder.tsx | 124+++++--------------------------------------------------------------------------
Meditor/app/widget/components/MonitorWidget.tsx | 226+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Aeditor/app/widget/components/WidgetConfigForm.tsx | 146+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/widget/components/WidgetControls.tsx | 10++++++----
Meditor/app/widget/lib/config.ts | 14++++++++++++++
Meditor/e2e/widget.spec.ts | 97++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mexport/CHANGELOG.md | 7++++++-
10 files changed, 598 insertions(+), 172 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,8 @@ # 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 per-channel transcript & live-chat archive zips.** The archive builders (per-channel `<slug>.zip` / `<slug>.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`). 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}`. - **Oversize archives now overflow to Cloudflare R2 instead of being dropped.** A single file over 25 MB breaks a Cloudflare Pages deploy, so a channel zip over the cap (default 25 MB; `0` = no cap) used to be removed from what's served and flagged `oversize`. Now, when **Archive overflow storage** is configured in Settings (an R2 **bucket** + its **public URL**), `compose-site` stages each oversize archive to `export/.r2-staging/<siteId>/` and records its future public URL in the manifest; the deploy step then uploads it via `wrangler r2 object put <bucket>/<siteId>/archives/<file>` (reusing the host's wrangler auth) **before** the Pages deploy, so the Downloads page links straight to R2. With no bucket configured the old drop-and-flag behavior is unchanged (uploads run only in the editor's Deploy / Build & deploy actions, not a raw `pnpm deploy`). The **combined "whole site" archives were removed** — they duplicated the per-channel content and were always the first to blow the cap. See `common/lib/settings.ts` (`archiveStorage`), `common/bin/compose-site.ts` (staging + manifest `url`), `common/lib/archiveOptions.ts` (`ArchiveManifestEntry.url`), and `editor/app/deploy/buildDeployCore.ts` (`runArchiveUploadIntoLog`) / `deploy/deployAction.ts` / `build/buildAction.ts`. - **The Duplicates page is now a per-site toggle and hides itself when empty.** Each site's editor page gains a **Show the Duplicates page** checkbox (on by default). `compose-site` writes the site-filtered `duplicates.json` only when the toggle is on *and* there's at least one in-scope cluster, and the export Header keys its Duplicates nav link off a new `hasDuplicates()` — so the link and page disappear both when a site opts out and when it simply has no detected duplicates. See `common/lib/site.ts` (`duplicates` flag), `common/bin/compose-site.ts` (gated write), `export/app/lib/duplicates.ts` (new), `export/app/components/Header.tsx`, and `editor/app/sites/{components/SiteForm.tsx,actions.ts}`. diff --git a/editor/app/api/widget/actionable/route.ts b/editor/app/api/widget/actionable/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { + actionableUndownloadedCount, + actionableUntranscribedCount, + loadActionableSummary, +} from "../../../actionable/lib/loadActionable"; + +export const dynamic = "force-dynamic"; + +export type WidgetActionableChannel = { + slug: string; + undownloaded: number; + untranscribed: number; +}; + +export type WidgetActionablePayload = { + channels: WidgetActionableChannel[]; +}; + +// Backs the monitor widget's optional "Needs work" strip. Reuses the same +// actionable summary that powers the /actionable page, reduced to the two +// buckets a widget acts on — videos to download and videos to transcribe — for +// each channel that has any, sorted by the larger backlog first. +export async function GET() { + const summary = await loadActionableSummary(getPaths()); + const channels = summary.rows + .map((row) => ({ + slug: row.channel.slug, + undownloaded: actionableUndownloadedCount(row), + untranscribed: actionableUntranscribedCount(row), + })) + .filter((c) => c.undownloaded > 0 || c.untranscribed > 0) + .sort( + (a, b) => + b.undownloaded + b.untranscribed - (a.undownloaded + a.untranscribed), + ); + return NextResponse.json({ channels } satisfies WidgetActionablePayload); +} diff --git a/editor/app/widget/builder/components/WidgetBuilder.tsx b/editor/app/widget/builder/components/WidgetBuilder.tsx @@ -6,6 +6,7 @@ import { WIDGET_DEFAULTS, type WidgetConfig, } from "../../lib/config"; +import { WidgetConfigForm } from "../../components/WidgetConfigForm"; import { loadWidgetConfig, saveWidgetConfig } from "../widgetConfigStorage"; const SIZE_PRESETS: { label: string; width: number; height: number }[] = [ @@ -73,99 +74,7 @@ export function WidgetBuilder() { return ( <div className="flex flex-col gap-6 lg:flex-row lg:items-start"> <form className="flex flex-col gap-4 lg:w-80 lg:shrink-0"> - <fieldset className="flex flex-col gap-2"> - <legend className="text-sm font-medium mb-1">Sections</legend> - <Check - label="Workers" - checked={config.workers} - onChange={(v) => patch({ workers: v })} - /> - <Check - label="Active jobs" - checked={config.jobs} - onChange={(v) => patch({ jobs: v })} - /> - </fieldset> - - <fieldset className="flex flex-col gap-2"> - <legend className="text-sm font-medium mb-1">Display</legend> - <Check - label="Compact (hide per-task detail)" - checked={config.compact} - onChange={(v) => patch({ compact: v })} - /> - <Check - label="Show batch progress bar" - checked={config.jobBar} - onChange={(v) => patch({ jobBar: v })} - /> - <Check - label="Show progress in job heading" - checked={config.headingProgress} - onChange={(v) => patch({ headingProgress: v })} - /> - <Check - label="Show time estimates (ETA)" - checked={config.eta} - onChange={(v) => patch({ eta: v })} - /> - <Check - label="Show disk indicator" - checked={config.disk} - onChange={(v) => patch({ disk: v })} - /> - <Check - label="Show cleanable indicator" - checked={config.cleanable} - onChange={(v) => patch({ cleanable: v })} - /> - <Check - label="Show worker names" - checked={config.workerLabels} - onChange={(v) => patch({ workerLabels: v })} - /> - <Check - label="Show section titles" - checked={config.showTitles} - onChange={(v) => patch({ showTitles: v })} - /> - <Check - label="Hide when idle" - checked={config.hideIdle} - onChange={(v) => patch({ hideIdle: v })} - /> - <Check - label="Show control buttons (pause, drain)" - checked={config.controls} - onChange={(v) => patch({ controls: v })} - /> - </fieldset> - - <label className="flex flex-col gap-1 text-sm"> - <span className="font-medium">Channel filter (slug, optional)</span> - <input - type="text" - value={config.channel ?? ""} - onChange={(e) => patch({ channel: e.target.value || undefined })} - placeholder="all channels" - className="rounded border border-border bg-card px-2 py-1" - /> - </label> - - <label className="flex flex-col gap-1 text-sm"> - <span className="font-medium">Poll interval (seconds)</span> - <input - type="number" - min={1} - value={config.pollSeconds} - onChange={(e) => - patch({ - pollSeconds: Math.max(1, Math.round(Number(e.target.value) || 1)), - }) - } - className="rounded border border-border bg-card px-2 py-1 w-24" - /> - </label> + <WidgetConfigForm config={config} onChange={patch} /> </form> <div className="flex flex-1 flex-col gap-3 min-w-0"> @@ -200,8 +109,11 @@ export function WidgetBuilder() { <code className="font-mono">&lt;iframe&gt;</code>. It has no sidebar; by default it&apos;s read-only, but{" "} <strong>Show control buttons</strong> adds Pause/Resume - Transcriptions and Drain all. <strong>Open popup</strong> launches a - chromeless window at the selected preview size. + Transcriptions, Drain all, Retry all failed, and per-channel + Download/Transcribe on the needs-work list. The{" "} + <strong>settings gear</strong> lets you reconfigure the widget in + place; turn it off for a locked-down link. <strong>Open popup</strong>{" "} + launches a chromeless window at the selected preview size. </p> </div> @@ -243,25 +155,3 @@ export function WidgetBuilder() { </div> ); } - -function Check({ - label, - checked, - onChange, -}: { - label: string; - checked: boolean; - onChange: (v: boolean) => void; -}) { - return ( - <label className="flex items-center gap-2 text-sm"> - <input - type="checkbox" - checked={checked} - onChange={(e) => onChange(e.target.checked)} - className="h-4 w-4" - /> - <span>{label}</span> - </label> - ); -} diff --git a/editor/app/widget/components/MonitorWidget.tsx b/editor/app/widget/components/MonitorWidget.tsx @@ -9,7 +9,10 @@ import type { import type { RunningJobsListItem } from "../../jobs/components/RunningJobsList"; import { jobKindLabel } from "../../jobs/jobKindLabels"; import type { WorkersPayload, WorkerView } from "../../workers/components/WorkersView"; -import type { WidgetConfig } from "../lib/config"; +import { InlineActionButton } from "../../actionable/components/InlineActionButton"; +import type { WidgetActionablePayload } from "../../api/widget/actionable/route"; +import { buildWidgetQuery, type WidgetConfig } from "../lib/config"; +import { WidgetConfigForm } from "./WidgetConfigForm"; import { WidgetControls } from "./WidgetControls"; // Read-only monitor widget. Reuses the existing ~1s poll pattern from @@ -61,7 +64,11 @@ function usePolledPayload<T>( if (!cancelled) timer = setTimeout(tick, pollMs); } } - timer = setTimeout(tick, pollMs); + // Fetch immediately on (re)subscribe, then poll on the interval. Sections + // seeded from the server (jobs/workers) get a harmless refresh; those with a + // null initial (cleanable/actionable) render on first tick instead of after + // a full interval — matters most for the actionable strip's 15s cadence. + void tick(); return () => { cancelled = true; if (timer) clearTimeout(timer); @@ -71,7 +78,7 @@ function usePolledPayload<T>( } export function MonitorWidget({ - config, + config: initialConfig, initialJobs, initialWorkers, }: { @@ -79,6 +86,14 @@ export function MonitorWidget({ initialJobs: ActiveJobsPayload | null; initialWorkers: WorkersPayload | null; }) { + // The URL (parsed into `initialConfig` server-side) is authoritative, so we + // seed state from the prop only — never localStorage — to keep the first + // client render byte-identical to the server's. The in-widget settings gear + // edits this state and mirrors it back into the address bar (see `patch`), so + // a reload re-parses the same config. + const [config, setConfig] = useState<WidgetConfig>(initialConfig); + const [settingsOpen, setSettingsOpen] = useState(false); + const pollMs = config.pollSeconds * 1000; // The controls row needs live `paused` state, so poll workers whenever either // the Workers section or the controls are shown. @@ -102,6 +117,27 @@ export function MonitorWidget({ pollMs, null, ); + // The worklist changes on job completions (minutes), not seconds, and reading + // every channel snapshot each tick is heavier than the other endpoints — so + // poll it no faster than every 15s regardless of the configured cadence. + const { data: actionablePayload } = usePolledPayload<WidgetActionablePayload>( + "/api/widget/actionable", + config.actionable, + Math.max(pollMs, 15000), + null, + ); + + // Apply a config change and mirror it into the address bar so the widget is + // self-describing: a reload re-parses the same config and the link stays + // copyable. `history.replaceState` (not router.replace) avoids a server + // round-trip and the global AutoRefresh race. An all-default config drops the + // query entirely. + function patch(p: Partial<WidgetConfig>) { + const next = { ...config, ...p }; + setConfig(next); + const q = buildWidgetQuery(next); + window.history.replaceState(null, "", q ? `?${q}` : window.location.pathname); + } const jobs = (jobsPayload?.jobs ?? []).filter( (j) => !config.channel || j.channelSlug === config.channel, @@ -109,23 +145,44 @@ export function MonitorWidget({ const workers = workersPayload?.workers ?? []; const disk = jobsPayload?.disk; + const actionableChannels = actionablePayload?.channels ?? []; + const nothingActive = (!config.jobs || jobs.length === 0) && (!config.workers || workers.every((w) => !w.busy)); + // The settings gear + its in-place overlay. Shared by the idle and full + // renders so you can reconfigure a collapsed widget too. Positioned against + // the widget's own relative container (or the viewport, for the overlay). + const settingsLayer = ( + <SettingsLayer + enabled={config.settings} + open={settingsOpen} + onToggle={() => setSettingsOpen((o) => !o)} + onClose={() => setSettingsOpen(false)} + config={config} + onChange={patch} + /> + ); + // A low-disk warning is worth showing even when nothing is running, since it // explains why no downloads start — so it overrides hideIdle. Interactive // controls also stay visible when idle (you may want to pause preemptively). if (config.hideIdle && nothingActive && !disk?.low && !config.controls) { return ( - <div className="p-2 text-xs text-muted-foreground" aria-label="monitor idle"> + <div + className="relative p-2 text-xs text-muted-foreground" + aria-label="monitor idle" + > Idle + {settingsLayer} </div> ); } return ( - <div className="flex flex-col gap-3 p-2 text-foreground"> + <div className="relative flex flex-col gap-3 p-2 text-foreground"> + {settingsLayer} {config.controls && ( <WidgetControls paused={workersPayload?.paused ?? false} @@ -154,6 +211,13 @@ export function MonitorWidget({ eta={config.eta} /> )} + {config.actionable && ( + <ActionableStrip + channels={actionableChannels} + showTitle={config.showTitles} + interactive={config.controls} + /> + )} </div> ); } @@ -291,6 +355,92 @@ function CleanableStrip({ bytes }: { bytes: number }) { ); } +// Compact "needs work" list: channels with videos to download or transcribe, +// echoing the ↓ (download) / ✎ (transcribe) verbs used by the task bars so the +// worklist reads in the same language. Read-only counts by default; when the +// widget is interactive (controls=1), each row gains the same per-channel +// Download/Transcribe buttons as the /actionable page. +const ACTIONABLE_LIMIT = 6; + +function ActionableStrip({ + channels, + showTitle, + interactive, +}: { + channels: WidgetActionablePayload["channels"]; + showTitle: boolean; + interactive: boolean; +}) { + const shown = channels.slice(0, ACTIONABLE_LIMIT); + const overflow = channels.length - shown.length; + return ( + <section aria-label="Needs work" className="flex flex-col gap-1.5"> + {showTitle && ( + <div className="flex items-baseline gap-2"> + <h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> + Needs work + </h2> + <span className="text-xs text-muted-foreground"> + {channels.length} {channels.length === 1 ? "channel" : "channels"} + </span> + </div> + )} + {channels.length === 0 ? ( + <p className="text-xs text-muted-foreground">Everything&apos;s handled.</p> + ) : ( + <ul className="flex flex-col gap-1.5"> + {shown.map((c) => ( + <li + key={c.slug} + className="flex flex-col gap-1 rounded border border-border bg-card px-2 py-1" + > + <div className="flex items-center justify-between gap-2"> + <span + className="font-mono text-xs truncate" + title={c.slug} + > + {c.slug} + </span> + <span className="flex shrink-0 items-center gap-1"> + {c.undownloaded > 0 && ( + <span className="rounded px-1.5 py-0.5 text-[10px] tabular-nums bg-warning-soft text-warning"> + ↓ {c.undownloaded} + </span> + )} + {c.untranscribed > 0 && ( + <span className="rounded px-1.5 py-0.5 text-[10px] tabular-nums bg-info-soft text-info"> + ✎ {c.untranscribed} + </span> + )} + </span> + </div> + {interactive && ( + <div className="flex flex-wrap items-center gap-1.5"> + {c.undownloaded > 0 && ( + <InlineActionButton + variant={{ kind: "downloadMissing", slug: c.slug }} + /> + )} + {c.untranscribed > 0 && ( + <InlineActionButton + variant={{ kind: "transcribeMissing", slug: c.slug }} + /> + )} + </div> + )} + </li> + ))} + {overflow > 0 && ( + <li className="text-xs text-muted-foreground"> + +{overflow} more {overflow === 1 ? "channel" : "channels"} + </li> + )} + </ul> + )} + </section> + ); +} + function ActiveJobsStrip({ jobs, showTitle, @@ -538,3 +688,69 @@ function TaskBar({ </div> ); } + +// The settings gear and its in-place config overlay. The gear anchors to the +// widget's own (relative) container; the overlay covers the whole widget window +// (fixed inset-0) and hosts the same form the builder uses, so a pinned widget +// can be reconfigured without opening the full builder page. Rendering nothing +// when the gear is disabled keeps hooks out of this component entirely. +function SettingsLayer({ + enabled, + open, + onToggle, + onClose, + config, + onChange, +}: { + enabled: boolean; + open: boolean; + onToggle: () => void; + onClose: () => void; + config: WidgetConfig; + onChange: (patch: Partial<WidgetConfig>) => void; +}) { + if (!enabled) return null; + return ( + <> + <button + type="button" + aria-label="Settings" + aria-expanded={open} + onClick={onToggle} + className="absolute right-1 top-1 z-10 flex h-6 w-6 items-center justify-center rounded border border-border bg-card/80 text-muted-foreground backdrop-blur hover:bg-muted hover:text-foreground" + > + <span aria-hidden className="text-sm leading-none"> + ⚙ + </span> + </button> + {open && ( + <div + role="dialog" + aria-label="Widget settings" + className="fixed inset-0 z-50 overflow-auto bg-background p-3 text-foreground" + > + <div className="mb-3 flex items-center justify-between"> + <h2 className="text-sm font-semibold">Widget settings</h2> + <button + type="button" + aria-label="Close settings" + onClick={onClose} + className="flex h-6 w-6 items-center justify-center rounded border border-border hover:bg-muted" + > + <span aria-hidden className="text-sm leading-none"> + ✕ + </span> + </button> + </div> + <form className="flex flex-col gap-4"> + <WidgetConfigForm config={config} onChange={onChange} /> + </form> + <p className="mt-3 text-xs text-muted-foreground"> + Changes apply live and update this widget&apos;s link — reload keeps + them. + </p> + </div> + )} + </> + ); +} diff --git a/editor/app/widget/components/WidgetConfigForm.tsx b/editor/app/widget/components/WidgetConfigForm.tsx @@ -0,0 +1,146 @@ +"use client"; + +import type { WidgetConfig } from "../lib/config"; + +// The widget's configuration form — the section/display checkboxes plus the +// channel and poll-interval inputs. Shared by the /widget/builder page (which +// wraps it with the link/copy/preview UI) and the in-widget settings overlay +// (which lets you reconfigure a pinned widget in place). Callers own the +// surrounding <form> element. +export function WidgetConfigForm({ + config, + onChange, +}: { + config: WidgetConfig; + onChange: (patch: Partial<WidgetConfig>) => void; +}) { + return ( + <> + <fieldset className="flex flex-col gap-2"> + <legend className="text-sm font-medium mb-1">Sections</legend> + <Check + label="Workers" + checked={config.workers} + onChange={(v) => onChange({ workers: v })} + /> + <Check + label="Active jobs" + checked={config.jobs} + onChange={(v) => onChange({ jobs: v })} + /> + <Check + label="Channels needing work" + checked={config.actionable} + onChange={(v) => onChange({ actionable: v })} + /> + </fieldset> + + <fieldset className="flex flex-col gap-2"> + <legend className="text-sm font-medium mb-1">Display</legend> + <Check + label="Compact (hide per-task detail)" + checked={config.compact} + onChange={(v) => onChange({ compact: v })} + /> + <Check + label="Show batch progress bar" + checked={config.jobBar} + onChange={(v) => onChange({ jobBar: v })} + /> + <Check + label="Show progress in job heading" + checked={config.headingProgress} + onChange={(v) => onChange({ headingProgress: v })} + /> + <Check + label="Show time estimates (ETA)" + checked={config.eta} + onChange={(v) => onChange({ eta: v })} + /> + <Check + label="Show disk indicator" + checked={config.disk} + onChange={(v) => onChange({ disk: v })} + /> + <Check + label="Show cleanable indicator" + checked={config.cleanable} + onChange={(v) => onChange({ cleanable: v })} + /> + <Check + label="Show worker names" + checked={config.workerLabels} + onChange={(v) => onChange({ workerLabels: v })} + /> + <Check + label="Show section titles" + checked={config.showTitles} + onChange={(v) => onChange({ showTitles: v })} + /> + <Check + label="Hide when idle" + checked={config.hideIdle} + onChange={(v) => onChange({ hideIdle: v })} + /> + <Check + label="Show settings gear" + checked={config.settings} + onChange={(v) => onChange({ settings: v })} + /> + <Check + label="Show control buttons (pause, drain, retry, per-channel actions)" + checked={config.controls} + onChange={(v) => onChange({ controls: v })} + /> + </fieldset> + + <label className="flex flex-col gap-1 text-sm"> + <span className="font-medium">Channel filter (slug, optional)</span> + <input + type="text" + value={config.channel ?? ""} + onChange={(e) => onChange({ channel: e.target.value || undefined })} + placeholder="all channels" + className="rounded border border-border bg-card px-2 py-1" + /> + </label> + + <label className="flex flex-col gap-1 text-sm"> + <span className="font-medium">Poll interval (seconds)</span> + <input + type="number" + min={1} + value={config.pollSeconds} + onChange={(e) => + onChange({ + pollSeconds: Math.max(1, Math.round(Number(e.target.value) || 1)), + }) + } + className="rounded border border-border bg-card px-2 py-1 w-24" + /> + </label> + </> + ); +} + +function Check({ + label, + checked, + onChange, +}: { + label: string; + checked: boolean; + onChange: (v: boolean) => void; +}) { + return ( + <label className="flex items-center gap-2 text-sm"> + <input + type="checkbox" + checked={checked} + onChange={(e) => onChange(e.target.checked)} + className="h-4 w-4" + /> + <span>{label}</span> + </label> + ); +} diff --git a/editor/app/widget/components/WidgetControls.tsx b/editor/app/widget/components/WidgetControls.tsx @@ -2,13 +2,14 @@ import { PauseTranscriptionsButton } from "../../jobs/components/PauseTranscriptionsButton"; import { DrainAllButton } from "../../jobs/components/DrainAllButton"; +import { RetryAllFailedButton } from "../../jobs/components/RetryAllFailedButton"; // Opt-in interactive controls for the monitor widget (enabled with controls=1). // Reuses the same actions/buttons as the Workers and Active Jobs pages so a -// pinned widget can free the GPU (pause) or wind work down (drain) without -// opening the full app. `onWorkersChange` refetches the widget's worker payload -// so the pause/resume label flips immediately instead of waiting for the next -// poll. +// pinned widget can free the GPU (pause), wind work down (drain), or recover +// failures (retry) without opening the full app. `onWorkersChange` refetches the +// widget's worker payload so the pause/resume label flips immediately instead of +// waiting for the next poll. export function WidgetControls({ paused, onWorkersChange, @@ -23,6 +24,7 @@ export function WidgetControls({ > <PauseTranscriptionsButton paused={paused} onChange={onWorkersChange} /> <DrainAllButton /> + <RetryAllFailedButton /> </section> ); } diff --git a/editor/app/widget/lib/config.ts b/editor/app/widget/lib/config.ts @@ -36,6 +36,12 @@ export type WidgetConfig = { // Opt-in interactive controls (Pause/Resume Transcriptions, Drain all). Off by // default — the widget stays read-only unless this is enabled. controls: boolean; + // Show the "Needs work" strip — a compact list of channels with videos to + // download or transcribe. Off by default (mirrors the opt-in cleanable strip). + actionable: boolean; + // Show the in-widget settings gear that opens the config overlay in place. On + // by default; turn off to bake a locked-down shared/embedded link. + settings: boolean; }; export const WIDGET_DEFAULTS: WidgetConfig = { @@ -53,6 +59,8 @@ export const WIDGET_DEFAULTS: WidgetConfig = { cleanable: false, workerLabels: true, controls: false, + actionable: false, + settings: true, }; // Next's searchParams give each key as string | string[] | undefined. @@ -96,6 +104,8 @@ export function parseWidgetConfig(params: RawParams): WidgetConfig { cleanable: parseBool(params.clean, WIDGET_DEFAULTS.cleanable), workerLabels: parseBool(params.wnames, WIDGET_DEFAULTS.workerLabels), controls: parseBool(params.controls, WIDGET_DEFAULTS.controls), + actionable: parseBool(params.act, WIDGET_DEFAULTS.actionable), + settings: parseBool(params.gear, WIDGET_DEFAULTS.settings), }; } @@ -127,5 +137,9 @@ export function buildWidgetQuery(config: WidgetConfig): string { sp.set("wnames", config.workerLabels ? "1" : "0"); if (config.controls !== WIDGET_DEFAULTS.controls) sp.set("controls", config.controls ? "1" : "0"); + if (config.actionable !== WIDGET_DEFAULTS.actionable) + sp.set("act", config.actionable ? "1" : "0"); + if (config.settings !== WIDGET_DEFAULTS.settings) + sp.set("gear", config.settings ? "1" : "0"); return sp.toString(); } diff --git a/editor/e2e/widget.spec.ts b/editor/e2e/widget.spec.ts @@ -1,7 +1,9 @@ // The monitor widget (/widget) and its builder (/widget/builder). The widget is // meant to embed in a small pinned window or iframe: the app shell (sidebar, -// command palette) is stripped on exactly /widget. It is read-only by default, -// but `controls=1` opts into interactive Pause/Resume + Drain buttons. The +// command palette) is stripped on exactly /widget. It is read-only by default +// (aside from an in-place settings gear), but `controls=1` opts into interactive +// Pause/Resume + Drain + Retry buttons, `act=1` shows a "needs work" channel +// list, and the two combine into per-channel Download/Transcribe actions. The // builder lives inside the normal shell and composes links. import { test, expect } from "@playwright/test"; @@ -19,7 +21,7 @@ test.beforeEach(async () => { await writeSettings(TWO_WORKERS); }); -test("the bare widget has no sidebar and no controls", async ({ page }) => { +test("the bare widget has no sidebar and stays read-only", async ({ page }) => { await page.goto("/widget"); // Chrome is stripped: no sidebar nav. @@ -31,11 +33,46 @@ test("the bare widget has no sidebar and no controls", async ({ page }) => { await expect(page.getByLabel("worker GPU idle")).toBeVisible(); await expect(page.getByLabel("worker CPU idle")).toBeVisible(); - // Read-only: no action buttons of any kind. - await expect(page.getByRole("button")).toHaveCount(0); + // The settings gear ships on by default, but nothing mutates worker/job state: + // no Pause/Resume/Drain/Retry controls unless controls=1. + await expect(page.getByRole("button", { name: "Settings" })).toBeVisible(); + await expect( + page.getByRole("button", { name: "Pause Transcriptions" }), + ).toHaveCount(0); + await expect(page.getByRole("button", { name: "Drain all" })).toHaveCount(0); +}); + +test("gear=0 hides the settings gear", async ({ page }) => { + await page.goto("/widget?gear=0"); + await expect(page.getByRole("heading", { name: "Workers" })).toBeVisible(); + // The gear is gone, so a locked-down link exposes no settings affordance. + // (Scoped to the gear rather than all buttons — `next dev` injects its own + // Dev Tools button, absent from a production build.) + await expect(page.getByRole("button", { name: "Settings" })).toHaveCount(0); +}); + +test("the settings gear opens an in-place overlay that edits the widget live", async ({ + page, +}) => { + await page.goto("/widget"); + + await page.getByRole("button", { name: "Settings" }).click(); + const dialog = page.getByRole("dialog", { name: "Widget settings" }); + await expect(dialog).toBeVisible(); + + // Toggling a section in the overlay mirrors into the address bar immediately + // (history.replaceState) so a reload preserves it and the link stays copyable. + await dialog.getByRole("checkbox", { name: "Workers" }).uncheck(); + await expect(page).toHaveURL(/[?&]workers=0(&|$)/); + + // Close the overlay and confirm the change is applied to the live widget. + await page.getByRole("button", { name: "Close settings" }).click(); + await expect(dialog).toHaveCount(0); + await expect(page.getByRole("heading", { name: "Workers" })).toHaveCount(0); + await expect(page.getByRole("heading", { name: "Active" })).toBeVisible(); }); -test("controls=1 adds interactive Pause/Resume and Drain buttons", async ({ +test("controls=1 adds interactive Pause/Resume, Drain, and Retry buttons", async ({ page, }) => { await page.goto("/widget?controls=1"); @@ -44,6 +81,9 @@ test("controls=1 adds interactive Pause/Resume and Drain buttons", async ({ const pause = page.getByRole("button", { name: "Pause Transcriptions" }); await expect(pause).toBeVisible(); await expect(page.getByRole("button", { name: "Drain all" })).toBeVisible(); + await expect( + page.getByRole("button", { name: "Retry all failed" }), + ).toBeVisible(); // Pausing flips the toggle to Resume (the widget refetches workers on change). await pause.click(); @@ -69,6 +109,39 @@ test("section params gate what renders", async ({ page }) => { await expect(page.getByRole("heading", { name: "Workers" })).toHaveCount(0); }); +test("act=1 shows a compact needs-work list of channels", async ({ page }) => { + await resetData("youtube-with-playlist"); + // Visiting the channel detail page auto-generates the snapshot the list reads. + await page.goto("/channels/test-youtube"); + + await page.goto("/widget?act=1"); + const list = page.getByRole("region", { name: "Needs work" }); + await expect(list).toBeVisible({ timeout: 10_000 }); + // youtube-with-playlist leaves 4 videos undownloaded (↓), none awaiting + // transcription — the strip echoes the task-bar download verb. + await expect(list).toContainText("test-youtube"); + await expect(list).toContainText("↓ 4"); +}); + +test("act=1&controls=1 gives the list per-channel Download actions", async ({ + page, +}) => { + await resetData("youtube-with-playlist"); + await page.goto("/channels/test-youtube"); // auto-generate snapshot + + await page.goto("/widget?act=1&controls=1"); + const download = page.getByRole("button", { + name: "download missing test-youtube", + }); + await expect(download).toBeVisible({ timeout: 10_000 }); + + // Reuses the /actionable inline action: clicking queues a download-missing job. + await download.click(); + await expect( + page.getByLabel("download missing test-youtube job"), + ).toBeVisible({ timeout: 10_000 }); +}); + test("the builder keeps the shell and composes a link", async ({ page }) => { await page.goto("/widget/builder"); @@ -92,7 +165,7 @@ test("the builder keeps the shell and composes a link", async ({ page }) => { const popupPromise = page.waitForEvent("popup"); await page.getByRole("button", { name: "Open popup" }).click(); const popup = await popupPromise; - await expect(popup).toHaveURL(/\/widget\?workers=0$/); + await expect(popup).toHaveURL(/\/widget\?workers=0(&|$)/); await popup.close(); }); @@ -124,8 +197,16 @@ test("display toggles serialize into the link and preview", async ({ page }) => await expect(url).toHaveValue(/[?&]wnames=0(&|$)/); await expect(preview).toHaveAttribute("src", /[?&]wnames=0(&|$)/); + // New flags: the needs-work list (act) and the settings gear off-switch (gear). + await page.getByRole("checkbox", { name: "Channels needing work" }).check(); + await expect(url).toHaveValue(/[?&]act=1(&|$)/); + await expect(preview).toHaveAttribute("src", /[?&]act=1(&|$)/); + + await page.getByRole("checkbox", { name: "Show settings gear" }).uncheck(); + await expect(url).toHaveValue(/[?&]gear=0(&|$)/); + await page - .getByRole("checkbox", { name: "Show control buttons (pause, drain)" }) + .getByRole("checkbox", { name: "Show control buttons" }) .check(); await expect(url).toHaveValue(/[?&]controls=1(&|$)/); await expect(preview).toHaveAttribute("src", /[?&]controls=1(&|$)/); diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md @@ -1,10 +1,15 @@ # Changelog ## [Unreleased] -- **A new Downloads page lets you take a channel's archive with you.** Reachable from the header nav and footer (shown only when a build actually produced downloadable archives), `/downloads` lists the site's transcript and live-chat bundles as per-channel `.zip` downloads, each printing its video count and file size. The zips are regenerated on every build. - **Big archives are no longer dropped — they overflow to object storage.** Cloudflare Pages rejects any single asset over 25 MB, so oversize channel zips (a busy channel's live-chat log can be hundreds of MB) used to show as "too large to host." When an R2 overflow bucket is configured, those archives now upload there on deploy and the Downloads page links straight to them. Without a bucket configured, the old "unavailable" behavior stands. - **Combined "whole site" archives were removed** in favor of per-channel bundles — they duplicated the per-channel content and were always the first to blow past the size cap. - **The Duplicates page can be turned off per site, and hides itself when empty.** Sites can opt out of the cross-channel duplicate-shorts page from their editor settings, and even when it's on, the header link and page now hide automatically when a site has no detected duplicates. + +## [0.6.2] - 2026-07-04 +- **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). ## [0.6.0] - 2026-07-02