Archilyzer · Source

archilyzer

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

commit 6a5b22b3ef396a123ae7cb19ca98263470da7267
parent 8ea7c4f1f3455da0db53389eb012c9d69d179f1f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  1 Oct 2026 01:14:09 -0400

editor: a form that fails keeps what was typed — formState's values contract, one Field, SiteForm and SettingsForm

React resets a <form> when its action's transition commits, success or
failure; an uncontrolled field went back to its default and every editor form
wiped what the operator typed the moment its action said no. A failed save now
returns the submitted values (lib/formState.ts: formValues, seedValue,
seedChecked, one implementation for the { ok } and the channels flavour) and
the fields seed their default from them.

- components/forms/Field.tsx: the one Field (SiteForm's and ChannelForm's own
  copies fold in: min, placeholder, readOnly, a controlled mode), SeededInput
  (a number input re-mounts on a new default: React skips a focused one) and
  SeededSelect (re-mounts: a select's defaultValue is applied only at mount).
- components/forms/Controlled.tsx: ControlledCheck and ControlledSelect. A
  controlled checkbox, radio or select is NOT reset-proof — react-dom sets
  defaultChecked/defaultSelected only at mount — so after any submit it showed
  (and a named one posted) what it mounted with. They keep the DOM default in
  step after every commit.
- SiteForm (create and edit) and SettingsForm, with their actions.

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

Diffstat:
Aeditor/app/components/forms/Controlled.tsx | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/components/forms/Field.tsx | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Aeditor/app/lib/formState.test.ts | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/lib/formState.ts | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/settings/actions.ts | 39+++++++++++++++++++++++++++++++--------
Meditor/app/settings/components/SettingsForm.tsx | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Meditor/app/sites/actions.ts | 48+++++++++++++++++++++++++++++++++++++++---------
Meditor/app/sites/components/SiteForm.tsx | 94++++++++++++++++++++++++++++++++++++++++---------------------------------------
8 files changed, 546 insertions(+), 97 deletions(-)

diff --git a/editor/app/components/forms/Controlled.tsx b/editor/app/components/forms/Controlled.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { + useLayoutEffect, + useRef, + type InputHTMLAttributes, + type SelectHTMLAttributes, +} from "react"; + +// A CONTROLLED CHECKBOX, RADIO OR SELECT INSIDE AN ACTION FORM (release 16, +// slice FK). +// +// React resets a `<form>` when its action's transition commits (see +// lib/formState.ts). A controlled TEXT input survives that: React keeps its +// `defaultValue` in step with `value`, so `form.reset()` puts the same text +// back. It does not do the same for the other two kinds: +// - a checkbox or radio's `defaultChecked` is set only when it MOUNTS +// (react-dom `initInput`; `updateInput` sets `checked` alone); +// - a select's options' `defaultSelected` likewise (`updateOptions` is +// called with `setDefaultSelected` false on every update). +// So after ANY submit — failure or success — the reset returns the element to +// what it mounted with while React's state still says otherwise, and nothing +// re-renders it: the box shows the old choice, and a NAMED one posts the old +// choice on the next submit. Measured in a browser against react-dom 19.2.4, +// and the same code is in Next's bundled react-dom. +// +// These keep the DOM's default in step with the controlled value after every +// commit, and re-assert the value itself: a layout effect runs after the +// commit's mutations, and the reset is one of them. Same props, same markup as +// the bare element. +export function ControlledCheck( + props: InputHTMLAttributes<HTMLInputElement> & { + type: "checkbox" | "radio"; + checked: boolean; + }, +) { + const ref = useRef<HTMLInputElement>(null); + const { checked } = props; + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + el.defaultChecked = checked; + el.checked = checked; + }); + return <input ref={ref} {...props} />; +} + +export function ControlledSelect( + props: SelectHTMLAttributes<HTMLSelectElement> & { value: string }, +) { + const ref = useRef<HTMLSelectElement>(null); + const { value } = props; + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + for (const option of el.options) { + option.defaultSelected = option.value === value; + } + el.value = value; + }); + return <select ref={ref} {...props} />; +} diff --git a/editor/app/components/forms/Field.tsx b/editor/app/components/forms/Field.tsx @@ -1,24 +1,47 @@ -// The labelled text/number input the long settings forms are built out of, -// lifted out of SettingsForm when slice 3 moved four of its fieldsets onto the -// operation pages and two files needed the same helper. +import type { + InputHTMLAttributes, + SelectHTMLAttributes, +} from "react"; +import { seedValue, type SeedSource } from "../../lib/formState"; + +// The labelled text/number input the editor's forms are built out of. // -// DELIBERATELY NOT A DEDUPLICATION SWEEP. Three other files still define their -// own `Field` — ChannelForm.tsx, SiteForm.tsx and OverviewPanel.tsx — and they -// are NOT this one: they differ in props (no `type`/`step`) and in markup, so -// folding them in is a behaviour change to three forms that slice 3 does not -// touch. Leave them until something actually needs them to be one. +// ONE FIELD SINCE RELEASE 16 (slice FK). Slice 3 lifted this out of +// SettingsForm and left SiteForm's and ChannelForm's own copies alone on +// purpose ("leave them until something actually needs them to be one"). Slice +// FK is that something: all three had to learn to seed from a failed action's +// values, and they were the same label → input → hint, differing only in props +// (`step` here, `min` in SiteForm's, `placeholder`/`readOnly`/a controlled mode +// in ChannelForm's). Those props are all here now. OverviewPanel's `Field` is a +// different thing (a read-only figure, no input) and stays its own. +// +// `state` is the form's `useActionState` state. When the last submit failed, +// the input's default is what was submitted (lib/formState.ts), so the reset +// React runs after the action puts the operator's text back instead of the +// stored value. `defaultValue` is the INITIAL value — the stored one. +// +// `value` + `onChange` make the field CONTROLLED (ChannelForm's download-filter +// patterns are, so their live values can be read without touching the DOM); a +// controlled field survives the reset on its own and ignores `state`. export function Field({ label, name, defaultValue, + state, hint, required, type = "text", step, + min, + placeholder, + readOnly, + value, + onChange, }: { label: string; name: string; defaultValue?: string; + state?: SeedSource; hint?: string; required?: boolean; type?: string; @@ -27,19 +50,95 @@ export function Field({ // error, no request, the save just never happens. Any numeric field that // accepts fractions must pass a step. step?: string; + min?: string; + placeholder?: string; + readOnly?: boolean; + value?: string; + onChange?: (v: string) => void; }) { + const className = `rounded border border-border bg-card px-2 py-1 text-sm${readOnly ? " opacity-60" : ""}`; + const shared = { type, required, step, min, placeholder, readOnly, className }; return ( <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">{label}</span> - <input - type={type} - name={name} - defaultValue={defaultValue} - required={required} - step={step} - className="rounded border border-border bg-card px-2 py-1 text-sm" - /> + {value === undefined ? ( + <SeededInput + {...shared} + name={name} + state={state} + initial={defaultValue ?? ""} + /> + ) : ( + <input + {...shared} + name={name} + value={value} + onChange={(e) => onChange?.(e.target.value)} + /> + )} {hint && <span className="text-xs text-muted-foreground">{hint}</span>} </label> ); } + +// An UNCONTROLLED input whose default is the failed submit's value, else +// `initial`. For the inputs that are not a `Field` (their own label markup). +// +// A NUMBER INPUT IS RE-MOUNTED when its default changes. React does not write a +// new `defaultValue` into a number input that has focus (react-dom +// `setDefaultValue` skips it, so typing "1." is not rewritten under the cursor) +// — and pressing Enter in one submits the form with the focus still there, so +// the reset would put the OLD default back. A new element takes its default at +// mount, focused or not. The key is the default itself: when it does not +// change there is nothing to re-apply. +export function SeededInput({ + state, + name, + initial, + type = "text", + ...rest +}: Omit< + InputHTMLAttributes<HTMLInputElement>, + "defaultValue" | "value" | "name" +> & { + state: SeedSource; + name: string; + initial: string; +}) { + const seeded = seedValue(state, name, initial); + return ( + <input + key={type === "number" ? seeded : undefined} + {...rest} + type={type} + name={name} + defaultValue={seeded} + /> + ); +} + +// An UNCONTROLLED select whose default is the failed submit's value, else +// `initial`. +// +// RE-MOUNTED WHENEVER ITS DEFAULT CHANGES: React applies a select's +// `defaultValue` only when it mounts (react-dom: an update re-applies it only +// when `multiple` toggles), so a new `defaultValue` never reaches the DOM and +// the reset puts back the option the select MOUNTED with — after a failure, and +// after a success too (the stored value changed, the select did not). The key +// is the default itself. +export function SeededSelect({ + state, + name, + initial, + ...rest +}: Omit< + SelectHTMLAttributes<HTMLSelectElement>, + "defaultValue" | "value" | "name" +> & { + state: SeedSource; + name: string; + initial: string; +}) { + const seeded = seedValue(state, name, initial); + return <select key={seeded} {...rest} name={name} defaultValue={seeded} />; +} diff --git a/editor/app/lib/formState.test.ts b/editor/app/lib/formState.test.ts @@ -0,0 +1,105 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + failedValues, + formValues, + seedChecked, + seedValue, + type FormErrorState, + type FormState, +} from "./formState"; + +// Run with: pnpm --filter editor test + +test("formValues keeps each name's first value and skips files", () => { + const fd = new FormData(); + fd.append("siteTitle", "Typed title"); + fd.append("digestSections", "chapters"); + fd.append("digestSections", "tags"); + fd.append("upload", new File(["x"], "x.txt")); + fd.append("empty", ""); + assert.deepEqual(formValues(fd), { + siteTitle: "Typed title", + digestSections: "chapters", + empty: "", + }); +}); + +test("formValues keeps a field named like an Object.prototype key", () => { + const fd = new FormData(); + fd.append("__proto__", "a"); + fd.append("constructor", "b"); + const values = formValues(fd); + assert.equal(Object.getPrototypeOf(values), Object.prototype); + assert.equal(Object.hasOwn(values, "__proto__"), true); + assert.equal(values.constructor, "b"); + assert.deepEqual(Object.keys(values), ["__proto__", "constructor"]); +}); + +test("seedValue: no state, a success, or a failure without values → initial", () => { + const ok: FormState<{ siteId: string }> = { ok: true, siteId: "x" }; + const bare: FormState = { ok: false, error: "no" }; + for (const state of [undefined, null, ok, bare]) { + assert.equal(seedValue(state, "siteTitle", "Stored"), "Stored"); + } +}); + +test("seedValue: a failure with values → what was submitted, even empty", () => { + const state: FormState = { + ok: false, + error: "Site ID must be …", + values: { siteTitle: "Typed", wordmarkLead: "" }, + }; + assert.equal(seedValue(state, "siteTitle", "Stored"), "Typed"); + // Cleared on purpose is kept cleared: "" is a value, not an absence. + assert.equal(seedValue(state, "wordmarkLead", "Jer"), ""); +}); + +test("seedValue: a name the failed submit did not carry keeps its initial", () => { + // A disabled field, or one not rendered at the time, posts nothing. + const state: FormState = { ok: false, error: "no", values: { a: "1" } }; + assert.equal(seedValue(state, "notPosted", "Stored"), "Stored"); + // Not fooled by Object.prototype. + assert.equal(seedValue(state, "toString", "Stored"), "Stored"); +}); + +test("seedChecked: no state, a success, or no values → initial", () => { + const ok: FormState = { ok: true }; + const bare: FormState = { ok: false, error: "no" }; + for (const state of [undefined, null, ok, bare]) { + assert.equal(seedChecked(state, "pwa", true), true); + assert.equal(seedChecked(state, "pwa", false), false); + } +}); + +test("seedChecked: with values, present is ticked and ABSENT is unticked", () => { + // An unticked box sends nothing, so absence is the answer, not "unknown". + const state: FormState = { + ok: false, + error: "no", + values: { pwa: "on", commit: "true" }, + }; + assert.equal(seedChecked(state, "pwa", false), true); + assert.equal(seedChecked(state, "commit", false), true); + assert.equal(seedChecked(state, "archives", true), false); + assert.equal(seedChecked(state, "hasOwnProperty", true), false); +}); + +test("the channels flavour reads through the same helpers", () => { + const failed: FormErrorState = { + error: "already exists", + values: { name: "Typed", fetchPlaylist: "on" }, + }; + const success: FormErrorState = undefined; + assert.deepEqual(failedValues(failed), { + name: "Typed", + fetchPlaylist: "on", + }); + assert.equal(seedValue(failed, "name", "Stored"), "Typed"); + assert.equal(seedChecked(failed, "fetchPlaylist", false), true); + assert.equal(seedChecked(failed, "prioritizeDownload", true), false); + assert.equal(failedValues(success), undefined); + assert.equal(seedValue(success, "name", "Stored"), "Stored"); + // An error with no values (an action that returns none) seeds from initial. + assert.equal(seedValue({ error: "no" }, "name", "Stored"), "Stored"); +}); diff --git a/editor/app/lib/formState.ts b/editor/app/lib/formState.ts @@ -0,0 +1,91 @@ +// A FORM THAT FAILS KEEPS WHAT WAS TYPED (release 16, slice FK). +// +// React resets a `<form>` when its action's transition commits — success or +// failure alike. `startHostTransition` calls `requestFormReset` beside the +// action itself, and the commit ends with a native `form.reset()` (react-dom, +// `recursivelyResetForms`). An UNCONTROLLED field (`defaultValue`, +// `defaultChecked`) goes back to its default; so before this, every editor form +// wiped what the operator typed the moment its action returned an error. +// +// The fix is a data contract, not a widget: an action that can fail captures +// what was submitted at its top (`formValues`) and returns it beside the error, +// and every uncontrolled field seeds its default from it (`seedValue`, +// `seedChecked`). React writes the new default in the same commit, before the +// reset runs, so the reset puts the typed value back. A success returns no +// values, so a field seeds from its initial value — the stored one — exactly +// as before. +// +// PURE, AND IMPORTED BY BOTH SIDES: the server actions build `values` with it +// and the client forms read it. No React, no node: — a "use server" module and +// a client bundle both import this file. + +// What a form posted, as a failed action hands it back: each name's FIRST +// value, files skipped (no editor form posts one, and a File does not survive +// the trip back to the client). +export type FormValues = Record<string, string>; + +// The `{ ok }` flavour, which most save actions return. `T` is what a success +// carries (`{ siteId }`, `{ note }`, …). +export type FormFailure = { ok: false; error: string; values?: FormValues }; +export type FormState<T extends object = object> = ({ ok: true } & T) | FormFailure; + +// The channels flavour: `undefined` is success (the action redirects, or there +// is nothing to say), an object is the refusal. +export type FormErrorState = { error: string; values?: FormValues } | undefined; + +// EITHER FLAVOUR, as the seeding helpers read it — one implementation for both. +// A state is a failure unless it says `ok: true`; the channels flavour has no +// `ok` at all, and its success is `undefined`. +export type SeedSource = + | { ok?: boolean; error?: string; values?: FormValues } + | null + | undefined; + +export function formValues(formData: FormData): FormValues { + const values: FormValues = {}; + for (const [name, value] of formData.entries()) { + if (typeof value !== "string") continue; + if (Object.hasOwn(values, name)) continue; + // defineProperty, not assignment: a field named `__proto__` would otherwise + // hit the prototype setter and vanish. + Object.defineProperty(values, name, { + value, + enumerable: true, + writable: true, + configurable: true, + }); + } + return values; +} + +// The values a failed submit carried back, or undefined (no submit yet, a +// success, or an action that returned no values). +export function failedValues(state: SeedSource): FormValues | undefined { + if (!state || state.ok === true) return undefined; + return state.values; +} + +// A text, number, textarea, select or radio-group field's default: what was +// submitted when the last submit failed, else `initial`. A name the failed +// submit did not carry was not submitted at all (a disabled field, or one not +// rendered then), so it keeps `initial` rather than going blank. +export function seedValue( + state: SeedSource, + name: string, + initial: string, +): string { + const values = failedValues(state); + return values && Object.hasOwn(values, name) ? values[name] : initial; +} + +// A checkbox's default: ticked when the last failed submit carried its name, +// else `initial`. AN UNTICKED BOX SENDS NOTHING, so once `values` is present +// absence means unchecked — not "unknown, use the initial". +export function seedChecked( + state: SeedSource, + name: string, + initial: boolean, +): boolean { + const values = failedValues(state); + return values ? Object.hasOwn(values, name) : initial; +} diff --git a/editor/app/settings/actions.ts b/editor/app/settings/actions.ts @@ -26,13 +26,18 @@ import { isCookieMode, } from "yt-dlp-transcript-common/lib/cookiePolicy"; import { isDownloadFormatPreset } from "yt-dlp-transcript-common/ytdlp/downloadFormat"; +import { formValues, type FormState } from "../lib/formState"; -export type SaveResult = { ok: true } | { ok: false; error: string }; +// THE SHARED SAVE RESULT of /settings and the four operation-settings forms. A +// failure carries what was submitted, so the form keeps what was typed +// (lib/formState.ts). +export type SaveResult = FormState; export async function saveSettingsAction( _prev: SaveResult | undefined, formData: FormData, ): Promise<SaveResult> { + const values = formValues(formData); const adminTitle = String(formData.get("adminTitle") ?? "").trim(); const homepageUrl = String(formData.get("homepageUrl") ?? "").trim(); const maxBytesRaw = String( @@ -76,11 +81,17 @@ export async function saveSettingsAction( ? downloadFormatRaw : "auto"; - if (!adminTitle) return { ok: false, error: "Admin title is required" }; + if (!adminTitle) { + return { ok: false, error: "Admin title is required", values }; + } const parsed = Number.parseInt(maxBytesRaw, 10); if (!Number.isFinite(parsed)) { - return { ok: false, error: "maxTranscriptPageBytes must be a number" }; + return { + ok: false, + error: "maxTranscriptPageBytes must be a number", + values, + }; } if ( parsed < TRANSCRIPT_PAGE_MIN_BYTES || @@ -89,6 +100,7 @@ export async function saveSettingsAction( return { ok: false, error: `maxTranscriptPageBytes must be between ${TRANSCRIPT_PAGE_MIN_BYTES} and ${TRANSCRIPT_PAGE_HARD_CAP_BYTES}`, + values, }; } @@ -97,19 +109,25 @@ export async function saveSettingsAction( return { ok: false, error: "sleepBetweenDownloadsSeconds must be a number", + values, }; } if (sleepParsed < 0 || sleepParsed > SLEEP_BETWEEN_DOWNLOADS_MAX_SECONDS) { return { ok: false, error: `sleepBetweenDownloadsSeconds must be between 0 and ${SLEEP_BETWEEN_DOWNLOADS_MAX_SECONDS}`, + values, }; } // 0 disables passive refresh; any other value must land in the allowed window. const autoRefreshParsed = Number.parseInt(autoRefreshRaw, 10); if (!Number.isFinite(autoRefreshParsed)) { - return { ok: false, error: "autoRefreshIntervalSeconds must be a number" }; + return { + ok: false, + error: "autoRefreshIntervalSeconds must be a number", + values, + }; } if ( autoRefreshParsed !== 0 && @@ -119,17 +137,19 @@ export async function saveSettingsAction( return { ok: false, error: `autoRefreshIntervalSeconds must be 0 (off) or between ${AUTO_REFRESH_INTERVAL_MIN_SECONDS} and ${AUTO_REFRESH_INTERVAL_MAX_SECONDS}`, + values, }; } const minFreeDiskParsed = Number.parseInt(minFreeDiskRaw, 10); if (!Number.isFinite(minFreeDiskParsed)) { - return { ok: false, error: "minFreeDiskGB must be a number" }; + return { ok: false, error: "minFreeDiskGB must be a number", values }; } if (minFreeDiskParsed < 0 || minFreeDiskParsed > MIN_FREE_DISK_GB_MAX) { return { ok: false, error: `minFreeDiskGB must be between 0 and ${MIN_FREE_DISK_GB_MAX}`, + values, }; } @@ -141,12 +161,13 @@ export async function saveSettingsAction( ? RESUME_MARGIN_GB_DEFAULT : Number.parseInt(resumeMarginRaw, 10); if (!Number.isFinite(resumeMarginParsed)) { - return { ok: false, error: "resumeMarginGB must be a number" }; + return { ok: false, error: "resumeMarginGB must be a number", values }; } if (resumeMarginParsed < 0 || resumeMarginParsed > RESUME_MARGIN_GB_MAX) { return { ok: false, error: `resumeMarginGB must be between 0 and ${RESUME_MARGIN_GB_MAX}`, + values, }; } @@ -154,7 +175,7 @@ export async function saveSettingsAction( try { socialInput = JSON.parse(String(formData.get("socialLinksJson") ?? "[]")); } catch { - return { ok: false, error: "Social links payload is malformed" }; + return { ok: false, error: "Social links payload is malformed", values }; } const socialParsed = parseSocialLinks(socialInput); if ( @@ -165,6 +186,7 @@ export async function saveSettingsAction( ok: false, error: "Each social link needs a label, URL (http(s)://, mailto:, or /), and SVG.", + values, }; } // A link whose SVG is unchanged from settings.json is kept as it is; a new @@ -174,6 +196,7 @@ export async function saveSettingsAction( return { ok: false, error: `Social link "${checked.refused.label}" has an invalid SVG: ${checked.refused.problem}.`, + values, }; } const socialLinks: SocialLink[] = checked.links; @@ -226,7 +249,7 @@ export async function saveSettingsAction( try { await saveSettings(next); } catch (e) { - return { ok: false, error: (e as Error).message }; + return { ok: false, error: (e as Error).message, values }; } revalidatePath("/settings"); return { ok: true }; diff --git a/editor/app/settings/components/SettingsForm.tsx b/editor/app/settings/components/SettingsForm.tsx @@ -11,7 +11,12 @@ import { DOWNLOAD_FORMAT_LABELS, DOWNLOAD_FORMAT_PRESETS, } from "yt-dlp-transcript-common/ytdlp/downloadFormat"; -import { Field } from "../../components/forms/Field"; +import { + Field, + SeededInput, + SeededSelect, +} from "../../components/forms/Field"; +import { seedChecked } from "../../lib/formState"; import { SocialLinksField, toSocialRow, @@ -36,6 +41,7 @@ export function SettingsForm({ initial }: Props) { <Field label="Admin title" name="adminTitle" + state={state} defaultValue={initial.adminTitle} required hint="Title for this editor's own shell. Public sites get their titles from each site's config." @@ -43,6 +49,7 @@ export function SettingsForm({ initial }: Props) { <Field label="Family hub URL" name="homepageUrl" + state={state} defaultValue={initial.homepageUrl} type="url" hint="Absolute URL of the family hub (e.g. https://archilyzer-hub.pages.dev). Each site that names no hub of its own publishes it in its /site.json and /corpus.json, so the hub can tell its member sites; no page links to it. Leave blank to publish none." @@ -50,6 +57,7 @@ export function SettingsForm({ initial }: Props) { <Field label="Max transcript page bytes" name="maxTranscriptPageBytes" + state={state} defaultValue={String(initial.maxTranscriptPageBytes)} hint="Bytes per generated transcript page (256 KB – 20 MB). Affects pagination next build." type="number" @@ -57,14 +65,16 @@ export function SettingsForm({ initial }: Props) { <Field label="Cookies from browser" name="cookiesFromBrowser" + state={state} defaultValue={initial.cookiesFromBrowser} hint="Browser spec passed to yt-dlp --cookies-from-browser (e.g. firefox, chrome:Default) for age-restricted, members-only, and private videos. When it is used is set by the cookie mode below. Leave blank for no cookies. Each channel can override both in its Advanced settings." /> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Cookie mode</span> - <select + <SeededSelect + state={state} name="cookieMode" - defaultValue={initial.cookieMode} + initial={initial.cookieMode} className="rounded border border-border bg-card px-2 py-1 text-sm" > <option value="when-required"> @@ -76,7 +86,7 @@ export function SettingsForm({ initial }: Props) { <option value="defer"> Defer — never in normal runs; collect into "Needs cookies" </option> - </select> + </SeededSelect> <span className="text-xs text-muted-foreground"> <strong>When required</strong> (default) runs cookie-free and retries only the attempts that fail with an auth/age error.{" "} @@ -92,15 +102,17 @@ export function SettingsForm({ initial }: Props) { <Field label="Sleep between managed downloads (seconds)" name="sleepBetweenDownloadsSeconds" + state={state} defaultValue={String(initial.sleepBetweenDownloadsSeconds)} type="number" hint="Pause inserted between per-video yt-dlp invocations in managed batch downloads (download-from-playlist, download-missing). 0 disables. Default 10s. Each channel can override this in its Advanced settings." /> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Default download format</span> - <select + <SeededSelect + state={state} name="downloadFormat" - defaultValue={initial.downloadFormat} + initial={initial.downloadFormat} className="rounded border border-border bg-card px-2 py-1 text-sm" > {DOWNLOAD_FORMAT_PRESETS.map((p) => ( @@ -108,7 +120,7 @@ export function SettingsForm({ initial }: Props) { {DOWNLOAD_FORMAT_LABELS[p]} </option> ))} - </select> + </SeededSelect> <span className="text-xs text-muted-foreground"> The yt-dlp <code>-f</code> selector for managed downloads.{" "} <strong>Auto</strong> picks per-source: Odysee/LBRY downloads its @@ -121,6 +133,7 @@ export function SettingsForm({ initial }: Props) { <Field label="Minimum free disk space (GB)" name="minFreeDiskGB" + state={state} defaultValue={String(initial.minFreeDiskGB)} type="number" hint="Downloads are prevented from starting, and a running batch stops launching new videos, when free space on the transcripts directory falls below this floor. Default 5 GB. Set to 0 to disable the gate." @@ -128,6 +141,7 @@ export function SettingsForm({ initial }: Props) { <Field label="Resume margin (GB)" name="resumeMarginGB" + state={state} defaultValue={String(initial.resumeMarginGB)} type="number" hint="Extra headroom above the floor that a disk-stopped pipeline must see before it starts writing again. Without it the first resumed download drops free space back under the floor and the pipeline flaps. Default 2 GB. Set to 0 to resume at the floor." @@ -153,6 +167,7 @@ export function SettingsForm({ initial }: Props) { <Field label="Auto-refresh interval (seconds)" name="autoRefreshIntervalSeconds" + state={state} defaultValue={String(initial.autoRefreshIntervalSeconds)} type="number" hint="How often the editor passively re-fetches the current page so sidebar badges and reports stay live without a manual reload. Pauses automatically while the tab is hidden and refreshes once when you return. Default 5s (range 1–600). Set to 0 to disable." @@ -161,7 +176,11 @@ export function SettingsForm({ initial }: Props) { <input type="checkbox" name="inlineTranscribeOnFallback" - defaultChecked={initial.inlineTranscribeOnFallback} + defaultChecked={seedChecked( + state, + "inlineTranscribeOnFallback", + initial.inlineTranscribeOnFallback, + )} className="mt-1" /> <span className="flex flex-col gap-1"> @@ -181,7 +200,11 @@ export function SettingsForm({ initial }: Props) { <input type="checkbox" name="skipLiveDownloads" - defaultChecked={initial.skipLiveDownloads} + defaultChecked={seedChecked( + state, + "skipLiveDownloads", + initial.skipLiveDownloads, + )} className="mt-1" /> <span className="flex flex-col gap-1"> @@ -202,7 +225,11 @@ export function SettingsForm({ initial }: Props) { <input type="checkbox" name="verifyAvailabilityBeforeClean" - defaultChecked={initial.verifyAvailabilityBeforeClean} + defaultChecked={seedChecked( + state, + "verifyAvailabilityBeforeClean", + initial.verifyAvailabilityBeforeClean, + )} className="mt-1" /> <span className="flex flex-col gap-1"> @@ -226,7 +253,11 @@ export function SettingsForm({ initial }: Props) { <input type="checkbox" name="buildArchives" - defaultChecked={initial.buildArchives} + defaultChecked={seedChecked( + state, + "buildArchives", + initial.buildArchives, + )} className="mt-1" /> <span className="flex flex-col gap-1"> @@ -243,10 +274,11 @@ export function SettingsForm({ initial }: Props) { </label> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Archive overflow storage (R2 bucket)</span> - <input + <SeededInput + state={state} type="text" name="archiveStorageBucket" - defaultValue={initial.archiveStorage?.bucket ?? ""} + initial={initial.archiveStorage?.bucket ?? ""} placeholder="my-archives-bucket" className="rounded border border-border bg-card px-2 py-1 text-sm" /> @@ -259,10 +291,11 @@ export function SettingsForm({ initial }: Props) { </label> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Archive overflow public URL</span> - <input + <SeededInput + state={state} type="text" name="archiveStoragePublicBaseUrl" - defaultValue={initial.archiveStorage?.publicBaseUrl ?? ""} + initial={initial.archiveStorage?.publicBaseUrl ?? ""} placeholder="https://archives.example.com" className="rounded border border-border bg-card px-2 py-1 text-sm" /> @@ -277,9 +310,10 @@ export function SettingsForm({ initial }: Props) { </label> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Report refresh debounce</span> - <select + <SeededSelect + state={state} name="reportDebouncePreset" - defaultValue={initial.reportDebouncePreset} + initial={initial.reportDebouncePreset} className="rounded border border-border bg-card px-2 py-1 text-sm" > <option value="fast">Fast — ~1s after activity, no cap</option> @@ -287,7 +321,7 @@ export function SettingsForm({ initial }: Props) { Balanced — ~3s after activity, 30s cap </option> <option value="lazy">Lazy — ~10s after activity, 60s cap</option> - </select> + </SeededSelect> <span className="text-xs text-muted-foreground"> After any action that changes a channel report (download, transcribe, cleanup, file edits…), the report is regenerated automatically. This @@ -307,6 +341,7 @@ export function SettingsForm({ initial }: Props) { <Field label="Max parallel builds" name="maxParallelBuilds" + state={state} defaultValue={String(initial.buildPipeline.maxParallelBuilds)} type="number" hint="Cap on concurrent per-site container builds (1–16) when Build all sites runs in containers — whenever a container engine answers." @@ -314,12 +349,14 @@ export function SettingsForm({ initial }: Props) { <Field label="Docker image tag" name="dockerImage" + state={state} defaultValue={initial.buildPipeline.dockerImage} hint="Tag of the reusable build image, built once and reused for every site." /> <Field label="Dockerfile path" name="dockerfile" + state={state} defaultValue={initial.buildPipeline.dockerfile} hint="Path (relative to the repo root) used to build the image." /> diff --git a/editor/app/sites/actions.ts b/editor/app/sites/actions.ts @@ -30,24 +30,33 @@ import { } from "yt-dlp-transcript-common/lib/channelGroups"; import { migrateToSites } from "yt-dlp-transcript-common/controller/migrateToSites"; import { socialLinksForSave } from "yt-dlp-transcript-common/lib/socialLinks"; +import { formValues, type FormState } from "../lib/formState"; -export type SaveResult = { ok: true; siteId: string } | { ok: false; error: string }; +// A failure carries what was submitted, so SiteForm keeps what was typed +// (lib/formState.ts). +export type SaveResult = FormState<{ siteId: string }>; export async function saveSiteAction( _prev: SaveResult | undefined, formData: FormData, ): Promise<SaveResult> { + const values = formValues(formData); const siteId = String(formData.get("siteId") ?? "").trim(); if (!isValidSiteId(siteId)) { return { ok: false, error: "Site ID must be lowercase letters, numbers, and hyphens.", + values, }; } 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 siteDescription = String(formData.get("siteDescription") ?? "").trim(); const homeTagline = String(formData.get("homeTagline") ?? "").trim(); // The wordmark's heavy lead must be a proper prefix of the header title; @@ -57,6 +66,7 @@ export async function saveSiteAction( return { ok: false, error: `Wordmark lead must be how the header title starts, same case, and shorter than it (e.g. "Jer" for "Jeralyzer") — or left blank.`, + values, }; } // Brand accent: a named id from the radio group, or "custom" + its hex @@ -72,12 +82,17 @@ export async function saveSiteAction( return { ok: false, error: "Custom accent must be a hex color like #cc3366.", + values, }; } } else if (accentChoice) { accent = parseAccentSetting(accentChoice); if (!accent) { - return { ok: false, error: `Unknown brand accent "${accentChoice}".` }; + return { + ok: false, + error: `Unknown brand accent "${accentChoice}".`, + values, + }; } if (accent === DEFAULT_ACCENT) accent = undefined; } @@ -89,6 +104,7 @@ export async function saveSiteAction( return { ok: false, error: "Public URL must be an absolute http(s):// URL, or left blank.", + values, }; } const siteUrl = parseSiteUrl(siteUrlRaw); @@ -102,6 +118,7 @@ export async function saveSiteAction( return { ok: false, error: "Hub URL must be an absolute http(s):// URL, or left blank.", + values, }; } const hubUrl = parseSiteUrl(hubUrlRaw); @@ -121,6 +138,7 @@ export async function saveSiteAction( return { ok: false, error: "Archive size cap must be a non-negative number of MB, or blank.", + values, }; } archiveMaxBytes = Math.floor(mb * 1024 * 1024); @@ -132,7 +150,7 @@ export async function saveSiteAction( String(formData.get("relatedSitesJson") ?? "[]"), ); } catch { - return { ok: false, error: "Related sites payload is malformed" }; + return { ok: false, error: "Related sites payload is malformed", values }; } const relatedSites = parseRelatedSites(relatedSitesInput); @@ -140,13 +158,14 @@ export async function saveSiteAction( try { groupsInput = JSON.parse(String(formData.get("groupsJson") ?? "[]")); } catch { - return { ok: false, error: "Channel groups payload is malformed" }; + return { ok: false, error: "Channel groups payload is malformed", values }; } const groups = parseChannelGroups(groupsInput); if (groups.length === 0) { return { ok: false, error: "At least one channel group is required (check ID/name fields)", + values, }; } const defaultGroupIdRaw = String(formData.get("defaultGroupId") ?? "").trim(); @@ -155,6 +174,7 @@ export async function saveSiteAction( return { ok: false, error: `Default group "${defaultGroupIdRaw}" is not among the configured group IDs`, + values, }; } @@ -167,7 +187,7 @@ export async function saveSiteAction( try { socialInput = JSON.parse(String(formData.get("socialLinksJson") ?? "[]")); } catch { - return { ok: false, error: "Social links payload is malformed" }; + return { ok: false, error: "Social links payload is malformed", values }; } const socialParsed = parseSocialLinks(socialInput); if ( @@ -178,6 +198,7 @@ export async function saveSiteAction( ok: false, error: "Each social link needs a label, URL (http(s)://, mailto:, or /), and SVG.", + values, }; } // A link whose SVG is unchanged from the site's file is kept as it is; a @@ -193,6 +214,7 @@ export async function saveSiteAction( return { ok: false, error: `Social link "${checked.refused.label}" has an invalid SVG: ${checked.refused.problem}.`, + values, }; } socialLinks = checked.links; @@ -202,7 +224,11 @@ export async function saveSiteAction( try { channelsInput = JSON.parse(String(formData.get("channelsJson") ?? "[]")); } catch { - return { ok: false, error: "Channel membership payload is malformed" }; + return { + ok: false, + error: "Channel membership payload is malformed", + values, + }; } const channels = parseSiteChannels(channelsInput); @@ -233,7 +259,11 @@ export async function saveSiteAction( try { await writeSite(site, getPaths()); } 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"); // "layout": the h1 above every tab reads siteTitle, and a literal path would diff --git a/editor/app/sites/components/SiteForm.tsx b/editor/app/sites/components/SiteForm.tsx @@ -16,6 +16,12 @@ import { toSocialRow, type SocialRow, } from "../../components/SocialLinksField"; +import { Field } from "../../components/forms/Field"; +import { + ControlledCheck, + ControlledSelect, +} from "../../components/forms/Controlled"; +import { seedChecked } from "../../lib/formState"; export type ChannelOption = { slug: string; name: string }; export type SiteOption = { siteId: string; siteTitle: string }; @@ -208,6 +214,7 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { {isNew ? ( <Field label="Site ID" + state={state} name="siteId" defaultValue={initial.siteId} required @@ -224,29 +231,34 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { )} <Field label="Site title" + state={state} name="siteTitle" defaultValue={initial.siteTitle} required /> <Field label="Header title" + state={state} name="headerTitle" defaultValue={initial.headerTitle} required /> <Field label="Wordmark lead" + state={state} name="wordmarkLead" defaultValue={initial.wordmarkLead ?? ""} hint={'The heavy start of the wordmark — the subject\'s name, e.g. "Jer" for Jeralyzer; the rest is set light. It must be how the header text begins (same case) and shorter than it. Leave blank to set the whole name heavy.'} /> <Field label="Site description" + state={state} name="siteDescription" defaultValue={initial.siteDescription} /> <Field label="Home tagline" + state={state} name="homeTagline" defaultValue={initial.homeTagline} /> @@ -260,7 +272,7 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { <div className="flex flex-wrap gap-x-4 gap-y-2"> {ACCENT_IDS.map((id) => ( <label key={id} className="flex items-center gap-1.5"> - <input + <ControlledCheck type="radio" name="accent" value={id} @@ -283,7 +295,7 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { </label> ))} <label className="flex items-center gap-1.5"> - <input + <ControlledCheck type="radio" name="accent" value="custom" @@ -317,12 +329,14 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { </fieldset> <Field label="Cloudflare Pages project" + state={state} name="cloudflareProject" defaultValue={initial.cloudflareProject ?? ""} hint="Project name this site deploys to (wrangler --project-name). Leave blank to set later." /> <Field label="Public URL" + state={state} name="siteUrl" defaultValue={initial.siteUrl ?? ""} hint="Absolute URL this site is served at (e.g. https://jeralyzer.com). Used so other sites can link to it in their footer. Leave blank to omit this site from cross-site lists." @@ -331,7 +345,11 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { <input type="checkbox" name="listed" - defaultChecked={initial.listed !== false} + defaultChecked={seedChecked( + state, + "listed", + initial.listed !== false, + )} className="accent-brand" /> List on the Archilyzer homepage and hub @@ -345,6 +363,7 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { </p> <Field label="Hub URL" + state={state} name="hubUrl" defaultValue={initial.hubUrl ?? ""} hint="The hub this site belongs under (e.g. https://archilyzer-hub.pages.dev), published in this site's /site.json and /corpus.json so the hub can tell it is a member; the header does not link to it. Leave blank to inherit the family default from Settings." @@ -353,7 +372,7 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { <input type="checkbox" name="pwa" - defaultChecked={initial.pwa === true} + defaultChecked={seedChecked(state, "pwa", initial.pwa === true)} className="accent-brand" /> Ship as an installable PWA @@ -368,7 +387,11 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { <input type="checkbox" name="archives" - defaultChecked={initial.archives !== false} + defaultChecked={seedChecked( + state, + "archives", + initial.archives !== false, + )} className="accent-brand" /> Generate downloadable archive zips on build @@ -380,8 +403,10 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { </p> <Field label="Archive size cap (MB)" + state={state} name="archiveMaxMB" type="number" + min="0" defaultValue={ typeof initial.archiveMaxBytes === "number" ? String(Math.round(initial.archiveMaxBytes / (1024 * 1024))) @@ -393,7 +418,11 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { <input type="checkbox" name="transcriptDownloads" - defaultChecked={initial.transcriptDownloads !== false} + defaultChecked={seedChecked( + state, + "transcriptDownloads", + initial.transcriptDownloads !== false, + )} className="accent-brand" /> Per-video transcript downloads (Download menu, Copy Markdown) @@ -409,7 +438,11 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { <input type="checkbox" name="duplicates" - defaultChecked={initial.duplicates !== false} + defaultChecked={seedChecked( + state, + "duplicates", + initial.duplicates !== false, + )} className="accent-brand" /> Show the Duplicates page @@ -438,7 +471,7 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { className="flex items-center gap-2 text-sm py-1 border-b border-border" > <label className="flex items-center gap-2 flex-1 min-w-0"> - <input + <ControlledCheck type="checkbox" checked={on} onChange={(e) => toggleMember(c.slug, e.target.checked)} @@ -450,7 +483,7 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { </span> </label> {on && groupOptions.length > 0 && ( - <select + <ControlledSelect value={members.get(c.slug) ?? ""} onChange={(e) => setMemberGroup(c.slug, e.target.value)} aria-label={`Group for ${c.name || c.slug}`} @@ -462,7 +495,7 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { {g.label} </option> ))} - </select> + </ControlledSelect> )} </div> ); @@ -486,7 +519,7 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { > <div className="flex items-center justify-between gap-2"> <label className="flex items-center gap-1.5 text-xs select-none"> - <input + <ControlledCheck type="radio" checked={isDefault} onChange={() => setDefaultGroupId(idTrim)} @@ -574,7 +607,7 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { /> </label> <label className="flex items-center gap-2 text-sm"> - <input + <ControlledCheck type="checkbox" checked={g.selectedByDefault} onChange={(e) => @@ -591,7 +624,7 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { Selected by default </label> <label className="flex items-center gap-2 text-sm"> - <input + <ControlledCheck type="checkbox" checked={g.inline} onChange={(e) => @@ -631,7 +664,7 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { value={inheritSocial ? "on" : ""} /> <label className="flex items-center gap-2 text-sm"> - <input + <ControlledCheck type="checkbox" checked={inheritSocial} onChange={(e) => setInheritSocial(e.target.checked)} @@ -718,7 +751,7 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { key={s.siteId} className="flex items-center gap-2 text-sm" > - <input + <ControlledCheck type="checkbox" checked={g.siteIds.includes(s.siteId)} onChange={(e) => @@ -775,34 +808,3 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { </form> ); } - -function Field({ - label, - name, - defaultValue, - hint, - required, - type = "text", -}: { - label: string; - name: string; - defaultValue?: string; - hint?: string; - required?: boolean; - type?: string; -}) { - return ( - <label className="flex flex-col gap-1 text-sm"> - <span className="font-medium">{label}</span> - <input - type={type} - name={name} - defaultValue={defaultValue} - required={required} - min={type === "number" ? 0 : undefined} - className="rounded border border-border bg-card px-2 py-1 text-sm" - /> - {hint && <span className="text-xs text-muted-foreground">{hint}</span>} - </label> - ); -}