/**
*
* @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();