// Hand-written mirror of arr-api's /api/policies and /api/roots schemas, // plus the settings view over them — same reasoning as search.ts: the // generated client (src/api/) is uncommitted, so CI's tsc cannot see it. import { navigate } from "./router"; import type { Root } from "./search"; /** One policy row: the JSON columns as the API returns them. */ export interface PolicyDoc { id: number; name: string; required_audio: { require: "original_language" } | { require: "any_of"; langs: string[] }; dub_blacklist: string[]; hdr_rules: { dv_profile_reject: string[] }; size_bands: Record< string, { floor_gib: number; target_gib: number; penalty_points_per_gib_over: number } >; resolution_pref: string[]; source_weights: Record; score_weights: { size_at_target: number; source_tier: number; seeder_doubling: number; resolution_step: number; }; } export interface RootInput { kind: string; audience: string; path: string; policy_id: number; } const RESOLUTIONS = ["2160p", "1080p", "720p"] as const; const SOURCES = ["Remux", "BluRay", "WEB-DL", "WEBRip", "HDTV"] as const; /** DESIGN.md §15: OpenSubtitles and Podnapisi, no cargo feature gate. */ const PROVIDERS = ["opensubtitles", "podnapisi"] as const; /** The runtime-editable half of subtitle config (§15) — issue #198's shape. */ export interface SubtitleSettings { wanted_languages: string[]; providers_enabled: string[]; translation_engine: string | null; provider_daily_budgets: Record; translator_daily_budgets: Record; remote_command_timeout_seconds: number; /** * Where the OpenAI-compatible backend points and which model it names * (§15, #220). `null` on either means the backend's own default — that * backend is anything speaking the shape, `llama.cpp` included, so both * are settings rather than deployment facts. Neither is a secret; the API * key stays in the environment. */ openai_base_url: string | null; openai_model: string | null; /** Engines this binary compiled in — `translation_engine` is always one of these, or null. */ available_engines: string[]; } export type SubtitleSettingsInput = Omit; async function errorDetail(response: Response): Promise { try { const body = (await response.json()) as { error?: string }; return body.error ?? `http ${response.status}`; } catch { return `http ${response.status}`; } } async function fetchPolicies(): Promise { const response = await fetch("/api/policies"); if (!response.ok) { throw new Error(await errorDetail(response)); } return (await response.json()) as PolicyDoc[]; } async function fetchRoots(): Promise { const response = await fetch("/api/roots"); if (!response.ok) { throw new Error(await errorDetail(response)); } return (await response.json()) as Root[]; } async function fetchSubtitleSettings(): Promise { const response = await fetch("/api/settings/subtitles"); if (!response.ok) { throw new Error(await errorDetail(response)); } return (await response.json()) as SubtitleSettings; } function saveSubtitleSettings(input: SubtitleSettingsInput): Promise { return sendJson("/api/settings/subtitles", "PUT", input); } type SaveOutcome = { ok: true; json: unknown } | { ok: false; detail: string }; async function sendJson(url: string, method: string, body: unknown): Promise { try { const response = await fetch(url, { method, headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); if (!response.ok) { return { ok: false, detail: await errorDetail(response) }; } return { ok: true, json: await response.json() }; } catch { return { ok: false, detail: "daemon unreachable" }; } } function savePolicy(id: number | null, policy: Omit): Promise { return id === null ? sendJson("/api/policies", "POST", policy) : sendJson(`/api/policies/${id}`, "PUT", policy); } function saveRoot(id: number | null, root: RootInput): Promise { return id === null ? sendJson("/api/roots", "POST", root) : sendJson(`/api/roots/${id}`, "PUT", root); } async function deleteRow(kind: "policies" | "roots", id: number): Promise { try { const response = await fetch(`/api/${kind}/${id}`, { method: "DELETE" }); if (!response.ok) { return await errorDetail(response); } return null; } catch { return "daemon unreachable"; } } /* ---- form primitives ---------------------------------------------------- */ function must(selector: string): T { const element = document.querySelector(selector); if (!element) { throw new Error(`settings markup is missing ${selector}`); } return element; } function el( tag: K, className: string, text?: string, ): HTMLElementTagNameMap[K] { const node = document.createElement(tag); node.className = className; if (text !== undefined) { node.textContent = text; } return node; } function chip(text: string): HTMLSpanElement { return el("span", "chip readout", text); } function rowTitle(title: string): HTMLElement { const wrap = el("span", "row-id"); wrap.append(el("span", "row-title", title)); return wrap; } function field(labelText: string, control: HTMLElement): HTMLDivElement { const wrap = el("div", "field"); wrap.append(el("span", "field-label readout dim", labelText), control); return wrap; } function textControl(value: string, placeholder = ""): HTMLInputElement { const input = document.createElement("input"); input.type = "text"; input.className = "form-input readout"; input.value = value; input.placeholder = placeholder; input.spellcheck = false; input.autocomplete = "off"; return input; } function numberControl(value: number): HTMLInputElement { const input = document.createElement("input"); input.type = "number"; input.className = "form-input readout form-num"; input.value = String(value); return input; } function selectControl(options: readonly string[], value: string): HTMLSelectElement { const select = document.createElement("select"); select.className = "form-input readout"; for (const option of options) { const item = document.createElement("option"); item.value = option; item.textContent = option; if (option === value) { item.selected = true; } select.append(item); } select.value = value; return select; } /** `a, b, c` ⇄ parts. Empty entries dropped; validation happens at save. */ function listValue(input: HTMLInputElement): string[] { return input.value .split(",") .map((part) => part.trim()) .filter((part) => part !== ""); } /** * First click arms the destructive action, second confirms. The armed state * clears on blur or after a few seconds, so an accidental double click * never deletes. Shared with the series detail rows — one confirmation * idiom for row-level destruction, not one per page. */ export function armedDelete(label: string, execute: () => void): HTMLButtonElement { const button = el("button", "control control-quiet readout", label); button.type = "button"; let armed = false; let resetTimer: number | undefined; const disarm = () => { armed = false; window.clearTimeout(resetTimer); delete button.dataset.armed; button.textContent = label; }; button.addEventListener("click", () => { if (armed) { disarm(); button.disabled = true; execute(); return; } armed = true; button.dataset.armed = "true"; button.textContent = `confirm ${label}`; resetTimer = window.setTimeout(disarm, 4000); }); button.addEventListener("blur", () => { if (armed) { disarm(); } }); return button; } interface SettingsView { hide: () => void; open: () => void; } export function settingsMain(views: { hide: () => void }[], goHome: () => void): SettingsView { const view = must("#settings"); const deck = must("#deck"); const nav = must("#nav-settings"); const summary = must("#settings-summary"); const status = must("#settings-status"); const rootsSection = must("#group-roots"); const rootsRows = must("#rows-roots"); const policiesSection = must("#group-policies"); const policiesRows = must("#rows-policies"); const subtitlesSection = must("#group-subtitles"); const subtitlesBody = must("#subtitles-body"); let roots: Root[] = []; let policies: PolicyDoc[] = []; let subtitleSettings: SubtitleSettings | null = null; // guards a stale fetch from painting over a newer view let sequence = 0; function setStatus(text: string | null, tone?: "fault") { status.hidden = text === null; status.textContent = text ?? ""; if (tone) { status.dataset.tone = tone; } else { delete status.dataset.tone; } } function clearGroups() { for (const section of [rootsSection, policiesSection, subtitlesSection]) { section.hidden = true; } rootsRows.replaceChildren(); policiesRows.replaceChildren(); subtitlesBody.replaceChildren(); summary.textContent = ""; } async function load() { sequence += 1; const ticket = sequence; setStatus("reading settings…"); try { const [fetchedRoots, fetchedPolicies, fetchedSubtitleSettings] = await Promise.all([ fetchRoots(), fetchPolicies(), fetchSubtitleSettings(), ]); if (ticket !== sequence) { return; } roots = fetchedRoots; policies = fetchedPolicies; subtitleSettings = fetchedSubtitleSettings; } catch (error) { if (ticket !== sequence) { return; } clearGroups(); setStatus(`settings unavailable — ${(error as Error).message}`, "fault"); return; } render(); } function render() { clearGroups(); setStatus(null); rootsSection.hidden = false; policiesSection.hidden = false; summary.textContent = `${roots.length} roots · ${policies.length} policies`; for (const root of roots) { rootsRows.append(rootRow(root)); } for (const policy of policies) { policiesRows.append(policyRow(policy)); } if (subtitleSettings) { subtitlesSection.hidden = false; subtitlesBody.append(subtitlesPanel(subtitleSettings)); } } /* -- roots -------------------------------------------------------------- */ /** A delete/edit action pair sharing the row's feedback line. */ function rowActions( item: HTMLLIElement, del: HTMLButtonElement, edit: HTMLButtonElement, ): HTMLParagraphElement { const actions = el("div", "settings-row-actions"); actions.append(edit, del); item.append(actions); const note = el("p", "add-note readout"); note.setAttribute("role", "status"); note.hidden = true; item.append(note); return note; } function rootRow(root: Root): HTMLLIElement { const item = el("li", "queue-item"); const row = el("div", "row"); const chips = el("span", "row-chips"); chips.append(chip(root.kind), chip(root.audience), chip(root.policy_name)); row.append(rowTitle(root.path), chips); item.append(row); const del = armedDelete("delete", () => { void deleteRow("roots", root.id).then((detail) => { if (detail !== null) { note.hidden = false; note.dataset.tone = "fault"; note.textContent = detail; return; } void load(); }); }); const edit = el("button", "control control-quiet readout", "edit"); edit.type = "button"; edit.setAttribute("aria-expanded", "false"); edit.addEventListener("click", () => { const existing = item.querySelector(".edit-panel"); if (existing) { existing.remove(); edit.setAttribute("aria-expanded", "false"); return; } item.append(rootPanel(root)); edit.setAttribute("aria-expanded", "true"); }); const note = rowActions(item, del, edit); return item; } function rootPanel(root: Root | null): HTMLElement { const panel = el("div", "edit-panel add-panel"); const grid = el("div", "form-grid"); const kind = selectControl(["movie", "tv"], root?.kind ?? "movie"); const audience = selectControl(["main", "kids"], root?.audience ?? "main"); const path = textControl(root?.path ?? "", "/mnt/media/…"); const policyId = selectControl( policies.map((policy) => String(policy.id)), String(root?.policy_id ?? policies[0]?.id ?? ""), ); // readable option labels without coupling value → name lookups for (const [index, policy] of policies.entries()) { const option = policyId.options[index]; if (option) { option.textContent = policy.name; } } grid.append( field("kind", kind), field("audience", audience), field("path", path), field("policy", policyId), ); const note = el("p", "add-note readout"); note.setAttribute("role", "status"); note.hidden = true; const save = el("button", "control readout", root === null ? "create root" : "save root"); save.type = "button"; save.addEventListener("click", () => { save.disabled = true; note.hidden = false; delete note.dataset.tone; note.textContent = "saving…"; const input: RootInput = { kind: kind.value, audience: audience.value, path: path.value.trim(), policy_id: Number(policyId.value), }; void saveRoot(root?.id ?? null, input).then((outcome) => { if (outcome.ok) { void load(); return; } save.disabled = false; note.hidden = false; note.dataset.tone = "fault"; note.textContent = outcome.detail; }); }); const cancel = el("button", "control control-quiet readout", "cancel"); cancel.type = "button"; cancel.addEventListener("click", () => { panel.remove(); }); const actions = el("div", "add-actions"); actions.append(save, cancel, note); panel.append(grid, actions); queueMicrotask(() => (root === null ? kind : path).focus()); return panel; } /* -- policies ------------------------------------------------------------ */ function requiredAudioLabel(policy: PolicyDoc): string { return policy.required_audio.require === "original_language" ? "original language" : `any of ${policy.required_audio.langs.join(", ")}`; } function policyRow(policy: PolicyDoc): HTMLLIElement { const item = el("li", "queue-item"); const row = el("div", "row"); const chips = el("span", "row-chips"); chips.append(chip(policy.resolution_pref.join(" › ") || "no resolutions")); chips.append(chip(requiredAudioLabel(policy))); const bandCount = Object.keys(policy.size_bands).length; chips.append(chip(`${bandCount} band${bandCount === 1 ? "" : "s"}`)); row.append(rowTitle(policy.name), chips); item.append(row); const del = armedDelete("delete", () => { void deleteRow("policies", policy.id).then((detail) => { if (detail !== null) { note.hidden = false; note.dataset.tone = "fault"; note.textContent = detail; return; } void load(); }); }); const edit = el("button", "control control-quiet readout", "edit"); edit.type = "button"; edit.setAttribute("aria-expanded", "false"); edit.addEventListener("click", () => { const existing = item.querySelector(".edit-panel"); if (existing) { existing.remove(); edit.setAttribute("aria-expanded", "false"); return; } item.append(policyPanel(policy)); edit.setAttribute("aria-expanded", "true"); }); const note = rowActions(item, del, edit); return item; } /** * Comma-list editor that checks its vocabulary while typing, so an unknown * resolution or profile is named before a round trip. */ function knownList( labelText: string, values: string[], known: readonly string[], invalid: (message: string | null) => void, ): HTMLDivElement { const input = textControl(values.join(", "), known.join(", ")); input.addEventListener("input", () => { const unknown = listValue(input).find((part) => !known.includes(part)); if (unknown !== undefined) { input.dataset.invalid = "true"; invalid(`${labelText}: unknown value '${unknown}'`); } else if (input.dataset.invalid !== undefined) { delete input.dataset.invalid; invalid(null); } }); return field(labelText, input); } function newPolicyDoc(): Omit { return { name: "", required_audio: { require: "original_language" }, dub_blacklist: ["pt-BR"], hdr_rules: { dv_profile_reject: ["5", "7"] }, size_bands: {}, resolution_pref: ["2160p", "1080p"], source_weights: {}, score_weights: { size_at_target: 1000, source_tier: 25, seeder_doubling: 8, resolution_step: 300, }, }; } function policyPanel(policy: PolicyDoc | null): HTMLElement { const current: Omit = policy ?? newPolicyDoc(); const panel = el("div", "edit-panel add-panel"); const grid = el("div", "form-grid"); const invalidate = (message: string | null) => { note.hidden = message === null || note.textContent === ""; if (message !== null) { note.dataset.tone = "fault"; note.textContent = message; } else if (note.dataset.tone === "fault") { note.hidden = true; } }; const note = el("p", "add-note readout"); note.setAttribute("role", "status"); note.hidden = true; const name = textControl(current.name, "policy name"); grid.append(field("name", name)); const audioMode = selectControl( ["original_language", "any_of"], current.required_audio.require, ); const audioLangs = textControl( current.required_audio.require === "any_of" ? current.required_audio.langs.join(", ") : "", "pt-PT, …", ); audioMode.addEventListener("change", () => { audioLangs.disabled = audioMode.value === "original_language"; }); audioLangs.disabled = audioMode.value === "original_language"; const blacklist = textControl(current.dub_blacklist.join(", "), "pt-BR"); grid.append(field("required audio", audioMode), field("required langs", audioLangs)); grid.append(field("dub blacklist", blacklist)); let profilesInput: HTMLInputElement; const profiles = knownList( "rejected dv profiles", current.hdr_rules.dv_profile_reject, ["5", "7", "8"], invalidate, ); profilesInput = profiles.querySelector("input") ?? document.createElement("input"); grid.append(profiles); // one line per resolution: floor, target, penalty — numbers stay numbers for (const resolution of RESOLUTIONS) { const band = current.size_bands[resolution]; const line = el("div", "band-line"); line.dataset.resolution = resolution; line.append(el("span", "field-label readout dim", resolution)); const floor = numberControl(band?.floor_gib ?? 0); const target = numberControl(band?.target_gib ?? 0); const penalty = numberControl(band?.penalty_points_per_gib_over ?? 0); floor.setAttribute("aria-label", `${resolution} floor GiB`); target.setAttribute("aria-label", `${resolution} target GiB`); penalty.setAttribute("aria-label", `${resolution} penalty points per GiB over`); for (const input of [floor, target, penalty]) { input.classList.add("band-num"); line.append(input); } grid.append(line); } let prefInput: HTMLInputElement; const pref = knownList( "resolution preference (first wins)", current.resolution_pref, RESOLUTIONS, invalidate, ); prefInput = pref.querySelector("input") ?? document.createElement("input"); grid.append(pref); const WEIGHT_KEYS = [ "size_at_target", "source_tier", "seeder_doubling", "resolution_step", ] as const; type SourceKey = (typeof SOURCES)[number]; type WeightKey = (typeof WEIGHT_KEYS)[number]; const sourceInputs = {} as Record; for (const source of SOURCES) { const weight = numberControl(current.source_weights[source] ?? 0); weight.setAttribute("aria-label", `${source} source weight`); grid.append(field(`source · ${source.toLowerCase()}`, weight)); sourceInputs[source] = weight; } const scoreInputs = {} as Record; for (const key of WEIGHT_KEYS) { const input = numberControl(current.score_weights[key]); input.setAttribute("aria-label", key.replaceAll("_", " ")); grid.append(field(key.replaceAll("_", " "), input)); scoreInputs[key] = input; } const save = el("button", "control readout", policy === null ? "create policy" : "save policy"); save.type = "button"; save.addEventListener("click", () => { const bad = panel.querySelector("[data-invalid]"); if (bad !== null) { note.hidden = false; note.dataset.tone = "fault"; note.textContent = "fix the highlighted fields first"; return; } const emptyWeight = [...Object.values(sourceInputs), ...Object.values(scoreInputs)].find( (input) => input.value === "", ); if (name.value.trim() === "" || emptyWeight !== undefined) { note.hidden = false; note.dataset.tone = "fault"; note.textContent = "name and every scoring weight are required"; return; } const sizeBands: PolicyDoc["size_bands"] = {}; for (const line of panel.querySelectorAll(".band-line")) { const [floor, target, penalty] = [...line.querySelectorAll("input")]; if (!floor || !target || !penalty) { continue; } sizeBands[line.dataset.resolution ?? ""] = { floor_gib: Number(floor.value), target_gib: Number(target.value), penalty_points_per_gib_over: Number(penalty.value), }; } save.disabled = true; note.hidden = false; delete note.dataset.tone; note.textContent = "saving…"; const payload: Omit = { name: name.value.trim(), required_audio: audioMode.value === "any_of" ? { require: "any_of", langs: listValue(audioLangs) } : { require: "original_language" }, dub_blacklist: listValue(blacklist), hdr_rules: { dv_profile_reject: listValue(profilesInput) }, size_bands: sizeBands, resolution_pref: listValue(prefInput), source_weights: Object.fromEntries( SOURCES.filter((source) => sourceInputs[source].value !== "").map((source) => [ source, Number(sourceInputs[source].value), ]), ), score_weights: Object.fromEntries( WEIGHT_KEYS.map((key) => [key, Number(scoreInputs[key].value)]), ) as PolicyDoc["score_weights"], }; void savePolicy(policy?.id ?? null, payload).then((outcome) => { if (outcome.ok) { void load(); return; } save.disabled = false; note.hidden = false; note.dataset.tone = "fault"; note.textContent = outcome.detail; }); }); const cancel = el("button", "control control-quiet readout", "cancel"); cancel.type = "button"; cancel.addEventListener("click", () => { panel.remove(); }); const actions = el("div", "add-actions"); actions.append(save, cancel, note); panel.append(grid, actions); queueMicrotask(() => name.focus()); return panel; } /* -- subtitles ------------------------------------------------------------ */ /** * One row per known provider, checkbox to enable, a daily-budget number, * and up/down controls — list order is the priority order the API stores * in `providers_enabled`. Disabled providers stay in the list, at * whatever position they were left, so re-enabling one keeps its spot. */ function providerRow( provider: string, enabled: boolean, budget: number, ): { item: HTMLLIElement; enabledInput: HTMLInputElement; budgetInput: HTMLInputElement } { const item = el("li", "provider-row"); item.dataset.provider = provider; const enabledInput = document.createElement("input"); enabledInput.type = "checkbox"; enabledInput.className = "provider-toggle"; enabledInput.checked = enabled; enabledInput.setAttribute("aria-label", `enable ${provider}`); const name = el("span", "row-title provider-name", provider); const budgetInput = numberControl(budget); budgetInput.classList.add("provider-budget"); budgetInput.setAttribute("aria-label", `${provider} daily budget`); const up = el("button", "control control-quiet readout provider-move", "up"); up.type = "button"; up.setAttribute("aria-label", `move ${provider} up`); const down = el("button", "control control-quiet readout provider-move", "down"); down.type = "button"; down.setAttribute("aria-label", `move ${provider} down`); up.addEventListener("click", () => { const previous = item.previousElementSibling; if (previous) { previous.before(item); refreshProviderEdges(); } }); down.addEventListener("click", () => { const next = item.nextElementSibling; if (next) { next.after(item); refreshProviderEdges(); } }); item.append(enabledInput, name, budgetInput, up, down); return { item, enabledInput, budgetInput }; } let refreshProviderEdges: () => void = () => {}; function subtitlesPanel(settings: SubtitleSettings): HTMLElement { const panel = el("div", "add-panel subtitle-panel"); const grid = el("div", "form-grid"); const wanted = textControl(settings.wanted_languages.join(", "), "pt-PT, en"); grid.append(field("wanted languages", wanted)); const hasEngines = settings.available_engines.length > 0; const engineSelect = selectControl( ["none", ...settings.available_engines], settings.translation_engine ?? "none", ); engineSelect.disabled = !hasEngines; grid.append(field("translation engine", engineSelect)); const timeout = numberControl(settings.remote_command_timeout_seconds); grid.append(field("remote command timeout (s)", timeout)); const hasOpenAi = settings.available_engines.includes("openai"); const openAiBaseUrl = textControl( settings.openai_base_url ?? "", "optional — e.g. http://127.0.0.1:8080/v1", ); const openAiModel = textControl(settings.openai_model ?? "", "optional — e.g. gpt-4o-mini"); openAiBaseUrl.setAttribute("aria-label", "openai base url"); openAiModel.setAttribute("aria-label", "openai model"); panel.append(grid); if (!hasEngines) { panel.append( el( "p", "add-note readout dim", "no translation engine is compiled into this binary — pick is unavailable", ), ); } // Their own labelled row rather than two more cells in the grid above: // paired, they belong to one engine, and a base URL needs the width. if (hasOpenAi) { panel.append( el("p", "field-label readout dim", "openai-compatible endpoint — blank means its default"), ); const openAiGrid = el("div", "form-grid"); openAiGrid.append(field("base url", openAiBaseUrl), field("model", openAiModel)); panel.append(openAiGrid); } const translatorInputs: Record = {}; if (hasEngines) { panel.append(el("p", "field-label readout dim", "translator daily budgets")); const translatorGrid = el("div", "form-grid"); for (const engine of settings.available_engines) { const input = numberControl(settings.translator_daily_budgets[engine] ?? 0); input.setAttribute("aria-label", `${engine} daily budget`); translatorInputs[engine] = input; translatorGrid.append(field(engine, input)); } panel.append(translatorGrid); } panel.append(el("p", "field-label readout dim", "providers — enabled, and in priority order")); const enabledProviders = new Set(settings.providers_enabled); const order = [ ...settings.providers_enabled, ...PROVIDERS.filter((provider) => !enabledProviders.has(provider)), ]; const list = el("ul", "provider-list"); const rows: Record = {}; for (const provider of order) { const { item, enabledInput, budgetInput } = providerRow( provider, enabledProviders.has(provider), settings.provider_daily_budgets[provider] ?? 0, ); rows[provider] = { enabledInput, budgetInput }; list.append(item); } refreshProviderEdges = () => { const items = [...list.children]; items.forEach((item, index) => { const up = item.querySelector("button:nth-of-type(1)"); const down = item.querySelector("button:nth-of-type(2)"); if (up) up.disabled = index === 0; if (down) down.disabled = index === items.length - 1; }); }; refreshProviderEdges(); panel.append(list); const note = el("p", "add-note readout"); note.setAttribute("role", "status"); note.hidden = true; const save = el("button", "control readout", "save subtitles"); save.type = "button"; save.addEventListener("click", () => { const providerOrder = [...list.children].map( (item) => (item as HTMLElement).dataset.provider ?? "", ); const providersEnabled = providerOrder.filter( (provider) => rows[provider]?.enabledInput.checked, ); const providerBudgets: Record = {}; for (const provider of providerOrder) { const value = Number(rows[provider]?.budgetInput.value ?? 0); if (value > 0) { providerBudgets[provider] = value; } } const translatorBudgets: Record = {}; for (const [engine, input] of Object.entries(translatorInputs)) { const value = Number(input.value); if (value > 0) { translatorBudgets[engine] = value; } } save.disabled = true; note.hidden = false; delete note.dataset.tone; note.textContent = "saving…"; const input: SubtitleSettingsInput = { wanted_languages: listValue(wanted), providers_enabled: providersEnabled, translation_engine: engineSelect.value === "none" ? null : engineSelect.value, provider_daily_budgets: providerBudgets, translator_daily_budgets: translatorBudgets, remote_command_timeout_seconds: Number(timeout.value), // An emptied field is "use the backend's default", which the API // stores as NULL. openai_base_url: hasOpenAi ? openAiBaseUrl.value.trim() || null : settings.openai_base_url, openai_model: hasOpenAi ? openAiModel.value.trim() || null : settings.openai_model, }; void saveSubtitleSettings(input).then((outcome) => { if (outcome.ok) { void load(); return; } save.disabled = false; note.hidden = false; note.dataset.tone = "fault"; note.textContent = outcome.detail; }); }); const actions = el("div", "add-actions"); actions.append(save, note); panel.append(actions); return panel; } /* -- shell ---------------------------------------------------------------- */ function open() { for (const sibling of views) { sibling.hide(); } deck.hidden = true; view.hidden = false; nav.setAttribute("aria-pressed", "true"); void load(); } function hide() { view.hidden = true; nav.setAttribute("aria-pressed", "false"); sequence += 1; } function close() { hide(); goHome(); nav.focus(); } nav.addEventListener("click", () => { if (view.hidden) { navigate({ kind: "settings" }); open(); } else { navigate({ kind: "library" }); close(); } }); must("#root-add").addEventListener("click", () => { rootsSection.append(rootPanel(null)); }); must("#policy-add").addEventListener("click", () => { policiesSection.append(policyPanel(null)); }); // capture, like the other decks: one Esc steps back one layer window.addEventListener( "keydown", (event) => { if (event.key === "Escape" && !view.hidden) { event.stopImmediatePropagation(); navigate({ kind: "library" }); close(); } }, true, ); return { hide, open }; }