2026-10-14 09:40:12 -07:00
|
|
|
|
type Child = Node | string | number | null | undefined | false;
|
|
|
|
|
|
|
|
|
|
|
|
type Props<K extends keyof HTMLElementTagNameMap> = Partial<Omit<HTMLElementTagNameMap[K], "style" | "dataset">> & {
|
|
|
|
|
|
class?: string;
|
|
|
|
|
|
style?: string;
|
|
|
|
|
|
data?: Record<string, string>;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export function h<K extends keyof HTMLElementTagNameMap>(tag: K, props: Props<K> = {}, ...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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-15 16:52:20 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-14 09:40:12 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-10-14 22:42:46 -07:00
|
|
|
|
|
2026-10-15 18:55:20 -07:00
|
|
|
|
function modal(content: string | Node[], buttons: { label: string; value: string; class?: string }[], anchor: Element | null = null): Promise<string> {
|
|
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-10-15 14:00:18 -07:00
|
|
|
|
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`;
|
|
|
|
|
|
}
|
2026-10-14 22:42:46 -07:00
|
|
|
|
document.body.append(dialog);
|
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
|
dialog.onclose = () => {
|
|
|
|
|
|
dialog.remove();
|
|
|
|
|
|
resolve(dialog.returnValue);
|
|
|
|
|
|
};
|
|
|
|
|
|
dialog.showModal();
|
2026-10-15 18:55:20 -07:00
|
|
|
|
const input = dialog.querySelector("input");
|
|
|
|
|
|
if (input) {
|
|
|
|
|
|
input.focus();
|
|
|
|
|
|
input.select();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-10-14 22:42:46 -07:00
|
|
|
|
actions.at(-1)?.focus();
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-15 09:40:30 -07:00
|
|
|
|
export function openPanel(title: string, mount: (root: HTMLElement) => () => void): void {
|
|
|
|
|
|
if (document.querySelector("dialog.panel[open]")) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const close = h("button", { type: "button", class: "panel-close", title: "Close (Esc)" }, "×");
|
|
|
|
|
|
const body = h("div", { class: "panel-body" });
|
|
|
|
|
|
const dialog = h("dialog", { class: "modal panel" }, h("header", { class: "panel-head" }, h("h2", {}, title), close), body);
|
|
|
|
|
|
close.onclick = () => dialog.close();
|
|
|
|
|
|
dialog.onclick = (e) => {
|
|
|
|
|
|
if (e.target === dialog || (e.target as Element).closest("a[href^='#']")) {
|
|
|
|
|
|
dialog.close();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
document.body.append(dialog);
|
|
|
|
|
|
const dispose = mount(body);
|
|
|
|
|
|
dialog.onclose = () => {
|
|
|
|
|
|
dispose();
|
|
|
|
|
|
dialog.remove();
|
|
|
|
|
|
};
|
|
|
|
|
|
dialog.showModal();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-14 22:42:46 -07:00
|
|
|
|
export async function showAlert(text: string): Promise<void> {
|
|
|
|
|
|
await modal(text, [{ label: "OK", value: "ok", class: "primary" }]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-15 18:55:20 -07:00
|
|
|
|
export async function showConfirm(content: string | Node[], action: string, danger = false, anchor: Element | null = null): Promise<boolean> {
|
2026-10-15 14:00:18 -07:00
|
|
|
|
const value = await modal(
|
2026-10-15 18:55:20 -07:00
|
|
|
|
content,
|
2026-10-15 14:00:18 -07:00
|
|
|
|
[
|
|
|
|
|
|
{ label: "Cancel", value: "cancel" },
|
|
|
|
|
|
{ label: action, value: "ok", class: danger ? "danger-primary" : "primary" },
|
|
|
|
|
|
],
|
|
|
|
|
|
anchor,
|
|
|
|
|
|
);
|
2026-10-14 22:42:46 -07:00
|
|
|
|
return value === "ok";
|
|
|
|
|
|
}
|