/** * * @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 to "X time ago" format * * @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 seconds = Math.floor((now - date) / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); if (hours < 24) { if (hours === 0) { return minutes === 0 ? 'Just now' : `${minutes} ${minutes === 1 ? 'minute' : 'minutes'} ago`; } return `${hours} ${hours === 1 ? 'hour' : 'hours'} ago`; } if (days < 7) { return `${days} ${days === 1 ? 'day' : 'days'} ago`; } return date.toLocaleDateString(); } /** * Format a future date for display * * @param {string|Date} dateStr Future date * @returns {string} Formatted string */ window.formatTimeSoon = function(dateStr) { const date = dateStr instanceof Date ? dateStr : new Date(dateStr); const now = new Date(); // Handle past dates if (date <= now) { return "Just now"; } const seconds = Math.floor((date - now) / 1000); const minutes = Math.floor(seconds / 60); if (seconds < 60) { return "In a moment"; } if (minutes < 5) { return "In a few minutes"; } if (minutes < 20) { return "Coming up soon"; } if (minutes < 60) { return "In about half an hour"; } return "Later today"; } /** * 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) { loadTemplates(); } if(window.templates.has(template)){ return window.templates.get(template).cloneNode(true); } return false; } /** * Formats vote from template * @param item * @param status * @returns {Node|ActiveX.IXMLDOMNode|boolean} */ window.formatVote = function(item, status) { let vote = window.getTemplate('voteButton'); vote.dataset.itemId = item.id; vote.dataset.content = item.content; let up =vote.querySelector('button.up'); let down =vote.querySelector('button.down'); if(status === 'up'){ up.classList.add('voted'); } if(status === 'down'){ down.classList.add('voted'); } if(item.upvotes > 0){ up.querySelector('.count').textContent = item.upvotes; } if(item.downvotes > 0){ down.querySelector('.count').textContent = '-'+item.downvotes; } return vote; } /** * Tests if user has voted for this item * @param content * @param id * @returns {string} */ window.checkVoteStatus = function(content, id){ if(!jvbSettings.currentUser){ return ''; } let status = ''; if(window.userVotes && window.userVotes[content]?.has(id)){ status = window.userVotes[content].get(id); } return status; } /** * Gets a clone of an icon element if it exists for efficient DOM manipulation * @param icon * @returns {Node | ActiveX.IXMLDOMNode} */ window.getIcon = function getIcon(icon){ if (typeof icon === 'undefined') { return ''; } if(!window.jvbIcons){ window.jvbIcons = new Map(); } if(!window.jvbIcons.has(icon) && jvbSettings.icons[icon]){ let temp = document.createElement('div'); temp.innerHTML = jvbSettings.icons[icon]; window.jvbIcons.set(icon, temp.firstElementChild.cloneNode(true)); temp.remove(); } return window.jvbIcons.get(icon)?.cloneNode(true); } /** * Tests for empty object * @param obj * @returns {boolean} */ window.isEmptyObject = function(obj) { return Object.keys(obj).length === 0; } /** * 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, "'"); } /** * Truncate text to a specific length with ellipsis * @param {string} text - Text to truncate * @param {number} length - Maximum length * @returns {string} - Truncated text */ window.truncateText = function(text, length = 100) { if (!text || text.length <= length) return text; return text.substring(0, length) + '...'; } /** * 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-US', { 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-US', { 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-US', { month: 'short', day: 'numeric' })} - ${end.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}, ${end.getFullYear()}`; } // Different years, show full dates return `${start.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} - ${end.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`; } /** * Debounce function to limit frequent calls * @param {Function} func - Function to debounce * @param {number} wait - Wait time in milliseconds * @returns {Function} - Debounced function */ window.debounce = function(func, wait = 300) { let timeout; return function(...args) { clearTimeout(timeout); timeout = setTimeout(() => func.apply(this, args), wait); }; } window.throttle = function(func, limit) { let inThrottle; return function() { const args = arguments; const context = this; if (!inThrottle) { func.apply(context, args); inThrottle = true; setTimeout(() => inThrottle = false, limit); } } } /** * 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); } /** * 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 (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); }; window.handleListField = function (elem, value) { if (!Array.isArray(value)) { elem.remove(); return; } let li = elem.querySelector('li'); value.forEach((v) => { let l = li.cloneNode(true); l.textContent = v; elem.append(l); }); li.remove(); }; window.handleTextField = function (elem, value) { if (typeof value !== "string") { elem.remove(); return; } elem.textContent = value; }; window.handleImageField = function (elem, value) { if (!Array.isArray(value) || value === 0) { elem.remove(); return; } let img = (elem.tagName === 'IMG') ? elem : elem.querySelector('img'); if (!img) { elem.remove(); return; } img.alt = value.alt; img.src = value.thumbnail; img.dataset.small = value.small; img.dataset.medium = value.medium; img.dataset.large = value.full; }; window.handleGalleryField = function (elem, value) { if (!Array.isArray(value)) { elem.remove(); return; } let img = elem.querySelector('img'); value.forEach((v) => { let i = img.cloneNode(true); window.handleImageField(i, v); elem.append(i); }); img.remove(); }; /** * * @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); console.log('Scheduling action: ', key); console.log('With callback', callback); this.timeouts.set(key, setTimeout(() => { callback(); this.timeouts.delete(key); }, delay)); } cancel(key) { if (this.timeouts.has(key)) { console.log('Cancelling ', key); clearTimeout(this.timeouts.get(key)); this.timeouts.delete(key); } } cleanup() { for (let timeout of this.timeouts.values()) { console.log('clearing timeout: ', timeout); clearTimeout(timeout); } this.timeouts.clear(); } } window.debouncer = new DebouncedActions();