| File was renamed from assets/js/dash/UtilityFunctions.js |
| | |
| | | } |
| | | } |
| | | /** |
| | | * Format a time value to "X time ago" format |
| | | * 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 seconds = Math.floor((now - date) / 1000); |
| | | 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); |
| | | |
| | | if (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) { |
| | | return minutes === 0 ? 'Just now' : `${minutes} ${minutes === 1 ? 'minute' : 'minutes'} ago`; |
| | | // Minutes only |
| | | timeStr = `${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`; |
| | | } else { |
| | | // Hours |
| | | timeStr = `${hours} ${hours === 1 ? 'hour' : 'hours'}`; |
| | | } |
| | | return `${hours} ${hours === 1 ? 'hour' : 'hours'} ago`; |
| | | } else if (days < 7) { |
| | | // Days |
| | | timeStr = `${days} ${days === 1 ? 'day' : 'days'}`; |
| | | } else { |
| | | // More than a week - just show the date |
| | | return date.toLocaleDateString(); |
| | | } |
| | | |
| | | 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"; |
| | | // Add appropriate prefix/suffix based on past or future |
| | | return isPast ? `${timeStr} ago` : `in ${timeStr}`; |
| | | } |
| | | |
| | | /** |
| | |
| | | } |
| | | |
| | | /** |
| | | * 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} |
| | |
| | | } |
| | | |
| | | /** |
| | | * 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 |
| | |
| | | } |
| | | |
| | | /** |
| | | * 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 |
| | | */ |
| | |
| | | |
| | | // If same day, just show one date |
| | | if (start.toDateString() === end.toDateString()) { |
| | | return start.toLocaleDateString('en-US', { |
| | | 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-US', { month: 'short', day: 'numeric' })} - ${end.getDate()}, ${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-US', { month: 'short', day: 'numeric' })} - ${end.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}, ${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-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); |
| | | } |
| | | } |
| | | return `${start.toLocaleDateString('en-CA', { month: 'short', day: 'numeric', year: 'numeric' })} - ${end.toLocaleDateString('en-CA', { month: 'short', day: 'numeric', year: 'numeric' })}`; |
| | | } |
| | | |
| | | |
| | |
| | | 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 |
| | |
| | | } |
| | | } |
| | | 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); |
| | | |