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:
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>
- );
-}