webOS: a TV app with the views of arr-tui

A TypeScript web app for LG webOS TVs (webOS 5 / Chromium 68 and newer),
driven by the remote: Movies and Series search with the Trakt lists,
Library with status, title and file-property filters and download marks,
seasons and episodes, release search and grab, Downloads, Calendar,
Settings and Log. It talks to Radarr, Sonarr, SABnzbd and Trakt directly
(they answer cross-origin requests); the arr-tui config is baked in at
build time and can be changed on the TV.

dev/screenshots/server.py answers cross-origin requests and accepts writes
for it; webos/dev/drive.mjs drives the app in Chromium for screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 16:51:48 +01:00
co-authored by Claude Opus 5.5
parent 1015135a4b
commit 7f6d4d07b9
47 changed files with 12294 additions and 1 deletions
+106
View File
@@ -0,0 +1,106 @@
// The details pane: poster, title, facts, state, plot and the cast.
import { app, people } from "../app.ts";
import { refKey, type People, type Ref } from "../api/trakt.ts";
import { h, replace } from "./dom.ts";
export interface Line {
text: string;
cls?: string; // ok, warn, muted, dl (downloading), bold
}
export interface Detail {
title: string;
sub?: string; // a second heading line (an episode)
facts: string[];
lines: Line[];
plot: string;
poster: string; // URL, "" for none
ref?: Ref; // for the cast
}
export class DetailPane {
el: HTMLElement;
private poster: HTMLImageElement;
private text: HTMLElement;
private cast: HTMLElement;
private castKey = "";
private settle = 0;
private posterSettle = 0;
constructor() {
this.poster = h("img.detail-poster", { alt: "" });
this.poster.addEventListener("error", () => this.poster.classList.add("none"));
this.poster.addEventListener("load", () => this.poster.classList.remove("loading"));
this.text = h("div.detail-text");
this.cast = h("div.detail-cast");
this.el = h("div.detail", this.poster, this.text, this.cast);
}
show(d: Detail | null): void {
if (!d) {
replace(this.text);
replace(this.cast);
this.poster.classList.add("none");
this.castKey = "";
return;
}
replace(this.text,
h("h2.detail-title", d.title),
d.sub ? h("div.detail-sub", d.sub) : null,
d.facts.length ? h("div.detail-facts", d.facts.join(" · ")) : null,
...d.lines.map((l) => h(`div.detail-line${l.cls ? "." + l.cls : ""}` as "div", l.text)),
d.plot ? h("p.detail-plot", d.plot) : null,
);
this.showPoster(app.covers() ? d.poster : "");
this.showCast(d.ref);
}
// showPoster waits until the selection rests (a held arrow key must not
// start a download per step).
private showPoster(url: string): void {
clearTimeout(this.posterSettle);
if (!url) {
this.poster.classList.add("none");
this.poster.removeAttribute("src");
return;
}
if (this.poster.getAttribute("src") === url) return;
this.poster.classList.remove("none");
this.poster.classList.add("loading");
this.posterSettle = window.setTimeout(() => {
this.poster.src = url;
}, 150);
}
private showCast(ref: Ref | undefined): void {
const key = ref ? refKey(ref) : "";
if (key === this.castKey) return;
this.castKey = key;
replace(this.cast);
clearTimeout(this.settle);
if (!ref || !key || !app.trakt.configured()) return;
this.settle = window.setTimeout(() => {
people.load(ref).then((p) => {
if (this.castKey === key) replace(this.cast, ...castLines(p, ref.kind));
}, () => {
/* no cast: nothing to show */
});
}, 300);
}
}
function castLines(p: People, kind: "movie" | "show"): HTMLElement[] {
const out: HTMLElement[] = [];
if (p.leaders.length) {
out.push(h("div.cast-head", kind === "show" ? "Created by" : "Director"), h("div.cast-names", p.leaders.map((l) => l.name).join(", ")));
}
if (p.cast.length) {
out.push(h("div.cast-head", "Cast"));
for (const c of p.cast.slice(0, 8)) {
out.push(h("div.cast-person", h("span", c.name), c.role ? h("span.muted", " as " + c.role) : null));
}
if (p.cast.length > 8) out.push(h("div.muted", `and ${p.cast.length - 8} more`));
}
return out;
}
+42
View File
@@ -0,0 +1,42 @@
// Tiny DOM helpers: h("div.card", {onclick}, child, "text").
type Child = Node | string | number | null | undefined | false;
type Attrs = Record<string, unknown>;
export function h<K extends keyof HTMLElementTagNameMap>(spec: K | `${K}.${string}` | `${K}#${string}`, attrs?: Attrs | Child, ...children: Child[]): HTMLElementTagNameMap[K] {
const [tagAndId, ...classes] = spec.split(".");
const [tag, id] = tagAndId.split("#");
const el = document.createElement(tag) as HTMLElementTagNameMap[K];
if (id) el.id = id;
if (classes.length) el.className = classes.join(" ");
if (attrs && typeof attrs === "object" && !(attrs instanceof Node)) {
for (const [k, v] of Object.entries(attrs)) {
if (v === undefined || v === null || v === false) continue;
if (k.startsWith("on") && typeof v === "function") el.addEventListener(k.slice(2), v as EventListener);
else if (k === "class") el.className += (el.className ? " " : "") + v;
else if (k === "style") el.setAttribute("style", String(v));
else if (k in el && typeof v !== "string") (el as unknown as Record<string, unknown>)[k] = v;
else el.setAttribute(k, v === true ? "" : String(v));
}
} else if (attrs !== undefined) {
children.unshift(attrs as Child);
}
append(el, ...children);
return el;
}
export function append(el: Element, ...children: Child[]): void {
for (const c of children) {
if (c === null || c === undefined || c === false) continue;
el.appendChild(c instanceof Node ? c : document.createTextNode(String(c)));
}
}
export function clear(el: Element): void {
while (el.firstChild) el.removeChild(el.firstChild);
}
export function replace(el: Element, ...children: Child[]): void {
clear(el);
append(el, ...children);
}
+222
View File
@@ -0,0 +1,222 @@
// Remote-control focus: the arrow keys move between focusable elements by
// their position on screen (spatial navigation), OK presses, Back goes back.
//
// Focusable elements carry the class "f". Dialogs open a layer: navigation
// stays inside the topmost layer, and closing it returns the focus to where
// it was. Elements can handle keys themselves (a list moves its selection
// with up and down) by setting onKey; returning true stops the navigation.
// A group (class "fgroup") remembers its last focused element, so moving back
// into a group continues where one left it.
export const Keys = {
left: 37,
up: 38,
right: 39,
down: 40,
enter: 13,
back: 461, // webOS remote
escape: 27,
backspace: 8,
red: 403,
green: 404,
yellow: 405,
blue: 406,
pageUp: 33, // also channel up on the remote
pageDown: 34,
} as const;
export type Dir = "left" | "right" | "up" | "down";
export interface KeyHandling {
onKey?: (ev: KeyboardEvent) => boolean;
}
interface Layer {
root: HTMLElement;
back: () => void;
restore: HTMLElement | null; // focused when the layer opened
}
const layers: Layer[] = [];
let globalKeys: (ev: KeyboardEvent) => boolean = () => false;
export function initFocus(root: HTMLElement, back: () => void, keys: (ev: KeyboardEvent) => boolean): void {
layers.length = 0;
layers.push({ root, back, restore: null });
globalKeys = keys;
document.addEventListener("keydown", onKeyDown, true);
// The Magic Remote's pointer: hovering focuses, like moving there.
document.addEventListener("mouseover", (ev) => {
const el = (ev.target as Element | null)?.closest?.(".f") as HTMLElement | null;
if (el && inLayer(el) && el !== document.activeElement && !isTextInput(document.activeElement)) focus(el, false);
});
}
export function pushLayer(root: HTMLElement, back: () => void): void {
layers.push({ root, back, restore: current() });
}
// popLayer closes the topmost layer (root must be it) and refocuses.
export function popLayer(root: HTMLElement): void {
const i = layers.findIndex((l) => l.root === root);
if (i <= 0) return;
const [layer] = layers.splice(i, 1);
if (i === layers.length) {
const back = layer.restore;
if (back && back.isConnected && visible(back)) focus(back);
else focusFirst(top().root);
}
}
export function top(): Layer {
return layers[layers.length - 1];
}
export function current(): HTMLElement | null {
const a = document.activeElement as HTMLElement | null;
return a && a !== document.body && a.classList?.contains("f") ? a : null;
}
export function focus(el: HTMLElement, scroll = true): void {
if (!el.hasAttribute("tabindex") && !isTextInput(el)) el.tabIndex = -1;
el.focus({ preventScroll: true });
if (scroll) el.scrollIntoView({ block: "nearest", inline: "nearest" });
const g = el.closest(".fgroup") as (HTMLElement & { lastFocus?: HTMLElement }) | null;
if (g) g.lastFocus = el;
}
// focusFirst focuses the first focusable element in root (or a remembered
// one of its group).
export function focusFirst(root: HTMLElement): boolean {
const g = root as HTMLElement & { lastFocus?: HTMLElement };
if (g.lastFocus && g.lastFocus.isConnected && visible(g.lastFocus) && root.contains(g.lastFocus)) {
focus(g.lastFocus);
return true;
}
const el = focusables(root)[0];
if (el) focus(el);
return !!el;
}
export function focusables(root: Element): HTMLElement[] {
return Array.from(root.querySelectorAll<HTMLElement>(".f")).filter(visible);
}
function visible(el: HTMLElement): boolean {
if (el.classList.contains("f-off") || (el as HTMLButtonElement).disabled) return false;
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0;
}
function inLayer(el: Element): boolean {
return top().root.contains(el);
}
function isTextInput(el: Element | null): el is HTMLInputElement {
return !!el && el.tagName === "INPUT" && ["text", "search", "password", "url", ""].includes((el as HTMLInputElement).type);
}
export function isBackKey(ev: KeyboardEvent): boolean {
if (ev.keyCode === Keys.back || ev.keyCode === Keys.escape) return true;
return ev.keyCode === Keys.backspace && !isTextInput(document.activeElement);
}
function onKeyDown(ev: KeyboardEvent): void {
const el = current();
if (el && inLayer(el) && (el as KeyHandling).onKey?.(ev)) {
ev.preventDefault();
return;
}
if (isBackKey(ev)) {
if (isTextInput(document.activeElement) && ev.keyCode === Keys.escape && document.activeElement.value) {
return; // let Escape do nothing special in a field with text
}
ev.preventDefault();
top().back();
return;
}
const dir = ({ 37: "left", 38: "up", 39: "right", 40: "down" } as Record<number, Dir>)[ev.keyCode];
if (dir) {
if (isTextInput(el) && (dir === "left" || dir === "right") && caretInside(el, dir)) return;
ev.preventDefault();
if (!el || !inLayer(el)) {
focusFirst(top().root);
return;
}
const next = neighbour(el, dir, focusables(top().root));
if (next) focus(enterGroup(el, next));
return;
}
if (ev.keyCode === Keys.enter && el && !isTextInput(el)) {
ev.preventDefault();
el.click();
return;
}
// View keys (numbers, colour buttons) only while no dialog or page is open.
if (layers.length === 1 && !isTextInput(document.activeElement) && globalKeys(ev)) ev.preventDefault();
}
function caretInside(input: HTMLInputElement, dir: Dir): boolean {
const pos = input.selectionStart ?? 0;
return dir === "left" ? pos > 0 : pos < input.value.length;
}
// enterGroup: moving into another group goes to the element remembered there.
function enterGroup(from: HTMLElement, to: HTMLElement): HTMLElement {
const g = to.closest(".fgroup") as (HTMLElement & { lastFocus?: HTMLElement }) | null;
if (!g || g.contains(from)) return to;
const last = g.lastFocus;
return last && last.isConnected && visible(last) && g.contains(last) ? last : to;
}
// neighbour finds the element to move to: the nearest one in the direction,
// preferring those that overlap the current one across the direction (the
// card right below, not the one diagonally closer).
export function neighbour(from: HTMLElement, dir: Dir, candidates: HTMLElement[]): HTMLElement | null {
const c = from.getBoundingClientRect();
let best: HTMLElement | null = null;
let bestScore = Infinity;
for (const el of candidates) {
if (el === from || el.contains(from) || from.contains(el)) continue;
const r = el.getBoundingClientRect();
const slack = 4;
let gap: number, cross: number;
switch (dir) {
case "right":
if (r.left < c.right - slack) continue;
gap = r.left - c.right;
cross = intervalGap(c.top, c.bottom, r.top, r.bottom);
break;
case "left":
if (r.right > c.left + slack) continue;
gap = c.left - r.right;
cross = intervalGap(c.top, c.bottom, r.top, r.bottom);
break;
case "down":
if (r.top < c.bottom - slack) continue;
gap = r.top - c.bottom;
cross = intervalGap(c.left, c.right, r.left, r.right);
break;
case "up":
if (r.bottom > c.top + slack) continue;
gap = c.top - r.bottom;
cross = intervalGap(c.left, c.right, r.left, r.right);
break;
}
// Among overlapping ones, the one whose start is nearest wins (a list
// of columns under a wide bar: the left-most below it, not a random one).
const align = dir === "up" || dir === "down" ? Math.abs(r.left - c.left) : Math.abs(r.top - c.top);
const score = Math.max(gap, 0) + (cross > 0 ? 10000 + cross * 3 : align * 0.01);
if (score < bestScore) {
bestScore = score;
best = el;
}
}
return best;
}
function intervalGap(a1: number, a2: number, b1: number, b2: number): number {
if (b2 <= a1) return a1 - b2;
if (b1 >= a2) return b1 - a2;
return 0;
}
+188
View File
@@ -0,0 +1,188 @@
// ListView: a focusable list with its own selection, like the TUI's tables.
// Up and down move the selection (and leave the list at its ends), channel
// up / down page, OK activates. Only the rows in view are in the DOM, so a
// library of thousands of titles stays quick on TV hardware.
import { h, replace } from "./dom.ts";
import { Keys, type KeyHandling } from "./focus.ts";
export interface Row<T> {
item?: T; // undefined: a heading (not selectable)
key: string;
}
export interface ListOptions<T> {
rowHeight: number; // pixels
render: (item: T, selected: boolean) => HTMLElement;
heading?: (key: string) => HTMLElement;
onSelect?: (item: T | undefined) => void;
onActivate?: (item: T) => void;
placeholder?: string;
header?: HTMLElement; // column titles, above the rows
}
export class ListView<T> {
el: HTMLElement & KeyHandling;
private body: HTMLElement;
private rows: Row<T>[] = [];
private sel = -1;
private top = 0;
private opts: ListOptions<T>;
private placeholder: string;
constructor(cls: string, opts: ListOptions<T>) {
this.opts = opts;
this.placeholder = opts.placeholder ?? "";
this.body = h("div.list-body");
opts.header?.classList.add("list-row");
this.el = h(`div.list.f.${cls}`, {}, opts.header ?? null, this.body) as HTMLElement & KeyHandling;
this.el.tabIndex = -1;
this.el.onKey = (ev) => this.key(ev);
this.el.addEventListener("focus", () => this.draw());
this.el.addEventListener("blur", () => this.draw());
this.el.addEventListener("wheel", (ev) => {
ev.preventDefault();
this.move(ev.deltaY > 0 ? 3 : -3);
}, { passive: false });
this.body.addEventListener("click", (ev) => {
const rowEl = (ev.target as Element).closest("[data-i]") as HTMLElement | null;
if (!rowEl) return;
const i = Number(rowEl.dataset.i);
if (i === this.sel) this.activate();
else this.select(i);
});
new ResizeObserverLike(this.body, () => this.draw());
}
get selected(): T | undefined {
return this.rows[this.sel]?.item;
}
get length(): number {
return this.rows.filter((r) => r.item !== undefined).length;
}
setPlaceholder(text: string): void {
this.placeholder = text;
if (!this.rows.length) this.draw();
}
// setRows replaces the rows, keeping the selection on the same key, or at
// the same place if that row is gone (removed: the next one moves up).
setRows(rows: Row<T>[], keepPlace = true): void {
const oldKey = this.rows[this.sel]?.key;
const oldIndex = this.sel;
this.rows = rows;
let sel = oldKey !== undefined ? rows.findIndex((r) => r.key === oldKey) : -1;
if (sel < 0 && keepPlace && oldIndex >= 0) sel = Math.min(oldIndex, rows.length - 1);
if (sel < 0) sel = 0;
this.sel = this.nearestItem(sel, 1);
if (this.sel < 0) this.sel = this.nearestItem(rows.length - 1, -1);
this.draw();
this.opts.onSelect?.(this.selected);
}
setItems(items: T[], key: (t: T) => string): void {
this.setRows(items.map((item) => ({ item, key: key(item) })));
}
// redraw re-renders the visible rows (an item changed in place).
redraw(): void {
this.draw();
}
select(i: number): void {
const n = this.nearestItem(i, i >= this.sel ? 1 : -1);
if (n < 0 || n === this.sel) return;
this.sel = n;
this.draw();
this.opts.onSelect?.(this.selected);
}
selectKey(key: string): void {
const i = this.rows.findIndex((r) => r.key === key);
if (i >= 0) this.select(i);
}
private nearestItem(i: number, step: number): number {
for (let j = i; j >= 0 && j < this.rows.length; j += step) if (this.rows[j].item !== undefined) return j;
return -1;
}
private activate(): void {
const it = this.selected;
if (it !== undefined) this.opts.onActivate?.(it);
}
private move(delta: number): boolean {
const target = this.nearestItem(Math.max(0, Math.min(this.rows.length - 1, this.sel + delta)), delta > 0 ? 1 : -1);
const fallback = this.nearestItem(Math.max(0, Math.min(this.rows.length - 1, this.sel + delta)), delta > 0 ? -1 : 1);
const n = target >= 0 ? target : fallback;
if (n < 0 || n === this.sel) return false;
this.select(n);
return true;
}
private key(ev: KeyboardEvent): boolean {
switch (ev.keyCode) {
case Keys.up:
return this.move(-1);
case Keys.down:
return this.move(1);
case Keys.pageUp:
this.move(-this.visibleRows());
return true;
case Keys.pageDown:
this.move(this.visibleRows());
return true;
case Keys.enter:
this.activate();
return true;
}
return false;
}
private visibleRows(): number {
return Math.max(1, Math.floor(this.body.clientHeight / this.opts.rowHeight));
}
private draw(): void {
const n = this.visibleRows();
if (this.sel < this.top) this.top = this.sel;
if (this.sel >= this.top + n) this.top = this.sel - n + 1;
this.top = Math.max(0, Math.min(this.top, Math.max(0, this.rows.length - n)));
const focused = document.activeElement === this.el;
const out: HTMLElement[] = [];
if (!this.rows.length && this.placeholder) out.push(h("div.list-placeholder", this.placeholder));
for (let i = this.top; i < Math.min(this.rows.length, this.top + n); i++) {
const row = this.rows[i];
let el: HTMLElement;
if (row.item === undefined) {
el = this.opts.heading ? this.opts.heading(row.key) : h("div.list-heading", row.key);
} else {
el = this.opts.render(row.item, i === this.sel);
el.dataset.i = String(i);
if (i === this.sel) el.classList.add(focused ? "sel" : "sel-idle");
}
el.classList.add("list-row");
el.style.height = this.opts.rowHeight + "px";
out.push(el);
}
if (this.rows.length > n) {
const pct = this.top / (this.rows.length - n);
const bar = h("div.list-scroll", h("div.list-thumb", { style: `height:${Math.max(8, (n / this.rows.length) * 100)}%;top:${pct * (100 - Math.max(8, (n / this.rows.length) * 100))}%` }));
out.push(bar);
}
replace(this.body, ...out);
}
}
// ResizeObserverLike redraws when the element changes size: ResizeObserver
// (Chrome 64+), else on window resizes.
class ResizeObserverLike {
constructor(el: HTMLElement, cb: () => void) {
const RO = (window as unknown as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver;
if (RO) new RO(() => cb()).observe(el);
else window.addEventListener("resize", cb);
}
}
+206
View File
@@ -0,0 +1,206 @@
// Dialogs, action menus, option pickers, buttons and toasts.
import { append, h, replace } from "./dom.ts";
import { focus, focusFirst, Keys, popLayer, pushLayer, type KeyHandling } from "./focus.ts";
export function button(label: string, onClick: () => void, cls = ""): HTMLButtonElement {
return h(`button.btn.f${cls ? "." + cls : ""}` as "button", { onclick: onClick, tabindex: -1 }, label);
}
// Dialog: a centred box over everything; Back closes it.
export class Dialog {
el: HTMLElement;
box: HTMLElement;
private onClose?: () => void;
private closed = false;
constructor(title: string, cls = "", onClose?: () => void) {
this.onClose = onClose;
this.box = h("div.dialog-box", h("h2", title));
if (cls) this.box.classList.add(cls);
this.el = h("div.dialog", this.box);
}
add(...children: (Node | string | null)[]): this {
append(this.box, ...children);
return this;
}
// open shows the dialog; called again (content changed), it only moves
// the focus.
open(focusEl?: HTMLElement): this {
if (this.closed) return this;
if (!this.el.isConnected) {
document.getElementById("overlays")!.appendChild(this.el);
pushLayer(this.el, () => this.close());
}
if (focusEl) focus(focusEl);
else focusFirst(this.el);
return this;
}
close(): void {
if (this.closed) return;
this.closed = true;
popLayer(this.el);
this.el.remove();
this.onClose?.();
}
}
export interface Action {
label: string;
run: () => void;
danger?: boolean;
hint?: string;
}
// actionSheet lists what can be done with an item; OK runs one.
export function actionSheet(title: string, actions: Action[]): void {
const d = new Dialog(title, "sheet");
for (const a of actions) {
const b = button(a.label, () => {
d.close();
a.run();
}, a.danger ? "danger" : "");
if (a.hint) b.appendChild(h("span.btn-hint", a.hint));
d.add(b);
}
d.add(button("Cancel", () => d.close(), "ghost"));
d.open();
}
export function confirmDialog(message: string, yes: string, run: () => void, danger = false): void {
const d = new Dialog("Are you sure?");
d.add(h("p.dialog-text", message));
const no = button("Cancel", () => d.close(), "ghost");
d.add(h("div.row-buttons", button(yes, () => {
d.close();
run();
}, danger ? "danger" : ""), no));
d.open(no);
}
// Choice: a value picked from options with left / right (or OK for the
// next one), the TV-friendly dropdown.
export class Choice<V> {
el: HTMLElement & KeyHandling;
private valueEl: HTMLElement;
options: { label: string; value: V }[] = [];
index = 0;
onChange?: (v: V) => void;
constructor(label: string, options: { label: string; value: V }[], value?: V, onChange?: (v: V) => void) {
this.valueEl = h("span.choice-value");
this.el = h("div.choice.f", { tabindex: -1 }, h("span.choice-label", label), h("span.choice-arrows", "‹", this.valueEl, "›")) as HTMLElement & KeyHandling;
this.onChange = onChange;
this.setOptions(options, value);
this.el.onKey = (ev) => {
if (ev.keyCode !== Keys.left && ev.keyCode !== Keys.right) return false;
this.step(ev.keyCode === Keys.left ? -1 : 1);
return true;
};
this.el.addEventListener("click", (ev) => {
const r = this.el.getBoundingClientRect();
const x = (ev as MouseEvent).clientX;
// A pointer click on the left third goes back; OK and other clicks forward.
this.step(x > 0 && x < r.left + r.width / 3 ? -1 : 1);
});
}
get value(): V {
return this.options[this.index]?.value;
}
setOptions(options: { label: string; value: V }[], value?: V): void {
this.options = options;
const i = value === undefined ? -1 : options.findIndex((o) => o.value === value);
this.index = i >= 0 ? i : Math.min(this.index, Math.max(0, options.length - 1));
this.show();
}
private step(d: number): void {
if (this.options.length < 2) return;
this.index = (this.index + d + this.options.length) % this.options.length;
this.show();
this.onChange?.(this.value);
}
private show(): void {
replace(this.valueEl, this.options[this.index]?.label ?? "–");
}
}
// Toggle: a checkbox; OK flips it.
export class Toggle {
el: HTMLElement;
value: boolean;
onChange?: (v: boolean) => void;
constructor(label: string, value: boolean, onChange?: (v: boolean) => void) {
this.value = value;
this.onChange = onChange;
this.el = h("div.toggle.f", { tabindex: -1, onclick: () => this.set(!this.value, true) }, h("span.toggle-box"), h("span", label));
this.set(value, false);
}
set(v: boolean, notify: boolean): void {
this.value = v;
this.el.classList.toggle("on", v);
if (notify) this.onChange?.(v);
}
}
export function textField(label: string, value: string, opts: { secret?: boolean; placeholder?: string; onInput?: (v: string) => void } = {}): { el: HTMLElement; input: HTMLInputElement } {
const input = h("input.f", { type: opts.secret ? "password" : "text", value, placeholder: opts.placeholder ?? "", spellcheck: false, autocomplete: "off" });
if (opts.onInput) input.addEventListener("input", () => opts.onInput!(input.value));
return { el: h("label.field", h("span.field-label", label), input), input };
}
// --- Toasts and the log ---
export type Level = "info" | "ok" | "warn" | "error";
export interface LogEntry {
at: Date;
level: Level;
text: string;
}
export const log: LogEntry[] = [];
const logListeners: (() => void)[] = [];
export function onLog(f: () => void): void {
logListeners.push(f);
}
// toast shows a message for a few seconds and keeps it in the log.
export function toast(text: string, level: Level = "info"): void {
log.push({ at: new Date(), level, text });
if (log.length > 500) log.splice(0, log.length - 500);
for (const f of logListeners) f();
const box = document.getElementById("toasts");
if (!box) return;
const el = h(`div.toast.toast-${level}` as "div", text);
box.appendChild(el);
while (box.children.length > 4) box.firstElementChild!.remove();
setTimeout(() => el.classList.add("gone"), level === "error" ? 7000 : 4000);
setTimeout(() => el.remove(), level === "error" ? 7600 : 4600);
}
export function errorText(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
// run executes an action with a "…" toast and a result toast.
export async function run(doing: string, done: string, f: () => Promise<unknown>): Promise<boolean> {
toast(doing + "…");
try {
await f();
toast(done, "ok");
return true;
} catch (e) {
toast(`${doing} failed: ${errorText(e)}`, "error");
return false;
}
}