diff options
Diffstat (limited to 'src/zenserver/frontend/html/util')
| -rw-r--r-- | src/zenserver/frontend/html/util/compactbinary.js | 41 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/util/friendly.js | 53 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/util/widgets.js | 199 |
3 files changed, 285 insertions, 8 deletions
diff --git a/src/zenserver/frontend/html/util/compactbinary.js b/src/zenserver/frontend/html/util/compactbinary.js index 415fa4be8..270c96a2f 100644 --- a/src/zenserver/frontend/html/util/compactbinary.js +++ b/src/zenserver/frontend/html/util/compactbinary.js @@ -369,6 +369,14 @@ CbObjectView.prototype[Symbol.iterator] = function() //////////////////////////////////////////////////////////////////////////////// CbObjectView.prototype.to_js_object = function() { + const TicksPerMs = 10000; + const UnixEpochTicks = 621355968000000000n; // .NET ticks at 1970-01-01 + + const readTicks = function(data) { + const dv = new DataView(data.buffer, data.byteOffset, 8); + return dv.getBigInt64(0, false); + }; + const impl = function(node) { if (node.is_object()) @@ -388,9 +396,40 @@ CbObjectView.prototype.to_js_object = function() } if (node.is_string()) return node.as_value(); - if (node.is_integer()) return node.as_value(); if (node.is_float()) return node.as_value(); + if (node.is_integer()) + { + const v = node.as_value(); + if (v >= -9007199254740991n && v <= 9007199254740991n) + return Number(v); + return v; + } + + const type = CbFieldTypeOps.get_type(node.get_type()); + + if (type == CbFieldType.DateTime) + { + const ticks = readTicks(node.as_value()); + const unixMs = Number((ticks - UnixEpochTicks) / BigInt(TicksPerMs)); + return new Date(unixMs).toISOString(); + } + + if (type == CbFieldType.TimeSpan) + { + const ticks = readTicks(node.as_value()); + const absTicks = ticks < 0n ? -ticks : ticks; + const totalMs = Number(absTicks / BigInt(TicksPerMs)); + const ms = totalMs % 1000; + const totalSec = Math.floor(totalMs / 1000); + const sec = totalSec % 60; + const totalMin = Math.floor(totalSec / 60); + const min = totalMin % 60; + const hours = Math.floor(totalMin / 60); + const sign = ticks < 0n ? "-" : ""; + return `${sign}${hours}:${String(min).padStart(2, "0")}:${String(sec).padStart(2, "0")}.${String(ms).padStart(3, "0")}0000`; + } + var ret = node.as_value(); if (ret instanceof Uint8Array) { diff --git a/src/zenserver/frontend/html/util/friendly.js b/src/zenserver/frontend/html/util/friendly.js index 5d4586165..f400bbce0 100644 --- a/src/zenserver/frontend/html/util/friendly.js +++ b/src/zenserver/frontend/html/util/friendly.js @@ -30,6 +30,59 @@ export class Friendly return Friendly.sep(v * 1000000000, 0) + " ns"; } + /** Format a .NET-style TimeSpan string (e.g. "0:05:23.4560000") as a human-readable duration. */ + static timespan(value) + { + if (typeof value === "number") + { + return Friendly._formatDurationMs(value); + } + + const str = String(value); + const match = str.match(/^[+-]?(?:(\d+)\.)?(\d+):(\d+):(\d+)(?:\.(\d+))?$/); + if (!match) + { + return str; + } + + const days = parseInt(match[1] || "0", 10); + const hours = parseInt(match[2], 10); + const minutes = parseInt(match[3], 10); + const seconds = parseInt(match[4], 10); + const frac = match[5] ? parseInt(match[5].substring(0, 3).padEnd(3, "0"), 10) : 0; + const totalMs = ((days * 86400 + hours * 3600 + minutes * 60 + seconds) * 1000) + frac; + + return Friendly._formatDurationMs(totalMs); + } + + static _formatDurationMs(ms) + { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) return `${days}d ${hours % 24}h ${minutes % 60}m`; + if (hours > 0) return `${hours}h ${minutes % 60}m`; + if (minutes > 0) return `${minutes}m ${seconds % 60}s`; + if (seconds > 0) return `${seconds}.${String(ms % 1000).padStart(3, "0")}s`; + return `${ms}ms`; + } + + /** Format an ISO / .NET datetime string as a friendly local date+time. */ + static datetime(value) + { + const d = new Date(value); + if (isNaN(d.getTime())) + { + return String(value); + } + return d.toLocaleString(undefined, { + year: "numeric", month: "short", day: "numeric", + hour: "2-digit", minute: "2-digit", second: "2-digit", + }); + } + static bytes(x) { const v = BigInt(Math.trunc(Number(x))); diff --git a/src/zenserver/frontend/html/util/widgets.js b/src/zenserver/frontend/html/util/widgets.js index 2964f92f2..651686a11 100644 --- a/src/zenserver/frontend/html/util/widgets.js +++ b/src/zenserver/frontend/html/util/widgets.js @@ -6,6 +6,58 @@ import { Component } from "./component.js" import { Friendly } from "../util/friendly.js" //////////////////////////////////////////////////////////////////////////////// +export function flash_highlight(element) +{ + if (!element) { return; } + element.classList.add("pager-search-highlight"); + setTimeout(() => { element.classList.remove("pager-search-highlight"); }, 1500); +} + +//////////////////////////////////////////////////////////////////////////////// +export function copy_button(value_or_fn) +{ + if (!navigator.clipboard) + { + const stub = document.createElement("span"); + stub.style.display = "none"; + return stub; + } + + let reset_timer = 0; + const btn = document.createElement("button"); + btn.className = "zen-copy-btn"; + btn.title = "Copy to clipboard"; + btn.textContent = "\u29C9"; + btn.addEventListener("click", async (e) => { + e.stopPropagation(); + const v = typeof value_or_fn === "function" ? value_or_fn() : value_or_fn; + if (!v) { return; } + try + { + await navigator.clipboard.writeText(v); + clearTimeout(reset_timer); + btn.classList.add("zen-copy-ok"); + btn.textContent = "\u2713"; + reset_timer = setTimeout(() => { btn.classList.remove("zen-copy-ok"); btn.textContent = "\u29C9"; }, 800); + } + catch (_e) { /* clipboard not available */ } + }); + return btn; +} + +// Wraps the existing children of `element` plus a copy button into an +// inline-flex nowrap container so the button never wraps to a new line. +export function add_copy_button(element, value_or_fn) +{ + if (!navigator.clipboard) { return; } + const wrap = document.createElement("span"); + wrap.className = "zen-copy-wrap"; + while (element.firstChild) { wrap.appendChild(element.firstChild); } + wrap.appendChild(copy_button(value_or_fn)); + element.appendChild(wrap); +} + +//////////////////////////////////////////////////////////////////////////////// class Widget extends Component { } @@ -243,7 +295,7 @@ export class Table extends Widget var row = this._add_row(args); this._rows.push(row); - if ((this._flags & Table.Flag_AlignNumeric) && this._rows.length === 1) + if (this._flags & Table.Flag_AlignNumeric) { this._align_header(); } @@ -253,19 +305,23 @@ export class Table extends Widget _align_header() { - const first_row = this._rows[0]; - if (!first_row) + if (this._rows.length === 0) { return; } const header_elem = this._element.firstElementChild; const header_cells = header_elem.children; - const data_cells = first_row.inner().children; - for (let i = 0; i < data_cells.length && i < header_cells.length; i++) + + // A column is numeric if any data row has right-aligned content in it. + for (const row of this._rows) { - if (data_cells[i].style.textAlign === "right") + const data_cells = row.inner().children; + for (let i = 0; i < data_cells.length && i < header_cells.length; i++) { - header_cells[i].style.textAlign = "right"; + if (data_cells[i].style.textAlign === "right") + { + header_cells[i].style.textAlign = "right"; + } } } } @@ -398,6 +454,135 @@ export class ProgressBar extends Widget //////////////////////////////////////////////////////////////////////////////// +export class Pager +{ + constructor(section, page_size, on_change, search_fn) + { + this._page = 0; + this._page_size = page_size; + this._total = 0; + this._on_change = on_change; + this._search_fn = search_fn || null; + this._search_input = null; + + const pager = section.tag().classify("module-pager").inner(); + this._btn_prev = document.createElement("button"); + this._btn_prev.className = "module-pager-btn"; + this._btn_prev.textContent = "\u2190 Prev"; + this._btn_prev.addEventListener("click", () => this._go_page(this._page - 1)); + this._label = document.createElement("span"); + this._label.className = "module-pager-label"; + this._btn_next = document.createElement("button"); + this._btn_next.className = "module-pager-btn"; + this._btn_next.textContent = "Next \u2192"; + this._btn_next.addEventListener("click", () => this._go_page(this._page + 1)); + + if (this._search_fn) + { + this._search_input = document.createElement("input"); + this._search_input.type = "text"; + this._search_input.className = "module-pager-search"; + this._search_input.placeholder = "Search\u2026"; + this._search_input.addEventListener("keydown", (e) => + { + if (e.key === "Enter") + { + this._do_search(this._search_input.value.trim()); + } + }); + pager.appendChild(this._search_input); + } + + pager.appendChild(this._btn_prev); + pager.appendChild(this._label); + pager.appendChild(this._btn_next); + this._pager = pager; + + this._update_ui(); + } + + prepend(element) + { + const ref = this._search_input || this._btn_prev; + this._pager.insertBefore(element, ref); + } + + set_total(n) + { + this._total = n; + const max_page = Math.max(0, Math.ceil(n / this._page_size) - 1); + if (this._page > max_page) + { + this._page = max_page; + } + this._update_ui(); + } + + page_range() + { + const start = this._page * this._page_size; + const end = Math.min(start + this._page_size, this._total); + return { start, end }; + } + + _go_page(n) + { + const max = Math.max(0, Math.ceil(this._total / this._page_size) - 1); + this._page = Math.max(0, Math.min(n, max)); + this._update_ui(); + this._on_change(); + } + + _do_search(term) + { + if (!term || !this._search_fn) + { + return; + } + const result = this._search_fn(term); + if (!result) + { + this._search_input.style.outline = "2px solid var(--theme_fail)"; + setTimeout(() => { this._search_input.style.outline = ""; }, 1000); + return; + } + this._go_page(Math.floor(result.index / this._page_size)); + flash_highlight(this._pager.parentNode.querySelector(`[zs_name="${CSS.escape(result.name)}"]`)); + } + + _update_ui() + { + const total = this._total; + const page_count = Math.max(1, Math.ceil(total / this._page_size)); + const start = this._page * this._page_size + 1; + const end = Math.min(start + this._page_size - 1, total); + + this._btn_prev.disabled = this._page === 0; + this._btn_next.disabled = this._page >= page_count - 1; + this._label.textContent = total === 0 + ? "No items" + : `${start}\u2013${end} of ${total}`; + } + + static make_search_fn(get_data, get_key) + { + return (term) => { + const t = term.toLowerCase(); + const data = get_data(); + const i = data.findIndex(item => get_key(item).toLowerCase().includes(t)); + return i < 0 ? null : { index: i, name: get_key(data[i]) }; + }; + } + + static loading(section) + { + return section.tag().classify("pager-loading").text("Loading\u2026").inner(); + } +} + + + +//////////////////////////////////////////////////////////////////////////////// export class WidgetHost { constructor(parent, depth=1) |