|
/**
|
*
|
* @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, """)
|
.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<unknown>}
|
*/
|
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<unknown>}
|
*/
|
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();
|