Own route beside library and queues. Roots edit kind, audience, path and their policy; policies get a form over the real fields — size bands as number inputs, score and source weights as numbers, resolution preference and audio/HDR rules as validated lists. Delete arms before firing; a 409 from a referenced policy or occupied root surfaces its message.
760 lines
23 KiB
TypeScript
760 lines
23 KiB
TypeScript
// 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<string, number>;
|
||
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;
|
||
|
||
async function errorDetail(response: Response): Promise<string> {
|
||
try {
|
||
const body = (await response.json()) as { error?: string };
|
||
return body.error ?? `http ${response.status}`;
|
||
} catch {
|
||
return `http ${response.status}`;
|
||
}
|
||
}
|
||
|
||
async function fetchPolicies(): Promise<PolicyDoc[]> {
|
||
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<Root[]> {
|
||
const response = await fetch("/api/roots");
|
||
if (!response.ok) {
|
||
throw new Error(await errorDetail(response));
|
||
}
|
||
return (await response.json()) as Root[];
|
||
}
|
||
|
||
type SaveOutcome = { ok: true; json: unknown } | { ok: false; detail: string };
|
||
|
||
async function sendJson(url: string, method: string, body: unknown): Promise<SaveOutcome> {
|
||
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<PolicyDoc, "id">): Promise<SaveOutcome> {
|
||
return id === null
|
||
? sendJson("/api/policies", "POST", policy)
|
||
: sendJson(`/api/policies/${id}`, "PUT", policy);
|
||
}
|
||
|
||
function saveRoot(id: number | null, root: RootInput): Promise<SaveOutcome> {
|
||
return id === null
|
||
? sendJson("/api/roots", "POST", root)
|
||
: sendJson(`/api/roots/${id}`, "PUT", root);
|
||
}
|
||
|
||
async function deleteRow(kind: "policies" | "roots", id: number): Promise<string | null> {
|
||
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<T extends Element>(selector: string): T {
|
||
const element = document.querySelector<T>(selector);
|
||
if (!element) {
|
||
throw new Error(`settings markup is missing ${selector}`);
|
||
}
|
||
return element;
|
||
}
|
||
|
||
function el<K extends keyof HTMLElementTagNameMap>(
|
||
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.
|
||
*/
|
||
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(board: HTMLElement, views: { hide: () => void }[]): SettingsView {
|
||
const view = must<HTMLElement>("#settings");
|
||
const deck = must<HTMLElement>("#deck");
|
||
const nav = must<HTMLButtonElement>("#nav-settings");
|
||
const summary = must<HTMLElement>("#settings-summary");
|
||
const status = must<HTMLElement>("#settings-status");
|
||
const rootsSection = must<HTMLElement>("#group-roots");
|
||
const rootsRows = must<HTMLUListElement>("#rows-roots");
|
||
const policiesSection = must<HTMLElement>("#group-policies");
|
||
const policiesRows = must<HTMLUListElement>("#rows-policies");
|
||
|
||
let roots: Root[] = [];
|
||
let policies: PolicyDoc[] = [];
|
||
// 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]) {
|
||
section.hidden = true;
|
||
}
|
||
rootsRows.replaceChildren();
|
||
policiesRows.replaceChildren();
|
||
summary.textContent = "";
|
||
}
|
||
|
||
async function load() {
|
||
sequence += 1;
|
||
const ticket = sequence;
|
||
setStatus("reading settings…");
|
||
try {
|
||
const [fetchedRoots, fetchedPolicies] = await Promise.all([fetchRoots(), fetchPolicies()]);
|
||
if (ticket !== sequence) {
|
||
return;
|
||
}
|
||
roots = fetchedRoots;
|
||
policies = fetchedPolicies;
|
||
} 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));
|
||
}
|
||
}
|
||
|
||
/* -- 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<PolicyDoc, "id"> {
|
||
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<PolicyDoc, "id"> = 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<SourceKey, HTMLInputElement>;
|
||
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<WeightKey, HTMLInputElement>;
|
||
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<HTMLElement>("[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<HTMLDivElement>(".band-line")) {
|
||
const [floor, target, penalty] = [...line.querySelectorAll<HTMLInputElement>("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<PolicyDoc, "id"> = {
|
||
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;
|
||
}
|
||
|
||
/* -- shell ---------------------------------------------------------------- */
|
||
|
||
function open() {
|
||
for (const sibling of views) {
|
||
sibling.hide();
|
||
}
|
||
board.hidden = true;
|
||
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();
|
||
board.hidden = false;
|
||
nav.focus();
|
||
}
|
||
|
||
nav.addEventListener("click", () => {
|
||
if (view.hidden) {
|
||
navigate({ kind: "settings" });
|
||
open();
|
||
} else {
|
||
navigate({ kind: "board" });
|
||
close();
|
||
}
|
||
});
|
||
|
||
must<HTMLButtonElement>("#root-add").addEventListener("click", () => {
|
||
rootsSection.append(rootPanel(null));
|
||
});
|
||
|
||
must<HTMLButtonElement>("#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: "board" });
|
||
close();
|
||
}
|
||
},
|
||
true,
|
||
);
|
||
|
||
return { hide, open };
|
||
}
|