| | |
| | | * @param {string|Date} dateStr Date to format |
| | | * @returns {string} Formatted time string |
| | | */ |
| | | window.formatTimeAgo = function(dateStr) { |
| | | window.formatTimeAgo = function(dateStr, dateFormat = 'default') { |
| | | const date = dateStr instanceof Date ? dateStr : new Date(dateStr); |
| | | const now = new Date(); |
| | | const diffMs = date - now; |
| | |
| | | timeStr = `${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`; |
| | | } else { |
| | | // Hours |
| | | timeStr = `${hours} ${hours === 1 ? 'hour' : 'hours'}`; |
| | | timeStr = `about ${hours} ${hours === 1 ? 'hour' : 'hours'}`; |
| | | } |
| | | } else if (days < 7) { |
| | | if (days === 1) { |
| | | return isPast ? 'yesterday' : 'tomorrow'; |
| | | } |
| | | timeStr = `about ${days} days`; |
| | | // Days |
| | | timeStr = `${days} ${days === 1 ? 'day' : 'days'}`; |
| | | } else { |
| | | // More than a week - just show the date |
| | | return date.toLocaleDateString(); |
| | | // More than a week - show the date based on format |
| | | if (dateFormat === 'default') { |
| | | return date.toLocaleDateString(); |
| | | } |
| | | |
| | | // Parse PHP-style format string |
| | | const formatMap = { |
| | | 'Y': date.getFullYear(), |
| | | 'y': String(date.getFullYear()).slice(-2), |
| | | 'F': date.toLocaleDateString('en-CA', { month: 'long' }), |
| | | 'M': date.toLocaleDateString('en-CA', { month: 'short' }), |
| | | 'm': String(date.getMonth() + 1).padStart(2, '0'), |
| | | 'n': date.getMonth() + 1, |
| | | 'd': String(date.getDate()).padStart(2, '0'), |
| | | 'j': date.getDate(), |
| | | 'D': date.toLocaleDateString('en-CA', { weekday: 'short' }), |
| | | 'l': date.toLocaleDateString('en-CA', { weekday: 'long' }), |
| | | 'H': String(date.getHours()).padStart(2, '0'), |
| | | 'i': String(date.getMinutes()).padStart(2, '0'), |
| | | 's': String(date.getSeconds()).padStart(2, '0'), |
| | | 'h': String(date.getHours() % 12 || 12).padStart(2, '0'), |
| | | 'g': date.getHours() % 12 || 12, |
| | | 'A': date.getHours() >= 12 ? 'PM' : 'AM', |
| | | 'a': date.getHours() >= 12 ? 'pm' : 'am', |
| | | }; |
| | | |
| | | return dateFormat.replace(/[YyFMmnjDlHishgAa]/g, match => formatMap[match]); |
| | | } |
| | | |
| | | // Add appropriate prefix/suffix based on past or future |
| | |
| | | return false; |
| | | } |
| | | |
| | | |
| | | |
| | | |
| | | /** |
| | | * Load and instantiate HTML templates as lightweight components |
| | | */ |
| | | class TemplateRegistry { |
| | | constructor() { |
| | | this.templates = new Map(); // name -> <template> |
| | | this.definitions = new Map(); // name -> component definition |
| | | } |
| | | |
| | | /** |
| | | * Collect all <template class="name"> elements |
| | | */ |
| | | registerAll(root = document) { |
| | | root.querySelectorAll('template').forEach(tpl => { |
| | | tpl.classList.forEach(name => { |
| | | if (!this.templates.has(name)) { |
| | | this.templates.set(name, tpl); |
| | | } |
| | | }); |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * Define component behavior |
| | | */ |
| | | define(name, definition = {}, context = null) { |
| | | this.definitions.set(name, { |
| | | refs: definition.refs || null, |
| | | manyRefs: definition.manyRefs || null, |
| | | setup: definition.setup || null, |
| | | context: context |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * Create a component instance |
| | | */ |
| | | create(name, data = {}) { |
| | | const tpl = this.templates.get(name); |
| | | if (!tpl) { |
| | | console.warn(`[TemplateRegistry] Template "${name}" not found`); |
| | | return null; |
| | | } |
| | | |
| | | const element = tpl.content.cloneNode(true).firstElementChild; |
| | | if (!element) return null; |
| | | |
| | | const def = this.definitions.get(name); |
| | | const refs = def?.refs |
| | | ? this.#collectRefs(element, def.refs) |
| | | : {}; |
| | | const manyRefs = def?.manyRefs |
| | | ? this.#collectRefs(element, def.manyRefs, false) |
| | | : {}; |
| | | |
| | | def?.setup?.({ |
| | | el: element, |
| | | refs, |
| | | manyRefs, |
| | | data |
| | | }); |
| | | |
| | | return element; |
| | | } |
| | | |
| | | /** |
| | | * Resolve refs declared in component definition |
| | | */ |
| | | #collectRefs(root, refMap, single = true) { |
| | | const refs = {}; |
| | | |
| | | for (const [key, value] of Object.entries(refMap)) { |
| | | let selector; |
| | | let required = false; |
| | | |
| | | if (typeof value === 'string') { |
| | | selector = value; |
| | | } else { |
| | | selector = value.selector; |
| | | required = !!value.required; |
| | | } |
| | | |
| | | const found = (single) ? root.querySelector(selector) : root.querySelectorAll(selector); |
| | | |
| | | if (required) { |
| | | if (single && !found) { |
| | | console.warn(`[TemplateRegistry] Required ref "${key}" not found: ${selector}`); |
| | | } |
| | | |
| | | if (!single && found.length === 0) { |
| | | console.warn(`[TemplateRegistry] Required manyRef "${key}" not found: ${selector}`); |
| | | } |
| | | } |
| | | |
| | | refs[key] = single ? found : Array.from(found); |
| | | } |
| | | |
| | | return refs; |
| | | } |
| | | |
| | | } |
| | | |
| | | |
| | | window.jvbTemplates = new TemplateRegistry(); |
| | | |
| | | document.addEventListener('DOMContentLoaded', () => { |
| | | window.jvbTemplates.registerAll(); |
| | | }); |
| | | |
| | | /** |
| | | * Gets a clone of an icon element if it exists for efficient DOM manipulation |
| | | * @param icon |
| | |
| | | }; |
| | | } |
| | | |
| | | window.chunkIt = async function(items, renderCallback, placementCallback, size = 10) { |
| | | const chunks = []; |
| | | for (let i = 0; i <items.length; i += size) { |
| | | chunks.push(items.slice(i, i + size)); |
| | | } |
| | | |
| | | for (const chunk of chunks) { |
| | | const fragment = document.createDocumentFragment(); |
| | | chunk.forEach(item => { |
| | | const element = renderCallback(item); |
| | | if (element) fragment.append(element); |
| | | }); |
| | | |
| | | placementCallback(fragment); |
| | | await new Promise(resolve => requestAnimationFrame(resolve)); |
| | | } |
| | | } |
| | | |
| | | window.prefixInput = function(input, prefix, wrapper = null, replace = false, name = false) { |
| | | if (!input) { |
| | | console.warn('prefixInput called with null/undefined input'); |
| | | return; |
| | | } |
| | | // console.log('Prefixing input: ', input); |
| | | // console.log('With prefix: ', prefix); |
| | | // console.log('Wrapper: ', wrapper); |
| | | const oldId = input.id; |
| | | const newId = replace ? prefix : `${prefix}${input.name}`; |
| | | // console.log('Old ID: ', oldId); |
| | | // console.log('New ID: ', newId); |
| | | // Search for label within wrapper if provided, otherwise use existing logic |
| | | let label = null; |
| | | |
| | | if (wrapper) { |
| | | // Most reliable: search within wrapper by old ID |
| | | label = wrapper.querySelector(`label[for="${oldId}"]`); |
| | | } else if (input.labels && input.labels.length > 0) { |
| | | // Fallback to input.labels if no wrapper provided |
| | | label = input.labels[0]; |
| | | } else if (input.previousElementSibling?.tagName === 'LABEL') { |
| | | label = input.previousElementSibling; |
| | | } else if (input.nextElementSibling?.tagName === 'LABEL') { |
| | | label = input.nextElementSibling; |
| | | } else { |
| | | // Final fallback: search up the tree |
| | | label = input.closest('[data-field]')?.querySelector(`label[for="${oldId}"]`); |
| | | } |
| | | |
| | | if (label) { |
| | | label.htmlFor = newId; |
| | | } |
| | | |
| | | input.id = newId; |
| | | if (name) { |
| | | input.name = newId; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Makes first letter uppercase |
| | | * @param string |
| | |
| | | return div.innerHTML; |
| | | } |
| | | |
| | | window.generateID = function(prefix = 'jvb') { |
| | | return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2,9)}`; |
| | | } |
| | | |
| | | window.showProgress = function(elements, current, total, message = '', icon = '') { |
| | | const show = current < total; |
| | | if (elements.progress && show) { |
| | | window.fade(elements.progress, true); |
| | | } |
| | | const percent = total > 0 ? (current / total) * 100 : 0; |
| | | if (elements.fill) elements.fill.style.width = `${percent}%`; |
| | | if (elements.details) elements.details.textContent = message; |
| | | if (elements.count) elements.count.textContent = `${current}/${total}`; |
| | | if (elements.icon) elements.icon.className = (icon === '') ? 'icon' : 'icon icon-'+icon; |
| | | |
| | | if (elements.progress && current === total) { |
| | | window.fade(elements.progress, false); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Format a date string for display |
| | | * @param {string} dateString - ISO date string |
| | |
| | | * @returns {Promise<unknown>} |
| | | */ |
| | | window.typeText = function(container, text, speed = 50) { |
| | | container.classList.add('typeText'); |
| | | return new Promise((resolve) => { |
| | | // Cancel any existing animation on this container |
| | | if (container._typeInterval) { |
| | | clearInterval(container._typeInterval); |
| | | delete container._typeInterval; |
| | | } |
| | | |
| | | let index = 0; |
| | | container.textContent = ''; |
| | | |
| | | const interval = setInterval(() => { |
| | | container._typeInterval = setInterval(() => { |
| | | if (index < text.length) { |
| | | container.textContent += text.charAt(index); |
| | | index++; |
| | | } else { |
| | | clearInterval(interval); |
| | | clearInterval(container._typeInterval); |
| | | delete container._typeInterval; |
| | | resolve(); |
| | | } |
| | | }, speed); |
| | |
| | | } |
| | | |
| | | /** |
| | | * Erases text like a keyboard would. TODO: erase a set word from existing text |
| | | * Erases text like a keyboard would. |
| | | * @param container |
| | | * @param speed |
| | | * @returns {Promise<unknown>} |
| | | */ |
| | | window.eraseText = function(container, speed = 10) { |
| | | return new Promise((resolve) => { |
| | | // Cancel any existing animation on this container |
| | | if (container._eraseInterval) { |
| | | clearInterval(container._eraseInterval); |
| | | delete container._eraseInterval; |
| | | } |
| | | |
| | | let text = container.textContent; |
| | | let index = text.length; |
| | | |
| | | const interval = setInterval(() => { |
| | | container._eraseInterval = setInterval(() => { |
| | | if (index > 0) { |
| | | index--; |
| | | container.textContent = text.substring(0, index); |
| | | } else { |
| | | clearInterval(interval); |
| | | clearInterval(container._eraseInterval); |
| | | delete container._eraseInterval; |
| | | resolve(); |
| | | } |
| | | }, speed); |
| | |
| | | * @returns {Function} - Call this function to stop the loop |
| | | */ |
| | | window.typeLoop = function(container, text, typeSpeed = 50, eraseSpeed = 10, pauseAfterType = 1000, pauseAfterErase = 250) { |
| | | // Generate unique key for this container |
| | | const containerId = container.id || container.dataset.typeKey || `type-${Date.now()}`; |
| | | if (!container.dataset.typeKey) { |
| | | container.dataset.typeKey = containerId; |
| | | } |
| | | |
| | | // Stop any existing loop immediately |
| | | if (container._stopTyping) { |
| | | container._stopTyping(); |
| | | } |
| | | |
| | | let isRunning = true; |
| | | |
| | | async function loop() { |
| | | while (isRunning) { |
| | | // Type the text |
| | | await window.typeText(container, text, typeSpeed); |
| | | |
| | | // Wait 1 second |
| | | if (!isRunning) break; |
| | | await new Promise(resolve => setTimeout(resolve, pauseAfterType)); |
| | | |
| | | // Erase the text |
| | | if (!isRunning) break; |
| | | await window.eraseText(container, eraseSpeed); |
| | | |
| | | // Wait 0.25 seconds before next iteration |
| | | if (!isRunning) break; |
| | | await new Promise(resolve => setTimeout(resolve, pauseAfterErase)); |
| | | } |
| | | } |
| | | |
| | | // Start the loop |
| | | loop(); |
| | | |
| | | // Return a function to stop the loop |
| | | return function stopLoop() { |
| | | const stopLoop = function() { |
| | | isRunning = false; |
| | | if (container._typeInterval) { |
| | | clearInterval(container._typeInterval); |
| | | delete container._typeInterval; |
| | | } |
| | | if (container._eraseInterval) { |
| | | clearInterval(container._eraseInterval); |
| | | delete container._eraseInterval; |
| | | } |
| | | }; |
| | | |
| | | container._stopTyping = stopLoop; |
| | | loop(); // Start immediately |
| | | |
| | | return stopLoop; |
| | | }; |
| | | |
| | | window.toCamelCase = function (string) { |
| | |
| | | * @param {HTMLElement|null} parent |
| | | * @returns {object} |
| | | */ |
| | | window.uiFromSelectors = function(selectors, parent = null) { |
| | | window.uiFromSelectors = function(selectors, parent = null, all = false) { |
| | | let ui = {}; |
| | | for (let [key, selector] of Object.entries(selectors)) { |
| | | if (typeof selector === 'object') { |
| | | ui[key] = window.uiFromSelectors(selector, parent); |
| | | }else { |
| | | if (!parent) { |
| | | ui[key] = document.querySelector(selector); |
| | | ui[key] = (all) ? document.querySelectorAll(selector) : document.querySelector(selector); |
| | | } else { |
| | | ui[key] = parent.querySelector(selector); |
| | | ui[key] = (all) ? parent.querySelectorAll(selector) : parent.querySelector(selector); |
| | | } |
| | | |
| | | } |
| | | } |
| | | return ui; |
| | | } |
| | | |
| | | window.sleep = async function (ms = 50) { |
| | | return new Promise(resolve => setTimeout(resolve, ms)); |
| | | }; |
| | | |
| | | class DebouncedActions { |
| | | constructor() { |
| | | this.timeouts = new Map(); |
| | |
| | | { passive: true } |
| | | ); |
| | | |
| | | |
| | | window.previousBGSize = 'Small'; |
| | | window.bgSizes = { |
| | | Small: 500, |
| | | Med: 768, |
| | | Large: 1024 |
| | | }; |
| | | |
| | | window.bgObserver = new IntersectionObserver((entries) => { |
| | | entries.forEach(entry => { |
| | | if (entry.isIntersecting) { |
| | | let newSize = entry.target.dataset[`bg${window.previousBGSize}`]; |
| | | entry.target.style.backgroundImage = `url(${newSize})`; |
| | | entry.target.dataset.bgImg = window.previousBGSize; |
| | | window.bgObserver.unobserve(entry.target); |
| | | } |
| | | }) |
| | | }, |
| | | { |
| | | root: null, |
| | | rootMargin: '0px 0px -100px 0px', |
| | | threshold: 0 |
| | | }); |
| | | |
| | | function updateBG() { |
| | | let current = window.innerWidth; |
| | | let newWidth = getBGWidth(current); |
| | | |
| | | if (newWidth) { |
| | | window.previousBGSize = newWidth; |
| | | document.querySelectorAll('[data-bg-img]:not([data-bg-img="'+window.previousBGSize+'"])').forEach(img => { |
| | | window.bgObserver.observe(img); |
| | | }); |
| | | } |
| | | |
| | | } |
| | | function getBGWidth(width) { |
| | | let prev = window.previousBGSize; |
| | | |
| | | let check = { |
| | | Small: ['Med','Large'], |
| | | Med: ['Large'], |
| | | Large: false |
| | | }; |
| | | |
| | | if (!check[prev]) { |
| | | return false; |
| | | } |
| | | let next = 'Small'; |
| | | |
| | | check[prev].forEach(w => { |
| | | if (width => window.bgSizes[w]) { |
| | | next = w; |
| | | } |
| | | }); |
| | | return next; |
| | | } |
| | | |
| | | updateBG(); |
| | | |
| | | // Debounced resize to recalc scrollable height |
| | | window.addEventListener('resize', () => { |
| | | window.debouncer.schedule('recalc-max-scroll', () => { |
| | | updateMaxScroll(); |
| | | updateScrollProgress(window.scrollY || docEl.scrollTop || 0); |
| | | }, 20); |
| | | window.debouncer.schedule('bg-resize', () => { |
| | | updateBG(); |
| | | }); |
| | | }); |
| | | |
| | | // Initial setup |
| | | updateMaxScroll(); |
| | | updateScrollProgress(lastY); |
| | | |
| | | |
| | | |
| | | window.decodeHTMLEntities = function(text) { |
| | | if (!window.decodeHelper) { |
| | | window.decodeHelper = document.createElement('textarea'); |
| | | } |
| | | |
| | | window.decodeHelper.innerHTML = text; |
| | | return window.decodeHelper.value; |
| | | } |
| | | |
| | | |
| | | window.focusNextElement = function() { |
| | | //add all elements we want to include in our selection |
| | | var focussableElements = |
| | | 'a:not([disabled]), button:not([disabled]), input[type=text]:not([disabled]), [tabindex]:not([disabled]):not([tabindex="-1"])'; |
| | | if (document.activeElement && document.activeElement.form) { |
| | | var focussable = Array.prototype.filter.call( |
| | | document.activeElement.form.querySelectorAll(focussableElements), |
| | | function (element) { |
| | | //check for visibility while always include the current activeElement |
| | | return ( |
| | | element.offsetWidth > 0 || |
| | | element.offsetHeight > 0 || |
| | | element === document.activeElement |
| | | ); |
| | | } |
| | | ); |
| | | var index = focussable.indexOf(document.activeElement); |
| | | if (index > -1) { |
| | | var nextElement = focussable[index + 1] || focussable[0]; |
| | | nextElement.focus(); |
| | | } |
| | | } |
| | | } |