refactor: split CSS and JS into modular files (SRP)

This commit is contained in:
2025-12-26 13:55:02 +01:00
parent 94cf36bff3
commit 2e5731b5d6
47 changed files with 8882 additions and 2298 deletions

View File

@@ -0,0 +1,120 @@
/**
* AniWorld - API Client Module
*
* HTTP request wrapper with automatic authentication
* and error handling.
*
* Dependencies: constants.js, auth.js
*/
var AniWorld = window.AniWorld || {};
AniWorld.ApiClient = (function() {
'use strict';
/**
* Make an authenticated HTTP request
* Automatically includes Authorization header and handles 401 responses
*
* @param {string} url - The API endpoint URL
* @param {Object} options - Fetch options (method, headers, body, etc.)
* @returns {Promise<Response|null>} The fetch response or null if auth failed
*/
async function request(url, options) {
options = options || {};
// Get JWT token from localStorage
const token = AniWorld.Auth.getToken();
// Check if token exists
if (!token) {
window.location.href = '/login';
return null;
}
// Build request options with auth header
const requestOptions = {
credentials: 'same-origin',
...options,
headers: {
'Authorization': 'Bearer ' + token,
...options.headers
}
};
// Add Content-Type for JSON body if not already set
if (options.body && typeof options.body === 'string' && !requestOptions.headers['Content-Type']) {
requestOptions.headers['Content-Type'] = 'application/json';
}
const response = await fetch(url, requestOptions);
if (response.status === 401) {
// Token is invalid or expired, clear it and redirect to login
AniWorld.Auth.removeToken();
window.location.href = '/login';
return null;
}
return response;
}
/**
* Make a GET request
* @param {string} url - The API endpoint URL
* @param {Object} headers - Additional headers
* @returns {Promise<Response|null>}
*/
async function get(url, headers) {
return request(url, { method: 'GET', headers: headers });
}
/**
* Make a POST request with JSON body
* @param {string} url - The API endpoint URL
* @param {Object} data - The data to send
* @param {Object} headers - Additional headers
* @returns {Promise<Response|null>}
*/
async function post(url, data, headers) {
return request(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify(data)
});
}
/**
* Make a DELETE request
* @param {string} url - The API endpoint URL
* @param {Object} headers - Additional headers
* @returns {Promise<Response|null>}
*/
async function del(url, headers) {
return request(url, { method: 'DELETE', headers: headers });
}
/**
* Make a PUT request with JSON body
* @param {string} url - The API endpoint URL
* @param {Object} data - The data to send
* @param {Object} headers - Additional headers
* @returns {Promise<Response|null>}
*/
async function put(url, data, headers) {
return request(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify(data)
});
}
// Public API
return {
request: request,
get: get,
post: post,
delete: del,
put: put
};
})();

View File

@@ -0,0 +1,173 @@
/**
* AniWorld - Authentication Module
*
* Handles user authentication, token management,
* and session validation.
*
* Dependencies: constants.js
*/
var AniWorld = window.AniWorld || {};
AniWorld.Auth = (function() {
'use strict';
const STORAGE = AniWorld.Constants.STORAGE_KEYS;
const API = AniWorld.Constants.API;
/**
* Get the stored access token
* @returns {string|null} The access token or null if not found
*/
function getToken() {
return localStorage.getItem(STORAGE.ACCESS_TOKEN);
}
/**
* Store the access token
* @param {string} token - The access token to store
*/
function setToken(token) {
localStorage.setItem(STORAGE.ACCESS_TOKEN, token);
}
/**
* Remove the stored access token
*/
function removeToken() {
localStorage.removeItem(STORAGE.ACCESS_TOKEN);
localStorage.removeItem(STORAGE.TOKEN_EXPIRES_AT);
}
/**
* Get authorization headers for API requests
* @returns {Object} Headers object with Authorization if token exists
*/
function getAuthHeaders() {
const token = getToken();
return token ? { 'Authorization': 'Bearer ' + token } : {};
}
/**
* Check if user is authenticated
* Redirects to login page if not authenticated
* @returns {Promise<boolean>} True if authenticated, false otherwise
*/
async function checkAuth() {
const currentPath = window.location.pathname;
// Don't check authentication if already on login or setup pages
if (currentPath === '/login' || currentPath === '/setup') {
return false;
}
try {
const token = getToken();
console.log('checkAuthentication: token exists =', !!token);
if (!token) {
console.log('checkAuthentication: No token found, redirecting to /login');
window.location.href = '/login';
return false;
}
const headers = {
'Authorization': 'Bearer ' + token
};
const response = await fetch(API.AUTH_STATUS, { headers });
console.log('checkAuthentication: response status =', response.status);
if (!response.ok) {
console.log('checkAuthentication: Response not OK, status =', response.status);
throw new Error('HTTP ' + response.status);
}
const data = await response.json();
console.log('checkAuthentication: data =', data);
if (!data.configured) {
console.log('checkAuthentication: Not configured, redirecting to /setup');
window.location.href = '/setup';
return false;
}
if (!data.authenticated) {
console.log('checkAuthentication: Not authenticated, redirecting to /login');
removeToken();
window.location.href = '/login';
return false;
}
console.log('checkAuthentication: Authenticated successfully');
// Show logout button if it exists
const logoutBtn = document.getElementById('logout-btn');
if (logoutBtn) {
logoutBtn.style.display = 'block';
}
return true;
} catch (error) {
console.error('Authentication check failed:', error);
removeToken();
window.location.href = '/login';
return false;
}
}
/**
* Log out the current user
* @param {Function} showToast - Optional function to show toast messages
*/
async function logout(showToast) {
try {
const response = await AniWorld.ApiClient.request(API.AUTH_LOGOUT, { method: 'POST' });
removeToken();
if (response && response.ok) {
const data = await response.json();
if (showToast) {
showToast(data.status === 'ok' ? 'Logged out successfully' : 'Logged out', 'success');
}
} else {
if (showToast) {
showToast('Logged out', 'success');
}
}
setTimeout(function() {
window.location.href = '/login';
}, 1000);
} catch (error) {
console.error('Logout error:', error);
removeToken();
if (showToast) {
showToast('Logged out', 'success');
}
setTimeout(function() {
window.location.href = '/login';
}, 1000);
}
}
/**
* Check if user has a valid token stored
* @returns {boolean} True if token exists
*/
function hasToken() {
return !!getToken();
}
// Public API
return {
getToken: getToken,
setToken: setToken,
removeToken: removeToken,
getAuthHeaders: getAuthHeaders,
checkAuth: checkAuth,
logout: logout,
hasToken: hasToken
};
})();

View File

@@ -0,0 +1,147 @@
/**
* AniWorld - Constants Module
*
* Shared constants, API endpoints, and configuration values
* used across all JavaScript modules.
*
* Dependencies: None (must be loaded first)
*/
var AniWorld = window.AniWorld || {};
AniWorld.Constants = (function() {
'use strict';
// API Endpoints
const API = {
// Auth endpoints
AUTH_STATUS: '/api/auth/status',
AUTH_LOGIN: '/api/auth/login',
AUTH_LOGOUT: '/api/auth/logout',
// Anime endpoints
ANIME_LIST: '/api/anime',
ANIME_SEARCH: '/api/anime/search',
ANIME_ADD: '/api/anime/add',
ANIME_RESCAN: '/api/anime/rescan',
ANIME_STATUS: '/api/anime/status',
ANIME_SCAN_STATUS: '/api/anime/scan/status',
// Queue endpoints
QUEUE_STATUS: '/api/queue/status',
QUEUE_ADD: '/api/queue/add',
QUEUE_START: '/api/queue/start',
QUEUE_STOP: '/api/queue/stop',
QUEUE_RETRY: '/api/queue/retry',
QUEUE_REMOVE: '/api/queue', // + /{id}
QUEUE_COMPLETED: '/api/queue/completed',
QUEUE_FAILED: '/api/queue/failed',
QUEUE_PENDING: '/api/queue/pending',
// Config endpoints
CONFIG_DIRECTORY: '/api/config/directory',
CONFIG_SECTION: '/api/config/section', // + /{section}
CONFIG_BACKUP: '/api/config/backup',
CONFIG_BACKUPS: '/api/config/backups',
CONFIG_VALIDATE: '/api/config/validate',
CONFIG_RESET: '/api/config/reset',
// Scheduler endpoints
SCHEDULER_CONFIG: '/api/scheduler/config',
SCHEDULER_TRIGGER: '/api/scheduler/trigger-rescan',
// Logging endpoints
LOGGING_CONFIG: '/api/logging/config',
LOGGING_FILES: '/api/logging/files',
LOGGING_CLEANUP: '/api/logging/cleanup',
LOGGING_TEST: '/api/logging/test',
// Diagnostics
DIAGNOSTICS_NETWORK: '/api/diagnostics/network'
};
// Local Storage Keys
const STORAGE_KEYS = {
ACCESS_TOKEN: 'access_token',
TOKEN_EXPIRES_AT: 'token_expires_at',
THEME: 'theme'
};
// Default Values
const DEFAULTS = {
THEME: 'light',
TOAST_DURATION: 5000,
SCAN_AUTO_DISMISS: 3000,
REFRESH_INTERVAL: 2000
};
// WebSocket Rooms
const WS_ROOMS = {
DOWNLOADS: 'downloads',
QUEUE: 'queue',
SCAN: 'scan',
SYSTEM: 'system',
ERRORS: 'errors'
};
// WebSocket Events
const WS_EVENTS = {
// Connection
CONNECTED: 'connected',
CONNECT: 'connect',
DISCONNECT: 'disconnect',
// Scan events
SCAN_STARTED: 'scan_started',
SCAN_PROGRESS: 'scan_progress',
SCAN_COMPLETED: 'scan_completed',
SCAN_COMPLETE: 'scan_complete',
SCAN_ERROR: 'scan_error',
SCAN_FAILED: 'scan_failed',
// Scheduled scan events
SCHEDULED_RESCAN_STARTED: 'scheduled_rescan_started',
SCHEDULED_RESCAN_COMPLETED: 'scheduled_rescan_completed',
SCHEDULED_RESCAN_ERROR: 'scheduled_rescan_error',
SCHEDULED_RESCAN_SKIPPED: 'scheduled_rescan_skipped',
// Download events
DOWNLOAD_STARTED: 'download_started',
DOWNLOAD_PROGRESS: 'download_progress',
DOWNLOAD_COMPLETED: 'download_completed',
DOWNLOAD_COMPLETE: 'download_complete',
DOWNLOAD_ERROR: 'download_error',
DOWNLOAD_FAILED: 'download_failed',
DOWNLOAD_PAUSED: 'download_paused',
DOWNLOAD_RESUMED: 'download_resumed',
DOWNLOAD_CANCELLED: 'download_cancelled',
DOWNLOAD_STOPPED: 'download_stopped',
DOWNLOAD_STOP_REQUESTED: 'download_stop_requested',
// Queue events
QUEUE_UPDATED: 'queue_updated',
QUEUE_STATUS: 'queue_status',
QUEUE_STARTED: 'queue_started',
QUEUE_STOPPED: 'queue_stopped',
QUEUE_PAUSED: 'queue_paused',
QUEUE_RESUMED: 'queue_resumed',
QUEUE_COMPLETED: 'queue_completed',
DOWNLOAD_QUEUE_COMPLETED: 'download_queue_completed',
DOWNLOAD_QUEUE_UPDATE: 'download_queue_update',
DOWNLOAD_EPISODE_UPDATE: 'download_episode_update',
DOWNLOAD_SERIES_COMPLETED: 'download_series_completed',
// Auto download
AUTO_DOWNLOAD_STARTED: 'auto_download_started',
AUTO_DOWNLOAD_ERROR: 'auto_download_error'
};
// Public API
return {
API: API,
STORAGE_KEYS: STORAGE_KEYS,
DEFAULTS: DEFAULTS,
WS_ROOMS: WS_ROOMS,
WS_EVENTS: WS_EVENTS
};
})();

View File

@@ -0,0 +1,73 @@
/**
* AniWorld - Theme Module
*
* Dark/light mode management and persistence.
*
* Dependencies: constants.js
*/
var AniWorld = window.AniWorld || {};
AniWorld.Theme = (function() {
'use strict';
const STORAGE = AniWorld.Constants.STORAGE_KEYS;
const DEFAULTS = AniWorld.Constants.DEFAULTS;
/**
* Initialize theme from saved preference
*/
function init() {
const savedTheme = localStorage.getItem(STORAGE.THEME) || DEFAULTS.THEME;
setTheme(savedTheme);
}
/**
* Set the application theme
* @param {string} theme - 'light' or 'dark'
*/
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem(STORAGE.THEME, theme);
// Update theme toggle icon if it exists
const themeIcon = document.querySelector('#theme-toggle i');
if (themeIcon) {
themeIcon.className = theme === 'light' ? 'fas fa-moon' : 'fas fa-sun';
}
}
/**
* Toggle between light and dark themes
*/
function toggle() {
const currentTheme = document.documentElement.getAttribute('data-theme') || DEFAULTS.THEME;
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
setTheme(newTheme);
}
/**
* Get the current theme
* @returns {string} 'light' or 'dark'
*/
function getCurrentTheme() {
return document.documentElement.getAttribute('data-theme') || DEFAULTS.THEME;
}
/**
* Check if dark mode is active
* @returns {boolean}
*/
function isDarkMode() {
return getCurrentTheme() === 'dark';
}
// Public API
return {
init: init,
setTheme: setTheme,
toggle: toggle,
getCurrentTheme: getCurrentTheme,
isDarkMode: isDarkMode
};
})();

View File

@@ -0,0 +1,245 @@
/**
* 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.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
};
})();

View File

@@ -0,0 +1,164 @@
/**
* AniWorld - WebSocket Client Module
*
* WebSocket connection management and event handling.
*
* Dependencies: constants.js
*/
var AniWorld = window.AniWorld || {};
AniWorld.WebSocketClient = (function() {
'use strict';
const WS_EVENTS = AniWorld.Constants.WS_EVENTS;
let socket = null;
let isConnected = false;
let eventHandlers = {};
/**
* Initialize WebSocket connection
* @param {Object} handlers - Object mapping event names to handler functions
*/
function init(handlers) {
handlers = handlers || {};
eventHandlers = handlers;
// Check if Socket.IO is available
if (typeof io === 'undefined') {
console.error('Socket.IO not loaded');
return;
}
socket = io();
// Handle connection events
socket.on('connected', function(data) {
console.log('WebSocket connection confirmed', data);
});
socket.on('connect', function() {
isConnected = true;
console.log('Connected to server');
// Subscribe to rooms
if (socket.join) {
socket.join('scan');
socket.join('downloads');
socket.join('queue');
}
// Call custom connect handler if provided
if (eventHandlers.onConnect) {
eventHandlers.onConnect();
}
});
socket.on('disconnect', function() {
isConnected = false;
console.log('Disconnected from server');
// Call custom disconnect handler if provided
if (eventHandlers.onDisconnect) {
eventHandlers.onDisconnect();
}
});
// Set up event handlers for common events
setupDefaultHandlers();
}
/**
* Set up default event handlers
*/
function setupDefaultHandlers() {
if (!socket) return;
// Register any events that have handlers
Object.keys(eventHandlers).forEach(function(eventName) {
if (eventName !== 'onConnect' && eventName !== 'onDisconnect') {
socket.on(eventName, eventHandlers[eventName]);
}
});
}
/**
* Register an event handler
* @param {string} eventName - The event name
* @param {Function} handler - The handler function
*/
function on(eventName, handler) {
if (!socket) {
console.warn('Socket not initialized');
return;
}
eventHandlers[eventName] = handler;
socket.off(eventName); // Remove existing handler
socket.on(eventName, handler);
}
/**
* Remove an event handler
* @param {string} eventName - The event name
*/
function off(eventName) {
if (!socket) return;
delete eventHandlers[eventName];
socket.off(eventName);
}
/**
* Emit an event to the server
* @param {string} eventName - The event name
* @param {*} data - The data to send
*/
function emit(eventName, data) {
if (!socket || !isConnected) {
console.warn('Socket not connected');
return;
}
socket.emit(eventName, data);
}
/**
* Get connection status
* @returns {boolean} True if connected
*/
function getConnectionStatus() {
return isConnected;
}
/**
* Get the socket instance
* @returns {Object} The Socket.IO socket instance
*/
function getSocket() {
return socket;
}
/**
* Disconnect from server
*/
function disconnect() {
if (socket) {
socket.disconnect();
socket = null;
isConnected = false;
}
}
// Public API
return {
init: init,
on: on,
off: off,
emit: emit,
isConnected: getConnectionStatus,
getSocket: getSocket,
disconnect: disconnect
};
})();