- Add data-testid='toast' to toast element in ui-utils.js - Update toast selector in ui_keywords.resource to use [data-testid='toast'] - Update login rate limit test to check for 'invalid' instead of 'lockout' (testing mode disables lockout, so test verifies proper error message) - Remove completed task docs from tasks.md
247 lines
7.6 KiB
JavaScript
247 lines
7.6 KiB
JavaScript
/**
|
|
* AniWorld - UI Utilities Module
|
|
*
|
|
* Toast notifications, loading overlays, and
|
|
* common UI helper functions.
|
|
*
|
|
* Dependencies: constants.js
|
|
*/
|
|
|
|
var AniWorld = window.AniWorld || {};
|
|
|
|
AniWorld.UI = (function() {
|
|
'use strict';
|
|
|
|
const DEFAULTS = AniWorld.Constants.DEFAULTS;
|
|
|
|
/**
|
|
* Show a toast notification
|
|
* @param {string} message - The message to display
|
|
* @param {string} type - 'info', 'success', 'warning', or 'error'
|
|
* @param {number} duration - Duration in milliseconds (optional)
|
|
*/
|
|
function showToast(message, type, duration) {
|
|
type = type || 'info';
|
|
duration = duration || DEFAULTS.TOAST_DURATION;
|
|
|
|
const container = document.getElementById('toast-container');
|
|
if (!container) {
|
|
console.warn('Toast container not found');
|
|
return;
|
|
}
|
|
|
|
const toast = document.createElement('div');
|
|
toast.className = 'toast ' + type;
|
|
toast.setAttribute('data-testid', 'toast');
|
|
toast.innerHTML =
|
|
'<div style="display: flex; justify-content: space-between; align-items: center;">' +
|
|
'<span>' + escapeHtml(message) + '</span>' +
|
|
'<button onclick="this.parentElement.parentElement.remove()" ' +
|
|
'style="background: none; border: none; color: var(--color-text-secondary); ' +
|
|
'cursor: pointer; padding: 0; margin-left: 1rem;">' +
|
|
'<i class="fas fa-times"></i>' +
|
|
'</button>' +
|
|
'</div>';
|
|
|
|
container.appendChild(toast);
|
|
|
|
// Auto-remove after duration
|
|
setTimeout(function() {
|
|
if (toast.parentElement) {
|
|
toast.remove();
|
|
}
|
|
}, duration);
|
|
}
|
|
|
|
/**
|
|
* Show loading overlay
|
|
*/
|
|
function showLoading() {
|
|
const overlay = document.getElementById('loading-overlay');
|
|
if (overlay) {
|
|
overlay.classList.remove('hidden');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hide loading overlay
|
|
*/
|
|
function hideLoading() {
|
|
const overlay = document.getElementById('loading-overlay');
|
|
if (overlay) {
|
|
overlay.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Escape HTML to prevent XSS
|
|
* @param {string} text - The text to escape
|
|
* @returns {string} Escaped HTML
|
|
*/
|
|
function escapeHtml(text) {
|
|
if (text === null || text === undefined) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
/**
|
|
* Format bytes to human readable string
|
|
* @param {number} bytes - Number of bytes
|
|
* @param {number} decimals - Decimal places (default 2)
|
|
* @returns {string} Formatted string like "1.5 MB"
|
|
*/
|
|
function formatBytes(bytes, decimals) {
|
|
decimals = decimals || 2;
|
|
if (bytes === 0) return '0 Bytes';
|
|
|
|
const k = 1024;
|
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i];
|
|
}
|
|
|
|
/**
|
|
* Format duration in seconds to human readable string
|
|
* @param {number} seconds - Duration in seconds
|
|
* @returns {string} Formatted string like "1h 30m"
|
|
*/
|
|
function formatDuration(seconds) {
|
|
if (!seconds || seconds <= 0) return '---';
|
|
|
|
if (seconds < 60) {
|
|
return Math.round(seconds) + 's';
|
|
} else if (seconds < 3600) {
|
|
const minutes = Math.round(seconds / 60);
|
|
return minutes + 'm';
|
|
} else if (seconds < 86400) {
|
|
const hours = Math.floor(seconds / 3600);
|
|
const minutes = Math.round((seconds % 3600) / 60);
|
|
return hours + 'h ' + minutes + 'm';
|
|
} else {
|
|
const days = Math.floor(seconds / 86400);
|
|
const hours = Math.round((seconds % 86400) / 3600);
|
|
return days + 'd ' + hours + 'h';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Format ETA (alias for formatDuration)
|
|
* @param {number} seconds - ETA in seconds
|
|
* @returns {string} Formatted ETA string
|
|
*/
|
|
function formatETA(seconds) {
|
|
return formatDuration(seconds);
|
|
}
|
|
|
|
/**
|
|
* Format date to locale string
|
|
* @param {string|Date} date - Date to format
|
|
* @returns {string} Formatted date string
|
|
*/
|
|
function formatDate(date) {
|
|
if (!date) return '';
|
|
const d = new Date(date);
|
|
return d.toLocaleString();
|
|
}
|
|
|
|
/**
|
|
* Get display name for anime/series object
|
|
* Returns name if available, otherwise key or folder
|
|
* @param {Object} anime - Anime/series object
|
|
* @returns {string} Display name
|
|
*/
|
|
function getDisplayName(anime) {
|
|
if (!anime) return '';
|
|
const name = anime.name || '';
|
|
const trimmedName = name.trim();
|
|
if (trimmedName) {
|
|
return trimmedName;
|
|
}
|
|
return anime.key || anime.folder || '';
|
|
}
|
|
|
|
/**
|
|
* Calculate duration between two timestamps
|
|
* @param {string} startTime - Start timestamp
|
|
* @param {string} endTime - End timestamp
|
|
* @returns {string} Formatted duration
|
|
*/
|
|
function calculateDuration(startTime, endTime) {
|
|
const start = new Date(startTime);
|
|
const end = new Date(endTime);
|
|
const diffMs = end - start;
|
|
|
|
const minutes = Math.floor(diffMs / (1000 * 60));
|
|
const seconds = Math.floor((diffMs % (1000 * 60)) / 1000);
|
|
|
|
return minutes + 'm ' + seconds + 's';
|
|
}
|
|
|
|
/**
|
|
* Show a confirmation modal
|
|
* @param {string} title - Modal title
|
|
* @param {string} message - Modal message
|
|
* @returns {Promise<boolean>} Resolves to true if confirmed, false if cancelled
|
|
*/
|
|
function showConfirmModal(title, message) {
|
|
return new Promise(function(resolve) {
|
|
const modal = document.getElementById('confirm-modal');
|
|
if (!modal) {
|
|
resolve(window.confirm(message));
|
|
return;
|
|
}
|
|
|
|
document.getElementById('confirm-title').textContent = title;
|
|
document.getElementById('confirm-message').textContent = message;
|
|
modal.classList.remove('hidden');
|
|
|
|
function handleConfirm() {
|
|
cleanup();
|
|
resolve(true);
|
|
}
|
|
|
|
function handleCancel() {
|
|
cleanup();
|
|
resolve(false);
|
|
}
|
|
|
|
function cleanup() {
|
|
document.getElementById('confirm-ok').removeEventListener('click', handleConfirm);
|
|
document.getElementById('confirm-cancel').removeEventListener('click', handleCancel);
|
|
modal.classList.add('hidden');
|
|
}
|
|
|
|
document.getElementById('confirm-ok').addEventListener('click', handleConfirm);
|
|
document.getElementById('confirm-cancel').addEventListener('click', handleCancel);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Hide the confirmation modal
|
|
*/
|
|
function hideConfirmModal() {
|
|
const modal = document.getElementById('confirm-modal');
|
|
if (modal) {
|
|
modal.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
// Public API
|
|
return {
|
|
showToast: showToast,
|
|
showLoading: showLoading,
|
|
hideLoading: hideLoading,
|
|
escapeHtml: escapeHtml,
|
|
formatBytes: formatBytes,
|
|
formatDuration: formatDuration,
|
|
formatETA: formatETA,
|
|
formatDate: formatDate,
|
|
getDisplayName: getDisplayName,
|
|
calculateDuration: calculateDuration,
|
|
showConfirmModal: showConfirmModal,
|
|
hideConfirmModal: hideConfirmModal
|
|
};
|
|
})();
|