type Child = Node | string | number | null | undefined | false; type Props = Partial> & { class?: string; style?: string; data?: Record; }; export function h(tag: K, props: Props = {}, ...children: Child[]): HTMLElementTagNameMap[K] { const el = document.createElement(tag); const { class: className, style, data, ...rest } = props; if (className) { el.className = className; } if (style) { el.setAttribute("style", style); } for (const [key, value] of Object.entries(data ?? {})) { el.dataset[key] = value; } Object.assign(el, rest); append(el, ...children); return el; } export function append(el: Node, ...children: Child[]): void { for (const child of children) { if (child === null || child === undefined || child === false) { continue; } el.appendChild(typeof child === "object" ? child : document.createTextNode(String(child))); } } export function replace(el: Element, ...children: Child[]): void { el.replaceChildren(); append(el, ...children); } export function copyable(pre: HTMLPreElement): HTMLElement { const button = h("button", { type: "button", class: "copy", title: "Copy to the clipboard" }, "copy"); button.onclick = async () => { try { await navigator.clipboard.writeText(pre.textContent ?? ""); } catch (err) { void showAlert(errorText(err)); return; } button.textContent = "copied"; setTimeout(() => { button.textContent = "copy"; }, 1500); }; return h("div", { class: "copyable" }, pre, button); } export function ago(iso: string): string { const seconds = Math.round((Date.now() - Date.parse(iso)) / 1000); if (seconds < 60) { return "just now"; } if (seconds < 3600) { return `${Math.floor(seconds / 60)}m ago`; } if (seconds < 86400) { return `${Math.floor(seconds / 3600)}h ago`; } return `${Math.floor(seconds / 86400)}d ago`; } export function errorText(err: unknown): string { return err instanceof Error ? err.message : String(err); } function modal(content: string | Node[], buttons: { label: string; value: string; class?: string }[], anchor: Element | null = null): Promise { const actions = buttons.map((b) => h("button", { type: "submit", value: b.value, class: b.class ?? "", formNoValidate: b.value === "cancel" }, b.label)); const body = typeof content === "string" ? [h("p", { class: "modal-text" }, content)] : content; const dialog = h("dialog", { class: "modal" }, h("form", { method: "dialog" }, ...body, h("div", { class: "modal-actions" }, ...actions))); for (const input of dialog.querySelectorAll("input")) { input.onkeydown = (e) => { if (e.key === "Enter") { e.preventDefault(); actions.at(-1)?.click(); } }; } if (anchor) { const rect = anchor.getBoundingClientRect(); dialog.classList.add("anchored"); dialog.style.top = `${rect.bottom + 4}px`; dialog.style.right = `${document.documentElement.clientWidth - rect.right}px`; } document.body.append(dialog); return new Promise((resolve) => { dialog.onclose = () => { dialog.remove(); resolve(dialog.returnValue); }; dialog.showModal(); const input = dialog.querySelector("input"); if (input) { input.focus(); input.setSelectionRange(0, input.value.length, "backward"); return; } actions.at(-1)?.focus(); }); } export async function showAlert(text: string): Promise { await modal(text, [{ label: "OK", value: "ok", class: "primary" }]); } export async function showConfirm(content: string | Node[], action: string, danger = false, anchor: Element | null = null): Promise { const value = await modal( content, [ { label: "Cancel", value: "cancel" }, { label: action, value: "ok", class: danger ? "danger-primary" : "primary" }, ], anchor, ); return value === "ok"; }