Archilyzer · Source

archilyzer

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

commit 138c6d3f81b993e5f6b17456199438441db764b1
parent 6a5b22b3ef396a123ae7cb19ca98263470da7267
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  1 Oct 2026 01:21:27 -0400

editor: every other action form keeps what was typed — the operation settings, sync, drive timing, hub, cut release, saved videos, channels, video dir

The values contract and the seeding helpers from the previous commit, on the
rest of the forms the inventory found: AttributionSettingsForm,
DiarizationSettingsForm, DigestSettingsForm, LaneSettingsForm,
SchedulerSettingsForm, HealthTimingForm, HomepageConfigForm, CutReleaseForm,
SavedVideosControls' checkbox, ChannelForm (create and edit), RenameChannelForm,
DeleteChannelForm and DeleteVideoDirSection. Each action captures the submitted
values at its top and returns them with every refusal, validation and caught
failure alike; a success returns what it did.

Two are shaped differently, and say so where they are:
- DigestSettingsForm's sections are one name posted once per ticked box, which
  `values` (each name's first value) cannot carry. They are controlled now
  (ControlledCheck), re-read from the stored list after a success; the posted
  shape is unchanged.
- ChannelForm's edit-mode handling radios seed from the value posted, not from
  the name's presence. Create mode's controlled handling radios and platform
  select are ControlledCheck/ControlledSelect: they post, and a plain
  controlled one posted what it mounted with after a refusal.

ChannelForm's own Field folds into components/forms/Field.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Meditor/app/channels/[slug]/videos/[id]/components/cards/DeleteVideoDirSection.tsx | 5++++-
Meditor/app/channels/[slug]/videos/[id]/videoActions.ts | 8++++++--
Meditor/app/channels/actions.ts | 48++++++++++++++++++++++++++++++------------------
Meditor/app/channels/components/ChannelForm.tsx | 198+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Meditor/app/channels/components/ChannelFormClient.tsx | 1+
Meditor/app/channels/components/DeleteChannelForm.tsx | 5++++-
Meditor/app/channels/components/RenameChannelForm.tsx | 9+++++++--
Meditor/app/operations/components/settings/AttributionSettingsForm.tsx | 30+++++++++++++++++++++++-------
Meditor/app/operations/components/settings/DiarizationSettingsForm.tsx | 38+++++++++++++++++++++++++++++---------
Meditor/app/operations/components/settings/DigestSettingsForm.tsx | 71++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Meditor/app/operations/components/settings/LaneSettingsForm.tsx | 10++++++++--
Meditor/app/operations/components/sync/SchedulerSettingsForm.tsx | 12+++++++++++-
Meditor/app/operations/settingsActions.ts | 19++++++++++++++-----
Meditor/app/saved-videos/backupActions.ts | 8++++++--
Meditor/app/saved-videos/components/SavedVideosControls.tsx | 3++-
Meditor/app/scheduler/actions.ts | 18+++++++++++++-----
Meditor/app/sites/components/CutReleaseForm.tsx | 9++++++---
Meditor/app/sites/components/HomepageConfigForm.tsx | 40++++++++++++++++++++++++++++------------
Meditor/app/sites/lib/cutReleaseAction.ts | 14++++++++------
Meditor/app/sites/lib/homepageActions.ts | 20++++++++++++++++----
Meditor/app/storage/actions.ts | 10+++++++---
Meditor/app/storage/components/HealthTimingForm.tsx | 6++++--
22 files changed, 395 insertions(+), 187 deletions(-)

diff --git a/editor/app/channels/[slug]/videos/[id]/components/cards/DeleteVideoDirSection.tsx b/editor/app/channels/[slug]/videos/[id]/components/cards/DeleteVideoDirSection.tsx @@ -5,6 +5,7 @@ import { deleteVideoDirAction, type DeleteDirActionResult, } from "../../videoActions"; +import { SeededInput } from "../../../../../../components/forms/Field"; export function DeleteVideoDirSection({ slug, @@ -29,8 +30,10 @@ export function DeleteVideoDirSection({ aria-label={`delete video directory ${videoId}`} className="flex flex-wrap items-start gap-2" > - <input + <SeededInput + state={state} name="confirmId" + initial="" required placeholder={videoId} aria-label="confirm video id" diff --git a/editor/app/channels/[slug]/videos/[id]/videoActions.ts b/editor/app/channels/[slug]/videos/[id]/videoActions.ts @@ -83,6 +83,7 @@ import { requestChannelSnapshot } from "yt-dlp-transcript-common/jobs/snapshotSc import { makeTaskTracker } from "yt-dlp-transcript-common/jobs/taskHooks"; import { fixIncompleteTranscriptOne } from "../../lib/fixIncompleteTranscript"; import { writeFileAtomic, writeJsonAtomic } from "yt-dlp-transcript-common/lib/jsonFile-server"; +import { formValues, type FormErrorState } from "../../../../lib/formState"; function videoQueueKey(config: ChannelConfig, override: string | undefined): string { return resolveQueueKey(downloadQueueKey(config), override); @@ -544,7 +545,8 @@ export async function setPrimaryTranscriptAction( return { ok: true }; } -export type DeleteDirActionResult = { error: string } | undefined; +// A refusal carries what was submitted (lib/formState.ts). +export type DeleteDirActionResult = FormErrorState; // Validated, non-redirecting directory delete shared by the single-video form // action and the bulk action. Pure filesystem op — queues no job, so it never @@ -604,14 +606,16 @@ export async function deleteVideoDirAction( _prev: DeleteDirActionResult, formData: FormData, ): Promise<DeleteDirActionResult> { + const values = formValues(formData); const confirm = String(formData.get("confirmId") ?? "").trim(); if (confirm !== videoId) { return { error: `Type the video id "${videoId}" exactly to confirm deletion`, + values, }; } const result = await deleteOneVideoDir(slug, videoId); - if (!result.ok) return { error: result.error }; + if (!result.ok) return { error: result.error, values }; revalidatePath(`/channels/${slug}`); requestChannelSnapshot(getPaths(), slug); redirect(`/channels/${slug}`); diff --git a/editor/app/channels/actions.ts b/editor/app/channels/actions.ts @@ -80,8 +80,11 @@ import { import { queueForSlugs, type QueueOutcome } from "./lib/queueForSlugs"; import { storePlaylistAction, syncAction } from "./[slug]/pipelineActions"; import { fetchPostsAction } from "./[slug]/socialActions"; +import { formValues, type FormErrorState } from "../lib/formState"; -export type ActionResult = { error: string } | undefined; +// `undefined` is success; a refusal says why, and a form's refusal carries +// what was submitted so the form keeps what was typed (lib/formState.ts). +export type ActionResult = FormErrorState; // Import-for-side-effect, deferred to call time so the heavy fetcher modules // never enter the module graph a client component imports. @@ -182,20 +185,22 @@ export async function createChannelAction( _prev: ActionResult, formData: FormData, ): Promise<ActionResult> { + const values = formValues(formData); let parsed; try { parsed = parseChannelForm(formData); } catch (e) { - return { error: (e as Error).message }; + return { error: (e as Error).message, values }; } const { name, config } = parsed; const slug = parsed.slug || slugify(name); if (!slug) { - return { error: "Could not derive a slug from the name" }; + return { error: "Could not derive a slug from the name", values }; } if (!isValidChannelSlug(slug)) { return { error: `"${slug}" is not a valid slug (letters, digits, ".", "_", "-"; must start with a letter or digit)`, + values, }; } const paths = getPaths(); @@ -212,10 +217,10 @@ export async function createChannelAction( siteWrites = planSiteMembershipWrites(paths, slug, requests); } } catch (e) { - return { error: (e as Error).message }; + return { error: (e as Error).message, values }; } if (await channelExists(paths, slug)) { - return { error: `Channel "${slug}" already exists` }; + return { error: `Channel "${slug}" already exists`, values }; } await createChannel(paths, slug, config); // THE COMPILED TREES NAME THEIR CHANNELS. A channel created since the last @@ -234,6 +239,7 @@ export async function createChannelAction( // The channel itself was created; don't redirect as if nothing happened. return { error: `Channel "${slug}" was created, but updating site memberships failed: ${(e as Error).message}. Open its Configure panel to retry.`, + values, }; } // URL-first onboarding: unless opted out ("Fetch playlist now", default on), @@ -284,15 +290,16 @@ export async function updateChannelAction( _prev: ActionResult, formData: FormData, ): Promise<ActionResult> { + const values = formValues(formData); let parsed; try { parsed = parseChannelForm(formData); } catch (e) { - return { error: (e as Error).message }; + return { error: (e as Error).message, values }; } const paths = getPaths(); const existing = await readChannelConfig(paths, slug); - if (!existing) return { error: `Channel "${slug}" not found` }; + if (!existing) return { error: `Channel "${slug}" not found`, values }; // Plan the site-membership writes (Sites section) before touching anything, // so bad input errors out with no partial write. Null = field absent // (zero sites configured / legacy submit) → leave memberships alone. @@ -305,7 +312,7 @@ export async function updateChannelAction( siteWrites = planSiteMembershipWrites(paths, slug, requests); } } catch (e) { - return { error: (e as Error).message }; + return { error: (e as Error).message, values }; } // The form parser only emits keys whose form value is meaningful, so a // cleared input is absent from `parsed.config`. A plain spread would keep @@ -317,11 +324,11 @@ export async function updateChannelAction( const written = await patchChannelConfig(paths, slug, parsed.config, { unset: CHANNEL_FORM_FIELDS, }); - if (!written) return { error: `Channel "${slug}" not found` }; + if (!written) return { error: `Channel "${slug}" not found`, values }; try { await applySiteWrites(siteWrites, paths); } catch (e) { - return { error: (e as Error).message }; + return { error: (e as Error).message, values }; } // Config changes (e.g. audioFormat / handling) feed snapshot buckets, so // refresh the report through the global debounced scheduler. @@ -584,10 +591,12 @@ export async function deleteChannelAction( _prev: ActionResult, formData: FormData, ): Promise<ActionResult> { + const values = formValues(formData); const confirm = String(formData.get("confirmSlug") ?? "").trim(); if (confirm !== slug) { return { error: `Type the channel slug "${slug}" exactly to confirm deletion`, + values, }; } // THE RENAME'S GUARD, AND DELETE NEEDED IT MORE. Renaming while a job runs @@ -598,7 +607,7 @@ export async function deleteChannelAction( // races the writer for the tree and whichever loses reports an ENOENT nobody // asked about. const busy = channelMediaBusyReason(slug, "deleting it"); - if (busy) return { error: busy }; + if (busy) return { error: busy, values }; // THE OTHER REFUSAL REACHES THE FORM THE SAME WAY. `deleteChannel` THROWS // when `.relocating.json` is present — media in transition is not a channel // anyone may delete — and an uncaught throw from a server action is a @@ -609,7 +618,7 @@ export async function deleteChannelAction( try { await deleteChannel(getPaths(), slug); } catch (e) { - return { error: (e as Error).message }; + return { error: (e as Error).message, values }; } // Same reason as createChannelAction: the deleted channel keeps a leaf in // every compiled tree until something recompiles. A leaf matching nothing is @@ -635,29 +644,32 @@ export async function renameChannelAction( _prev: ActionResult, formData: FormData, ): Promise<ActionResult> { + const values = formValues(formData); const confirm = String(formData.get("confirmSlug") ?? "").trim(); if (confirm !== oldSlug) { return { error: `Type the channel slug "${oldSlug}" exactly to confirm the rename`, + values, }; } const newSlug = String(formData.get("newSlug") ?? "").trim(); if (!newSlug) { - return { error: "Enter a new slug" }; + return { error: "Enter a new slug", values }; } if (newSlug === oldSlug) { - return { error: "The new slug is the same as the current one" }; + return { error: "The new slug is the same as the current one", values }; } if (!isValidChannelSlug(newSlug)) { return { error: `"${newSlug}" is not a valid slug (letters, digits, ".", "_", "-"; must start with a letter or digit)`, + values, }; } const paths = getPaths(); const config = await readChannelConfig(paths, oldSlug); - if (!config) return { error: `Channel "${oldSlug}" not found` }; + if (!config) return { error: `Channel "${oldSlug}" not found`, values }; if (await channelExists(paths, newSlug)) { - return { error: `Channel "${newSlug}" already exists` }; + return { error: `Channel "${newSlug}" already exists`, values }; } // THE REGISTRY IS HALF THE TRUTH, and this check used to be the other half's @@ -668,13 +680,13 @@ export async function renameChannelAction( // under it. One question, one answer, the same sentence the Storage panel // and the bulk move say. const busy = channelMediaBusyReason(oldSlug, "renaming it"); - if (busy) return { error: busy }; + if (busy) return { error: busy, values }; let result; try { result = await renameChannel(paths, oldSlug, newSlug, config); } catch (e) { - return { error: (e as Error).message }; + return { error: (e as Error).message, values }; } // THE PRIORITY DOCUMENT KEYS BY SLUG, so it has to follow the rename or the // channel's tier, rank and per-operation overrides stay under a slug that no diff --git a/editor/app/channels/components/ChannelForm.tsx b/editor/app/channels/components/ChannelForm.tsx @@ -34,6 +34,20 @@ import { DurationField } from "../../components/DurationField"; import { handleFromAccountUrl } from "yt-dlp-transcript-common/social/fetchers"; import { probeChannelUrlAction } from "../actions"; import { + Field, + SeededInput, + SeededSelect, +} from "../../components/forms/Field"; +import { + ControlledCheck, + ControlledSelect, +} from "../../components/forms/Controlled"; +import { + seedChecked, + seedValue, + type SeedSource, +} from "../../lib/formState"; +import { SiteMembershipsSection, type InitialMembership, type SiteMembershipOption, @@ -92,6 +106,10 @@ type Props = { initial?: { slug: string; config: ChannelConfig }; submitLabel: string; errorMessage?: string; + // The form's action state (ChannelFormClient's useActionState): after a + // refused submit, every uncontrolled field seeds from what was submitted + // (lib/formState.ts). + state?: SeedSource; // All configured sites, for the Sites membership section. sites: SiteMembershipOption[]; // Edit mode: the channel's current site memberships. @@ -106,12 +124,20 @@ export function ChannelForm({ initial, submitLabel, errorMessage, + state, sites, initialMemberships, activeSiteId, }: Props) { const isEdit = !!initial; const c = initial?.config; + // Edit mode's handling radios are uncontrolled: a radio group seeds from the + // VALUE it posted, not from whether its name was there. + const editHandling = seedValue( + state, + "handling", + c?.handling === "transcribe" ? "transcribe" : "youtube", + ); // Controlled, so a surface that has to compare what is TYPED against what a // stored scan was run under can read them. (Nothing does yet; the metadata @@ -232,6 +258,7 @@ export function ChannelForm({ <Field label="Name" name="name" + state={state} required defaultValue={c?.name ?? ""} placeholder="Display name (e.g., TheQuartering)" @@ -254,6 +281,7 @@ export function ChannelForm({ <Field label="Slug" name="slug" + state={state} readOnly defaultValue={initial!.slug} hint="To change the slug, use Rename in the Danger zone below." @@ -292,7 +320,7 @@ export function ChannelForm({ type="radio" name="handling" value="youtube" - defaultChecked={c?.handling !== "transcribe"} + defaultChecked={editHandling === "youtube"} /> YouTube (auto-subtitles via yt-dlp) </label> @@ -301,7 +329,7 @@ export function ChannelForm({ type="radio" name="handling" value="transcribe" - defaultChecked={c?.handling === "transcribe"} + defaultChecked={editHandling === "transcribe"} /> Transcribe (download audio, run whisper-cpp) </label> @@ -309,15 +337,17 @@ export function ChannelForm({ <Field label="URL" name="url" + state={state} defaultValue={c?.url ?? ""} placeholder="https://www.youtube.com/@channel/videos" hint="Channel or playlist URL passed to yt-dlp." /> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Platform</span> - <select + <SeededSelect + state={state} name="platform" - defaultValue={c?.platform ?? ""} + initial={c?.platform ?? ""} className="rounded border border-border bg-card px-2 py-1 text-sm" > <option value="">Auto (detect from URL)</option> @@ -328,7 +358,7 @@ export function ChannelForm({ <option value="kick">Kick</option> <option value="twitter">X / Twitter (posts)</option> <option value="bluesky">Bluesky (posts)</option> - </select> + </SeededSelect> <span className="text-xs text-muted-foreground"> Used as the default job queue, so all channels on the same platform run sequentially. @@ -400,7 +430,7 @@ export function ChannelForm({ <fieldset className="flex flex-col gap-2"> <legend className="text-sm font-medium">Handling</legend> <label className="flex items-center gap-2 text-sm"> - <input + <ControlledCheck type="radio" name="handling" value="youtube" @@ -413,7 +443,7 @@ export function ChannelForm({ YouTube (auto-subtitles via yt-dlp) </label> <label className="flex items-center gap-2 text-sm"> - <input + <ControlledCheck type="radio" name="handling" value="transcribe" @@ -429,7 +459,7 @@ export function ChannelForm({ )} <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Platform</span> - <select + <ControlledSelect name="platform" value={platform} onChange={(e) => { @@ -447,7 +477,7 @@ export function ChannelForm({ <option value="kick">Kick</option> <option value="twitter">X / Twitter (posts)</option> <option value="bluesky">Bluesky (posts)</option> - </select> + </ControlledSelect> <span className="text-xs text-muted-foreground"> Used as the default job queue, so all channels on the same platform run sequentially. Leave on Auto for a host yt-dlp knows @@ -463,7 +493,7 @@ export function ChannelForm({ <input type="checkbox" name="fetchPostsNow" - defaultChecked + defaultChecked={seedChecked(state, "fetchPostsNow", true)} className="mt-1" /> <span className="flex flex-col gap-0.5"> @@ -480,7 +510,7 @@ export function ChannelForm({ <input type="checkbox" name="fetchPlaylist" - defaultChecked + defaultChecked={seedChecked(state, "fetchPlaylist", true)} className="mt-1" /> <span className="flex flex-col gap-0.5"> @@ -496,6 +526,11 @@ export function ChannelForm({ <input type="checkbox" name="prioritizeDownload" + defaultChecked={seedChecked( + state, + "prioritizeDownload", + false, + )} className="mt-1" /> <span className="flex flex-col gap-0.5"> @@ -557,22 +592,24 @@ export function ChannelForm({ Audio format{" "} <span className="text-muted-foreground font-normal">(transcribe only)</span> </span> - <select + <SeededSelect + state={state} name="audioFormat" - defaultValue={c?.audioFormat ?? ""} + initial={c?.audioFormat ?? ""} className="rounded border border-border bg-card px-2 py-1 text-sm" > <option value="">Default (mp3)</option> <option value="m4a">m4a</option> <option value="mp3">mp3</option> <option value="opus">opus</option> - </select> + </SeededSelect> </label> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Download format</span> - <select + <SeededSelect + state={state} name="downloadFormat" - defaultValue={c?.downloadFormat ?? ""} + initial={c?.downloadFormat ?? ""} className="rounded border border-border bg-card px-2 py-1 text-sm" > <option value="">Inherit (global default)</option> @@ -581,7 +618,7 @@ export function ChannelForm({ {DOWNLOAD_FORMAT_LABELS[p]} </option> ))} - </select> + </SeededSelect> <span className="text-xs text-muted-foreground"> The yt-dlp <code>-f</code> selector for this channel&apos;s downloads. Leave on Inherit to use the global default; choose{" "} @@ -593,7 +630,11 @@ export function ChannelForm({ <input type="checkbox" name="keepSourceVideo" - defaultChecked={c?.keepSourceVideo === true} + defaultChecked={seedChecked( + state, + "keepSourceVideo", + c?.keepSourceVideo === true, + )} className="mt-1" /> <span className="flex flex-col gap-0.5"> @@ -610,15 +651,16 @@ export function ChannelForm({ </span> </span> </label> - <AudioCheckFields config={c} /> + <AudioCheckFields config={c} state={state} /> </Section> <Section title="Retention & persistence"> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Keep latest (source videos)</span> - <input + <SeededInput + state={state} type="number" name="keepLatest" - defaultValue={c?.keepLatest != null ? String(c.keepLatest) : ""} + initial={c?.keepLatest != null ? String(c.keepLatest) : ""} min={0} max={100000} placeholder="0 (off)" @@ -633,16 +675,17 @@ export function ChannelForm({ </label> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Extraction mode</span> - <select + <SeededSelect + state={state} name="extractionMode" - defaultValue={c?.extractionMode ?? ""} + initial={c?.extractionMode ?? ""} aria-label="extraction mode" className="rounded border border-border bg-card px-2 py-1 text-sm" > <option value="">Default (yt-dlp extracts)</option> <option value="ytdlp">yt-dlp (-x postprocessor)</option> <option value="app">App (download container, ffmpeg extracts)</option> - </select> + </SeededSelect> <span className="text-xs text-muted-foreground"> Who extracts audio for transcribe-handling downloads. App mode keeps the source container in hand so it can be persisted; the keep-latest @@ -652,6 +695,7 @@ export function ChannelForm({ <Field label="Saved-video store dir" name="savedVideosDir" + state={state} defaultValue={c?.savedVideosDir ?? ""} placeholder="(global default)" hint="Per-channel override for where this channel's persisted source videos live (e.g. a larger disk). Blank uses the global SAVED_VIDEOS_DIR default." @@ -694,7 +738,11 @@ export function ChannelForm({ </span> <textarea name="ytdlpExtraArgs" - defaultValue={(c?.ytdlpExtraArgs ?? []).join("\n")} + defaultValue={seedValue( + state, + "ytdlpExtraArgs", + (c?.ytdlpExtraArgs ?? []).join("\n"), + )} rows={4} className="rounded border border-border bg-card px-2 py-1 text-sm font-mono" /> @@ -719,7 +767,11 @@ export function ChannelForm({ <input type="checkbox" name="downloadFilterIncludeLivestreams" - defaultChecked={c?.downloadFilter?.includeLivestreams ?? false} + defaultChecked={seedChecked( + state, + "downloadFilterIncludeLivestreams", + c?.downloadFilter?.includeLivestreams ?? false, + )} className="mt-1" /> <span className="flex flex-col gap-0.5"> @@ -734,9 +786,10 @@ export function ChannelForm({ </label> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Filtered-out livestreams</span> - <select + <SeededSelect + state={state} name="downloadFilterRejectedLivestreams" - defaultValue={c?.downloadFilter?.rejectedLivestreams ?? "skip"} + initial={c?.downloadFilter?.rejectedLivestreams ?? "skip"} aria-label="filtered-out livestreams" className="rounded border border-border bg-card px-2 py-1 text-sm" > @@ -744,7 +797,7 @@ export function ChannelForm({ <option value="chat-only"> Keep the live chat — no audio, no transcript </option> - </select> + </SeededSelect> <span className="text-xs text-muted-foreground"> What to do with a livestream the filter above rejected. A multi-hour stream whose title says nothing is rarely worth its audio, but its @@ -756,15 +809,17 @@ export function ChannelForm({ <Field label="Cookies from browser" name="cookiesFromBrowser" + state={state} defaultValue={c?.cookiesFromBrowser ?? ""} placeholder="(inherit global)" hint="Per-channel override of the global yt-dlp --cookies-from-browser browser spec (e.g. firefox, chrome:Default). Blank inherits the global value from /settings." /> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Cookie mode</span> - <select + <SeededSelect + state={state} name="cookieMode" - defaultValue={c?.cookieMode ?? ""} + initial={c?.cookieMode ?? ""} aria-label="cookie mode" className="rounded border border-border bg-card px-2 py-1 text-sm" > @@ -778,7 +833,7 @@ export function ChannelForm({ <option value="defer"> Defer — never in normal runs; collect into "Needs cookies" </option> - </select> + </SeededSelect> <span className="text-xs text-muted-foreground"> When this channel&apos;s downloads use browser cookies. Leave on Inherit to use the global mode from /settings. Defer excludes @@ -794,10 +849,11 @@ export function ChannelForm({ (seconds, optional) </span> </span> - <input + <SeededInput + state={state} type="number" name="sleepBetweenDownloadsSeconds" - defaultValue={ + initial={ c?.sleepBetweenDownloadsSeconds != null ? String(c.sleepBetweenDownloadsSeconds) : "" @@ -931,7 +987,13 @@ function SyncIntervalField({ ); } -function AudioCheckFields({ config }: { config?: ChannelConfig }) { +function AudioCheckFields({ + config, + state, +}: { + config?: ChannelConfig; + state?: SeedSource; +}) { const ac = config?.audioCheck; const enabled = ac?.enabled === true; return ( @@ -940,7 +1002,7 @@ function AudioCheckFields({ config }: { config?: ChannelConfig }) { <input type="checkbox" name="audioCheckEnabled" - defaultChecked={enabled} + defaultChecked={seedChecked(state, "audioCheckEnabled", enabled)} aria-label="audio-check enabled" className="mt-1" /> @@ -964,10 +1026,11 @@ function AudioCheckFields({ config }: { config?: ChannelConfig }) { <span className="font-medium"> Check interval (seconds) </span> - <input + <SeededInput + state={state} type="number" name="audioCheckIntervalSeconds" - defaultValue={ac?.intervalSeconds ?? ""} + initial={String(ac?.intervalSeconds ?? "")} placeholder={String(AUDIO_CHECK_INTERVAL_DEFAULT_SECONDS)} min={AUDIO_CHECK_INTERVAL_MIN_SECONDS} max={AUDIO_CHECK_INTERVAL_MAX_SECONDS} @@ -979,10 +1042,11 @@ function AudioCheckFields({ config }: { config?: ChannelConfig }) { <span className="font-medium"> Max consecutive rollbacks </span> - <input + <SeededInput + state={state} type="number" name="audioCheckMaxRollbacks" - defaultValue={ac?.maxRollbacks ?? ""} + initial={String(ac?.maxRollbacks ?? "")} placeholder={String(AUDIO_CHECK_MAX_ROLLBACKS_DEFAULT)} min={AUDIO_CHECK_MAX_ROLLBACKS_MIN} max={AUDIO_CHECK_MAX_ROLLBACKS_MAX} @@ -998,10 +1062,11 @@ function AudioCheckFields({ config }: { config?: ChannelConfig }) { <span className="font-medium"> Copy timeout (seconds) </span> - <input + <SeededInput + state={state} type="number" name="audioCheckCopyTimeoutSeconds" - defaultValue={ac?.copyTimeoutSeconds ?? ""} + initial={String(ac?.copyTimeoutSeconds ?? "")} placeholder={String(AUDIO_CHECK_COPY_TIMEOUT_DEFAULT_SECONDS)} min={AUDIO_CHECK_COPY_TIMEOUT_MIN_SECONDS} max={AUDIO_CHECK_COPY_TIMEOUT_MAX_SECONDS} @@ -1018,7 +1083,11 @@ function AudioCheckFields({ config }: { config?: ChannelConfig }) { <input type="checkbox" name="audioCheckResumeDuringProbe" - defaultChecked={ac?.resumeDuringProbe ?? false} + defaultChecked={seedChecked( + state, + "audioCheckResumeDuringProbe", + ac?.resumeDuringProbe ?? false, + )} aria-label="audio-check resume during probe" className="mt-1" /> @@ -1036,46 +1105,3 @@ function AudioCheckFields({ config }: { config?: ChannelConfig }) { </div> ); } - -// `value` + `onChange` make the field CONTROLLED (the download-filter patterns -// are, so their live values can be read without touching the DOM). Omit both -// for the ordinary uncontrolled field. -function Field({ - label, - name, - defaultValue, - value, - onChange, - placeholder, - hint, - required, - readOnly, -}: { - label: string; - name: string; - defaultValue?: string; - value?: string; - onChange?: (v: string) => void; - placeholder?: string; - hint?: string; - required?: boolean; - readOnly?: boolean; -}) { - return ( - <label className="flex flex-col gap-1 text-sm"> - <span className="font-medium">{label}</span> - <input - type="text" - name={name} - {...(value === undefined - ? { defaultValue } - : { value, onChange: (e) => onChange?.(e.target.value) })} - placeholder={placeholder} - required={required} - readOnly={readOnly} - className={`rounded border border-border bg-card px-2 py-1 text-sm ${readOnly ? "opacity-60" : ""}`} - /> - {hint && <span className="text-xs text-muted-foreground">{hint}</span>} - </label> - ); -} diff --git a/editor/app/channels/components/ChannelFormClient.tsx b/editor/app/channels/components/ChannelFormClient.tsx @@ -52,6 +52,7 @@ export function ChannelFormClient({ initial={initial} submitLabel={submitLabel} errorMessage={state?.error} + state={state} sites={sites} initialMemberships={initialMemberships} activeSiteId={activeSiteId} diff --git a/editor/app/channels/components/DeleteChannelForm.tsx b/editor/app/channels/components/DeleteChannelForm.tsx @@ -2,6 +2,7 @@ import { useActionState } from "react"; import type { ActionResult } from "../actions"; +import { SeededInput } from "../../components/forms/Field"; type Props = { slug: string; @@ -35,8 +36,10 @@ export function DeleteChannelForm({ slug, action, busyReason = null }: Props) { </p> )} <div className="flex gap-2 items-start"> - <input + <SeededInput + state={state} name="confirmSlug" + initial="" required placeholder={slug} aria-label="confirm slug to delete" diff --git a/editor/app/channels/components/RenameChannelForm.tsx b/editor/app/channels/components/RenameChannelForm.tsx @@ -2,6 +2,7 @@ import { useActionState } from "react"; import type { ActionResult } from "../actions"; +import { SeededInput } from "../../components/forms/Field"; type Props = { slug: string; @@ -27,8 +28,10 @@ export function RenameChannelForm({ slug, action, busyReason = null }: Props) { </p> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">New slug</span> - <input + <SeededInput + state={state} name="newSlug" + initial="" required placeholder="new-channel-slug" aria-label="new slug" @@ -45,8 +48,10 @@ export function RenameChannelForm({ slug, action, busyReason = null }: Props) { </p> )} <div className="flex gap-2 items-start"> - <input + <SeededInput + state={state} name="confirmSlug" + initial="" required placeholder={slug} aria-label="confirm current slug" diff --git a/editor/app/operations/components/settings/AttributionSettingsForm.tsx b/editor/app/operations/components/settings/AttributionSettingsForm.tsx @@ -3,7 +3,8 @@ import { useActionState } from "react"; import type { SiteSettings } from "yt-dlp-transcript-common/lib/settings"; import type { DigestAppDescriptor } from "yt-dlp-transcript-common/lib/digestApps"; -import { Field } from "../../../components/forms/Field"; +import { Field, SeededSelect } from "../../../components/forms/Field"; +import { seedChecked } from "../../../lib/formState"; import { saveAttributionSettingsAction } from "../../settingsActions"; import type { SaveResult } from "../../../settings/actions"; @@ -41,7 +42,11 @@ export function AttributionSettingsForm({ <input type="checkbox" name="attributionEnabled" - defaultChecked={initial.enabled} + defaultChecked={seedChecked( + state, + "attributionEnabled", + initial.enabled, + )} className="mt-1" /> <span className="flex flex-col gap-1"> @@ -56,7 +61,11 @@ export function AttributionSettingsForm({ <input type="checkbox" name="attributionDiarized" - defaultChecked={initial.diarizedEnabled} + defaultChecked={seedChecked( + state, + "attributionDiarized", + initial.diarizedEnabled, + )} className="mt-1" /> <span className="flex flex-col gap-1"> @@ -75,7 +84,11 @@ export function AttributionSettingsForm({ <input type="checkbox" name="attributionTextOnly" - defaultChecked={initial.textOnlyEnabled} + defaultChecked={seedChecked( + state, + "attributionTextOnly", + initial.textOnlyEnabled, + )} className="mt-1" /> <span className="flex flex-col gap-1"> @@ -95,9 +108,10 @@ export function AttributionSettingsForm({ </label> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Engine</span> - <select + <SeededSelect + state={state} name="attributionAppId" - defaultValue={initial.appId} + initial={initial.appId} className="rounded border border-border bg-card px-2 py-1 text-sm" > {digestApps.map((a) => ( @@ -106,7 +120,7 @@ export function AttributionSettingsForm({ {a.metered ? " (metered)" : ""} </option> ))} - </select> + </SeededSelect> <span className="text-xs text-muted-foreground"> Uses the digest engines and their configuration, set on the Digest operation&apos;s page — same constrained-JSON workload, so there is @@ -116,12 +130,14 @@ export function AttributionSettingsForm({ <Field label="Model override" name="attributionModel" + state={state} defaultValue={initial.model} hint="Empty means the engine's own configured model. Set separately from the digest's so a digest bake-off does not invalidate every attribution record as a side effect — this value is part of what makes a record fresh." /> <Field label="Prompt generation" name="attributionPromptVersion" + state={state} defaultValue={String(initial.promptVersion)} type="number" hint="A record generated at an older number is stale and will be redone. Raise it to force a corpus-wide regeneration after changing the prompt; it cannot be set below the shipped value, because pinning it lower would freeze superseded output into the archive looking current." diff --git a/editor/app/operations/components/settings/DiarizationSettingsForm.tsx b/editor/app/operations/components/settings/DiarizationSettingsForm.tsx @@ -2,7 +2,8 @@ import { useActionState } from "react"; import type { SiteSettings } from "yt-dlp-transcript-common/lib/settings"; -import { Field } from "../../../components/forms/Field"; +import { Field, SeededSelect } from "../../../components/forms/Field"; +import { seedChecked } from "../../../lib/formState"; import { saveDiarizationSettingsAction } from "../../settingsActions"; import type { SaveResult } from "../../../settings/actions"; @@ -38,7 +39,11 @@ export function DiarizationSettingsForm({ <input type="checkbox" name="diarizationEnabled" - defaultChecked={initial.enabled} + defaultChecked={seedChecked( + state, + "diarizationEnabled", + initial.enabled, + )} className="mt-1" /> <span className="flex flex-col gap-1"> @@ -57,7 +62,11 @@ export function DiarizationSettingsForm({ <input type="checkbox" name="diarizationInlineAfterTranscribe" - defaultChecked={initial.inlineAfterTranscribe} + defaultChecked={seedChecked( + state, + "diarizationInlineAfterTranscribe", + initial.inlineAfterTranscribe, + )} className="mt-1" /> <span className="flex flex-col gap-1"> @@ -80,9 +89,10 @@ export function DiarizationSettingsForm({ </label> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Engine</span> - <select + <SeededSelect + state={state} name="diarizationEngine" - defaultValue={initial.engine} + initial={initial.engine} className="rounded border border-border bg-card px-2 py-1 text-sm" > <option value="sherpa-onnx"> @@ -91,7 +101,7 @@ export function DiarizationSettingsForm({ <option value="sortformer"> Sortformer — end-to-end, GPU or CPU </option> - </select> + </SeededSelect> <span className="text-xs text-muted-foreground"> <strong>sherpa-onnx</strong> groups voices that sound alike, and it splits far too eagerly: on a 13-minute video with one host it finds @@ -113,14 +123,15 @@ export function DiarizationSettingsForm({ </label> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Sortformer device</span> - <select + <SeededSelect + state={state} name="diarizationBackend" - defaultValue={initial.backend} + initial={initial.backend} className="rounded border border-border bg-card px-2 py-1 text-sm" > <option value="vulkan">Vulkan — the graphics card</option> <option value="cpu">CPU</option> - </select> + </SeededSelect> <span className="text-xs text-muted-foreground"> Ignored unless the engine above is Sortformer. Both produce{" "} <strong>identical</strong> speaker turns, so this only trades one @@ -134,36 +145,42 @@ export function DiarizationSettingsForm({ <Field label="Sortformer engine binary" name="diarizationSortformerBin" + state={state} defaultValue={initial.sortformerBin} hint="Absolute path to the diarize-file binary built by scripts/build-sortformer.sh. Empty means the Sortformer engine is not configured, and every run reports a skip rather than a failure." /> <Field label="Sortformer model (GGUF)" name="diarizationSortformerModel" + state={state} defaultValue={initial.sortformerModel} hint="Absolute path to the .gguf downloaded by scripts/build-sortformer.sh. Its filename is recorded in every sidecar, so changing the model is what marks earlier captures worth redoing." /> <Field label="Segmentation model (ONNX)" name="diarizationSegModel" + state={state} defaultValue={initial.segModel} hint="Absolute path to a pyannote segmentation-3.0 ONNX model. Empty means the lane is not configured and every run reports a skip rather than a failure." /> <Field label="Speaker-embedding model (ONNX)" name="diarizationEmbModel" + state={state} defaultValue={initial.embModel} hint="Absolute path to a speaker-embedding ONNX model (e.g. NeMo TitaNet)." /> <Field label="Python interpreter" name="diarizationPython" + state={state} defaultValue={initial.python} hint="Interpreter with sherpa-onnx installed. sherpa-onnx ships wheels only up to CPython 3.13, so on a 3.14 system this must point at a dedicated venv." /> <Field label="Clustering threshold" name="diarizationThreshold" + state={state} defaultValue={String(initial.threshold)} type="number" step="0.01" @@ -172,6 +189,7 @@ export function DiarizationSettingsForm({ <Field label="Engine threads" name="diarizationThreads" + state={state} defaultValue={String(initial.threads)} type="number" hint="Threads per diarize run." @@ -179,6 +197,7 @@ export function DiarizationSettingsForm({ <Field label="Max audio hours" name="diarizationMaxAudioHours" + state={state} defaultValue={String(initial.maxAudioHours)} type="number" step="0.5" @@ -187,6 +206,7 @@ export function DiarizationSettingsForm({ <Field label="Diarization concurrency" name="diarizationConcurrency" + state={state} defaultValue={String(initial.concurrency)} type="number" hint="Videos diarized at once during a backfill. Default 1: this is CPU-bound work competing with GPU feeding and the digest sweep for the same 8 threads." diff --git a/editor/app/operations/components/settings/DigestSettingsForm.tsx b/editor/app/operations/components/settings/DigestSettingsForm.tsx @@ -1,6 +1,6 @@ "use client"; -import { useActionState } from "react"; +import { useActionState, useState } from "react"; import type { SiteSettings } from "yt-dlp-transcript-common/lib/settings"; // From the CLIENT-SAFE digest module, NOT settings.ts's re-exports of them: // settings.ts opens with `import fs from "node:fs"`, so pulling the option @@ -12,7 +12,9 @@ import { DIGEST_TIMESTAMP_MODES as DIGEST_TIMESTAMP_MODE_OPTIONS, } from "yt-dlp-transcript-common/lib/digest"; import type { DigestAppDescriptor } from "yt-dlp-transcript-common/lib/digestApps"; -import { Field } from "../../../components/forms/Field"; +import { Field, SeededSelect } from "../../../components/forms/Field"; +import { ControlledCheck } from "../../../components/forms/Controlled"; +import { seedChecked } from "../../../lib/formState"; import { DigestAppsField } from "./DigestAppsField"; import { saveDigestSettingsAction } from "../../settingsActions"; import type { SaveResult } from "../../../settings/actions"; @@ -34,6 +36,24 @@ export function DigestSettingsForm({ SaveResult | undefined, FormData >(saveDigestSettingsAction, undefined); + // THE ONE NAME POSTED MORE THAN ONCE in the editor's forms — a box per + // section, all named `digestSections` — so a failed submit's `values` (each + // name's first value) cannot say which were ticked. Controlled instead: + // what the operator ticked lives here, and ControlledCheck keeps it through + // the reset. The posted shape is unchanged. + const [sections, setSections] = useState<string[]>(() => [ + ...initial.sections, + ]); + // AFTER A SUCCESS THE BOXES SHOW WHAT WAS STORED, as the uncontrolled boxes + // did after their reset: unticking every box saves the stored list again, so + // what was ticked is not always what was saved. Re-read on a new result or a + // new stored list, unless the result is a failure (then the ticks stay). + const stored = initial.sections.join(","); + const [shown, setShown] = useState({ state, stored }); + if (shown.state !== state || shown.stored !== stored) { + setShown({ state, stored }); + if (state?.ok !== false) setSections([...initial.sections]); + } return ( <form @@ -54,9 +74,10 @@ export function DigestSettingsForm({ </p> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Local engine</span> - <select + <SeededSelect + state={state} name="digestLocalAppId" - defaultValue={initial.localAppId} + initial={initial.localAppId} className="rounded border border-border bg-card px-2 py-1 text-sm" > {digestApps @@ -66,7 +87,7 @@ export function DigestSettingsForm({ {a.label} </option> ))} - </select> + </SeededSelect> <span className="text-xs text-muted-foreground"> The lane that carries the corpus. Runs on your own hardware; nothing leaves the machine. @@ -77,11 +98,18 @@ export function DigestSettingsForm({ <span className="flex flex-wrap gap-3"> {DIGEST_SECTION_OPTIONS.map((section) => ( <label key={section} className="flex items-center gap-1 text-sm"> - <input + <ControlledCheck type="checkbox" name="digestSections" value={section} - defaultChecked={initial.sections.includes(section)} + checked={sections.includes(section)} + onChange={(e) => + setSections((prev) => + e.target.checked + ? [...prev, section] + : prev.filter((s) => s !== section), + ) + } /> {section} </label> @@ -100,9 +128,10 @@ export function DigestSettingsForm({ </label> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Timestamp mode</span> - <select + <SeededSelect + state={state} name="digestTimestampMode" - defaultValue={initial.timestampMode} + initial={initial.timestampMode} className="rounded border border-border bg-card px-2 py-1 text-sm" > {DIGEST_TIMESTAMP_MODE_OPTIONS.map((mode) => ( @@ -111,7 +140,7 @@ export function DigestSettingsForm({ {mode === DEFAULT_DIGEST_TIMESTAMP_MODE ? " (recommended)" : ""} </option> ))} - </select> + </SeededSelect> <span className="text-xs text-muted-foreground"> How each chunk&apos;s transcript markers are numbered.{" "} <strong>chunk-local</strong> re-bases every chunk to 00:00:00 and @@ -126,6 +155,7 @@ export function DigestSettingsForm({ <Field label="Prompt variant label" name="digestPromptVariant" + state={state} defaultValue={initial.promptVariant} hint="Free-text label for a non-default prompt shape, recorded in every section's provenance (trimmed, max 40 chars). Setting or changing it invalidates digests generated under a different label — which is exactly what makes a bake-off round re-run its sample instead of skipping it as fresh. Leave blank unless you are running one." /> @@ -133,7 +163,11 @@ export function DigestSettingsForm({ <input type="checkbox" name="digestYieldToCpuWorkers" - defaultChecked={initial.yieldToCpuWorkers} + defaultChecked={seedChecked( + state, + "digestYieldToCpuWorkers", + initial.yieldToCpuWorkers, + )} className="mt-1" /> <span className="flex flex-col gap-1"> @@ -168,7 +202,11 @@ export function DigestSettingsForm({ <input type="checkbox" name="digestRemoteEnabled" - defaultChecked={initial.remoteEnabled} + defaultChecked={seedChecked( + state, + "digestRemoteEnabled", + initial.remoteEnabled, + )} className="mt-1" /> <span className="flex flex-col gap-1"> @@ -186,9 +224,10 @@ export function DigestSettingsForm({ </label> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Metered engine</span> - <select + <SeededSelect + state={state} name="digestRemoteAppId" - defaultValue={initial.remoteAppId} + initial={initial.remoteAppId} className="rounded border border-border bg-card px-2 py-1 text-sm" > {digestApps @@ -198,11 +237,12 @@ export function DigestSettingsForm({ {a.label} </option> ))} - </select> + </SeededSelect> </label> <Field label="Long-tail cutoff (seconds)" name="digestLongTailSeconds" + state={state} defaultValue={String(initial.longTailSeconds)} type="number" hint="Videos at least this long are what the metered lane takes when you run it. Default 14400 (4 h)." @@ -210,6 +250,7 @@ export function DigestSettingsForm({ <Field label="Spend cap (USD per job)" name="digestSpendCapUsd" + state={state} defaultValue={String(initial.spendCapUsd)} type="number" step="0.01" diff --git a/editor/app/operations/components/settings/LaneSettingsForm.tsx b/editor/app/operations/components/settings/LaneSettingsForm.tsx @@ -4,6 +4,7 @@ import { useActionState } from "react"; import type { SiteSettings } from "yt-dlp-transcript-common/lib/settings"; import { formatBytes } from "yt-dlp-transcript-common/lib/format"; import { Field } from "../../../components/forms/Field"; +import { seedChecked } from "../../../lib/formState"; import { saveBackfillLaneSettingsAction } from "../../settingsActions"; import type { SaveResult } from "../../../settings/actions"; @@ -105,7 +106,7 @@ export function LaneSettingsForm({ <input type="checkbox" name="backfillEnabled" - defaultChecked={!held} + defaultChecked={seedChecked(state, "backfillEnabled", !held)} className="mt-1" /> <span className="flex flex-col gap-1"> @@ -121,6 +122,7 @@ export function LaneSettingsForm({ <Field label="Lane concurrency" name="backfillConcurrency" + state={state} defaultValue={String(initial.concurrency)} type="number" hint="Slots the lane may use when it is not standing aside. Default 1 — this is CPU-bound work competing with GPU feeding and the digest lane for the same threads." @@ -129,7 +131,11 @@ export function LaneSettingsForm({ <input type="checkbox" name="backfillAllowRedownload" - defaultChecked={initial.allowRedownload} + defaultChecked={seedChecked( + state, + "backfillAllowRedownload", + initial.allowRedownload, + )} className="mt-1" /> <span className="flex flex-col gap-1"> diff --git a/editor/app/operations/components/sync/SchedulerSettingsForm.tsx b/editor/app/operations/components/sync/SchedulerSettingsForm.tsx @@ -12,6 +12,7 @@ import { } from "yt-dlp-transcript-common/lib/channelConfig"; import { DurationField } from "../../../components/DurationField"; import { Field } from "../../../components/forms/Field"; +import { seedChecked } from "../../../lib/formState"; import { FULL_SWEEP_PRESETS } from "../../../scheduler/intervalPresets"; // THE SYNC OPERATION'S SETTINGS BLOCK, whole. It is the form the descriptor's @@ -51,7 +52,11 @@ export function SchedulerSettingsForm({ <input type="checkbox" name="syncSchedulerEnabled" - defaultChecked={scheduler.enabled} + defaultChecked={seedChecked( + state, + "syncSchedulerEnabled", + scheduler.enabled, + )} className="mt-1" /> <span className="flex flex-col gap-0.5"> @@ -111,6 +116,7 @@ export function SchedulerSettingsForm({ <Field label="Full-sweep auto-confirm cap" name="syncSchedulerFullSweepConfirmMaxSuspects" + state={state} defaultValue={String(scheduler.fullSweepConfirmMaxSuspects)} type="number" hint="When a sweep finds at most this many videos missing from the listing, it probes each one upstream to confirm; above the cap it only flags them and leaves the probing to 'Check maybe-missing'. 0 = never auto-confirm. This is a count of videos, not a duration." @@ -118,6 +124,7 @@ export function SchedulerSettingsForm({ <Field label="Full-sweep shrink guard" name="syncSchedulerFullSweepShrinkGuardPercent" + state={state} defaultValue={String(scheduler.fullSweepShrinkGuardPercent)} type="number" hint="If a fresh listing comes back smaller than the stored one by more than this percentage (and by more than 25 entries), it is treated as suspect: the stored video list and the missing-video flags are left alone and the sweep is retried on the next sync rather than after the full cadence. A second enumeration reporting a similar count confirms it and is accepted, so a genuine mass deletion still lands. 0 = off; an empty listing is always refused. This is a percentage, not a duration." @@ -125,6 +132,7 @@ export function SchedulerSettingsForm({ <Field label="Max concurrent syncs" name="syncSchedulerMaxConcurrentSyncs" + state={state} defaultValue={String(scheduler.maxConcurrentSyncs)} type="number" hint="A tick queues at most (this − already-running) channels, most-overdue first; the rest roll to the next tick. Bounds load and staggers big batches." @@ -145,6 +153,7 @@ export function SchedulerSettingsForm({ <Field label="Quiet hours start (0–23)" name="syncSchedulerQuietHoursStart" + state={state} defaultValue={ scheduler.quietHoursStart != null ? String(scheduler.quietHoursStart) @@ -155,6 +164,7 @@ export function SchedulerSettingsForm({ <Field label="Quiet hours end (0–23)" name="syncSchedulerQuietHoursEnd" + state={state} defaultValue={ scheduler.quietHoursEnd != null ? String(scheduler.quietHoursEnd) diff --git a/editor/app/operations/settingsActions.ts b/editor/app/operations/settingsActions.ts @@ -25,6 +25,7 @@ import { } from "yt-dlp-transcript-common/lib/digest"; import { withGateHeld } from "yt-dlp-transcript-common/lib/pauseGates"; import type { SaveResult } from "../settings/actions"; +import { formValues } from "../lib/formState"; // Same helper as operations/actions.ts, and for the same reason — a "use server" // module may only EXPORT async functions, so this cannot be shared from there. @@ -53,6 +54,7 @@ export async function saveDigestSettingsAction( _prev: SaveResult | undefined, formData: FormData, ): Promise<SaveResult> { + const values = formValues(formData); const current = getSettings(); const dD = current.digest; const digestAppsRaw = String(formData.get("digestAppsJson") ?? "").trim(); @@ -61,7 +63,11 @@ export async function saveDigestSettingsAction( try { digestApps = JSON.parse(digestAppsRaw); } catch { - return { ok: false, error: "Digest app config payload is malformed" }; + return { + ok: false, + error: "Digest app config payload is malformed", + values, + }; } } // Filtered to KNOWN kinds here rather than leaning on the settings schema's sanitizer. @@ -122,7 +128,7 @@ export async function saveDigestSettingsAction( try { await saveSettings(next); } catch (e) { - return { ok: false, error: (e as Error).message }; + return { ok: false, error: (e as Error).message, values }; } revalidateOperations(); return { ok: true }; @@ -138,6 +144,7 @@ export async function saveDiarizationSettingsAction( _prev: SaveResult | undefined, formData: FormData, ): Promise<SaveResult> { + const values = formValues(formData); const current = getSettings(); const dDiar = current.diarization; const next: Partial<SiteSettings> = { @@ -181,7 +188,7 @@ export async function saveDiarizationSettingsAction( try { await saveSettings(next); } catch (e) { - return { ok: false, error: (e as Error).message }; + return { ok: false, error: (e as Error).message, values }; } revalidateOperations(); return { ok: true }; @@ -208,6 +215,7 @@ export async function saveBackfillLaneSettingsAction( _prev: SaveResult | undefined, formData: FormData, ): Promise<SaveResult> { + const values = formValues(formData); const current = getSettings(); const dBack = current.backfill; const next: Partial<SiteSettings> = { @@ -225,7 +233,7 @@ export async function saveBackfillLaneSettingsAction( try { await saveSettings(next); } catch (e) { - return { ok: false, error: (e as Error).message }; + return { ok: false, error: (e as Error).message, values }; } revalidateOperations(); return { ok: true }; @@ -243,6 +251,7 @@ export async function saveAttributionSettingsAction( _prev: SaveResult | undefined, formData: FormData, ): Promise<SaveResult> { + const values = formValues(formData); const current = getSettings(); const dAttr = current.attribution; const next: Partial<SiteSettings> = { @@ -266,7 +275,7 @@ export async function saveAttributionSettingsAction( try { await saveSettings(next); } catch (e) { - return { ok: false, error: (e as Error).message }; + return { ok: false, error: (e as Error).message, values }; } revalidateOperations(); return { ok: true }; diff --git a/editor/app/saved-videos/backupActions.ts b/editor/app/saved-videos/backupActions.ts @@ -6,6 +6,7 @@ import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; import { saveSettings } from "../settings/saveSettings"; import { SYNC_INTERVAL_MAX_MINUTES } from "yt-dlp-transcript-common/lib/channelConfig"; +import { formValues, type FormState } from "../lib/formState"; import { backupSavedVideos, verifySavedVideoBackup, @@ -82,7 +83,8 @@ export async function verifySavedVideoBackupAction( }); } -export type SaveBackupConfigResult = { ok: true } | { ok: false; error: string }; +// A failure carries what was submitted (lib/formState.ts). +export type SaveBackupConfigResult = FormState; // Persist just the saved-video backup block (dest / enabled / interval) from the // Saved Videos page, leaving the rest of SiteSettings untouched. writeSettings @@ -91,6 +93,7 @@ export async function saveSavedVideoBackupAction( _prev: SaveBackupConfigResult | undefined, formData: FormData, ): Promise<SaveBackupConfigResult> { + const values = formValues(formData); const dest = String(formData.get("backupDest") ?? "").trim(); const enabled = formData.get("backupEnabled") === "on"; const intervalRaw = String(formData.get("backupIntervalMinutes") ?? "").trim(); @@ -99,6 +102,7 @@ export async function saveSavedVideoBackupAction( return { ok: false, error: `Backup interval must be 1–${SYNC_INTERVAL_MAX_MINUTES} minutes`, + values, }; } const settings = getSettings(); @@ -113,7 +117,7 @@ export async function saveSavedVideoBackupAction( }, }); } catch (e) { - return { ok: false, error: (e as Error).message }; + return { ok: false, error: (e as Error).message, values }; } revalidatePath("/saved-videos"); return { ok: true }; diff --git a/editor/app/saved-videos/components/SavedVideosControls.tsx b/editor/app/saved-videos/components/SavedVideosControls.tsx @@ -11,6 +11,7 @@ import { verifySavedVideoBackupAction, type SaveBackupConfigResult, } from "../backupActions"; +import { seedChecked } from "../../lib/formState"; type Props = { enabled: boolean; @@ -76,7 +77,7 @@ export function SavedVideosControls({ enabled, dest, intervalMinutes }: Props) { <input type="checkbox" name="backupEnabled" - defaultChecked={enabled} + defaultChecked={seedChecked(state, "backupEnabled", enabled)} aria-label="scheduled backup enabled" className="mt-1" /> diff --git a/editor/app/scheduler/actions.ts b/editor/app/scheduler/actions.ts @@ -15,8 +15,10 @@ import { } from "yt-dlp-transcript-common/lib/settings"; import { saveSettings } from "../settings/saveSettings"; import { DURATION_KEEP } from "yt-dlp-transcript-common/lib/duration"; +import { formValues, type FormState } from "../lib/formState"; -export type SaveResult = { ok: true } | { ok: false; error: string }; +// A failure carries what was submitted (lib/formState.ts). +export type SaveResult = FormState; // One cadence field, as posted by DurationField. Four outcomes: // "keep" -> leave the stored value alone (bulk edits skipping this field) @@ -60,6 +62,7 @@ export async function setChannelCadencesAction( _prev: SaveResult | undefined, formData: FormData, ): Promise<SaveResult> { + const values = formValues(formData); let sync: CadenceEdit; let sweep: CadenceEdit; try { @@ -74,9 +77,11 @@ export async function setChannelCadencesAction( "Full sweep interval", ); } catch (e) { - return { ok: false, error: (e as Error).message }; + return { ok: false, error: (e as Error).message, values }; + } + if (slugs.length === 0) { + return { ok: false, error: "No channels selected", values }; } - if (slugs.length === 0) return { ok: false, error: "No channels selected" }; if (sync === "keep" && sweep === "keep") return { ok: true }; // nothing to do const paths = getPaths(); @@ -93,7 +98,9 @@ export async function setChannelCadencesAction( else patch.fullSweepIntervalMinutes = sweep.minutes; } const written = await patchChannelConfig(paths, slug, patch, { unset }); - if (!written) return { ok: false, error: `Channel "${slug}" not found` }; + if (!written) { + return { ok: false, error: `Channel "${slug}" not found`, values }; + } // Keep the channel report/badges in sync with the config edit. requestChannelSnapshot(paths, slug); revalidatePath(`/channels/${slug}`); @@ -119,6 +126,7 @@ export async function saveSchedulerSettingsAction( _prev: SaveResult | undefined, formData: FormData, ): Promise<SaveResult> { + const values = formValues(formData); const current = getSettings(); // An absent or blank field keeps the saved value rather than coercing to NaN // and resetting it to the default. It is the whole block's contract now, not @@ -189,7 +197,7 @@ export async function saveSchedulerSettingsAction( try { await saveSettings(next); } catch (e) { - return { ok: false, error: (e as Error).message }; + return { ok: false, error: (e as Error).message, values }; } revalidatePath("/operations/sync"); revalidatePath("/settings"); diff --git a/editor/app/sites/components/CutReleaseForm.tsx b/editor/app/sites/components/CutReleaseForm.tsx @@ -5,6 +5,8 @@ import { cutReleaseAction, type CutReleaseState, } from "../lib/cutReleaseAction"; +import { SeededInput } from "../../components/forms/Field"; +import { seedChecked } from "../../lib/formState"; type Props = { workspace: "editor" | "export"; @@ -36,12 +38,13 @@ export function CutReleaseForm({ <label className="sr-only" htmlFor={`version-${workspace}`}> New version </label> - <input + <SeededInput + state={state} id={`version-${workspace}`} name="version" type="text" placeholder={suggestedVersion} - defaultValue={suggestedVersion} + initial={suggestedVersion} disabled={!hasUnreleased || pending} className="w-40 px-3 py-2 rounded-md border border-border bg-card text-sm font-mono" aria-describedby={`hint-${workspace}`} @@ -58,7 +61,7 @@ export function CutReleaseForm({ type="checkbox" name="commit" value="true" - defaultChecked + defaultChecked={seedChecked(state, "commit", true)} disabled={!hasUnreleased || pending} /> <span>Commit changelog</span> diff --git a/editor/app/sites/components/HomepageConfigForm.tsx b/editor/app/sites/components/HomepageConfigForm.tsx @@ -3,6 +3,8 @@ import { useActionState } from "react"; import type { HomepageConfig } from "yt-dlp-transcript-common/lib/homepage"; import { saveHomepageConfigAction, type SaveResult } from "../lib/homepageActions"; +import { SeededInput } from "../../components/forms/Field"; +import { seedChecked } from "../../lib/formState"; const input = "rounded-md border border-border bg-card px-3 py-1.5 text-sm"; @@ -18,55 +20,69 @@ export function HomepageConfigForm({ config }: { config: HomepageConfig }) { <form action={formAction} className="flex flex-col gap-3 max-w-xl"> <label className={label}> Site title - <input className={input} name="siteTitle" defaultValue={config.siteTitle} /> + <SeededInput + state={state} + className={input} + name="siteTitle" + initial={config.siteTitle} + /> </label> <label className={label}> Header title - <input + <SeededInput + state={state} className={input} name="headerTitle" - defaultValue={config.headerTitle} + initial={config.headerTitle} /> </label> <label className={label}> Description - <input + <SeededInput + state={state} className={input} name="siteDescription" - defaultValue={config.siteDescription} + initial={config.siteDescription} /> </label> <label className={label}> Home tagline - <input + <SeededInput + state={state} className={input} name="homeTagline" - defaultValue={config.homeTagline} + initial={config.homeTagline} /> </label> <label className={label}> Public URL - <input + <SeededInput + state={state} className={input} name="siteUrl" placeholder="https://archilyzer-hub.pages.dev" - defaultValue={config.siteUrl ?? ""} + initial={config.siteUrl ?? ""} /> </label> <label className={label}> Cloudflare Pages project - <input + <SeededInput + state={state} className={input} name="cloudflareProject" placeholder="archilyzer-hub" - defaultValue={config.cloudflareProject ?? ""} + initial={config.cloudflareProject ?? ""} /> </label> <label className="flex items-center gap-2 text-sm"> <input type="checkbox" name="transcriptDownloads" - defaultChecked={config.transcriptDownloads !== false} + defaultChecked={seedChecked( + state, + "transcriptDownloads", + config.transcriptDownloads !== false, + )} className="accent-brand" /> Per-video transcript downloads (Download menu, Copy Markdown) diff --git a/editor/app/sites/lib/cutReleaseAction.ts b/editor/app/sites/lib/cutReleaseAction.ts @@ -2,6 +2,7 @@ import { cutReleaseForWorkspace } from "yt-dlp-transcript-common/controller/cutRelease"; import { revalidateAfterReleaseCut } from "./revalidateAfterReleaseCut"; +import { formValues, type FormState } from "../../lib/formState"; // The "Cut release" form's server action: a FormData adapter over the one // writer, `cutReleaseForWorkspace` (common/controller/cutRelease.ts), which the @@ -9,14 +10,15 @@ import { revalidateAfterReleaseCut } from "./revalidateAfterReleaseCut"; // dirty-tree guard, the cut, the atomic write, the commit and every refusal's // sentence live there. -export type CutReleaseState = - | { ok: true; version: string; committed: boolean } - | { ok: false; error: string }; +// A failure carries what was submitted, so the form keeps the typed version +// and the commit box (lib/formState.ts). +export type CutReleaseState = FormState<{ version: string; committed: boolean }>; export async function cutReleaseAction( _prev: CutReleaseState | undefined, formData: FormData, ): Promise<CutReleaseState> { + const values = formValues(formData); const workspaceRaw = formData.get("workspace"); const versionRaw = formData.get("version"); const commitRaw = formData.get("commit"); @@ -28,10 +30,10 @@ export async function cutReleaseAction( typeof versionRaw === "string" ? versionRaw.trim() : ""; const shouldCommit = commitRaw === "true" || commitRaw === "on"; if (!workspace) { - return { ok: false, error: "Invalid workspace." }; + return { ok: false, error: "Invalid workspace.", values }; } if (!version) { - return { ok: false, error: "Version is required." }; + return { ok: false, error: "Version is required.", values }; } const result = await cutReleaseForWorkspace({ workspace, @@ -43,7 +45,7 @@ export async function cutReleaseAction( // slice O3, release 10 review L3): the pages that render this changelog // are stale all the same. The error already says the file changed. if (result.written) revalidateAfterReleaseCut(); - return { ok: false, error: result.error }; + return { ok: false, error: result.error, values }; } revalidateAfterReleaseCut(); return { ok: true, version: result.version, committed: result.committed }; diff --git a/editor/app/sites/lib/homepageActions.ts b/editor/app/sites/lib/homepageActions.ts @@ -8,8 +8,10 @@ import { parseHomepageUrl, type HomepageConfig, } from "yt-dlp-transcript-common/lib/homepage"; +import { formValues, type FormState } from "../../lib/formState"; -export type SaveResult = { ok: true } | { ok: false; error: string }; +// A failure carries what was submitted (lib/formState.ts). +export type SaveResult = FormState; // Branding/config for the hub landing site. The hub is a single cross-site // landing page (no authored Markdown pages), so this is the only homepage action. @@ -17,17 +19,23 @@ export async function saveHomepageConfigAction( _prev: SaveResult | undefined, formData: FormData, ): Promise<SaveResult> { + const values = formValues(formData); const paths = getPaths(); const current = getHomepageConfig(paths); const siteTitle = String(formData.get("siteTitle") ?? "").trim(); const headerTitle = String(formData.get("headerTitle") ?? "").trim(); - if (!siteTitle) return { ok: false, error: "Site title is required" }; - if (!headerTitle) return { ok: false, error: "Header title is required" }; + if (!siteTitle) { + return { ok: false, error: "Site title is required", values }; + } + if (!headerTitle) { + return { ok: false, error: "Header title is required", values }; + } const siteUrlRaw = String(formData.get("siteUrl") ?? "").trim(); if (siteUrlRaw && !parseHomepageUrl(siteUrlRaw)) { return { ok: false, error: "Public URL must be an absolute http(s):// URL, or left blank.", + values, }; } const cloudflareProject = String( @@ -48,7 +56,11 @@ export async function saveHomepageConfigAction( try { await writeHomepageConfig(next, paths); } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) }; + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + values, + }; } revalidatePath("/sites"); return { ok: true }; diff --git a/editor/app/storage/actions.ts b/editor/app/storage/actions.ts @@ -46,6 +46,7 @@ import { secondsText, } from "yt-dlp-transcript-common/lib/storageHealthTimings"; import { parseHealthTimingsForm } from "./lib/healthTimingsForm"; +import { formValues, type FormState } from "../lib/formState"; // THE SIX THINGS AN OPERATOR MAY DO TO A STORAGE LOCATION (and, at the end, // the drive-health timings every location is judged by). @@ -491,7 +492,9 @@ export async function evictClipWindowsAction(opts: { // The drive-health timings // --------------------------------------------------------------------------- -export type HealthTimingsResult = { ok: true; note: string } | { ok: false; error: string }; +// A failure carries what was submitted, so HealthTimingForm keeps the five +// fields as typed (lib/formState.ts). +export type HealthTimingsResult = FormState<{ note: string }>; // SAVE `settings.storage.health` FROM THE /storage FORM (HealthTimingForm). // @@ -508,13 +511,14 @@ export async function saveHealthTimingsAction( _prev: HealthTimingsResult | undefined, formData: FormData, ): Promise<HealthTimingsResult> { + const values = formValues(formData); const parsed = parseHealthTimingsForm(formData); - if (!parsed.ok) return parsed; + if (!parsed.ok) return { ...parsed, values }; const settings = getSettings(); try { await saveSettings({ storage: { ...settings.storage, health: parsed.health } }); } catch (e) { - return { ok: false, error: (e as Error).message }; + return { ok: false, error: (e as Error).message, values }; } const t = applyHealthTimings(getSettings().storage.health); revalidatePath("/storage"); diff --git a/editor/app/storage/components/HealthTimingForm.tsx b/editor/app/storage/components/HealthTimingForm.tsx @@ -9,6 +9,7 @@ import { } from "yt-dlp-transcript-common/lib/storageHealthTimings"; import { HEALTH_TIMING_FIELDS } from "../lib/healthTimingsForm"; import { saveHealthTimingsAction, type HealthTimingsResult } from "../actions"; +import { SeededInput } from "../../components/forms/Field"; // THE DRIVE HEALTH TIMING — `settings.storage.health`, the five numbers the // editor decides "this drive is mounted and not answering" by @@ -70,12 +71,13 @@ export function HealthTimingForm({ stored }: { stored: StorageHealthSettings }) {f.label} {f.unit && <span className="font-normal text-muted-foreground"> ({f.unit})</span>} </span> - <input + <SeededInput + state={state} type="text" inputMode="numeric" name={f.key} aria-label={f.ariaLabel} - defaultValue={stored[f.key] === undefined ? "" : String(stored[f.key])} + initial={stored[f.key] === undefined ? "" : String(stored[f.key])} placeholder={String(HEALTH_TIMING_DEFAULTS[f.key])} className="rounded border border-border bg-background px-2 py-1 text-sm font-mono tabular-nums" />