/** * * @param {HTMLElement} element * @param {boolean} fadeIn */ window.fade = function (element, fadeIn = true) { if (fadeIn) { element.style.animation = 'fadeIn var(--transition-base)'; } else { element.style.animation = 'fadeOut var(--transition-base)'; window.debouncer.schedule( `remove-${element.dataset.id??element.id??element.className.replace(' ', '-')}`, () => { element.remove(); }, 500 ); } } /** * Format a time value as relative time (past or future) * Handles both "X time ago" and "in X time" formats * * @param {string|Date} dateStr Date to format * @returns {string} Formatted time string */ window.formatTimeAgo = function(dateStr) { const date = dateStr instanceof Date ? dateStr : new Date(dateStr); const now = new Date(); const diffMs = date - now; const isPast = diffMs < 0; // Work with absolute values for calculations const seconds = Math.floor(Math.abs(diffMs) / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); // Just now (within 1 minute either way) if (minutes === 0) { return 'Just now'; } // Format the time components let timeStr = ''; if (seconds < 10) { timeStr = 'a moment'; } else if (seconds < 60) { timeStr = 'less than a minute' } else if (minutes < 5) { timeStr = 'a few minutes'; } else if (hours < 24) { if (hours === 0) { // Minutes only timeStr = `${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`; } else { // Hours timeStr = `${hours} ${hours === 1 ? 'hour' : 'hours'}`; } } else if (days < 7) { // Days timeStr = `${days} ${days === 1 ? 'day' : 'days'}`; } else { // More than a week - just show the date return date.toLocaleDateString(); } // Add appropriate prefix/suffix based on past or future return isPast ? `${timeStr} ago` : `in ${timeStr}`; } /** * Capitalize first letter of a string * * @param {string} string String to capitalize * @returns {string} Capitalized string */ window.uppercaseFirst = function(string) { return string.charAt(0).toUpperCase() + string.slice(1); } /** * Load HTML templates */ window.templates = new Map(); document.addEventListener('DOMContentLoaded', ()=> { // // Default templates // window.templates.set('replyButton', this.createReplyButtonTemplate()); // window.templates.set('commentsButton', this.createCommentsButtonTemplate()); // window.templates.set('voteButton', this.createVoteButtonsTemplate()); window.loadTemplates(); }); window.loadTemplates = function() { document.querySelectorAll('template').forEach(template => { const classes = Array.from(template.classList); if (classes.length > 0) { const item = template.content.cloneNode(true).firstElementChild; classes.forEach(key => { if (!window.templates.has(key)) { window.templates.set(key, item); } }); } }); } /** * Helper to load a template from * @param template * @returns {Node|ActiveX.IXMLDOMNode|boolean} */ window.getTemplate = function (template){ if (window.templates.size === 0) { window.loadTemplates(); } if(window.templates.has(template)){ return window.templates.get(template).cloneNode(true); } return false; } /** * Gets a clone of an icon element if it exists for efficient DOM manipulation * @param icon * @returns {Node | ActiveX.IXMLDOMNode} */ window.icon = null; window.getIcon = function getIcon(icon, style = ''){ if (typeof icon === 'undefined') { return ''; } if (!window.icon) { window.icon = document.createElement('i'); window.icon.className = 'icon'; window.icon.ariaHidden = true; } let theIcon = window.icon.cloneNode(true); if (style !== '' && ['regular', 'bold', 'duotone', 'fill', 'light', 'thin' ].includes('style')) { style = `-${style.slice(0, 2)}`; } else { style = ''; } theIcon.classList.add(`icon-${icon}${style}`); return theIcon; } /** * Format a number with comma separator (e.g., 1,234) * @param {number} num - Number to format * @returns {string} - Formatted number */ window.formatNumber = function(num) { return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); } /** * Format a price with currency symbol * @param {number} price - Price to format * @param {string} currency - Currency code (default: 'CAD') * @returns {string} - Formatted price */ window.formatPrice = function(price, currency = 'CAD') { return new Intl.NumberFormat('en-CA', { style: 'currency', currency: currency }).format(price); } /** * Escape HTML special characters to prevent XSS * @param {string} text - Text to escape * @returns {string} - Escaped text */ window.escapeHtml = function(text) { if (!text) return ''; // Convert to string if it's not already a string if (typeof text !== "string" && !(text instanceof String)) { text = String(text); } return text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } /** * Should be faster than setting innerHTML = '' * @param node */ window.removeChildren = function(node) { if(node.children.length === 0){ return; } while (node.firstChild) { node.removeChild(node.firstChild); } } /** * Format a date range (e.g., "Jan 1 - Jan 5, 2023") * @param {string} startDate - Start date ISO string * @param {string} endDate - End date ISO string * @returns {string} - Formatted date range */ window.formatDateRange = function(startDate, endDate) { const start = new Date(startDate); const end = new Date(endDate); // If same day, just show one date if (start.toDateString() === end.toDateString()) { return start.toLocaleDateString('en-CA', { year: 'numeric', month: 'short', day: 'numeric' }); } // If same month and year, show range with month once if (start.getMonth() === end.getMonth() && start.getFullYear() === end.getFullYear()) { return `${start.toLocaleDateString('en-CA', { month: 'short', day: 'numeric' })} - ${end.getDate()}, ${end.getFullYear()}`; } // If same year, show full range with year once if (start.getFullYear() === end.getFullYear()) { return `${start.toLocaleDateString('en-CA', { month: 'short', day: 'numeric' })} - ${end.toLocaleDateString('en-CA', { month: 'short', day: 'numeric' })}, ${end.getFullYear()}`; } // Different years, show full dates return `${start.toLocaleDateString('en-CA', { month: 'short', day: 'numeric', year: 'numeric' })} - ${end.toLocaleDateString('en-CA', { month: 'short', day: 'numeric', year: 'numeric' })}`; } /** * Throttle function to limit call frequency * @param {Function} func - Function to throttle * @param {number} limit - Time limit in milliseconds * @returns {Function} - Throttled function */ window.throttle = function(func, limit = 300) { let inThrottle; return function(...args) { if (!inThrottle) { func.apply(this, args); inThrottle = true; setTimeout(() => inThrottle = false, limit); } }; } /** * Makes first letter uppercase * @param string * @returns {string} */ window.uppercaseFirst = function(string){ return string.charAt(0).toUpperCase() + string.slice(1) } /** * Sanitizes HTML * @param text * @returns {string} */ window.sanitizeHtml = function(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } /** * Format a date string for display * @param {string} dateString - ISO date string * @returns {string} Formatted date */ window.formatDate = function(dateString) { if (!dateString) return ''; const date = new Date(dateString); const now = new Date(); const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24)); if (diffDays < 1) { return 'Today'; } else if (diffDays < 2) { return 'Yesterday'; } else if (diffDays < 7) { return `${diffDays} days ago`; } else { return date.toLocaleDateString(); } } /** * Pluralizes the content * @param content * @returns {string|string} */ window.getPluralContent = function(content){ return (content === 'artwork') ? 'artwork' : content+'s'; } /** * Shortcut to add notification * @param message * @param type * @param actions */ window.showToast = function(message, type='success', actions={}){ window.jvbNotifications.showToast(message, type, actions); } window.dateFormatter = new Intl.DateTimeFormat('en-CA', { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', timeZoneName: 'short' }); window.formatDate = function(date) { if (!(date instanceof Date && !isNaN(date))) { date = new Date(date); } return window.dateFormatter.format(date); } /** * Outputs the set text as if a typewriter were writing it * @param container * @param text * @param speed * @returns {Promise} */ window.typeText = function(container, text, speed = 50) { container.classList.add('typeText'); return new Promise((resolve) => { let index = 0; container.textContent = ''; const interval = setInterval(() => { if (index < text.length) { container.textContent += text.charAt(index); index++; } else { clearInterval(interval); resolve(); } }, speed); }); } /** * Erases text like a keyboard would. TODO: erase a set word from existing text * @param container * @param speed * @returns {Promise} */ window.eraseText = function(container, speed = 10) { return new Promise((resolve) => { let text = container.textContent; let index = text.length; const interval = setInterval(() => { if (index > 0) { index--; container.textContent = text.substring(0, index); } else { clearInterval(interval); resolve(); } }, speed); }); } /** * Continuously types and erases text in a loop * @param container - The DOM element to display text in * @param text - The text to type and erase * @param typeSpeed - Speed of typing (ms between characters) * @param eraseSpeed - Speed of erasing (ms between character removals) * @param pauseAfterType - Pause after typing completes (ms) * @param pauseAfterErase - Pause after erasing completes (ms) * @returns {Function} - Call this function to stop the loop */ window.typeLoop = function(container, text, typeSpeed = 50, eraseSpeed = 10, pauseAfterType = 1000, pauseAfterErase = 250) { let isRunning = true; async function loop() { while (isRunning) { // Type the text await window.typeText(container, text, typeSpeed); // Wait 1 second await new Promise(resolve => setTimeout(resolve, pauseAfterType)); // Erase the text await window.eraseText(container, eraseSpeed); // Wait 0.25 seconds before next iteration await new Promise(resolve => setTimeout(resolve, pauseAfterErase)); } } // Start the loop loop(); // Return a function to stop the loop return function stopLoop() { isRunning = false; }; }; window.toCamelCase = function (string) { return string.replace(/-([a-z])/g, function (g) { return g[1].toUpperCase(); }); } window.targetCheck = function (e, selector) { if (Array.isArray(selector)) { selector = selector.join(','); } if (typeof selector !== 'string') { return false; } return (e.target.closest(selector))??false; } //Modified rom Stackoverflow: https://stackoverflow.com/a/8596559 window.getDifferences = { VALUE_CREATED: "created", VALUE_UPDATED: "updated", VALUE_DELETED: "deleted", VALUE_UNCHANGED: "unchanged", map: function(oldData, newData) { if (this.isFunction(oldData) || this.isFunction(newData)) { throw "Invalid argument. Function given, object expected."; } if (this.isFile(oldData) || this.isFile(newData)) { const changeType = this.compareFiles(oldData, newData); return changeType === this.VALUE_UNCHANGED ? null : { type: changeType, data: oldData === undefined ? newData : oldData }; } if (this.isValue(oldData) || this.isValue(newData)) { const changeType = this.compareValues(oldData, newData); if (changeType === this.VALUE_UNCHANGED) return null; let resultData; switch (changeType) { case this.VALUE_CREATED: resultData = newData; break; case this.VALUE_DELETED: resultData = this.getEmptyValue(oldData); break; case this.VALUE_UPDATED: default: resultData = newData; } return { type: changeType, data: resultData }; } let changes = {}; let hasChanges = false; // Check for modifications and deletions for (let key in oldData) { if (!this.isFunction(oldData[key])) { let newValue = undefined; if (newData && newData[key] !== undefined) { newValue = newData[key]; } const change = this.map(oldData[key], newValue); if (change !== null) { if (change.hasOwnProperty("type") && change.hasOwnProperty("data")) { changes[key] = change.data; } else { changes[key] = change; } hasChanges = true; } } } // Check for additions if (newData) { for (let key in newData) { if (!this.isFunction(newData[key]) && (oldData === undefined || oldData[key] === undefined)) { const change = this.map(undefined, newData[key]); if (change !== null) { if (change.hasOwnProperty("type") && change.hasOwnProperty("data")) { changes[key] = change.data; } else { changes[key] = change; } hasChanges = true; } } } } return hasChanges ? changes : null; }, /** * Get appropriate empty value for a deleted field */ getEmptyValue: function(originalValue) { if (this.isArray(originalValue)) { return []; } if (this.isObject(originalValue)) { return {}; } if (typeof originalValue === 'number') { return 0; } if (typeof originalValue === 'boolean') { return false; } // For strings and other types, return empty string return ""; }, compareValues: function(oldValue, newValue) { return oldValue === newValue || (this.isDate(oldValue) && this.isDate(newValue) && oldValue.getTime() === newValue.getTime()) ? this.VALUE_UNCHANGED : oldValue === undefined ? this.VALUE_CREATED : newValue === undefined ? this.VALUE_DELETED : this.VALUE_UPDATED; }, isFunction: function(value) { return Object.prototype.toString.call(value) === "[object Function]"; }, isArray: function(value) { return Object.prototype.toString.call(value) === "[object Array]"; }, isDate: function(value) { return Object.prototype.toString.call(value) === "[object Date]"; }, isObject: function(value) { return Object.prototype.toString.call(value) === "[object Object]"; }, isFile: function(value) { return value instanceof File; }, isValue: function(value) { return !this.isObject(value) && !this.isArray(value); }, compareFiles: function(oldFile, newFile) { if (!this.isFile(oldFile) && this.isFile(newFile)) { return this.VALUE_CREATED; } if (this.isFile(oldFile) && !this.isFile(newFile)) { return this.VALUE_DELETED; } if (this.isFile(oldFile) && this.isFile(newFile)) { return oldFile.name === newFile.name && oldFile.size === newFile.size && oldFile.type === newFile.type && oldFile.lastModified === newFile.lastModified ? this.VALUE_UNCHANGED : this.VALUE_UPDATED; } return this.VALUE_UNCHANGED; }, merge: function(oldData, newData) { if (oldData == null) return newData; if (newData == null) return oldData; if (this.isFunction(oldData) || this.isFunction(newData)) return newData; if (this.isFile(oldData) || this.isFile(newData)) return newData; if (this.isValue(oldData) || this.isValue(newData) || this.isArray(oldData) || this.isArray(newData)) { return newData; } if (this.isObject(oldData) && this.isObject(newData)) { let result = {}; for (let key in oldData) { if (!this.isFunction(oldData[key])) { result[key] = oldData[key]; } } for (let key in newData) { if (!this.isFunction(newData[key])) { if (oldData[key] !== undefined) { result[key] = this.merge(oldData[key], newData[key]); } else { result[key] = newData[key]; } } } return result; } return newData; } }; window.deepMerge = function(oldData, newData) { return window.getDifferences.merge(oldData, newData); }; window.isInt = function(n) { return !isNaN(parseInt(n)) && isFinite(n); }; window.isNumeric = function(n) { return !isNaN(parseFloat(n)) && isFinite(n); }; /** * * @param {object} selectors * @param {HTMLElement|null} parent * @returns {object} */ window.uiFromSelectors = function(selectors, parent = null) { 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); } else { ui[key] = parent.querySelector(selector); } } } return ui; } class DebouncedActions { constructor() { this.timeouts = new Map(); window.addEventListener('beforeunload', () => this.cleanup()); } schedule(key, callback, delay = 1000) { this.cancel(key); this.timeouts.set(key, setTimeout(() => { callback(); this.timeouts.delete(key); }, delay)); } cancel(key) { if (this.timeouts.has(key)) { clearTimeout(this.timeouts.get(key)); this.timeouts.delete(key); } } cleanup() { for (let timeout of this.timeouts.values()) { clearTimeout(timeout); } this.timeouts.clear(); } } window.debouncer = new DebouncedActions(); // ----------------------------------------------------- // Scroll direction + scroll progress // ----------------------------------------------------- const body = document.body; const docEl = document.documentElement; const progressBar = document.querySelector('.scroll-progress .bar'); let lastY = window.scrollY || docEl.scrollTop || 0; let direction = -1; let ticking = false; let maxScroll = 0; function updateMaxScroll() { maxScroll = Math.max(0, docEl.scrollHeight - window.innerHeight); } function updateScrollProgress(y) { if (!progressBar) return; const progress = maxScroll > 0 ? y / maxScroll : 0; const clamped = Math.max(0, Math.min(1, progress)); progressBar.style.transform = `scaleX(${clamped})`; } function onScrollFrame() { const y = window.scrollY || docEl.scrollTop || 0; // Direction: 1 = down, -1 = up, keep existing if no movement if (y > lastY) { direction = 1; } else if (y < lastY) { direction = -1; } lastY = y; // Only add scroll-up when actually below top & moving up document.body.classList.toggle('scroll-up', direction < 0 && y > 0); // Update progress bar updateScrollProgress(y); ticking = false; } // Throttled scroll listener window.addEventListener( 'scroll', () => { if (!ticking) { ticking = true; requestAnimationFrame(onScrollFrame); } }, { passive: true } ); // Debounced resize to recalc scrollable height window.addEventListener('resize', () => { window.debouncer.schedule('recalc-max-scroll', () => { updateMaxScroll(); updateScrollProgress(window.scrollY || docEl.scrollTop || 0); }, 20); }); // Initial setup updateMaxScroll(); updateScrollProgress(lastY);