UNPKG

@schukai/monster

Version:

Monster is a simple library for creating fast, robust and lightweight websites.

575 lines (501 loc) 16.8 kB
/** * Copyright © Volker Schukai and all contributing authors, {{copyRightYear}}. All rights reserved. * Node module: @schukai/monster * * This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3). * The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html * * For those who do not wish to adhere to the AGPLv3, a commercial license is available. * Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms. * For more information about purchasing a commercial license, please contact Volker Schukai. * * SPDX-License-Identifier: AGPL-3.0 */ import { instanceSymbol } from "../../constants.mjs"; import { assembleMethodSymbol, CustomElement, getSlottedElements, registerCustomElement, } from "../../dom/customelement.mjs"; import { LogStyleSheet } from "./stylesheet/log.mjs"; import { Entry } from "./log/entry.mjs"; import { validateInstance, validateString } from "../../types/validate.mjs"; import "./state.mjs"; import { ProxyObserver } from "../../types/proxyobserver.mjs"; import { Updater } from "../../dom/updater.mjs"; import { Pathfinder } from "../../data/pathfinder.mjs"; import { getLocaleOfDocument } from "../../dom/locale.mjs"; import { isArray } from "../../types/is.mjs"; export { Log }; /** * @private * @type {symbol} */ const logElementSymbol = Symbol("logElement"); /** * @private * @type {symbol} */ const emptyStateElementSymbol = Symbol("emptyStateElement"); const entriesSymbol = Symbol("entries"); const entriesListSymbol = Symbol("entriesList"); const entryTemplateSymbol = Symbol("entryTemplate"); const entryObserverMapSymbol = Symbol("entryObserverMap"); const entryUpdaterMapSymbol = Symbol("entryUpdaterMap"); const entryElementMapSymbol = Symbol("entryElementMap"); const entryMapSymbol = Symbol("entryMap"); const idCounterSymbol = Symbol("idCounter"); const timeAgoIntervalSymbol = Symbol("timeAgoInterval"); /** * A log entry * * @fragments /fragments/components/state/log * * @example /examples/components/state/log-simple Log * * @issue https://localhost.alvine.dev:8444/development/issues/closed/270.html * * @since 3.74.0 * @copyright Volker Schukai * @summary The log entry is a single entry in the log. **/ class Log extends CustomElement { /** * @return {void} */ [assembleMethodSymbol]() { super[assembleMethodSymbol](); initControlReferences.call(this); this[entriesSymbol] = []; this[entryObserverMapSymbol] = new Map(); this[entryUpdaterMapSymbol] = new Map(); this[entryElementMapSymbol] = new Map(); this[entryMapSymbol] = new Map(); this[idCounterSymbol] = 0; initTimeAgoTicker.call(this); initEventHandler.call(this); } /** * This method is called by the `instanceof` operator. * @return {symbol} */ static get [instanceSymbol]() { return Symbol.for("@schukai/monster/components/state/log@@instance"); } /** * To set the options via the HTML tag, the attribute `data-monster-options` must be used. * @see {@link https://monsterjs.org/en/doc/#configurate-a-monster-control} * * The individual configuration values can be found in the table. * * @property {Object} templates Template definitions * @property {string} templates.main Main template * @property {Object} labels Labels * @property {string} labels.nothingToReport Label for empty state * @property {Object} classes Classes * @property {string} classes.direction Direction of the log: ascending or descending * @property {number} updateFrequency Update frequency in milliseconds for the timestamp */ get defaults() { return Object.assign({}, super.defaults, { templates: { main: getTemplate(), }, labels: { nothingToReport: "There is nothing to report yet.", }, features: { direction: "ascending", timeAgoMaxHours: 12, }, updateFrequency: 10000, entries: [], timestamp: 0, }); } /** * @param {string} path * @param {*} defaultValue * @return {*} */ getOption(path, defaultValue = undefined) { if (path === "entries" || path?.startsWith("entries.")) { try { return new Pathfinder({ entries: this[entriesSymbol], }).getVia(path); } catch (e) { return defaultValue; } } return super.getOption(path, defaultValue); } /** * @param {string} path * @param {*} value * @return {Log} */ setOption(path, value) { if (path === "entries") { const prepared = prepareEntries(value); this[entriesSymbol] = prepared; this[idCounterSymbol] = 0; renderEntries.call(this, prepared); super.setOption("length", prepared.length); return this; } super.setOption(path, value); return this; } /** * @param {object|string} options * @return {Log} */ setOptions(options) { if (options && typeof options === "object" && options.entries) { const { entries, ...rest } = options; if (Object.keys(rest).length > 0) { super.setOptions(rest); } this.setOption("entries", entries); return this; } super.setOptions(options); return this; } /** * @return {void} */ connectedCallback() { super.connectedCallback(); const slottedElements = getSlottedElements.call(this); if (slottedElements.size > 0) { this[emptyStateElementSymbol].style.display = "none"; } } /** * Clear the log * * @return {Log} */ clear() { this.setOption("entries", []); return this; } /** * Add an entry to the log * @param {Entry} entry * @return {Log} */ addEntry(entry) { entry = normalizeEntry(entry); if (entry.date === undefined || entry.date === null) { entry.date = new Date(); } const entries = this.getOption("entries"); if (this.getOption("features.direction") === "ascending") { entries.unshift(entry); } else { entries.push(entry); } if (this[entriesListSymbol]) { renderEntry(this, entry, this[entriesListSymbol], { prepend: this.getOption("features.direction") === "ascending", }); } if (this[emptyStateElementSymbol]) { this[emptyStateElementSymbol].style.display = entries.length > 0 ? "none" : "block"; } super.setOption("length", entries.length); updateTimeAgo(this); return this; } /** * Add a log message * * @param {string} message * @param {Date} date * @return {Log} * @throws {TypeError} message is not a string */ addMessage(message, date) { if (!date) { date = new Date(); } validateString(message); this.addEntry( new Entry({ message: message, date: date, }), ); return this; } /** * * @return {string} */ static getTag() { return "monster-log"; } /** * @return {CSSStyleSheet[]} */ static getCSSStyleSheet() { return [LogStyleSheet]; } } /** * @private * @return {Select} * @throws {Error} no shadow-root is defined */ function initControlReferences() { if (!this.shadowRoot) { throw new Error("no shadow-root is defined"); } this[logElementSymbol] = this.shadowRoot.querySelector( "[data-monster-role=control]", ); this[emptyStateElementSymbol] = this.shadowRoot.querySelector( "[data-monster-role=empty-state]", ); this[entriesListSymbol] = this.shadowRoot.querySelector( "[data-monster-role=entries-list]", ); this[entryTemplateSymbol] = this.shadowRoot.querySelector("template#entry"); } /** * @private */ function initEventHandler() { if (!this.shadowRoot) { throw new Error("no shadow-root is defined"); } this.shadowRoot.addEventListener("slotchange", (event) => { const slottedElements = getSlottedElements.call(this); if (slottedElements.size > 0) { this[emptyStateElementSymbol].style.display = "none"; } else { this[emptyStateElementSymbol].style.display = "block"; } }); return this; } /** * @private * @return {void} */ function initTimeAgoTicker() { if (this[timeAgoIntervalSymbol]) { return; } const refresh = () => { updateTimeAgo(this); }; const interval = Number(this.getOption("updateFrequency")); const delay = Number.isFinite(interval) && interval > 0 ? interval : 10000; refresh(); this[timeAgoIntervalSymbol] = setInterval(refresh, delay); } /** * @private * @param {Entry|Object} entry * @return {Entry} */ function normalizeEntry(entry) { if (entry instanceof Entry) { return entry; } if (entry && typeof entry === "object") { return new Entry(entry); } validateInstance(entry, Entry); return entry; } /** * @private * @param {Entry[]|*} entries * @return {Entry[]} */ function prepareEntries(entries) { const list = isArray(entries) ? entries.map(normalizeEntry) : []; return list; } /** * @private * @param {Entry[]} entries * @return {void} */ function renderEntries(entries) { if (!this[entriesListSymbol]) { return; } clearContainer(this[entriesListSymbol]); this[entryObserverMapSymbol] = new Map(); this[entryUpdaterMapSymbol] = new Map(); this[entryElementMapSymbol] = new Map(); this[entryMapSymbol] = new Map(); const fragment = document.createDocumentFragment(); for (const entry of entries) { renderEntry(this, entry, fragment, {}); } this[entriesListSymbol].appendChild(fragment); if (this[emptyStateElementSymbol]) { this[emptyStateElementSymbol].style.display = entries.length > 0 ? "none" : "block"; } updateTimeAgo(this); } /** * @private * @param {Log} log * @param {Entry} entry * @param {HTMLElement|DocumentFragment} parentList * @param {{prepend?: boolean}} options * @return {void} */ function renderEntry(log, entry, parentList, { prepend } = {}) { if (!entry.id) { log[idCounterSymbol] += 1; entry.id = `entry-${log[idCounterSymbol]}`; } const template = log[entryTemplateSymbol]; if (!template) { return; } const fragment = template.content.cloneNode(true); const item = fragment.querySelector("[data-monster-role=entry]"); if (!item) { return; } item.setAttribute("data-entry-id", entry.id); const observer = new ProxyObserver({ entry }); const updater = new Updater(item, observer); updater.run().catch(() => {}); log[entryObserverMapSymbol].set(entry.id, observer); log[entryUpdaterMapSymbol].set(entry.id, updater); log[entryElementMapSymbol].set(entry.id, item); log[entryMapSymbol].set(entry.id, entry); if (prepend && parentList instanceof HTMLElement) { parentList.insertBefore(item, parentList.firstChild); } else { parentList.appendChild(item); } } /** * @private * @param {Log} log * @return {void} */ function updateTimeAgo(log) { const locale = getLocaleOfDocument().toString(); const maxHours = Number(log.getOption("features.timeAgoMaxHours", 12)); const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "always" }); for (const [entryId, element] of log[entryElementMapSymbol].entries()) { const entry = log[entryMapSymbol]?.get(entryId); if (!entry?.date) { continue; } const timeElement = element.querySelector("[data-monster-role=time-ago]"); if (!timeElement) { continue; } try { timeElement.textContent = formatRelativeTime( new Date(entry.date), locale, maxHours, rtf, ); } catch (e) {} } } /** * @private * @param {Date} date * @param {string} locale * @param {number} maxHours * @param {Intl.RelativeTimeFormat} rtf * @return {string} */ function formatRelativeTime(date, locale, maxHours, rtf) { let diffSeconds = Math.floor((Date.now() - date.getTime()) / 1000); if (!Number.isFinite(diffSeconds) || diffSeconds < 0) { diffSeconds = 0; } if (diffSeconds < 5) { return "just now"; } if (diffSeconds < 60) { return rtf.format(-diffSeconds, "second"); } const diffMinutes = Math.floor(diffSeconds / 60); if (diffMinutes < 60) { return rtf.format(-diffMinutes, "minute"); } const diffHours = Math.floor(diffMinutes / 60); if (diffHours < maxHours) { return rtf.format(-diffHours, "hour"); } return date.toLocaleDateString(locale, { year: "numeric", month: "2-digit", day: "2-digit", }); } /** * @private * @param {HTMLElement} container * @return {void} */ function clearContainer(container) { while (container.firstChild) { container.removeChild(container.firstChild); } } /** * @private * @return {string} */ function getTemplate() { // language=HTML return ` <template id="entry"> <li data-monster-role="entry"> <span data-monster-replace="path:entry.user" data-monster-attributes="class path:entry.user | ?:user:hidden"></span> <span data-monster-replace="path:entry.title" data-monster-attributes="class path:entry.title | ?:title:hidden"></span> <span data-monster-replace="path:entry.message" data-monster-attributes="class path:entry.message | ?:message:hidden"></span> <span data-monster-role="time-ago" data-monster-replace="path:entry.date | time-ago" data-monster-attributes="title path:entry.date | datetime"></span> </li> </template> <div part="control" data-monster-role="control"> <div data-monster-role="empty-state"> <monster-state> <div part="visual"> <svg width="4rem" height="4rem" viewBox="0 -12 512.00032 512" xmlns="http://www.w3.org/2000/svg"> <path d="m455.074219 172.613281 53.996093-53.996093c2.226563-2.222657 3.273438-5.367188 2.828126-8.480469-.441407-3.113281-2.328126-5.839844-5.085938-7.355469l-64.914062-35.644531c-4.839844-2.65625-10.917969-.886719-13.578126 3.953125-2.65625 4.84375-.890624 10.921875 3.953126 13.578125l53.234374 29.230469-46.339843 46.335937-166.667969-91.519531 46.335938-46.335938 46.839843 25.722656c4.839844 2.65625 10.921875.890626 13.578125-3.953124 2.660156-4.839844.890625-10.921876-3.953125-13.578126l-53.417969-29.335937c-3.898437-2.140625-8.742187-1.449219-11.882812 1.695313l-54 54-54-54c-3.144531-3.144532-7.988281-3.832032-11.882812-1.695313l-184.929688 101.546875c-2.757812 1.515625-4.644531 4.238281-5.085938 7.355469-.445312 3.113281.601563 6.257812 2.828126 8.480469l53.996093 53.996093-53.996093 53.992188c-2.226563 2.226562-3.273438 5.367187-2.828126 8.484375.441407 3.113281 2.328126 5.839844 5.085938 7.351562l55.882812 30.6875v102.570313c0 3.652343 1.988282 7.011719 5.1875 8.769531l184.929688 101.542969c1.5.824219 3.15625 1.234375 4.8125 1.234375s3.3125-.410156 4.8125-1.234375l184.929688-101.542969c3.199218-1.757812 5.1875-5.117188 5.1875-8.769531v-102.570313l55.882812-30.683594c2.757812-1.515624 4.644531-4.242187 5.085938-7.355468.445312-3.113282-.601563-6.257813-2.828126-8.480469zm-199.074219 90.132813-164.152344-90.136719 164.152344-90.140625 164.152344 90.140625zm-62.832031-240.367188 46.332031 46.335938-166.667969 91.519531-46.335937-46.335937zm-120.328125 162.609375 166.667968 91.519531-46.339843 46.339844-166.671875-91.519531zm358.089844 184.796875-164.929688 90.5625v-102.222656c0-5.523438-4.476562-10-10-10s-10 4.476562-10 10v102.222656l-164.929688-90.5625v-85.671875l109.046876 59.878907c1.511718.828124 3.167968 1.234374 4.808593 1.234374 2.589844 0 5.152344-1.007812 7.074219-2.929687l54-54 54 54c1.921875 1.925781 4.484375 2.929687 7.074219 2.929687 1.640625 0 3.296875-.40625 4.808593-1.234374l109.046876-59.878907zm-112.09375-46.9375-46.339844-46.34375 166.667968-91.515625 46.34375 46.335938zm0 0"/> <path d="m404.800781 68.175781c2.628907 0 5.199219-1.070312 7.070313-2.933593 1.859375-1.859376 2.929687-4.4375 2.929687-7.066407 0-2.632812-1.070312-5.210937-2.929687-7.070312-1.859375-1.863281-4.441406-2.929688-7.070313-2.929688-2.640625 0-5.210937 1.066407-7.070312 2.929688-1.871094 1.859375-2.929688 4.4375-2.929688 7.070312 0 2.628907 1.058594 5.207031 2.929688 7.066407 1.859375 1.863281 4.441406 2.933593 7.070312 2.933593zm0 0"/> <path d="m256 314.925781c-2.628906 0-5.210938 1.066407-7.070312 2.929688-1.859376 1.867187-2.929688 4.4375-2.929688 7.070312 0 2.636719 1.070312 5.207031 2.929688 7.078125 1.859374 1.859375 4.441406 2.921875 7.070312 2.921875s5.210938-1.0625 7.070312-2.921875c1.859376-1.871094 2.929688-4.441406 2.929688-7.078125 0-2.632812-1.070312-5.203125-2.929688-7.070312-1.859374-1.863281-4.441406-2.929688-7.070312-2.929688zm0 0"/> </svg> </div> <div part="content" data-monster-replace="path:labels.nothingToReport"> There is nothing to report yet. </div> </monster-state> </div> <div data-monster-role="entries"> <ul data-monster-role="entries-list"></ul> </div> </div> `; } registerCustomElement(Log);