diff --git a/src/server/app.py b/src/server/app.py index f715899..65252ec 100644 --- a/src/server/app.py +++ b/src/server/app.py @@ -1,9 +1,7 @@ # --- Global UTF-8 logging setup (fix UnicodeEncodeError) --- import sys -import io import logging import os -import threading from datetime import datetime # Add the parent directory to sys.path to import our modules @@ -16,40 +14,17 @@ from flask import Flask, render_template, request, jsonify, redirect, url_for from flask_socketio import SocketIO, emit import logging import atexit - -from src.cli.Main import SeriesApp - -# --- Fix Unicode logging error for Windows console --- -import sys -import io - - -from server.core.entities.series import Serie -from server.core.entities import SerieList -from server.core import SerieScanner -from server.infrastructure.providers.provider_factory import Loaders from web.controllers.auth_controller import session_manager, require_auth, optional_auth from config import config from application.services.queue_service import download_queue_bp -# Import route blueprints -from web.routes import ( - auth_bp, auth_api_bp, api_bp, main_bp, static_bp, - diagnostic_bp, config_bp -) -from web.routes.websocket_handlers import register_socketio_handlers # Import API blueprints from their correct locations -from web.controllers.api.v1.process import process_bp -from web.controllers.api.v1.scheduler import scheduler_bp -from web.controllers.api.v1.logging import logging_bp -from web.controllers.api.v1.health import health_bp + from application.services.scheduler_service import init_scheduler, get_scheduler from shared.utils.process_utils import (with_process_lock, RESCAN_LOCK, DOWNLOAD_LOCK, ProcessLockError, is_process_running, check_process_locks) -# Import error handling and monitoring modules -from web.middleware.error_handler import handle_api_errors app = Flask(__name__, template_folder='web/templates/base', @@ -89,48 +64,6 @@ def cleanup_on_exit(): except Exception as e: logging.error(f"Error during cleanup: {e}") - -def rescan_callback(): - """Callback for scheduled rescan operations.""" - try: - # Reinit and scan - series_app.SerieScanner.Reinit() - series_app.SerieScanner.Scan() - - # Refresh the series list - series_app.List = SerieList.SerieList(series_app.directory_to_search) - series_app.__InitList__() - - return {"status": "success", "message": "Scheduled rescan completed"} - except Exception as e: - raise Exception(f"Scheduled rescan failed: {e}") - -def download_callback(): - """Callback for auto-download after scheduled rescan.""" - try: - if not series_app or not series_app.List: - return {"status": "skipped", "message": "No series data available"} - - # Find series with missing episodes - series_with_missing = [] - for serie in series_app.List.GetList(): - if serie.episodeDict: - series_with_missing.append(serie) - - if not series_with_missing: - return {"status": "skipped", "message": "No series with missing episodes found"} - - # Note: Actual download implementation would go here - # For now, just return the count of series that would be downloaded - return { - "status": "started", - "message": f"Auto-download initiated for {len(series_with_missing)} series", - "series_count": len(series_with_missing) - } - - except Exception as e: - raise Exception(f"Auto-download failed: {e}") - # Register all blueprints app.register_blueprint(download_queue_bp) app.register_blueprint(main_bp) @@ -154,10 +87,9 @@ from web.routes.api_routes import set_socketio set_socketio(socketio) # Initialize scheduler -scheduler = init_scheduler(config, socketio) - -scheduler.set_rescan_callback(rescan_callback) -scheduler.set_download_callback(download_callback) + +CurrentSeriesApp = None +scheduler = init_scheduler(config, socketio, CurrentSeriesApp) if __name__ == '__main__': # Configure enhanced logging system first diff --git a/src/server/application/SeriesApp.py b/src/server/application/SeriesApp.py new file mode 100644 index 0000000..9435f1b --- /dev/null +++ b/src/server/application/SeriesApp.py @@ -0,0 +1,42 @@ +import sys +import os +import logging + +from src.core.SerieScanner import SerieScanner +from src.core.entities.SerieList import SerieList +from src.core.providers.provider_factory import Loaders + + +class SeriesApp: + + def __init__(self, directory_to_search: str): + + # Only show initialization message for the first instance + if SeriesApp._initialization_count <= 1: + print("Please wait while initializing...") + + self.progress = None + self.directory_to_search = directory_to_search + self.Loaders = Loaders() + self.loader = self.Loaders.GetLoader(key="aniworld.to") + self.SerieScanner = SerieScanner(directory_to_search, self.loader) + + self.List = SerieList(self.directory_to_search) + self.__InitList__() + + def __InitList__(self): + self.series_list = self.List.GetMissingEpisode() + + def search(self, words :str) -> list: + return self.loader.Search(words) + + def download(self, serieFolder: str, season: int, episode: int, key: str, callback) -> bool: + self.loader.Download(self.directory_to_search, serieFolder, season, episode, key, "German Dub", callback) + + def ReScan(self, callback): + + self.SerieScanner.Reinit() + self.SerieScanner.Scan(callback) + + self.List = SerieList(self.directory_to_search) + self.__InitList__() diff --git a/src/server/web/controllers/api_endpoints.py b/src/server/web/controllers/api/api_endpoints.py similarity index 76% rename from src/server/web/controllers/api_endpoints.py rename to src/server/web/controllers/api/api_endpoints.py index de328be..408f5c7 100644 --- a/src/server/web/controllers/api_endpoints.py +++ b/src/server/web/controllers/api/api_endpoints.py @@ -419,60 +419,6 @@ def api_start_download(): raise RetryableError(f"Failed to start download: {e}") -# Notification Service Endpoints -@api_integration_bp.route('/api/notifications/discord', methods=['POST']) -@handle_api_errors -@require_auth -def setup_discord_notifications(): - """Setup Discord webhook notifications.""" - try: - data = request.get_json() - webhook_url = data.get('webhook_url') - name = data.get('name', 'discord') - - if not webhook_url: - return jsonify({ - 'status': 'error', - 'message': 'webhook_url is required' - }), 400 - - notification_service.register_discord_webhook(webhook_url, name) - - return jsonify({ - 'status': 'success', - 'message': 'Discord notifications configured' - }) - - except Exception as e: - raise RetryableError(f"Failed to setup Discord notifications: {e}") - - -@api_integration_bp.route('/api/notifications/telegram', methods=['POST']) -@handle_api_errors -@require_auth -def setup_telegram_notifications(): - """Setup Telegram bot notifications.""" - try: - data = request.get_json() - bot_token = data.get('bot_token') - chat_id = data.get('chat_id') - name = data.get('name', 'telegram') - - if not bot_token or not chat_id: - return jsonify({ - 'status': 'error', - 'message': 'bot_token and chat_id are required' - }), 400 - - notification_service.register_telegram_bot(bot_token, chat_id, name) - - return jsonify({ - 'status': 'success', - 'message': 'Telegram notifications configured' - }) - - except Exception as e: - raise RetryableError(f"Failed to setup Telegram notifications: {e}") @api_integration_bp.route('/api/notifications/test', methods=['POST']) @@ -499,72 +445,5 @@ def test_notifications(): raise RetryableError(f"Failed to send test notification: {e}") -# API Documentation Endpoint -@api_integration_bp.route('/api/docs') -def api_documentation(): - """Get API documentation.""" - docs = { - 'title': 'AniWorld API Documentation', - 'version': '1.0.0', - 'description': 'REST API for AniWorld anime download management', - 'authentication': { - 'type': 'API Key', - 'header': 'Authorization: Bearer ', - 'note': 'API keys can be created through the web interface' - }, - 'endpoints': { - 'GET /api/v1/series': { - 'description': 'Get list of all series', - 'permissions': ['read'], - 'parameters': {}, - 'response': 'List of series with basic information' - }, - 'GET /api/v1/series/{folder}/episodes': { - 'description': 'Get episodes for specific series', - 'permissions': ['read'], - 'parameters': { - 'folder': 'Series folder name' - }, - 'response': 'Missing episodes for the series' - }, - 'POST /api/v1/download/start': { - 'description': 'Start download for specific episode', - 'permissions': ['download'], - 'parameters': { - 'serie_folder': 'Series folder name', - 'season': 'Season number', - 'episode': 'Episode number' - }, - 'response': 'Download status' - }, - 'GET /api/export/anime-list': { - 'description': 'Export anime list', - 'permissions': ['read', 'export'], - 'parameters': { - 'format': 'json or csv', - 'missing_only': 'true or false' - }, - 'response': 'Anime list in requested format' - } - }, - 'webhook_events': [ - 'download.started', - 'download.completed', - 'download.failed', - 'scan.started', - 'scan.completed', - 'scan.failed', - 'series.added', - 'series.removed' - ], - 'rate_limits': { - 'default': '1000 requests per hour per API key', - 'note': 'Rate limits are configurable per API key' - } - } - - return jsonify(docs) - - # Export the blueprint __all__ = ['api_integration_bp'] \ No newline at end of file diff --git a/src/server/web/controllers/api/v1/__init__.py b/src/server/web/controllers/api/v1/__init__.py deleted file mode 100644 index 2f23a91..0000000 --- a/src/server/web/controllers/api/v1/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# API version 1 endpoints diff --git a/src/server/web/routes/api_routes.py b/src/server/web/controllers/api/v1/api_routes.py similarity index 100% rename from src/server/web/routes/api_routes.py rename to src/server/web/controllers/api/v1/api_routes.py diff --git a/src/server/web/routes/auth_routes.py b/src/server/web/controllers/api/v1/auth_routes.py similarity index 100% rename from src/server/web/routes/auth_routes.py rename to src/server/web/controllers/api/v1/auth_routes.py diff --git a/src/server/web/routes/config_routes.py b/src/server/web/controllers/api/v1/config_routes.py similarity index 100% rename from src/server/web/routes/config_routes.py rename to src/server/web/controllers/api/v1/config_routes.py diff --git a/src/server/web/routes/diagnostic_routes.py b/src/server/web/controllers/api/v1/diagnostic_routes.py similarity index 100% rename from src/server/web/routes/diagnostic_routes.py rename to src/server/web/controllers/api/v1/diagnostic_routes.py diff --git a/src/server/web/routes/main_routes.py b/src/server/web/controllers/api/v1/main_routes.py similarity index 100% rename from src/server/web/routes/main_routes.py rename to src/server/web/controllers/api/v1/main_routes.py diff --git a/src/server/web/routes/static_routes.py b/src/server/web/controllers/api/v1/static_routes.py similarity index 100% rename from src/server/web/routes/static_routes.py rename to src/server/web/controllers/api/v1/static_routes.py diff --git a/src/server/web/routes/websocket_handlers.py b/src/server/web/controllers/api/v1/websocket_handlers.py similarity index 100% rename from src/server/web/routes/websocket_handlers.py rename to src/server/web/controllers/api/v1/websocket_handlers.py diff --git a/src/server/web/middleware/accessibility_middleware.py b/src/server/web/middleware/accessibility_middleware.py deleted file mode 100644 index d03c235..0000000 --- a/src/server/web/middleware/accessibility_middleware.py +++ /dev/null @@ -1,1554 +0,0 @@ -""" -Accessibility Features System - -This module provides comprehensive accessibility support including ARIA labels, -keyboard navigation, screen reader support, and WCAG compliance features. -""" - -from typing import Dict, List, Any, Optional -from flask import Blueprint, request, jsonify - -class AccessibilityManager: - """Manages accessibility features and WCAG compliance.""" - - def __init__(self, app=None): - self.app = app - self.accessibility_config = { - 'screen_reader_support': True, - 'keyboard_navigation': True, - 'high_contrast': False, - 'large_text': False, - 'reduced_motion': False, - 'focus_indicators': True, - 'skip_links': True, - 'aria_announcements': True - } - - def init_app(self, app): - """Initialize with Flask app.""" - self.app = app - - def get_accessibility_js(self): - """Generate JavaScript code for accessibility features.""" - import json - return f""" -// AniWorld Accessibility Manager -class AccessibilityManager {{ - constructor() {{ - this.config = {json.dumps(self.accessibility_config)}; - this.announcements = []; - this.focusHistory = []; - this.currentFocus = null; - this.skipLinks = []; - - // Screen reader detection - this.screenReaderDetected = false; - this.highContrastMode = false; - this.reducedMotion = false; - - this.init(); - }} - - init() {{ - this.detectAccessibilityFeatures(); - this.setupKeyboardNavigation(); - this.setupScreenReaderSupport(); - this.setupFocusManagement(); - this.setupSkipLinks(); - this.setupARIA(); - this.setupHighContrast(); - this.setupMotionPreferences(); - this.setupTextScaling(); - - // Initialize accessibility toolbar - this.createAccessibilityToolbar(); - - console.log('Accessibility manager initialized'); - }} - - detectAccessibilityFeatures() {{ - // Detect screen reader - this.screenReaderDetected = this.detectScreenReader(); - - // Detect high contrast preference - this.highContrastMode = window.matchMedia('(prefers-contrast: high)').matches; - - // Detect reduced motion preference - this.reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; - - // Set initial states - document.body.classList.toggle('screen-reader-detected', this.screenReaderDetected); - document.body.classList.toggle('high-contrast', this.highContrastMode); - document.body.classList.toggle('reduced-motion', this.reducedMotion); - - // Listen for preference changes - window.matchMedia('(prefers-contrast: high)').addEventListener('change', (e) => {{ - this.highContrastMode = e.matches; - this.updateHighContrast(); - }}); - - window.matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', (e) => {{ - this.reducedMotion = e.matches; - this.updateMotionPreferences(); - }}); - }} - - detectScreenReader() {{ - // Multiple detection methods - return ( - navigator.userAgent.includes('NVDA') || - navigator.userAgent.includes('JAWS') || - navigator.userAgent.includes('Dragon') || - navigator.userAgent.includes('ZoomText') || - window.speechSynthesis || - !!document.querySelector('[aria-live]') || - !!window.navigator.userAgent.match(/Windows NT.*rv:/) || - !!document.createElement('div').setAttribute - ); - }} - - setupKeyboardNavigation() {{ - // Tab navigation improvements - this.setupTabNavigation(); - - // Arrow key navigation - this.setupArrowKeyNavigation(); - - // Escape key handling - this.setupEscapeKey(); - - // Enter/Space key handling - this.setupActivationKeys(); - - // Custom keyboard shortcuts - this.setupAccessibilityShortcuts(); - }} - - setupTabNavigation() {{ - document.addEventListener('keydown', (e) => {{ - if (e.key === 'Tab') {{ - this.handleTabNavigation(e); - }} - }}); - - // Ensure all interactive elements are focusable - this.ensureFocusableElements(); - - // Create focus trap for modals - this.setupFocusTrap(); - }} - - ensureFocusableElements() {{ - const interactiveElements = document.querySelectorAll(` - button, [role="button"], a, input, select, textarea, - [tabindex], .btn, .dropdown-toggle, .series-card - `); - - interactiveElements.forEach(element => {{ - if (!element.hasAttribute('tabindex') && !this.isNativelyFocusable(element)) {{ - element.setAttribute('tabindex', '0'); - }} - - // Ensure proper ARIA attributes - this.enhanceElementAccessibility(element); - }}); - }} - - isNativelyFocusable(element) {{ - const focusableElements = [ - 'A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'IFRAME' - ]; - return focusableElements.includes(element.tagName) && !element.disabled; - }} - - enhanceElementAccessibility(element) {{ - // Add ARIA labels if missing - if (!element.hasAttribute('aria-label') && !element.hasAttribute('aria-labelledby')) {{ - const label = this.generateAccessibleLabel(element); - if (label) {{ - element.setAttribute('aria-label', label); - }} - }} - - // Add roles if missing - if (element.classList.contains('btn') && !element.hasAttribute('role')) {{ - element.setAttribute('role', 'button'); - }} - - if (element.classList.contains('series-card') && !element.hasAttribute('role')) {{ - element.setAttribute('role', 'article'); - element.setAttribute('tabindex', '0'); - }} - }} - - generateAccessibleLabel(element) {{ - // Try to find descriptive text - const textContent = element.textContent.trim(); - if (textContent) {{ - return textContent; - }} - - // Check for images - const img = element.querySelector('img'); - if (img && img.alt) {{ - return img.alt; - }} - - // Check for icons - const icon = element.querySelector('[class*="fa-"], [class*="icon-"]'); - if (icon) {{ - const iconClass = Array.from(icon.classList).find(cls => cls.startsWith('fa-')); - if (iconClass) {{ - return iconClass.replace('fa-', '').replace('-', ' '); - }} - }} - - // Use class names as fallback - const meaningfulClasses = ['download', 'play', 'pause', 'settings', 'close']; - for (const cls of element.classList) {{ - if (meaningfulClasses.some(meaningful => cls.includes(meaningful))) {{ - return cls.replace('-', ' '); - }} - }} - - return null; - }} - - setupArrowKeyNavigation() {{ - document.addEventListener('keydown', (e) => {{ - if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {{ - this.handleArrowNavigation(e); - }} - }}); - }} - - handleArrowNavigation(e) {{ - const currentElement = document.activeElement; - - // Grid navigation for series cards - if (currentElement.classList.contains('series-card')) {{ - e.preventDefault(); - this.navigateSeriesGrid(e.key, currentElement); - return; - }} - - // Menu navigation - if (currentElement.closest('.navbar, .dropdown-menu')) {{ - e.preventDefault(); - this.navigateMenu(e.key, currentElement); - return; - }} - - // Table navigation - if (currentElement.closest('table')) {{ - e.preventDefault(); - this.navigateTable(e.key, currentElement); - return; - }} - }} - - navigateSeriesGrid(direction, currentElement) {{ - const allCards = Array.from(document.querySelectorAll('.series-card')); - const currentIndex = allCards.indexOf(currentElement); - const columns = this.getGridColumns(); - - let newIndex; - - switch (direction) {{ - case 'ArrowUp': - newIndex = currentIndex - columns; - break; - case 'ArrowDown': - newIndex = currentIndex + columns; - break; - case 'ArrowLeft': - newIndex = currentIndex - 1; - break; - case 'ArrowRight': - newIndex = currentIndex + 1; - break; - }} - - if (newIndex >= 0 && newIndex < allCards.length) {{ - allCards[newIndex].focus(); - this.announceNavigation(allCards[newIndex]); - }} - }} - - getGridColumns() {{ - const container = document.querySelector('.series-grid, .row'); - if (!container) return 3; - - const containerWidth = container.offsetWidth; - const cardWidth = 300; // Approximate card width - return Math.floor(containerWidth / cardWidth) || 1; - }} - - navigateMenu(direction, currentElement) {{ - const menu = currentElement.closest('.navbar, .dropdown-menu'); - const menuItems = Array.from(menu.querySelectorAll('a, button, [tabindex="0"]')); - const currentIndex = menuItems.indexOf(currentElement); - - let newIndex; - - if (direction === 'ArrowUp') {{ - newIndex = currentIndex > 0 ? currentIndex - 1 : menuItems.length - 1; - }} else if (direction === 'ArrowDown') {{ - newIndex = currentIndex < menuItems.length - 1 ? currentIndex + 1 : 0; - }} - - if (newIndex !== undefined && menuItems[newIndex]) {{ - menuItems[newIndex].focus(); - }} - }} - - navigateTable(direction, currentElement) {{ - const table = currentElement.closest('table'); - const cell = currentElement.closest('td, th'); - if (!cell) return; - - const row = cell.parentElement; - const cellIndex = Array.from(row.children).indexOf(cell); - const rowIndex = Array.from(table.querySelectorAll('tr')).indexOf(row); - - let newCell; - - switch (direction) {{ - case 'ArrowUp': - const prevRow = table.querySelectorAll('tr')[rowIndex - 1]; - newCell = prevRow?.children[cellIndex]; - break; - case 'ArrowDown': - const nextRow = table.querySelectorAll('tr')[rowIndex + 1]; - newCell = nextRow?.children[cellIndex]; - break; - case 'ArrowLeft': - newCell = row.children[cellIndex - 1]; - break; - case 'ArrowRight': - newCell = row.children[cellIndex + 1]; - break; - }} - - if (newCell) {{ - const focusable = newCell.querySelector('button, a, [tabindex="0"]') || newCell; - focusable.focus(); - }} - }} - - setupEscapeKey() {{ - document.addEventListener('keydown', (e) => {{ - if (e.key === 'Escape') {{ - this.handleEscape(); - }} - }}); - }} - - handleEscape() {{ - // Close modals - const openModal = document.querySelector('.modal.show'); - if (openModal) {{ - const modal = bootstrap.Modal.getInstance(openModal); - if (modal) {{ - modal.hide(); - return; - }} - }} - - // Close dropdowns - const openDropdown = document.querySelector('.dropdown-menu.show'); - if (openDropdown) {{ - const dropdown = bootstrap.Dropdown.getInstance(openDropdown.previousElementSibling); - if (dropdown) {{ - dropdown.hide(); - return; - }} - }} - - // Clear search or exit fullscreen - const searchInput = document.querySelector('input[type="search"]:focus'); - if (searchInput && searchInput.value) {{ - searchInput.value = ''; - searchInput.dispatchEvent(new Event('input')); - return; - }} - - // Exit fullscreen - if (document.fullscreenElement) {{ - document.exitFullscreen(); - return; - }} - }} - - setupActivationKeys() {{ - document.addEventListener('keydown', (e) => {{ - if (e.key === 'Enter' || e.key === ' ') {{ - this.handleActivation(e); - }} - }}); - }} - - handleActivation(e) {{ - const element = e.target; - - // Handle custom interactive elements - if (element.hasAttribute('role') && - ['button', 'link', 'menuitem'].includes(element.getAttribute('role'))) {{ - e.preventDefault(); - element.click(); - return; - }} - - // Handle series cards - if (element.classList.contains('series-card')) {{ - e.preventDefault(); - const link = element.querySelector('a') || element; - if (link.href) {{ - window.location.href = link.href; - }} else {{ - element.click(); - }} - }} - }} - - setupAccessibilityShortcuts() {{ - document.addEventListener('keydown', (e) => {{ - // Alt + 1: Main content - if (e.altKey && e.key === '1') {{ - e.preventDefault(); - this.focusMainContent(); - }} - - // Alt + 2: Navigation - if (e.altKey && e.key === '2') {{ - e.preventDefault(); - this.focusNavigation(); - }} - - // Alt + 3: Search - if (e.altKey && e.key === '3') {{ - e.preventDefault(); - this.focusSearch(); - }} - - // Alt + H: Help - if (e.altKey && e.key === 'h') {{ - e.preventDefault(); - this.showAccessibilityHelp(); - }} - - // Alt + A: Accessibility toolbar - if (e.altKey && e.key === 'a') {{ - e.preventDefault(); - this.toggleAccessibilityToolbar(); - }} - }}); - }} - - focusMainContent() {{ - const main = document.querySelector('main, #main-content, .main-content') || - document.querySelector('[role="main"]'); - if (main) {{ - main.focus(); - this.announce('Navigated to main content'); - }} - }} - - focusNavigation() {{ - const nav = document.querySelector('nav, .navbar') || - document.querySelector('[role="navigation"]'); - if (nav) {{ - const firstLink = nav.querySelector('a, button'); - if (firstLink) {{ - firstLink.focus(); - this.announce('Navigated to main navigation'); - }} - }} - }} - - focusSearch() {{ - const search = document.querySelector('input[type="search"], .search-input'); - if (search) {{ - search.focus(); - this.announce('Navigated to search'); - }} - }} - - setupScreenReaderSupport() {{ - // Create live regions - this.createLiveRegions(); - - // Setup dynamic content announcements - this.setupDynamicAnnouncements(); - - // Enhanced form feedback - this.setupFormFeedback(); - - // Progress indicators - this.setupProgressIndicators(); - }} - - createLiveRegions() {{ - // Polite announcements - if (!document.getElementById('aria-live-polite')) {{ - const politeRegion = document.createElement('div'); - politeRegion.id = 'aria-live-polite'; - politeRegion.setAttribute('aria-live', 'polite'); - politeRegion.setAttribute('aria-atomic', 'true'); - politeRegion.style.cssText = ` - position: absolute; - left: -10000px; - width: 1px; - height: 1px; - overflow: hidden; - `; - document.body.appendChild(politeRegion); - }} - - // Assertive announcements - if (!document.getElementById('aria-live-assertive')) {{ - const assertiveRegion = document.createElement('div'); - assertiveRegion.id = 'aria-live-assertive'; - assertiveRegion.setAttribute('aria-live', 'assertive'); - assertiveRegion.setAttribute('aria-atomic', 'true'); - assertiveRegion.style.cssText = ` - position: absolute; - left: -10000px; - width: 1px; - height: 1px; - overflow: hidden; - `; - document.body.appendChild(assertiveRegion); - }} - - // Status region - if (!document.getElementById('aria-status')) {{ - const statusRegion = document.createElement('div'); - statusRegion.id = 'aria-status'; - statusRegion.setAttribute('role', 'status'); - statusRegion.setAttribute('aria-atomic', 'true'); - statusRegion.style.cssText = ` - position: absolute; - left: -10000px; - width: 1px; - height: 1px; - overflow: hidden; - `; - document.body.appendChild(statusRegion); - }} - }} - - announce(message, priority = 'polite') {{ - if (!message || !this.config.aria_announcements) return; - - const regionId = priority === 'assertive' ? 'aria-live-assertive' : - priority === 'status' ? 'aria-status' : 'aria-live-polite'; - - const region = document.getElementById(regionId); - if (region) {{ - // Clear and set new message - region.textContent = ''; - setTimeout(() => {{ - region.textContent = message; - }}, 100); - - // Track announcements - this.announcements.push({{ - message: message, - priority: priority, - timestamp: Date.now() - }}); - }} - }} - - announceNavigation(element) {{ - if (!element) return; - - const label = element.getAttribute('aria-label') || - element.textContent.trim() || - 'Interactive element'; - - const position = this.getElementPosition(element); - this.announce(`${{label}}${{position ? ', ' + position : ''}}`); - }} - - getElementPosition(element) {{ - const container = element.closest('.series-grid, .row, table'); - if (!container) return ''; - - const siblings = Array.from(container.querySelectorAll(element.tagName + '.' + element.className.split(' ')[0])); - const index = siblings.indexOf(element); - const total = siblings.length; - - return `${{index + 1}} of ${{total}}`; - }} - - setupDynamicAnnouncements() {{ - // Watch for dynamic content changes - const observer = new MutationObserver((mutations) => {{ - mutations.forEach(mutation => {{ - if (mutation.type === 'childList') {{ - this.handleContentChange(mutation); - }} - }}); - }}); - - observer.observe(document.body, {{ - childList: true, - subtree: true, - attributes: false - }}); - }} - - handleContentChange(mutation) {{ - mutation.addedNodes.forEach(node => {{ - if (node.nodeType === Node.ELEMENT_NODE) {{ - // Announce new series loaded - if (node.classList?.contains('series-card')) {{ - const title = node.querySelector('.series-title')?.textContent; - if (title) {{ - this.announce(`New series loaded: ${{title}}`); - }} - }} - - // Announce alerts and notifications - if (node.classList?.contains('alert')) {{ - const message = node.textContent.trim(); - this.announce(message, 'assertive'); - }} - - // Announce download progress - if (node.classList?.contains('download-progress')) {{ - const progress = node.querySelector('.progress-bar'); - if (progress) {{ - const percent = progress.style.width; - this.announce(`Download progress: ${{percent}}`, 'status'); - }} - }} - }} - }}); - }} - - setupFormFeedback() {{ - document.addEventListener('submit', (e) => {{ - const form = e.target; - if (form.checkValidity()) {{ - this.announce('Form submitted successfully'); - }} else {{ - this.announce('Form contains errors, please check required fields', 'assertive'); - }} - }}); - - document.addEventListener('invalid', (e) => {{ - const input = e.target; - const message = input.validationMessage || 'This field is required'; - this.announce(message, 'assertive'); - }}); - }} - - setupProgressIndicators() {{ - // Watch for progress elements - const progressBars = document.querySelectorAll('.progress-bar'); - progressBars.forEach(bar => {{ - if (!bar.hasAttribute('role')) {{ - bar.setAttribute('role', 'progressbar'); - }} - - // Watch for progress changes - const observer = new MutationObserver(() => {{ - const percent = bar.style.width || bar.getAttribute('aria-valuenow') + '%'; - this.announce(`Progress: ${{percent}}`, 'status'); - }}); - - observer.observe(bar, {{ - attributes: true, - attributeFilter: ['style', 'aria-valuenow'] - }}); - }}); - }} - - setupFocusManagement() {{ - document.addEventListener('focusin', (e) => {{ - this.currentFocus = e.target; - this.focusHistory.push(e.target); - - // Limit history size - if (this.focusHistory.length > 10) {{ - this.focusHistory.shift(); - }} - - // Ensure focused element is visible - this.ensureFocusVisible(e.target); - }}); - - document.addEventListener('focusout', (e) => {{ - // Add slight delay to handle focus transitions - setTimeout(() => {{ - if (!document.activeElement || document.activeElement === document.body) {{ - // Focus was lost, restore if needed - this.restoreFocus(); - }} - }}, 10); - }}); - }} - - ensureFocusVisible(element) {{ - // Scroll into view if needed - element.scrollIntoView({{ - behavior: this.reducedMotion ? 'auto' : 'smooth', - block: 'nearest', - inline: 'nearest' - }}); - - // Add focus indicator if missing - if (!element.classList.contains('focus-visible')) {{ - element.classList.add('accessibility-focus'); - }} - }} - - restoreFocus() {{ - if (this.focusHistory.length > 1) {{ - // Get previous focus (excluding current) - const previousFocus = this.focusHistory[this.focusHistory.length - 2]; - if (previousFocus && document.contains(previousFocus)) {{ - previousFocus.focus(); - }} - }} - }} - - setupFocusTrap() {{ - document.addEventListener('keydown', (e) => {{ - if (e.key === 'Tab') {{ - const modal = document.querySelector('.modal.show'); - if (modal) {{ - this.trapFocus(e, modal); - }} - }} - }}); - }} - - trapFocus(e, container) {{ - const focusableElements = container.querySelectorAll(` - button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"]) - `); - - const firstFocusable = focusableElements[0]; - const lastFocusable = focusableElements[focusableElements.length - 1]; - - if (e.shiftKey && document.activeElement === firstFocusable) {{ - e.preventDefault(); - lastFocusable.focus(); - }} else if (!e.shiftKey && document.activeElement === lastFocusable) {{ - e.preventDefault(); - firstFocusable.focus(); - }} - }} - - setupSkipLinks() {{ - this.createSkipLinks(); - this.setupSkipLinkHandlers(); - }} - - createSkipLinks() {{ - const skipNav = document.createElement('nav'); - skipNav.className = 'skip-links'; - skipNav.innerHTML = ` - - - - - `; - - document.body.insertBefore(skipNav, document.body.firstChild); - - this.skipLinks = Array.from(skipNav.querySelectorAll('.skip-link')); - }} - - setupSkipLinkHandlers() {{ - this.skipLinks.forEach(link => {{ - link.addEventListener('click', (e) => {{ - e.preventDefault(); - const targetId = link.getAttribute('href').substring(1); - const target = document.getElementById(targetId) || - document.querySelector(`[data-skip-target="${{targetId}}"]`); - - if (target) {{ - target.focus(); - target.scrollIntoView({{ behavior: 'smooth' }}); - this.announce(`Skipped to ${{targetId.replace('-', ' ')}}`); - }} - }}); - }}); - }} - - setupARIA() {{ - // Add missing ARIA landmarks - this.addARIALandmarks(); - - // Enhance existing elements - this.enhanceARIAAttributes(); - - // Setup live regions - this.setupARIALiveRegions(); - }} - - addARIALandmarks() {{ - // Main content - const main = document.querySelector('main') || document.querySelector('#main-content'); - if (main && !main.hasAttribute('role')) {{ - main.setAttribute('role', 'main'); - }} - - // Navigation - const nav = document.querySelector('nav'); - if (nav && !nav.hasAttribute('role')) {{ - nav.setAttribute('role', 'navigation'); - nav.setAttribute('aria-label', 'Main navigation'); - }} - - // Footer - const footer = document.querySelector('footer'); - if (footer && !footer.hasAttribute('role')) {{ - footer.setAttribute('role', 'contentinfo'); - }} - - // Aside/Sidebar - const aside = document.querySelector('aside, .sidebar'); - if (aside && !aside.hasAttribute('role')) {{ - aside.setAttribute('role', 'complementary'); - }} - }} - - enhanceARIAAttributes() {{ - // Forms - document.querySelectorAll('form').forEach(form => {{ - if (!form.hasAttribute('role')) {{ - form.setAttribute('role', 'form'); - }} - }}); - - // Buttons - document.querySelectorAll('.btn').forEach(btn => {{ - if (!btn.hasAttribute('role') && btn.tagName !== 'BUTTON') {{ - btn.setAttribute('role', 'button'); - }} - }}); - - // Links that look like buttons - document.querySelectorAll('a.btn').forEach(link => {{ - link.setAttribute('role', 'button'); - }}); - - // Series cards - document.querySelectorAll('.series-card').forEach(card => {{ - card.setAttribute('role', 'article'); - if (!card.hasAttribute('aria-labelledby')) {{ - const title = card.querySelector('.series-title, h3, h4'); - if (title && !title.id) {{ - title.id = `series-title-${{Date.now()}}-${{Math.random().toString(36).substr(2, 9)}}`; - card.setAttribute('aria-labelledby', title.id); - }} - }} - }}); - }} - - setupARIALiveRegions() {{ - // Status updates - document.querySelectorAll('.status, .badge').forEach(status => {{ - if (!status.hasAttribute('role')) {{ - status.setAttribute('role', 'status'); - }} - }}); - - // Alerts - document.querySelectorAll('.alert').forEach(alert => {{ - alert.setAttribute('role', 'alert'); - }}); - }} - - setupHighContrast() {{ - // Add high contrast toggle - this.createHighContrastToggle(); - - // Apply high contrast styles if needed - this.updateHighContrast(); - }} - - createHighContrastToggle() {{ - const toggle = document.createElement('button'); - toggle.className = 'high-contrast-toggle accessibility-control'; - toggle.innerHTML = ' High Contrast'; - toggle.setAttribute('aria-pressed', this.highContrastMode.toString()); - - toggle.addEventListener('click', () => {{ - this.toggleHighContrast(); - }}); - - return toggle; - }} - - toggleHighContrast() {{ - this.highContrastMode = !this.highContrastMode; - this.updateHighContrast(); - this.announce(`High contrast mode ${{this.highContrastMode ? 'enabled' : 'disabled'}}`); - }} - - updateHighContrast() {{ - document.body.classList.toggle('high-contrast-mode', this.highContrastMode); - - const toggle = document.querySelector('.high-contrast-toggle'); - if (toggle) {{ - toggle.setAttribute('aria-pressed', this.highContrastMode.toString()); - }} - }} - - setupMotionPreferences() {{ - this.updateMotionPreferences(); - }} - - updateMotionPreferences() {{ - document.body.classList.toggle('reduced-motion', this.reducedMotion); - - // Disable animations if reduced motion is preferred - if (this.reducedMotion) {{ - const style = document.createElement('style'); - style.textContent = ` - *, *::before, *::after {{ - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; - }} - `; - document.head.appendChild(style); - }} - }} - - setupTextScaling() {{ - // Create text size controls - this.createTextSizeControls(); - }} - - createTextSizeControls() {{ - const controls = document.createElement('div'); - controls.className = 'text-size-controls accessibility-control'; - controls.innerHTML = ` - - - - `; - - controls.querySelector('.text-size-decrease').addEventListener('click', () => {{ - this.adjustTextSize(-0.1); - }}); - - controls.querySelector('.text-size-reset').addEventListener('click', () => {{ - this.resetTextSize(); - }}); - - controls.querySelector('.text-size-increase').addEventListener('click', () => {{ - this.adjustTextSize(0.1); - }}); - - return controls; - }} - - adjustTextSize(delta) {{ - const currentSize = parseFloat(getComputedStyle(document.documentElement).fontSize); - const newSize = Math.max(12, Math.min(24, currentSize + (delta * 16))); - - document.documentElement.style.fontSize = newSize + 'px'; - this.announce(`Text size adjusted to ${{Math.round(newSize)}}px`); - }} - - resetTextSize() {{ - document.documentElement.style.fontSize = ''; - this.announce('Text size reset to default'); - }} - - createAccessibilityToolbar() {{ - const toolbar = document.createElement('div'); - toolbar.id = 'accessibility-toolbar'; - toolbar.className = 'accessibility-toolbar'; - toolbar.setAttribute('role', 'toolbar'); - toolbar.setAttribute('aria-label', 'Accessibility options'); - - toolbar.innerHTML = ` - -
-

Accessibility Options

-
- -
-
- `; - - // Add controls - const controlsContainer = toolbar.querySelector('.toolbar-controls'); - controlsContainer.appendChild(this.createHighContrastToggle()); - controlsContainer.appendChild(this.createTextSizeControls()); - - // Toggle functionality - const toggle = toolbar.querySelector('.toolbar-toggle'); - const content = toolbar.querySelector('.toolbar-content'); - - toggle.addEventListener('click', () => {{ - const isOpen = content.style.display === 'block'; - content.style.display = isOpen ? 'none' : 'block'; - toggle.setAttribute('aria-expanded', (!isOpen).toString()); - }}); - - document.body.appendChild(toolbar); - }} - - toggleAccessibilityToolbar() {{ - const toolbar = document.getElementById('accessibility-toolbar'); - if (toolbar) {{ - const toggle = toolbar.querySelector('.toolbar-toggle'); - toggle.click(); - }} - }} - - showAccessibilityHelp() {{ - const help = document.createElement('div'); - help.id = 'accessibility-help'; - help.className = 'accessibility-help modal'; - help.innerHTML = ` - - `; - - document.body.appendChild(help); - - const modal = new bootstrap.Modal(help); - modal.show(); - - // Clean up when closed - help.addEventListener('hidden.bs.modal', () => {{ - help.remove(); - }}); - }} - - // Public API methods - enableFeature(feature) {{ - if (this.config.hasOwnProperty(feature)) {{ - this.config[feature] = true; - this.announce(`${{feature.replace('_', ' ')}} enabled`); - }} - }} - - disableFeature(feature) {{ - if (this.config.hasOwnProperty(feature)) {{ - this.config[feature] = false; - this.announce(`${{feature.replace('_', ' ')}} disabled`); - }} - }} - - getConfig() {{ - return {{...this.config}}; - }} - - isFeatureEnabled(feature) {{ - return this.config[feature]; - }} - - getAnnouncements() {{ - return [...this.announcements]; - }} - - clearAnnouncements() {{ - this.announcements = []; - }} -}} - -// Initialize accessibility manager when DOM is loaded -document.addEventListener('DOMContentLoaded', () => {{ - window.accessibilityManager = new AccessibilityManager(); - console.log('Accessibility manager loaded'); -}}); -""" - - def get_css(self): - """Generate CSS for accessibility features.""" - return """ -/* Accessibility Features Styles */ - -/* Skip links */ -.skip-links { - position: absolute; - top: -40px; - left: 6px; - z-index: 2000; -} - -.skip-link { - position: absolute; - top: -40px; - left: 0; - background: var(--bs-dark); - color: var(--bs-light); - padding: 8px 16px; - text-decoration: none; - border-radius: 0 0 4px 4px; - font-weight: bold; - transition: top 0.3s ease; -} - -.skip-link:focus { - top: 0; - color: var(--bs-light); -} - -/* Focus indicators */ -:focus { - outline: 2px solid var(--bs-primary); - outline-offset: 2px; -} - -:focus:not(:focus-visible) { - outline: none; -} - -:focus-visible { - outline: 2px solid var(--bs-primary); - outline-offset: 2px; -} - -.accessibility-focus { - outline: 2px solid var(--bs-primary) !important; - outline-offset: 2px !important; -} - -/* High contrast mode */ -.high-contrast-mode { - --bs-body-bg: #000000; - --bs-body-color: #ffffff; - --bs-primary: #ffff00; - --bs-secondary: #00ffff; - --bs-border-color: #ffffff; - --bs-link-color: #ffff00; - --bs-link-hover-color: #00ffff; -} - -.high-contrast-mode .btn-primary { - background: #ffff00; - border-color: #ffff00; - color: #000000; -} - -.high-contrast-mode .btn-secondary { - background: #00ffff; - border-color: #00ffff; - color: #000000; -} - -.high-contrast-mode .card { - border: 2px solid #ffffff; - background: #000000; -} - -.high-contrast-mode .series-card:focus, -.high-contrast-mode .series-card.selected { - border-color: #ffff00; - background: #333333; -} - -/* Accessibility toolbar */ -.accessibility-toolbar { - position: fixed; - top: 50%; - right: 0; - transform: translateY(-50%); - z-index: 1060; - background: var(--bs-body-bg); - border: 1px solid var(--bs-border-color); - border-radius: 8px 0 0 8px; - box-shadow: -2px 0 10px rgba(0, 0, 0, 0.1); -} - -.accessibility-toolbar .toolbar-toggle { - display: block; - width: 48px; - height: 48px; - background: var(--bs-primary); - color: white; - border: none; - border-radius: 8px 0 0 8px; - cursor: pointer; - font-size: 20px; -} - -.accessibility-toolbar .toolbar-content { - display: none; - position: absolute; - right: 48px; - top: 0; - width: 300px; - background: var(--bs-body-bg); - border: 1px solid var(--bs-border-color); - border-radius: 8px 0 0 8px; - padding: 16px; -} - -.accessibility-toolbar .toolbar-content h3 { - margin: 0 0 16px 0; - font-size: 16px; - font-weight: bold; -} - -.accessibility-toolbar .toolbar-controls { - display: flex; - flex-direction: column; - gap: 12px; -} - -.accessibility-control { - display: flex; - align-items: center; - gap: 8px; -} - -.text-size-controls { - display: flex; - gap: 4px; -} - -.text-size-controls button { - width: 32px; - height: 32px; - border: 1px solid var(--bs-border-color); - background: var(--bs-body-bg); - color: var(--bs-body-color); - border-radius: 4px; - font-weight: bold; - cursor: pointer; -} - -.text-size-controls button:hover { - background: var(--bs-primary); - color: white; -} - -/* Screen reader only content */ -.sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} - -.sr-only-focusable:focus { - position: static; - width: auto; - height: auto; - padding: inherit; - margin: inherit; - overflow: visible; - clip: auto; - white-space: normal; -} - -/* ARIA live regions (hidden visually but available to screen readers) */ -#aria-live-polite, -#aria-live-assertive, -#aria-status { - position: absolute; - left: -10000px; - width: 1px; - height: 1px; - overflow: hidden; -} - -/* Enhanced focus for interactive elements */ -.series-card:focus, -.btn:focus, -.form-control:focus, -.dropdown-toggle:focus { - box-shadow: 0 0 0 3px rgba(var(--bs-primary-rgb), 0.25); -} - -/* Keyboard navigation helpers */ -.keyboard-user *:focus { - outline: 2px solid var(--bs-primary); - outline-offset: 2px; -} - -/* Touch device focus (less prominent) */ -.touch-device *:focus:not(.keyboard-focus) { - outline: 1px solid rgba(var(--bs-primary-rgb), 0.5); -} - -/* Progress bars accessibility */ -.progress-bar[role="progressbar"] { - position: relative; -} - -.progress-bar[role="progressbar"]::after { - content: attr(aria-valuenow) "% complete"; - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - color: var(--bs-contrast-color, inherit); - font-size: 0.75rem; - font-weight: bold; -} - -/* Form accessibility enhancements */ -.form-control:invalid { - border-color: var(--bs-danger); - box-shadow: 0 0 0 0.2rem rgba(var(--bs-danger-rgb), 0.25); -} - -.invalid-feedback { - display: block; - color: var(--bs-danger); - font-size: 0.875rem; - margin-top: 0.25rem; -} - -label.required::after { - content: " *"; - color: var(--bs-danger); -} - -/* Table accessibility */ -table th { - background: var(--bs-light); - font-weight: bold; -} - -table th[scope="col"]::before { - content: "Column: "; - position: absolute; - left: -10000px; -} - -table th[scope="row"]::before { - content: "Row: "; - position: absolute; - left: -10000px; -} - -/* Modal accessibility */ -.modal[role="dialog"] { - display: flex; - align-items: center; - justify-content: center; -} - -.modal-content { - outline: none; -} - -.modal-header .btn-close { - min-width: 44px; - min-height: 44px; -} - -/* Dropdown accessibility */ -.dropdown-menu[role="menu"] .dropdown-item { - role: menuitem; -} - -.dropdown-menu .dropdown-item:focus { - background: var(--bs-primary); - color: white; -} - -/* Navigation accessibility */ -nav[role="navigation"] ul { - list-style: none; - padding: 0; -} - -nav[role="navigation"] a:focus { - background: rgba(var(--bs-primary-rgb), 0.1); -} - -/* Card accessibility */ -.series-card[role="article"] { - border: 1px solid var(--bs-border-color); - cursor: pointer; -} - -.series-card[role="article"]:focus { - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); -} - -/* Alert accessibility */ -.alert[role="alert"] { - border-left: 4px solid currentColor; - font-weight: 500; -} - -.alert-success[role="alert"] { - border-left-color: var(--bs-success); -} - -.alert-danger[role="alert"] { - border-left-color: var(--bs-danger); -} - -.alert-warning[role="alert"] { - border-left-color: var(--bs-warning); -} - -.alert-info[role="alert"] { - border-left-color: var(--bs-info); -} - -/* Reduced motion preferences */ -@media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; - scroll-behavior: auto !important; - } - - .accessibility-toolbar .toolbar-content { - transition: none; - } -} - -/* Large text support */ -@media (min-resolution: 192dpi) { - body { - font-size: 1.125rem; - line-height: 1.6; - } - - .btn { - min-height: 48px; - padding: 0.75rem 1.5rem; - } -} - -/* Print accessibility */ -@media print { - .accessibility-toolbar, - .skip-links, - .sr-only { - display: none !important; - } - - a::after { - content: " (" attr(href) ")"; - font-size: 0.8em; - color: #666; - } - - .series-card { - border: 1px solid #000; - break-inside: avoid; - } -} - -/* Dark theme accessibility adjustments */ -[data-bs-theme="dark"] .accessibility-toolbar { - background: var(--bs-dark); - border-color: var(--bs-border-color-translucent); -} - -[data-bs-theme="dark"] .skip-link { - background: var(--bs-light); - color: var(--bs-dark); -} - -[data-bs-theme="dark"] .text-size-controls button { - background: var(--bs-dark); - border-color: var(--bs-border-color-translucent); - color: var(--bs-light); -} - -/* Focus trap for modals */ -.modal.show { - contain: layout style paint; -} - -/* Ensure minimum contrast ratios */ -.low-contrast-warning { - border: 2px solid var(--bs-warning); - background: rgba(var(--bs-warning-rgb), 0.1); -} - -/* Magnification support */ -@media (min-width: 1400px) { - .container-xxl { - max-width: 1600px; - } -} - -/* Voice control support */ -[data-voice-command] { - position: relative; -} - -[data-voice-command]::before { - content: attr(data-voice-command); - position: absolute; - top: -20px; - left: 0; - font-size: 0.75rem; - color: var(--bs-muted); - background: var(--bs-body-bg); - padding: 2px 4px; - border-radius: 2px; - opacity: 0; - transition: opacity 0.2s ease; -} - -.show-voice-commands [data-voice-command]::before { - opacity: 1; -} -""" - - -# Export the accessibility manager -accessibility_manager = AccessibilityManager() \ No newline at end of file diff --git a/src/server/web/middleware/contrast_middleware.py b/src/server/web/middleware/contrast_middleware.py deleted file mode 100644 index 806dbe3..0000000 --- a/src/server/web/middleware/contrast_middleware.py +++ /dev/null @@ -1,1431 +0,0 @@ -""" -Color Contrast Compliance System - -This module ensures WCAG color contrast compliance, provides high contrast modes, -and validates color accessibility across the interface. -""" - -from typing import Dict, List, Any, Optional, Tuple -from flask import Blueprint, request, jsonify -import colorsys - -class ColorContrastManager: - """Manages color contrast compliance and accessibility.""" - - def __init__(self, app=None): - self.app = app - self.wcag_ratios = { - 'AA': {'normal': 4.5, 'large': 3.0}, - 'AAA': {'normal': 7.0, 'large': 4.5} - } - self.color_palette = {} - - def init_app(self, app): - """Initialize with Flask app.""" - self.app = app - - def calculate_contrast_ratio(self, color1: str, color2: str) -> float: - """Calculate contrast ratio between two colors.""" - # Convert colors to RGB - rgb1 = self.hex_to_rgb(color1) - rgb2 = self.hex_to_rgb(color2) - - # Calculate relative luminance - lum1 = self.relative_luminance(rgb1) - lum2 = self.relative_luminance(rgb2) - - # Calculate contrast ratio - lighter = max(lum1, lum2) - darker = min(lum1, lum2) - - return (lighter + 0.05) / (darker + 0.05) - - def hex_to_rgb(self, hex_color: str) -> Tuple[int, int, int]: - """Convert hex color to RGB.""" - hex_color = hex_color.lstrip('#') - return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) - - def relative_luminance(self, rgb: Tuple[int, int, int]) -> float: - """Calculate relative luminance of RGB color.""" - def gamma_correct(channel): - channel = channel / 255.0 - return channel / 12.92 if channel <= 0.03928 else pow((channel + 0.055) / 1.055, 2.4) - - r, g, b = rgb - return 0.2126 * gamma_correct(r) + 0.7152 * gamma_correct(g) + 0.0722 * gamma_correct(b) - - def get_contrast_css(self): - """Generate CSS for color contrast compliance.""" - return """ -/* WCAG Color Contrast Compliance Styles */ - -/* Base color variables with WCAG AA compliant ratios */ -:root { - /* Primary colors - AA compliant */ - --color-primary: #0066cc; - --color-primary-contrast: #ffffff; - --color-primary-hover: #004d99; - --color-primary-light: #e6f2ff; - - /* Secondary colors - AA compliant */ - --color-secondary: #6c757d; - --color-secondary-contrast: #ffffff; - --color-secondary-hover: #545b62; - --color-secondary-light: #f8f9fa; - - /* Status colors - AA compliant */ - --color-success: #28a745; - --color-success-contrast: #ffffff; - --color-success-light: #d4edda; - - --color-danger: #dc3545; - --color-danger-contrast: #ffffff; - --color-danger-light: #f8d7da; - - --color-warning: #ffc107; - --color-warning-contrast: #000000; - --color-warning-light: #fff3cd; - - --color-info: #17a2b8; - --color-info-contrast: #ffffff; - --color-info-light: #d1ecf1; - - /* Background colors */ - --color-bg-primary: #ffffff; - --color-bg-secondary: #f8f9fa; - --color-bg-tertiary: #e9ecef; - - /* Text colors */ - --color-text-primary: #212529; - --color-text-secondary: #6c757d; - --color-text-tertiary: #495057; - --color-text-muted: #868e96; - - /* Border colors */ - --color-border: #dee2e6; - --color-border-dark: #6c757d; - - /* Link colors */ - --color-link: #0066cc; - --color-link-hover: #004d99; - --color-link-visited: #551a8b; - - /* Focus colors */ - --color-focus: #0066cc; - --color-focus-shadow: rgba(0, 102, 204, 0.25); -} - -/* Dark theme with AAA compliance where possible */ -[data-bs-theme="dark"] { - --color-primary: #4dabf7; - --color-primary-contrast: #000000; - --color-primary-hover: #339af0; - --color-primary-light: #0c3653; - - --color-secondary: #adb5bd; - --color-secondary-contrast: #000000; - --color-secondary-hover: #95a3b0; - --color-secondary-light: #343a40; - - --color-success: #51cf66; - --color-success-contrast: #000000; - --color-success-light: #0f3f1f; - - --color-danger: #ff6b6b; - --color-danger-contrast: #000000; - --color-danger-light: #4a1a1a; - - --color-warning: #ffd43b; - --color-warning-contrast: #000000; - --color-warning-light: #4a3a00; - - --color-info: #74c0fc; - --color-info-contrast: #000000; - --color-info-light: #0f2b3c; - - --color-bg-primary: #212529; - --color-bg-secondary: #343a40; - --color-bg-tertiary: #495057; - - --color-text-primary: #ffffff; - --color-text-secondary: #adb5bd; - --color-text-tertiary: #ced4da; - --color-text-muted: #6c757d; - - --color-border: #495057; - --color-border-dark: #343a40; - - --color-link: #4dabf7; - --color-link-hover: #339af0; - --color-link-visited: #9775fa; - - --color-focus: #4dabf7; - --color-focus-shadow: rgba(77, 171, 247, 0.25); -} - -/* High contrast mode - AAA compliance */ -.high-contrast-mode { - --color-primary: #000000; - --color-primary-contrast: #ffffff; - --color-primary-hover: #333333; - --color-primary-light: #f0f0f0; - - --color-secondary: #000000; - --color-secondary-contrast: #ffffff; - --color-secondary-hover: #333333; - --color-secondary-light: #f0f0f0; - - --color-success: #000000; - --color-success-contrast: #ffffff; - --color-success-light: #e6ffe6; - - --color-danger: #ffffff; - --color-danger-contrast: #000000; - --color-danger-light: #ffe6e6; - - --color-warning: #000000; - --color-warning-contrast: #ffff00; - --color-warning-light: #ffffcc; - - --color-info: #000000; - --color-info-contrast: #ffffff; - --color-info-light: #e6f7ff; - - --color-bg-primary: #ffffff; - --color-bg-secondary: #f0f0f0; - --color-bg-tertiary: #e0e0e0; - - --color-text-primary: #000000; - --color-text-secondary: #000000; - --color-text-tertiary: #000000; - --color-text-muted: #666666; - - --color-border: #000000; - --color-border-dark: #000000; - - --color-link: #0000ee; - --color-link-hover: #000080; - --color-link-visited: #800080; - - --color-focus: #ffff00; - --color-focus-shadow: rgba(255, 255, 0, 0.5); -} - -/* Apply WCAG compliant colors */ -body { - background-color: var(--color-bg-primary); - color: var(--color-text-primary); -} - -/* Button contrast compliance */ -.btn-primary { - background-color: var(--color-primary); - border-color: var(--color-primary); - color: var(--color-primary-contrast); -} - -.btn-primary:hover, -.btn-primary:focus { - background-color: var(--color-primary-hover); - border-color: var(--color-primary-hover); - color: var(--color-primary-contrast); -} - -.btn-secondary { - background-color: var(--color-secondary); - border-color: var(--color-secondary); - color: var(--color-secondary-contrast); -} - -.btn-secondary:hover, -.btn-secondary:focus { - background-color: var(--color-secondary-hover); - border-color: var(--color-secondary-hover); - color: var(--color-secondary-contrast); -} - -/* Status button colors */ -.btn-success { - background-color: var(--color-success); - border-color: var(--color-success); - color: var(--color-success-contrast); -} - -.btn-danger { - background-color: var(--color-danger); - border-color: var(--color-danger); - color: var(--color-danger-contrast); -} - -.btn-warning { - background-color: var(--color-warning); - border-color: var(--color-warning); - color: var(--color-warning-contrast); -} - -.btn-info { - background-color: var(--color-info); - border-color: var(--color-info); - color: var(--color-info-contrast); -} - -/* Link contrast compliance */ -a { - color: var(--color-link); -} - -a:hover, -a:focus { - color: var(--color-link-hover); -} - -a:visited { - color: var(--color-link-visited); -} - -/* Form element contrast */ -.form-control { - background-color: var(--color-bg-primary); - border-color: var(--color-border); - color: var(--color-text-primary); -} - -.form-control:focus { - background-color: var(--color-bg-primary); - border-color: var(--color-focus); - color: var(--color-text-primary); - box-shadow: 0 0 0 0.25rem var(--color-focus-shadow); -} - -.form-control::placeholder { - color: var(--color-text-muted); -} - -/* Alert contrast compliance */ -.alert { - border-left: 4px solid currentColor; -} - -.alert-success { - background-color: var(--color-success-light); - border-color: var(--color-success); - color: var(--color-text-primary); -} - -.alert-danger { - background-color: var(--color-danger-light); - border-color: var(--color-danger); - color: var(--color-text-primary); -} - -.alert-warning { - background-color: var(--color-warning-light); - border-color: var(--color-warning); - color: var(--color-text-primary); -} - -.alert-info { - background-color: var(--color-info-light); - border-color: var(--color-info); - color: var(--color-text-primary); -} - -/* Card contrast */ -.card { - background-color: var(--color-bg-primary); - border-color: var(--color-border); - color: var(--color-text-primary); -} - -.card-header { - background-color: var(--color-bg-secondary); - border-bottom-color: var(--color-border); -} - -/* Table contrast */ -.table { - color: var(--color-text-primary); -} - -.table th { - background-color: var(--color-bg-secondary); - border-color: var(--color-border); -} - -.table td { - border-color: var(--color-border); -} - -.table-striped tbody tr:nth-of-type(odd) { - background-color: var(--color-bg-secondary); -} - -/* Navigation contrast */ -.navbar { - background-color: var(--color-bg-primary); - border-color: var(--color-border); -} - -.navbar .nav-link { - color: var(--color-text-secondary); -} - -.navbar .nav-link:hover, -.navbar .nav-link:focus { - color: var(--color-text-primary); -} - -.navbar .nav-link.active { - color: var(--color-primary); -} - -/* Dropdown contrast */ -.dropdown-menu { - background-color: var(--color-bg-primary); - border-color: var(--color-border); -} - -.dropdown-item { - color: var(--color-text-primary); -} - -.dropdown-item:hover, -.dropdown-item:focus { - background-color: var(--color-primary-light); - color: var(--color-text-primary); -} - -/* Progress bar contrast */ -.progress { - background-color: var(--color-bg-secondary); -} - -.progress-bar { - background-color: var(--color-primary); - color: var(--color-primary-contrast); -} - -/* Badge contrast */ -.badge { - color: var(--color-primary-contrast); -} - -.badge-primary { - background-color: var(--color-primary); -} - -.badge-secondary { - background-color: var(--color-secondary); -} - -.badge-success { - background-color: var(--color-success); -} - -.badge-danger { - background-color: var(--color-danger); -} - -.badge-warning { - background-color: var(--color-warning); - color: var(--color-warning-contrast); -} - -.badge-info { - background-color: var(--color-info); -} - -/* Series card contrast */ -.series-card { - background-color: var(--color-bg-primary); - border-color: var(--color-border); - color: var(--color-text-primary); -} - -.series-card:hover { - border-color: var(--color-border-dark); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); -} - -.series-card .series-title { - color: var(--color-text-primary); -} - -.series-card .series-meta { - color: var(--color-text-secondary); -} - -/* Focus indicators with proper contrast */ -:focus { - outline: 2px solid var(--color-focus); - outline-offset: 2px; -} - -:focus-visible { - outline: 2px solid var(--color-focus); - outline-offset: 2px; - box-shadow: 0 0 0 4px var(--color-focus-shadow); -} - -/* Modal contrast */ -.modal-content { - background-color: var(--color-bg-primary); - border-color: var(--color-border); - color: var(--color-text-primary); -} - -.modal-header { - border-bottom-color: var(--color-border); -} - -.modal-footer { - border-top-color: var(--color-border); -} - -/* Tooltip contrast */ -.tooltip .tooltip-inner { - background-color: var(--color-text-primary); - color: var(--color-bg-primary); -} - -/* Breadcrumb contrast */ -.breadcrumb { - background-color: var(--color-bg-secondary); -} - -.breadcrumb-item { - color: var(--color-text-secondary); -} - -.breadcrumb-item.active { - color: var(--color-text-primary); -} - -.breadcrumb-item a { - color: var(--color-link); -} - -.breadcrumb-item a:hover { - color: var(--color-link-hover); -} - -/* Pagination contrast */ -.pagination .page-link { - background-color: var(--color-bg-primary); - border-color: var(--color-border); - color: var(--color-link); -} - -.pagination .page-link:hover { - background-color: var(--color-bg-secondary); - border-color: var(--color-border-dark); - color: var(--color-link-hover); -} - -.pagination .page-item.active .page-link { - background-color: var(--color-primary); - border-color: var(--color-primary); - color: var(--color-primary-contrast); -} - -.pagination .page-item.disabled .page-link { - background-color: var(--color-bg-secondary); - border-color: var(--color-border); - color: var(--color-text-muted); -} - -/* List group contrast */ -.list-group-item { - background-color: var(--color-bg-primary); - border-color: var(--color-border); - color: var(--color-text-primary); -} - -.list-group-item:hover { - background-color: var(--color-bg-secondary); -} - -.list-group-item.active { - background-color: var(--color-primary); - border-color: var(--color-primary); - color: var(--color-primary-contrast); -} - -/* Spinner contrast */ -.spinner-border { - border-color: var(--color-border); - border-left-color: var(--color-primary); -} - -/* Text utilities with proper contrast */ -.text-primary { - color: var(--color-primary) !important; -} - -.text-secondary { - color: var(--color-text-secondary) !important; -} - -.text-success { - color: var(--color-success) !important; -} - -.text-danger { - color: var(--color-danger) !important; -} - -.text-warning { - color: var(--color-warning-contrast) !important; -} - -.text-info { - color: var(--color-info) !important; -} - -.text-muted { - color: var(--color-text-muted) !important; -} - -/* Background utilities with proper contrast */ -.bg-primary { - background-color: var(--color-primary) !important; - color: var(--color-primary-contrast) !important; -} - -.bg-secondary { - background-color: var(--color-secondary) !important; - color: var(--color-secondary-contrast) !important; -} - -.bg-success { - background-color: var(--color-success) !important; - color: var(--color-success-contrast) !important; -} - -.bg-danger { - background-color: var(--color-danger) !important; - color: var(--color-danger-contrast) !important; -} - -.bg-warning { - background-color: var(--color-warning) !important; - color: var(--color-warning-contrast) !important; -} - -.bg-info { - background-color: var(--color-info) !important; - color: var(--color-info-contrast) !important; -} - -/* Selection highlight with proper contrast */ -::selection { - background-color: var(--color-primary); - color: var(--color-primary-contrast); -} - -::-moz-selection { - background-color: var(--color-primary); - color: var(--color-primary-contrast); -} - -/* Scrollbar contrast (webkit) */ -::-webkit-scrollbar { - background-color: var(--color-bg-secondary); -} - -::-webkit-scrollbar-thumb { - background-color: var(--color-border-dark); -} - -::-webkit-scrollbar-thumb:hover { - background-color: var(--color-text-muted); -} - -/* High contrast mode enhancements */ -.high-contrast-mode .btn { - border-width: 2px; - font-weight: bold; -} - -.high-contrast-mode .form-control { - border-width: 2px; -} - -.high-contrast-mode .card { - border-width: 2px; -} - -.high-contrast-mode .series-card { - border-width: 2px; -} - -.high-contrast-mode :focus { - outline-width: 3px; - outline-color: var(--color-focus); - box-shadow: 0 0 0 5px var(--color-focus-shadow); -} - -/* Media queries for contrast preferences */ -@media (prefers-contrast: high) { - :root { - --color-focus-shadow: rgba(0, 102, 204, 0.5); - } - - .btn { - border-width: 2px; - } - - .form-control { - border-width: 2px; - } - - :focus { - outline-width: 3px; - box-shadow: 0 0 0 5px var(--color-focus-shadow); - } -} - -@media (prefers-contrast: more) { - body { - --color-text-primary: #000000; - --color-bg-primary: #ffffff; - --color-border: #000000; - } - - [data-bs-theme="dark"] { - --color-text-primary: #ffffff; - --color-bg-primary: #000000; - --color-border: #ffffff; - } -} - -/* Force colors mode support (Windows High Contrast) */ -@media (forced-colors: active) { - .btn { - forced-color-adjust: none; - border: 2px solid ButtonBorder; - background: ButtonFace; - color: ButtonText; - } - - .btn:hover, - .btn:focus { - background: Highlight; - color: HighlightText; - border-color: HighlightText; - } - - .form-control { - forced-color-adjust: none; - border: 2px solid FieldText; - background: Field; - color: FieldText; - } - - .form-control:focus { - outline: 2px solid Highlight; - outline-offset: 2px; - } - - .series-card { - forced-color-adjust: none; - border: 2px solid CanvasText; - background: Canvas; - color: CanvasText; - } - - .series-card:focus { - outline: 2px solid Highlight; - outline-offset: 2px; - } -} - -/* Print contrast optimization */ -@media print { - :root { - --color-text-primary: #000000; - --color-bg-primary: #ffffff; - --color-border: #000000; - --color-link: #000080; - } - - .btn { - border: 2px solid #000000; - background: #ffffff; - color: #000000; - } - - .series-card { - border: 1px solid #000000; - } -} - -/* Reduced transparency for better contrast */ -.contrast-enhanced .modal-backdrop { - opacity: 0.8; -} - -.contrast-enhanced .dropdown-menu { - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); -} - -.contrast-enhanced .card { - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); -} - -/* Color blind friendly alternatives */ -.colorblind-friendly .text-success { - text-decoration: underline; -} - -.colorblind-friendly .text-danger { - font-weight: bold; - text-decoration: underline; -} - -.colorblind-friendly .badge-success::before { - content: "✓ "; -} - -.colorblind-friendly .badge-danger::before { - content: "✗ "; -} - -.colorblind-friendly .badge-warning::before { - content: "⚠ "; -} - -/* Ensure minimum 3:1 ratio for large text */ -h1, h2, h3, .h1, .h2, .h3, -.btn-lg, .display-1, .display-2, .display-3 { - /* These elements use large text AA standard (3:1 ratio) */ -} - -/* Ensure minimum 4.5:1 ratio for normal text */ -p, .btn, .form-control, .card-text, -h4, h5, h6, .h4, .h5, .h6 { - /* These elements use normal text AA standard (4.5:1 ratio) */ -} -""" - - def get_contrast_js(self): - """Generate JavaScript for contrast management.""" - return """ -// AniWorld Color Contrast Manager -class ColorContrastManager { - constructor() { - this.contrastLevel = 'AA'; // AA or AAA - this.highContrastMode = false; - this.colorBlindMode = false; - this.customColors = {}; - - this.wcagRatios = { - 'AA': { normal: 4.5, large: 3.0 }, - 'AAA': { normal: 7.0, large: 4.5 } - }; - - this.init(); - } - - init() { - this.detectContrastPreferences(); - this.setupContrastControls(); - this.validatePageContrast(); - this.setupContrastMonitoring(); - - console.log('Color contrast manager initialized'); - } - - detectContrastPreferences() { - // Detect system contrast preferences - if (window.matchMedia('(prefers-contrast: high)').matches) { - this.enableHighContrast(); - } - - if (window.matchMedia('(prefers-contrast: more)').matches) { - this.contrastLevel = 'AAA'; - } - - // Listen for changes - window.matchMedia('(prefers-contrast: high)').addEventListener('change', (e) => { - if (e.matches) { - this.enableHighContrast(); - } else { - this.disableHighContrast(); - } - }); - - // Check for forced colors (Windows High Contrast) - if (window.matchMedia('(forced-colors: active)').matches) { - this.enableForcedColors(); - } - } - - setupContrastControls() { - // Create contrast control panel - this.createContrastPanel(); - - // Add keyboard shortcuts - this.setupKeyboardShortcuts(); - } - - createContrastPanel() { - const panel = document.createElement('div'); - panel.id = 'contrast-panel'; - panel.className = 'contrast-panel'; - panel.innerHTML = ` -
-
Contrast Options
- -
-
-
- - -
-
- - -
-
- - -
- -
- `; - - document.body.appendChild(panel); - - this.setupPanelEvents(panel); - } - - setupPanelEvents(panel) { - // Toggle panel visibility - const toggle = panel.querySelector('.panel-toggle'); - const content = panel.querySelector('.panel-content'); - - toggle.addEventListener('click', () => { - const isOpen = content.style.display !== 'none'; - content.style.display = isOpen ? 'none' : 'block'; - toggle.setAttribute('aria-expanded', (!isOpen).toString()); - }); - - // High contrast toggle - const highContrastCheck = panel.querySelector('#high-contrast'); - highContrastCheck.addEventListener('change', (e) => { - if (e.target.checked) { - this.enableHighContrast(); - } else { - this.disableHighContrast(); - } - }); - - // Color blind mode toggle - const colorBlindCheck = panel.querySelector('#colorblind-mode'); - colorBlindCheck.addEventListener('change', (e) => { - if (e.target.checked) { - this.enableColorBlindMode(); - } else { - this.disableColorBlindMode(); - } - }); - - // Contrast level change - const levelSelect = panel.querySelector('#contrast-level'); - levelSelect.addEventListener('change', (e) => { - this.contrastLevel = e.target.value; - this.validatePageContrast(); - }); - - // Check contrast button - const checkButton = panel.querySelector('#check-contrast'); - checkButton.addEventListener('click', () => { - this.validatePageContrast(); - }); - } - - setupKeyboardShortcuts() { - document.addEventListener('keydown', (e) => { - // Ctrl+Shift+C: Toggle high contrast - if (e.ctrlKey && e.shiftKey && e.key === 'C') { - e.preventDefault(); - this.toggleHighContrast(); - } - - // Ctrl+Shift+B: Toggle color blind mode - if (e.ctrlKey && e.shiftKey && e.key === 'B') { - e.preventDefault(); - this.toggleColorBlindMode(); - } - - // Ctrl+Shift+V: Validate contrast - if (e.ctrlKey && e.shiftKey && e.key === 'V') { - e.preventDefault(); - this.validatePageContrast(); - } - }); - } - - enableHighContrast() { - this.highContrastMode = true; - document.body.classList.add('high-contrast-mode', 'contrast-enhanced'); - - // Update checkbox state - const checkbox = document.querySelector('#high-contrast'); - if (checkbox) { - checkbox.checked = true; - } - - this.announceChange('High contrast mode enabled'); - } - - disableHighContrast() { - this.highContrastMode = false; - document.body.classList.remove('high-contrast-mode', 'contrast-enhanced'); - - // Update checkbox state - const checkbox = document.querySelector('#high-contrast'); - if (checkbox) { - checkbox.checked = false; - } - - this.announceChange('High contrast mode disabled'); - } - - toggleHighContrast() { - if (this.highContrastMode) { - this.disableHighContrast(); - } else { - this.enableHighContrast(); - } - } - - enableColorBlindMode() { - this.colorBlindMode = true; - document.body.classList.add('colorblind-friendly'); - - // Add symbols to status indicators - this.addColorBlindSymbols(); - - const checkbox = document.querySelector('#colorblind-mode'); - if (checkbox) { - checkbox.checked = true; - } - - this.announceChange('Color blind friendly mode enabled'); - } - - disableColorBlindMode() { - this.colorBlindMode = false; - document.body.classList.remove('colorblind-friendly'); - - // Remove symbols - this.removeColorBlindSymbols(); - - const checkbox = document.querySelector('#colorblind-mode'); - if (checkbox) { - checkbox.checked = false; - } - - this.announceChange('Color blind friendly mode disabled'); - } - - toggleColorBlindMode() { - if (this.colorBlindMode) { - this.disableColorBlindMode(); - } else { - this.enableColorBlindMode(); - } - } - - addColorBlindSymbols() { - // Add symbols to elements that rely on color - document.querySelectorAll('.text-success, .bg-success, .btn-success').forEach(el => { - if (!el.querySelector('.cb-symbol')) { - const symbol = document.createElement('span'); - symbol.className = 'cb-symbol'; - symbol.textContent = '✓ '; - symbol.setAttribute('aria-hidden', 'true'); - el.insertBefore(symbol, el.firstChild); - } - }); - - document.querySelectorAll('.text-danger, .bg-danger, .btn-danger').forEach(el => { - if (!el.querySelector('.cb-symbol')) { - const symbol = document.createElement('span'); - symbol.className = 'cb-symbol'; - symbol.textContent = '✗ '; - symbol.setAttribute('aria-hidden', 'true'); - el.insertBefore(symbol, el.firstChild); - } - }); - - document.querySelectorAll('.text-warning, .bg-warning, .btn-warning').forEach(el => { - if (!el.querySelector('.cb-symbol')) { - const symbol = document.createElement('span'); - symbol.className = 'cb-symbol'; - symbol.textContent = '⚠ '; - symbol.setAttribute('aria-hidden', 'true'); - el.insertBefore(symbol, el.firstChild); - } - }); - } - - removeColorBlindSymbols() { - document.querySelectorAll('.cb-symbol').forEach(symbol => { - symbol.remove(); - }); - } - - enableForcedColors() { - document.body.classList.add('forced-colors-mode'); - this.announceChange('System high contrast mode detected'); - } - - validatePageContrast() { - const issues = []; - const elements = this.getTextElements(); - - elements.forEach(element => { - const contrast = this.analyzeElementContrast(element); - if (contrast.ratio < contrast.required) { - issues.push({ - element: element, - ratio: contrast.ratio, - required: contrast.required, - colors: contrast.colors - }); - } - }); - - this.reportContrastIssues(issues); - return issues; - } - - getTextElements() { - // Get all elements with text content - const selector = 'p, span, div, a, button, h1, h2, h3, h4, h5, h6, .btn, .form-control, .card-text, .nav-link, .dropdown-item'; - return Array.from(document.querySelectorAll(selector)).filter(el => { - return el.textContent.trim().length > 0 && - this.isVisible(el) && - !el.closest('.sr-only, .visually-hidden'); - }); - } - - isVisible(element) { - const style = window.getComputedStyle(element); - return style.display !== 'none' && - style.visibility !== 'hidden' && - style.opacity !== '0' && - element.offsetWidth > 0 && - element.offsetHeight > 0; - } - - analyzeElementContrast(element) { - const style = window.getComputedStyle(element); - const textColor = this.rgbToHex(style.color); - const backgroundColor = this.getBackgroundColor(element); - - const ratio = this.calculateContrastRatio(textColor, backgroundColor); - const fontSize = parseFloat(style.fontSize); - const fontWeight = style.fontWeight; - - const isLargeText = fontSize >= 18 || (fontSize >= 14 && (fontWeight === 'bold' || fontWeight >= 700)); - const requiredRatio = this.wcagRatios[this.contrastLevel][isLargeText ? 'large' : 'normal']; - - return { - ratio: ratio, - required: requiredRatio, - colors: { - text: textColor, - background: backgroundColor - }, - isLargeText: isLargeText, - passes: ratio >= requiredRatio - }; - } - - getBackgroundColor(element) { - let bgColor = 'rgba(0, 0, 0, 0)'; - let currentElement = element; - - while (currentElement && currentElement !== document.body) { - const style = window.getComputedStyle(currentElement); - const currentBg = style.backgroundColor; - - if (currentBg && currentBg !== 'rgba(0, 0, 0, 0)' && currentBg !== 'transparent') { - bgColor = currentBg; - break; - } - - currentElement = currentElement.parentElement; - } - - // Default to white if no background found - if (bgColor === 'rgba(0, 0, 0, 0)') { - bgColor = 'rgb(255, 255, 255)'; - } - - return this.rgbToHex(bgColor); - } - - rgbToHex(rgb) { - // Handle different RGB formats - if (rgb.startsWith('#')) { - return rgb; - } - - const match = rgb.match(/rgb\\((\\d+),\\s*(\\d+),\\s*(\\d+)\\)/); - if (match) { - const r = parseInt(match[1]); - const g = parseInt(match[2]); - const b = parseInt(match[3]); - return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`; - } - - return '#000000'; // fallback - } - - calculateContrastRatio(color1, color2) { - const lum1 = this.getLuminance(color1); - const lum2 = this.getLuminance(color2); - - const brightest = Math.max(lum1, lum2); - const darkest = Math.min(lum1, lum2); - - return (brightest + 0.05) / (darkest + 0.05); - } - - getLuminance(color) { - const rgb = this.hexToRgb(color); - const [r, g, b] = rgb.map(c => { - c = c / 255; - return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); - }); - - return 0.2126 * r + 0.7152 * g + 0.0722 * b; - } - - hexToRgb(hex) { - const result = /^#?([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})$/i.exec(hex); - return result ? [ - parseInt(result[1], 16), - parseInt(result[2], 16), - parseInt(result[3], 16) - ] : [0, 0, 0]; - } - - reportContrastIssues(issues) { - // Remove existing contrast warnings - document.querySelectorAll('.contrast-warning').forEach(warning => { - warning.remove(); - }); - - if (issues.length === 0) { - this.showContrastReport('All contrast ratios meet WCAG ' + this.contrastLevel + ' standards.', 'success'); - return; - } - - // Add warnings to problematic elements - issues.forEach((issue, index) => { - this.addContrastWarning(issue.element, issue, index); - }); - - this.showContrastReport(`Found ${issues.length} contrast issues.`, 'warning'); - } - - addContrastWarning(element, issue, index) { - const warning = document.createElement('div'); - warning.className = 'contrast-warning'; - warning.innerHTML = ` - ⚠ - Contrast warning: - Ratio ${issue.ratio.toFixed(2)}:1 (needs ${issue.required}:1) - `; - - warning.style.cssText = ` - position: absolute; - top: -25px; - left: 0; - background: #ff6b6b; - color: white; - padding: 2px 6px; - font-size: 11px; - border-radius: 3px; - z-index: 1000; - pointer-events: none; - `; - - // Position relative to element - element.style.position = element.style.position || 'relative'; - element.appendChild(warning); - - // Remove after 5 seconds - setTimeout(() => { - warning.remove(); - }, 5000); - } - - showContrastReport(message, type) { - const existingReport = document.querySelector('.contrast-report'); - if (existingReport) { - existingReport.remove(); - } - - const report = document.createElement('div'); - report.className = `contrast-report alert alert-${type}`; - report.innerHTML = ` -
- - ${message} - -
- `; - - report.style.cssText = ` - position: fixed; - top: 20px; - right: 20px; - z-index: 1060; - min-width: 300px; - `; - - document.body.appendChild(report); - - // Auto-remove and close button - const closeBtn = report.querySelector('.btn-close'); - closeBtn.addEventListener('click', () => report.remove()); - - setTimeout(() => { - if (document.contains(report)) { - report.remove(); - } - }, 8000); - } - - setupContrastMonitoring() { - // Monitor for dynamic content changes - const observer = new MutationObserver((mutations) => { - mutations.forEach(mutation => { - if (mutation.type === 'childList') { - mutation.addedNodes.forEach(node => { - if (node.nodeType === Node.ELEMENT_NODE) { - this.checkNewElement(node); - } - }); - } - }); - }); - - observer.observe(document.body, { - childList: true, - subtree: true - }); - } - - checkNewElement(element) { - // Check contrast for newly added elements - if (element.textContent && element.textContent.trim()) { - const contrast = this.analyzeElementContrast(element); - if (!contrast.passes) { - console.warn('New element added with poor contrast:', element, contrast); - } - } - } - - announceChange(message) { - // Announce changes to screen readers - if (window.accessibilityManager) { - window.accessibilityManager.announce(message, 'polite'); - } - } - - // Public API methods - getContrastLevel() { - return this.contrastLevel; - } - - setContrastLevel(level) { - if (['AA', 'AAA'].includes(level)) { - this.contrastLevel = level; - this.validatePageContrast(); - } - } - - isHighContrastMode() { - return this.highContrastMode; - } - - isColorBlindMode() { - return this.colorBlindMode; - } - - checkElementContrast(element) { - return this.analyzeElementContrast(element); - } - - getPageContrastIssues() { - return this.validatePageContrast(); - } -} - -// Initialize color contrast manager when DOM is loaded -document.addEventListener('DOMContentLoaded', () => { - window.colorContrastManager = new ColorContrastManager(); - console.log('Color contrast manager loaded'); -}); -""" - - def get_contrast_api_blueprint(self): - """Create Flask blueprint for contrast API endpoints.""" - bp = Blueprint('contrast', __name__, url_prefix='/api/contrast') - - @bp.route('/check', methods=['POST']) - def check_contrast(): - """Check contrast ratio between two colors.""" - data = request.get_json() - color1 = data.get('color1') - color2 = data.get('color2') - - if not color1 or not color2: - return jsonify({'error': 'Both color1 and color2 are required'}), 400 - - try: - ratio = self.calculate_contrast_ratio(color1, color2) - aa_normal = ratio >= self.wcag_ratios['AA']['normal'] - aa_large = ratio >= self.wcag_ratios['AA']['large'] - aaa_normal = ratio >= self.wcag_ratios['AAA']['normal'] - aaa_large = ratio >= self.wcag_ratios['AAA']['large'] - - return jsonify({ - 'ratio': round(ratio, 2), - 'passes': { - 'AA': {'normal': aa_normal, 'large': aa_large}, - 'AAA': {'normal': aaa_normal, 'large': aaa_large} - } - }) - except Exception as e: - return jsonify({'error': str(e)}), 400 - - @bp.route('/palette', methods=['GET']) - def get_color_palette(): - """Get WCAG compliant color palette.""" - return jsonify(self.color_palette) - - return bp - - -# Export the color contrast manager -color_contrast_manager = ColorContrastManager() \ No newline at end of file diff --git a/src/server/web/middleware/drag_drop_middleware.py b/src/server/web/middleware/drag_drop_middleware.py deleted file mode 100644 index 8e9a02f..0000000 --- a/src/server/web/middleware/drag_drop_middleware.py +++ /dev/null @@ -1,767 +0,0 @@ -""" -Drag and Drop Functionality for File Operations - -This module provides drag-and-drop capabilities for the AniWorld web interface, -including file uploads, series reordering, and batch operations. -""" - -import json - -class DragDropManager: - """Manages drag and drop operations for the web interface.""" - - def __init__(self): - self.supported_files = ['.mp4', '.mkv', '.avi', '.mov', '.wmv', '.flv', '.webm'] - self.max_file_size = 50 * 1024 * 1024 * 1024 # 50GB - - def get_drag_drop_js(self): - """Generate JavaScript code for drag and drop functionality.""" - return f""" -// AniWorld Drag & Drop Manager -class DragDropManager {{ - constructor() {{ - this.supportedFiles = {json.dumps(self.supported_files)}; - this.maxFileSize = {self.max_file_size}; - this.dropZones = new Map(); - this.dragData = null; - this.init(); - }} - - init() {{ - this.setupGlobalDragDrop(); - this.setupSeriesReordering(); - this.setupBatchOperations(); - this.createDropZoneOverlay(); - }} - - setupGlobalDragDrop() {{ - // Prevent default drag behaviors on document - document.addEventListener('dragenter', this.handleDragEnter.bind(this)); - document.addEventListener('dragover', this.handleDragOver.bind(this)); - document.addEventListener('dragleave', this.handleDragLeave.bind(this)); - document.addEventListener('drop', this.handleDrop.bind(this)); - - // Setup file drop zones - this.initializeDropZones(); - }} - - initializeDropZones() {{ - // Main content area drop zone - const mainContent = document.querySelector('.main-content, .container-fluid'); - if (mainContent) {{ - this.createDropZone(mainContent, {{ - types: ['files'], - accept: this.supportedFiles, - multiple: true, - callback: this.handleFileUpload.bind(this) - }}); - }} - - // Series list drop zone for reordering - const seriesList = document.querySelector('.series-list, .anime-grid'); - if (seriesList) {{ - this.createDropZone(seriesList, {{ - types: ['series'], - callback: this.handleSeriesReorder.bind(this) - }}); - }} - - // Queue drop zone - const queueArea = document.querySelector('.queue-area, .download-queue'); - if (queueArea) {{ - this.createDropZone(queueArea, {{ - types: ['series', 'episodes'], - callback: this.handleQueueOperation.bind(this) - }}); - }} - }} - - createDropZone(element, options) {{ - const dropZone = {{ - element: element, - options: options, - active: false - }}; - - this.dropZones.set(element, dropZone); - - // Add drop zone event listeners - element.addEventListener('dragenter', (e) => this.onDropZoneEnter(e, dropZone)); - element.addEventListener('dragover', (e) => this.onDropZoneOver(e, dropZone)); - element.addEventListener('dragleave', (e) => this.onDropZoneLeave(e, dropZone)); - element.addEventListener('drop', (e) => this.onDropZoneDrop(e, dropZone)); - - // Add visual indicators - element.classList.add('drop-zone'); - - return dropZone; - }} - - setupSeriesReordering() {{ - const seriesItems = document.querySelectorAll('.series-item, .anime-card'); - seriesItems.forEach(item => {{ - item.draggable = true; - item.addEventListener('dragstart', this.handleSeriesDragStart.bind(this)); - item.addEventListener('dragend', this.handleSeriesDragEnd.bind(this)); - }}); - }} - - setupBatchOperations() {{ - // Enable dragging of selected series for batch operations - const selectionArea = document.querySelector('.series-selection, .selection-controls'); - if (selectionArea) {{ - selectionArea.addEventListener('dragstart', this.handleBatchDragStart.bind(this)); - }} - }} - - handleDragEnter(e) {{ - e.preventDefault(); - e.stopPropagation(); - - if (this.hasFiles(e)) {{ - this.showDropOverlay(); - }} - }} - - handleDragOver(e) {{ - e.preventDefault(); - e.stopPropagation(); - e.dataTransfer.dropEffect = 'copy'; - }} - - handleDragLeave(e) {{ - e.preventDefault(); - e.stopPropagation(); - - // Only hide overlay if leaving the window - if (e.clientX === 0 && e.clientY === 0) {{ - this.hideDropOverlay(); - }} - }} - - handleDrop(e) {{ - e.preventDefault(); - e.stopPropagation(); - this.hideDropOverlay(); - - if (this.hasFiles(e)) {{ - this.handleFileUpload(e.dataTransfer.files); - }} - }} - - onDropZoneEnter(e, dropZone) {{ - e.preventDefault(); - e.stopPropagation(); - - if (this.canAcceptDrop(e, dropZone)) {{ - dropZone.element.classList.add('drag-over'); - dropZone.active = true; - }} - }} - - onDropZoneOver(e, dropZone) {{ - e.preventDefault(); - e.stopPropagation(); - - if (dropZone.active) {{ - e.dataTransfer.dropEffect = 'copy'; - }} - }} - - onDropZoneLeave(e, dropZone) {{ - e.preventDefault(); - - // Check if we're actually leaving the drop zone - const rect = dropZone.element.getBoundingClientRect(); - const x = e.clientX; - const y = e.clientY; - - if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {{ - dropZone.element.classList.remove('drag-over'); - dropZone.active = false; - }} - }} - - onDropZoneDrop(e, dropZone) {{ - e.preventDefault(); - e.stopPropagation(); - - dropZone.element.classList.remove('drag-over'); - dropZone.active = false; - - if (dropZone.options.callback) {{ - if (this.hasFiles(e)) {{ - dropZone.options.callback(e.dataTransfer.files, 'files'); - }} else {{ - dropZone.options.callback(this.dragData, 'data'); - }} - }} - }} - - canAcceptDrop(e, dropZone) {{ - const types = dropZone.options.types || []; - - if (this.hasFiles(e) && types.includes('files')) {{ - return this.validateFiles(e.dataTransfer.files, dropZone.options); - }} - - if (this.dragData && types.includes(this.dragData.type)) {{ - return true; - }} - - return false; - }} - - hasFiles(e) {{ - return e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length > 0; - }} - - validateFiles(files, options) {{ - const accept = options.accept || []; - const maxSize = options.maxSize || this.maxFileSize; - const multiple = options.multiple !== false; - - if (!multiple && files.length > 1) {{ - return false; - }} - - for (let file of files) {{ - // Check file size - if (file.size > maxSize) {{ - return false; - }} - - // Check file extension - if (accept.length > 0) {{ - const ext = '.' + file.name.split('.').pop().toLowerCase(); - if (!accept.includes(ext)) {{ - return false; - }} - }} - }} - - return true; - }} - - handleSeriesDragStart(e) {{ - const seriesItem = e.target.closest('.series-item, .anime-card'); - if (!seriesItem) return; - - this.dragData = {{ - type: 'series', - element: seriesItem, - data: {{ - id: seriesItem.dataset.seriesId || seriesItem.dataset.id, - name: seriesItem.dataset.seriesName || seriesItem.querySelector('.series-name, .anime-title')?.textContent, - folder: seriesItem.dataset.folder - }} - }}; - - // Create drag image - const dragImage = this.createDragImage(seriesItem); - e.dataTransfer.setDragImage(dragImage, 0, 0); - e.dataTransfer.effectAllowed = 'move'; - - seriesItem.classList.add('dragging'); - }} - - handleSeriesDragEnd(e) {{ - const seriesItem = e.target.closest('.series-item, .anime-card'); - if (seriesItem) {{ - seriesItem.classList.remove('dragging'); - }} - this.dragData = null; - }} - - handleBatchDragStart(e) {{ - const selectedItems = document.querySelectorAll('.series-item.selected, .anime-card.selected'); - if (selectedItems.length === 0) return; - - this.dragData = {{ - type: 'batch', - count: selectedItems.length, - items: Array.from(selectedItems).map(item => ({{ - id: item.dataset.seriesId || item.dataset.id, - name: item.dataset.seriesName || item.querySelector('.series-name, .anime-title')?.textContent, - folder: item.dataset.folder - }})) - }}; - - // Create batch drag image - const dragImage = this.createBatchDragImage(selectedItems.length); - e.dataTransfer.setDragImage(dragImage, 0, 0); - e.dataTransfer.effectAllowed = 'move'; - }} - - handleFileUpload(files, type = 'files') {{ - if (files.length === 0) return; - - const validFiles = []; - const errors = []; - - // Validate each file - for (let file of files) {{ - const ext = '.' + file.name.split('.').pop().toLowerCase(); - - if (!this.supportedFiles.includes(ext)) {{ - errors.push(`Unsupported file type: ${{file.name}}`); - continue; - }} - - if (file.size > this.maxFileSize) {{ - errors.push(`File too large: ${{file.name}} (${{this.formatFileSize(file.size)}})`); - continue; - }} - - validFiles.push(file); - }} - - // Show errors if any - if (errors.length > 0) {{ - this.showUploadErrors(errors); - }} - - // Process valid files - if (validFiles.length > 0) {{ - this.showUploadProgress(validFiles); - this.uploadFiles(validFiles); - }} - }} - - handleSeriesReorder(data, type) {{ - if (type !== 'data' || !data || data.type !== 'series') return; - - // Find drop position - const seriesList = document.querySelector('.series-list, .anime-grid'); - const items = seriesList.querySelectorAll('.series-item, .anime-card'); - - // Implement reordering logic - this.reorderSeries(data.data.id, items); - }} - - handleQueueOperation(data, type) {{ - if (type === 'files') {{ - // Handle file drops to queue - this.addFilesToQueue(data); - }} else if (type === 'data') {{ - // Handle series/episode drops to queue - this.addToQueue(data); - }} - }} - - createDropZoneOverlay() {{ - const overlay = document.createElement('div'); - overlay.id = 'drop-overlay'; - overlay.className = 'drop-overlay'; - overlay.innerHTML = ` -
- -

Drop Files Here

-

Supported formats: ${{this.supportedFiles.join(', ')}}

-

Maximum size: ${{this.formatFileSize(this.maxFileSize)}}

-
- `; - document.body.appendChild(overlay); - }} - - showDropOverlay() {{ - const overlay = document.getElementById('drop-overlay'); - if (overlay) {{ - overlay.style.display = 'flex'; - }} - }} - - hideDropOverlay() {{ - const overlay = document.getElementById('drop-overlay'); - if (overlay) {{ - overlay.style.display = 'none'; - }} - }} - - createDragImage(element) {{ - const clone = element.cloneNode(true); - clone.style.position = 'absolute'; - clone.style.top = '-1000px'; - clone.style.opacity = '0.8'; - clone.style.transform = 'rotate(5deg)'; - document.body.appendChild(clone); - - setTimeout(() => document.body.removeChild(clone), 100); - return clone; - }} - - createBatchDragImage(count) {{ - const dragImage = document.createElement('div'); - dragImage.className = 'batch-drag-image'; - dragImage.innerHTML = ` - - ${{count}} items - `; - dragImage.style.position = 'absolute'; - dragImage.style.top = '-1000px'; - document.body.appendChild(dragImage); - - setTimeout(() => document.body.removeChild(dragImage), 100); - return dragImage; - }} - - formatFileSize(bytes) {{ - const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; - if (bytes === 0) return '0 Bytes'; - const i = Math.floor(Math.log(bytes) / Math.log(1024)); - return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i]; - }} - - showUploadErrors(errors) {{ - const errorModal = document.createElement('div'); - errorModal.className = 'modal fade'; - errorModal.innerHTML = ` - - `; - - document.body.appendChild(errorModal); - const modal = new bootstrap.Modal(errorModal); - modal.show(); - - errorModal.addEventListener('hidden.bs.modal', () => {{ - document.body.removeChild(errorModal); - }}); - }} - - showUploadProgress(files) {{ - // Create upload progress modal - const progressModal = document.createElement('div'); - progressModal.className = 'modal fade'; - progressModal.id = 'upload-progress-modal'; - progressModal.setAttribute('data-bs-backdrop', 'static'); - progressModal.innerHTML = ` - - `; - - document.body.appendChild(progressModal); - const modal = new bootstrap.Modal(progressModal); - modal.show(); - - return modal; - }} - - uploadFiles(files) {{ - // This would implement the actual file upload logic - // For now, just simulate upload progress - const progressModal = this.showUploadProgress(files); - - files.forEach((file, index) => {{ - this.simulateFileUpload(file, index, files.length); - }}); - }} - - simulateFileUpload(file, index, total) {{ - const progressList = document.getElementById('upload-progress-list'); - const fileProgress = document.createElement('div'); - fileProgress.className = 'mb-2'; - fileProgress.innerHTML = ` -
- ${{file.name}} - ${{this.formatFileSize(file.size)}} -
-
-
-
- `; - progressList.appendChild(fileProgress); - - // Simulate progress - const progressBar = fileProgress.querySelector('.progress-bar'); - let progress = 0; - const interval = setInterval(() => {{ - progress += Math.random() * 15; - if (progress > 100) progress = 100; - - progressBar.style.width = progress + '%'; - - if (progress >= 100) {{ - clearInterval(interval); - progressBar.classList.add('bg-success'); - - // Update overall progress - this.updateOverallProgress(index + 1, total); - }} - }}, 200); - }} - - updateOverallProgress(completed, total) {{ - const overallProgress = document.getElementById('overall-progress'); - const percentage = (completed / total) * 100; - overallProgress.style.width = percentage + '%'; - - if (completed === total) {{ - setTimeout(() => {{ - const modal = bootstrap.Modal.getInstance(document.getElementById('upload-progress-modal')); - modal.hide(); - }}, 1000); - }} - }} - - reorderSeries(seriesId, items) {{ - // Implement series reordering logic - console.log('Reordering series:', seriesId); - - // This would send an API request to update the order - fetch('/api/series/reorder', {{ - method: 'POST', - headers: {{ - 'Content-Type': 'application/json' - }}, - body: JSON.stringify({{ - seriesId: seriesId, - newPosition: Array.from(items).findIndex(item => - item.classList.contains('drag-over')) - }}) - }}) - .then(response => response.json()) - .then(data => {{ - if (data.success) {{ - this.showToast('Series reordered successfully', 'success'); - }} else {{ - this.showToast('Failed to reorder series', 'error'); - }} - }}) - .catch(error => {{ - console.error('Reorder error:', error); - this.showToast('Error reordering series', 'error'); - }}); - }} - - addToQueue(data) {{ - // Add series or episodes to download queue - let items = []; - - if (data.type === 'series') {{ - items = [data.data]; - }} else if (data.type === 'batch') {{ - items = data.items; - }} - - fetch('/api/queue/add', {{ - method: 'POST', - headers: {{ - 'Content-Type': 'application/json' - }}, - body: JSON.stringify({{ - items: items - }}) - }}) - .then(response => response.json()) - .then(result => {{ - if (result.success) {{ - this.showToast(`Added ${{items.length}} item(s) to queue`, 'success'); - }} else {{ - this.showToast('Failed to add to queue', 'error'); - }} - }}) - .catch(error => {{ - console.error('Queue add error:', error); - this.showToast('Error adding to queue', 'error'); - }}); - }} - - showToast(message, type = 'info') {{ - // Create and show a toast notification - const toast = document.createElement('div'); - toast.className = `toast align-items-center text-white bg-${{type === 'error' ? 'danger' : type}}`; - toast.innerHTML = ` -
-
${{message}}
- -
- `; - - let toastContainer = document.querySelector('.toast-container'); - if (!toastContainer) {{ - toastContainer = document.createElement('div'); - toastContainer.className = 'toast-container position-fixed bottom-0 end-0 p-3'; - document.body.appendChild(toastContainer); - }} - - toastContainer.appendChild(toast); - const bsToast = new bootstrap.Toast(toast); - bsToast.show(); - - toast.addEventListener('hidden.bs.toast', () => {{ - toastContainer.removeChild(toast); - }}); - }} -}} - -// Initialize drag and drop when DOM is loaded -document.addEventListener('DOMContentLoaded', () => {{ - window.dragDropManager = new DragDropManager(); -}}); -""" - - def get_css(self): - """Generate CSS styles for drag and drop functionality.""" - return """ -/* Drag and Drop Styles */ -.drop-zone { - transition: all 0.3s ease; - position: relative; -} - -.drop-zone.drag-over { - background-color: rgba(13, 110, 253, 0.1); - border: 2px dashed #0d6efd; - border-radius: 8px; -} - -.drop-zone.drag-over::before { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(13, 110, 253, 0.05); - border-radius: 6px; - z-index: 1; -} - -.drop-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.8); - display: none; - justify-content: center; - align-items: center; - z-index: 9999; -} - -.drop-message { - text-align: center; - color: white; - padding: 2rem; - border: 3px dashed #0d6efd; - border-radius: 15px; - background: rgba(13, 110, 253, 0.1); - backdrop-filter: blur(10px); -} - -.drop-message i { - font-size: 4rem; - margin-bottom: 1rem; - color: #0d6efd; -} - -.drop-message h3 { - margin-bottom: 0.5rem; -} - -.drop-message p { - margin-bottom: 0.25rem; - opacity: 0.8; -} - -.series-item.dragging, -.anime-card.dragging { - opacity: 0.5; - transform: rotate(2deg); - z-index: 1000; -} - -.batch-drag-image { - background: #0d6efd; - color: white; - padding: 0.5rem 1rem; - border-radius: 20px; - display: flex; - align-items: center; - gap: 0.5rem; - font-size: 0.9rem; - box-shadow: 0 4px 8px rgba(0,0,0,0.2); -} - -.progress-sm { - height: 0.5rem; -} - -.toast-container { - z-index: 9999; -} - -/* Drag handle for reorderable items */ -.drag-handle { - cursor: grab; - color: #6c757d; - padding: 0.25rem; -} - -.drag-handle:hover { - color: #0d6efd; -} - -.drag-handle:active { - cursor: grabbing; -} - -/* Drop indicators */ -.drop-indicator { - height: 3px; - background: #0d6efd; - margin: 0.25rem 0; - opacity: 0; - transition: opacity 0.2s; -} - -.drop-indicator.active { - opacity: 1; -} - -/* Accessibility */ -@media (prefers-reduced-motion: reduce) { - .drop-zone, - .series-item.dragging, - .anime-card.dragging { - transition: none; - } -} -""" - - -# Export the drag drop manager -drag_drop_manager = DragDropManager() \ No newline at end of file diff --git a/src/server/web/middleware/error_handler.py b/src/server/web/middleware/error_handler.py deleted file mode 100644 index 41aa9ac..0000000 --- a/src/server/web/middleware/error_handler.py +++ /dev/null @@ -1,462 +0,0 @@ -""" -Error Handling & Recovery System for AniWorld App - -This module provides comprehensive error handling for network failures, -download errors, and system recovery mechanisms. -""" - -import logging -import time -import functools -import threading -from typing import Callable, Any, Dict, Optional, List -from datetime import datetime, timedelta -import requests -import socket -import ssl -from urllib3.exceptions import ConnectionError, TimeoutError, ReadTimeoutError -from requests.exceptions import RequestException, ConnectionError as ReqConnectionError -from flask import jsonify -import os -import hashlib - - -class NetworkError(Exception): - """Base class for network-related errors.""" - pass - - -class DownloadError(Exception): - """Base class for download-related errors.""" - pass - - -class RetryableError(Exception): - """Base class for errors that can be retried.""" - pass - - -class NonRetryableError(Exception): - """Base class for errors that should not be retried.""" - pass - - -class ErrorRecoveryManager: - """Manages error recovery strategies and retry mechanisms.""" - - def __init__(self, max_retries: int = 3, base_delay: float = 1.0, max_delay: float = 60.0): - self.max_retries = max_retries - self.base_delay = base_delay - self.max_delay = max_delay - self.error_history: List[Dict] = [] - self.blacklisted_urls: Dict[str, datetime] = {} - self.retry_counts: Dict[str, int] = {} - self.logger = logging.getLogger(__name__) - - def is_network_error(self, error: Exception) -> bool: - """Check if error is network-related.""" - network_errors = ( - ConnectionError, TimeoutError, ReadTimeoutError, - ReqConnectionError, socket.timeout, socket.gaierror, - ssl.SSLError, requests.exceptions.Timeout, - requests.exceptions.ConnectionError - ) - return isinstance(error, network_errors) - - def is_retryable_error(self, error: Exception) -> bool: - """Determine if an error should be retried.""" - if isinstance(error, NonRetryableError): - return False - - if isinstance(error, RetryableError): - return True - - # Network errors are generally retryable - if self.is_network_error(error): - return True - - # HTTP status codes that are retryable - if hasattr(error, 'response') and error.response: - status_code = error.response.status_code - retryable_codes = [408, 429, 500, 502, 503, 504] - return status_code in retryable_codes - - return False - - def calculate_delay(self, attempt: int) -> float: - """Calculate exponential backoff delay.""" - delay = self.base_delay * (2 ** (attempt - 1)) - return min(delay, self.max_delay) - - def log_error(self, error: Exception, context: str, attempt: int = None): - """Log error with context information.""" - error_info = { - 'timestamp': datetime.now().isoformat(), - 'error_type': type(error).__name__, - 'error_message': str(error), - 'context': context, - 'attempt': attempt, - 'retryable': self.is_retryable_error(error) - } - - self.error_history.append(error_info) - - # Keep only last 1000 errors - if len(self.error_history) > 1000: - self.error_history = self.error_history[-1000:] - - log_level = logging.WARNING if self.is_retryable_error(error) else logging.ERROR - self.logger.log(log_level, f"Error in {context}: {error}", exc_info=True) - - def add_to_blacklist(self, url: str, duration_minutes: int = 30): - """Add URL to temporary blacklist.""" - self.blacklisted_urls[url] = datetime.now() + timedelta(minutes=duration_minutes) - - def is_blacklisted(self, url: str) -> bool: - """Check if URL is currently blacklisted.""" - if url in self.blacklisted_urls: - if datetime.now() < self.blacklisted_urls[url]: - return True - else: - del self.blacklisted_urls[url] - return False - - def cleanup_blacklist(self): - """Remove expired entries from blacklist.""" - now = datetime.now() - expired_keys = [url for url, expiry in self.blacklisted_urls.items() if now >= expiry] - for key in expired_keys: - del self.blacklisted_urls[key] - - -class RetryMechanism: - """Advanced retry mechanism with exponential backoff and jitter.""" - - def __init__(self, recovery_manager: ErrorRecoveryManager): - self.recovery_manager = recovery_manager - self.logger = logging.getLogger(__name__) - - def retry_with_backoff( - self, - func: Callable, - *args, - max_retries: int = None, - backoff_factor: float = 1.0, - jitter: bool = True, - retry_on: tuple = None, - context: str = None, - **kwargs - ) -> Any: - """ - Retry function with exponential backoff and jitter. - - Args: - func: Function to retry - max_retries: Maximum number of retries (uses recovery manager default if None) - backoff_factor: Multiplier for backoff delay - jitter: Add random jitter to prevent thundering herd - retry_on: Tuple of exception types to retry on - context: Context string for logging - - Returns: - Function result - - Raises: - Last exception if all retries fail - """ - if max_retries is None: - max_retries = self.recovery_manager.max_retries - - if context is None: - context = f"{func.__name__}" - - last_exception = None - - for attempt in range(1, max_retries + 2): # +1 for initial attempt - try: - return func(*args, **kwargs) - except Exception as e: - last_exception = e - - # Check if we should retry this error - should_retry = ( - retry_on is None and self.recovery_manager.is_retryable_error(e) - ) or ( - retry_on is not None and isinstance(e, retry_on) - ) - - if attempt > max_retries or not should_retry: - self.recovery_manager.log_error(e, context, attempt) - raise e - - # Calculate delay with jitter - delay = self.recovery_manager.calculate_delay(attempt) * backoff_factor - if jitter: - import random - delay *= (0.5 + random.random() * 0.5) # Add 0-50% jitter - - self.recovery_manager.log_error(e, context, attempt) - self.logger.info(f"Retrying {context} in {delay:.2f}s (attempt {attempt}/{max_retries})") - - time.sleep(delay) - - raise last_exception - - -class NetworkHealthChecker: - """Monitor network connectivity and health.""" - - def __init__(self): - self.logger = logging.getLogger(__name__) - self.connectivity_cache = {} - self.cache_timeout = 60 # seconds - - def check_connectivity(self, host: str = "8.8.8.8", port: int = 53, timeout: float = 3.0) -> bool: - """Check basic network connectivity.""" - cache_key = f"{host}:{port}" - now = time.time() - - # Check cache - if cache_key in self.connectivity_cache: - timestamp, result = self.connectivity_cache[cache_key] - if now - timestamp < self.cache_timeout: - return result - - try: - socket.setdefaulttimeout(timeout) - socket.socket(socket.AF_INET, socket.SOCK_STREAM).connect((host, port)) - result = True - except Exception: - result = False - - self.connectivity_cache[cache_key] = (now, result) - return result - - def check_url_reachability(self, url: str, timeout: float = 10.0) -> bool: - """Check if a specific URL is reachable.""" - try: - response = requests.head(url, timeout=timeout, allow_redirects=True) - return response.status_code < 400 - except Exception as e: - self.logger.debug(f"URL {url} not reachable: {e}") - return False - - def get_network_status(self) -> Dict[str, Any]: - """Get comprehensive network status.""" - return { - 'basic_connectivity': self.check_connectivity(), - 'dns_resolution': self.check_connectivity("1.1.1.1", 53), - 'timestamp': datetime.now().isoformat() - } - - -class FileCorruptionDetector: - """Detect and handle file corruption.""" - - def __init__(self): - self.logger = logging.getLogger(__name__) - - def calculate_checksum(self, file_path: str, algorithm: str = 'md5') -> str: - """Calculate file checksum.""" - hash_func = getattr(hashlib, algorithm)() - - try: - with open(file_path, 'rb') as f: - for chunk in iter(lambda: f.read(4096), b""): - hash_func.update(chunk) - return hash_func.hexdigest() - except Exception as e: - self.logger.error(f"Failed to calculate checksum for {file_path}: {e}") - raise - - def verify_file_size(self, file_path: str, expected_size: int = None, min_size: int = 1024) -> bool: - """Verify file has reasonable size.""" - try: - actual_size = os.path.getsize(file_path) - - # Check minimum size - if actual_size < min_size: - self.logger.warning(f"File {file_path} too small: {actual_size} bytes") - return False - - # Check expected size if provided - if expected_size and abs(actual_size - expected_size) > expected_size * 0.1: # 10% tolerance - self.logger.warning(f"File {file_path} size mismatch: expected {expected_size}, got {actual_size}") - return False - - return True - except Exception as e: - self.logger.error(f"Failed to verify file size for {file_path}: {e}") - return False - - def is_valid_video_file(self, file_path: str) -> bool: - """Basic validation for video files.""" - if not os.path.exists(file_path): - return False - - # Check file size - if not self.verify_file_size(file_path): - return False - - # Check file extension - video_extensions = {'.mp4', '.mkv', '.avi', '.mov', '.wmv', '.flv', '.webm'} - ext = os.path.splitext(file_path)[1].lower() - if ext not in video_extensions: - self.logger.warning(f"File {file_path} has unexpected extension: {ext}") - - # Try to read first few bytes to check for valid headers - try: - with open(file_path, 'rb') as f: - header = f.read(32) - # Common video file signatures - video_signatures = [ - b'\x00\x00\x00\x18ftypmp4', # MP4 - b'\x1a\x45\xdf\xa3', # MKV (Matroska) - b'RIFF', # AVI - ] - - for sig in video_signatures: - if header.startswith(sig): - return True - - # If no specific signature matches, assume it's valid if size is reasonable - return True - except Exception as e: - self.logger.error(f"Failed to read file header for {file_path}: {e}") - return False - - -class RecoveryStrategies: - """Implement various recovery strategies for different error types.""" - - def __init__(self, recovery_manager: ErrorRecoveryManager): - self.recovery_manager = recovery_manager - self.retry_mechanism = RetryMechanism(recovery_manager) - self.health_checker = NetworkHealthChecker() - self.corruption_detector = FileCorruptionDetector() - self.logger = logging.getLogger(__name__) - - def handle_network_failure(self, func: Callable, *args, **kwargs) -> Any: - """Handle network failures with comprehensive recovery.""" - def recovery_wrapper(): - # Check basic connectivity first - if not self.health_checker.check_connectivity(): - raise NetworkError("No internet connectivity") - - return func(*args, **kwargs) - - return self.retry_mechanism.retry_with_backoff( - recovery_wrapper, - max_retries=5, - backoff_factor=1.5, - context=f"network_operation_{func.__name__}", - retry_on=(NetworkError, ConnectionError, TimeoutError) - ) - - def handle_download_failure( - self, - download_func: Callable, - file_path: str, - *args, - **kwargs - ) -> Any: - """Handle download failures with corruption checking and resume support.""" - def download_with_verification(): - result = download_func(*args, **kwargs) - - # Verify downloaded file if it exists - if os.path.exists(file_path): - if not self.corruption_detector.is_valid_video_file(file_path): - self.logger.warning(f"Downloaded file appears corrupted: {file_path}") - # Remove corrupted file to force re-download - try: - os.remove(file_path) - except Exception as e: - self.logger.error(f"Failed to remove corrupted file {file_path}: {e}") - raise DownloadError("Downloaded file is corrupted") - - return result - - return self.retry_mechanism.retry_with_backoff( - download_with_verification, - max_retries=3, - backoff_factor=2.0, - context=f"download_{os.path.basename(file_path)}", - retry_on=(DownloadError, NetworkError, ConnectionError) - ) - - -# Singleton instances -error_recovery_manager = ErrorRecoveryManager() -recovery_strategies = RecoveryStrategies(error_recovery_manager) -network_health_checker = NetworkHealthChecker() -file_corruption_detector = FileCorruptionDetector() - - -def with_error_recovery(max_retries: int = None, context: str = None): - """Decorator for adding error recovery to functions.""" - def decorator(func: Callable) -> Callable: - @functools.wraps(func) - def wrapper(*args, **kwargs): - return recovery_strategies.retry_mechanism.retry_with_backoff( - func, - *args, - max_retries=max_retries, - context=context or func.__name__, - **kwargs - ) - return wrapper - return decorator - - -def handle_api_errors(func: Callable) -> Callable: - """Decorator for consistent API error handling.""" - @functools.wraps(func) - def wrapper(*args, **kwargs): - try: - return func(*args, **kwargs) - except NonRetryableError as e: - error_recovery_manager.log_error(e, f"api_{func.__name__}") - return jsonify({ - 'status': 'error', - 'message': 'Operation failed', - 'error_type': 'non_retryable', - 'retry_suggested': False - }), 400 - except RetryableError as e: - error_recovery_manager.log_error(e, f"api_{func.__name__}") - return jsonify({ - 'status': 'error', - 'message': 'Temporary failure, please try again', - 'error_type': 'retryable', - 'retry_suggested': True - }), 503 - except Exception as e: - error_recovery_manager.log_error(e, f"api_{func.__name__}") - return jsonify({ - 'status': 'error', - 'message': 'An unexpected error occurred', - 'error_type': 'unknown', - 'retry_suggested': error_recovery_manager.is_retryable_error(e) - }), 500 - return wrapper - - -# Export main components -__all__ = [ - 'ErrorRecoveryManager', - 'RetryMechanism', - 'NetworkHealthChecker', - 'FileCorruptionDetector', - 'RecoveryStrategies', - 'NetworkError', - 'DownloadError', - 'RetryableError', - 'NonRetryableError', - 'with_error_recovery', - 'handle_api_errors', - 'error_recovery_manager', - 'recovery_strategies', - 'network_health_checker', - 'file_corruption_detector' -] \ No newline at end of file diff --git a/src/server/web/middleware/keyboard_middleware.py b/src/server/web/middleware/keyboard_middleware.py deleted file mode 100644 index 47f8ec2..0000000 --- a/src/server/web/middleware/keyboard_middleware.py +++ /dev/null @@ -1,474 +0,0 @@ -""" -Keyboard Shortcuts and Hotkey Management - -This module provides keyboard shortcut functionality for the AniWorld web interface, -including customizable hotkeys for common actions and accessibility support. -""" - -import json - -class KeyboardShortcutManager: - """Manages keyboard shortcuts for the web interface.""" - - def __init__(self): - self.shortcuts = { - # Navigation shortcuts - 'home': ['Alt+H', 'h'], - 'search': ['Ctrl+F', 'Alt+S', '/'], - 'queue': ['Alt+Q', 'q'], - 'config': ['Alt+C', 'c'], - 'logs': ['Alt+L', 'l'], - - # Action shortcuts - 'rescan': ['F5', 'Ctrl+R', 'r'], - 'start_download': ['Enter', 'Space', 'd'], - 'pause_download': ['Ctrl+Space', 'p'], - 'cancel_download': ['Escape', 'Ctrl+X'], - - # Selection shortcuts - 'select_all': ['Ctrl+A', 'a'], - 'deselect_all': ['Ctrl+D', 'Escape'], - 'toggle_selection': ['Ctrl+Click', 't'], - 'next_item': ['ArrowDown', 'j'], - 'prev_item': ['ArrowUp', 'k'], - - # Modal/Dialog shortcuts - 'close_modal': ['Escape', 'Ctrl+W'], - 'confirm_action': ['Enter', 'Ctrl+Enter'], - 'cancel_action': ['Escape', 'Ctrl+C'], - - # View shortcuts - 'toggle_details': ['Tab', 'i'], - 'refresh_view': ['F5', 'Ctrl+R'], - 'toggle_filters': ['f'], - 'toggle_sort': ['s'], - - # Quick actions - 'quick_help': ['F1', '?'], - 'settings': ['Ctrl+,', ','], - 'logout': ['Ctrl+Shift+L'], - } - - self.descriptions = { - 'home': 'Navigate to home page', - 'search': 'Focus search input', - 'queue': 'Open download queue', - 'config': 'Open configuration', - 'logs': 'View application logs', - 'rescan': 'Rescan anime collection', - 'start_download': 'Start selected downloads', - 'pause_download': 'Pause active downloads', - 'cancel_download': 'Cancel active downloads', - 'select_all': 'Select all items', - 'deselect_all': 'Deselect all items', - 'toggle_selection': 'Toggle item selection', - 'next_item': 'Navigate to next item', - 'prev_item': 'Navigate to previous item', - 'close_modal': 'Close modal dialog', - 'confirm_action': 'Confirm current action', - 'cancel_action': 'Cancel current action', - 'toggle_details': 'Toggle detailed view', - 'refresh_view': 'Refresh current view', - 'toggle_filters': 'Toggle filter panel', - 'toggle_sort': 'Change sort order', - 'quick_help': 'Show help dialog', - 'settings': 'Open settings panel', - 'logout': 'Logout from application' - } - - def get_shortcuts_js(self): - """Generate JavaScript code for keyboard shortcuts.""" - return f""" -// AniWorld Keyboard Shortcuts Manager -class KeyboardShortcutManager {{ - constructor() {{ - this.shortcuts = {self._format_shortcuts_for_js()}; - this.descriptions = {self._format_descriptions_for_js()}; - this.enabled = true; - this.activeModals = []; - this.init(); - }} - - init() {{ - document.addEventListener('keydown', this.handleKeyDown.bind(this)); - document.addEventListener('keyup', this.handleKeyUp.bind(this)); - this.createHelpModal(); - this.showKeyboardHints(); - }} - - handleKeyDown(event) {{ - if (!this.enabled) return; - - const key = this.getKeyString(event); - - // Check for matching shortcuts - for (const [action, keys] of Object.entries(this.shortcuts)) {{ - if (keys.includes(key)) {{ - if (this.executeAction(action, event)) {{ - event.preventDefault(); - event.stopPropagation(); - }} - }} - }} - }} - - handleKeyUp(event) {{ - // Handle key up events if needed - }} - - getKeyString(event) {{ - const parts = []; - if (event.ctrlKey) parts.push('Ctrl'); - if (event.altKey) parts.push('Alt'); - if (event.shiftKey) parts.push('Shift'); - if (event.metaKey) parts.push('Meta'); - - let key = event.key; - if (key === ' ') key = 'Space'; - - parts.push(key); - return parts.join('+'); - }} - - executeAction(action, event) {{ - // Prevent shortcuts in input fields unless explicitly allowed - const allowedInInputs = ['search', 'close_modal', 'cancel_action']; - const activeElement = document.activeElement; - const isInputElement = activeElement && ( - activeElement.tagName === 'INPUT' || - activeElement.tagName === 'TEXTAREA' || - activeElement.contentEditable === 'true' - ); - - if (isInputElement && !allowedInInputs.includes(action)) {{ - return false; - }} - - switch (action) {{ - case 'home': - window.location.href = '/'; - return true; - - case 'search': - const searchInput = document.querySelector('#search-input, .search-input, [data-search]'); - if (searchInput) {{ - searchInput.focus(); - searchInput.select(); - }} - return true; - - case 'queue': - window.location.href = '/queue'; - return true; - - case 'config': - window.location.href = '/config'; - return true; - - case 'logs': - window.location.href = '/logs'; - return true; - - case 'rescan': - const rescanBtn = document.querySelector('#rescan-btn, [data-action="rescan"]'); - if (rescanBtn && !rescanBtn.disabled) {{ - rescanBtn.click(); - }} - return true; - - case 'start_download': - const downloadBtn = document.querySelector('#download-btn, [data-action="download"]'); - if (downloadBtn && !downloadBtn.disabled) {{ - downloadBtn.click(); - }} - return true; - - case 'pause_download': - const pauseBtn = document.querySelector('#pause-btn, [data-action="pause"]'); - if (pauseBtn && !pauseBtn.disabled) {{ - pauseBtn.click(); - }} - return true; - - case 'cancel_download': - const cancelBtn = document.querySelector('#cancel-btn, [data-action="cancel"]'); - if (cancelBtn && !cancelBtn.disabled) {{ - cancelBtn.click(); - }} - return true; - - case 'select_all': - const selectAllBtn = document.querySelector('#select-all-btn, [data-action="select-all"]'); - if (selectAllBtn) {{ - selectAllBtn.click(); - }} else {{ - this.selectAllItems(); - }} - return true; - - case 'deselect_all': - const deselectAllBtn = document.querySelector('#deselect-all-btn, [data-action="deselect-all"]'); - if (deselectAllBtn) {{ - deselectAllBtn.click(); - }} else {{ - this.deselectAllItems(); - }} - return true; - - case 'next_item': - this.navigateItems('next'); - return true; - - case 'prev_item': - this.navigateItems('prev'); - return true; - - case 'close_modal': - this.closeTopModal(); - return true; - - case 'confirm_action': - const confirmBtn = document.querySelector('.modal.show .btn-primary, .modal.show [data-confirm]'); - if (confirmBtn) {{ - confirmBtn.click(); - }} - return true; - - case 'cancel_action': - const cancelActionBtn = document.querySelector('.modal.show .btn-secondary, .modal.show [data-cancel]'); - if (cancelActionBtn) {{ - cancelActionBtn.click(); - }} - return true; - - case 'toggle_details': - this.toggleDetailView(); - return true; - - case 'refresh_view': - window.location.reload(); - return true; - - case 'toggle_filters': - const filterPanel = document.querySelector('#filter-panel, .filters'); - if (filterPanel) {{ - filterPanel.classList.toggle('show'); - }} - return true; - - case 'toggle_sort': - const sortBtn = document.querySelector('#sort-btn, [data-action="sort"]'); - if (sortBtn) {{ - sortBtn.click(); - }} - return true; - - case 'quick_help': - this.showHelpModal(); - return true; - - case 'settings': - const settingsBtn = document.querySelector('#settings-btn, [data-action="settings"]'); - if (settingsBtn) {{ - settingsBtn.click(); - }} else {{ - window.location.href = '/config'; - }} - return true; - - case 'logout': - if (confirm('Are you sure you want to logout?')) {{ - window.location.href = '/logout'; - }} - return true; - - default: - return false; - }} - }} - - selectAllItems() {{ - const checkboxes = document.querySelectorAll('.series-checkbox, [data-selectable]'); - checkboxes.forEach(cb => {{ - if (cb.type === 'checkbox') {{ - cb.checked = true; - cb.dispatchEvent(new Event('change')); - }} else {{ - cb.classList.add('selected'); - }} - }}); - }} - - deselectAllItems() {{ - const checkboxes = document.querySelectorAll('.series-checkbox, [data-selectable]'); - checkboxes.forEach(cb => {{ - if (cb.type === 'checkbox') {{ - cb.checked = false; - cb.dispatchEvent(new Event('change')); - }} else {{ - cb.classList.remove('selected'); - }} - }}); - }} - - navigateItems(direction) {{ - const items = document.querySelectorAll('.series-item, .list-item, [data-navigable]'); - const currentIndex = Array.from(items).findIndex(item => - item.classList.contains('focused') || item.classList.contains('active') - ); - - let newIndex; - if (direction === 'next') {{ - newIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0; - }} else {{ - newIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1; - }} - - // Remove focus from current item - if (currentIndex >= 0) {{ - items[currentIndex].classList.remove('focused', 'active'); - }} - - // Add focus to new item - if (items[newIndex]) {{ - items[newIndex].classList.add('focused'); - items[newIndex].scrollIntoView({{ block: 'center' }}); - }} - }} - - closeTopModal() {{ - const modals = document.querySelectorAll('.modal.show'); - if (modals.length > 0) {{ - const topModal = modals[modals.length - 1]; - const closeBtn = topModal.querySelector('.btn-close, [data-bs-dismiss="modal"]'); - if (closeBtn) {{ - closeBtn.click(); - }} - }} - }} - - toggleDetailView() {{ - const detailToggle = document.querySelector('[data-toggle="details"]'); - if (detailToggle) {{ - detailToggle.click(); - }} else {{ - document.body.classList.toggle('detailed-view'); - }} - }} - - createHelpModal() {{ - const helpModal = document.createElement('div'); - helpModal.className = 'modal fade'; - helpModal.id = 'keyboard-help-modal'; - helpModal.innerHTML = ` - - `; - document.body.appendChild(helpModal); - }} - - generateHelpContent() {{ - let html = '
'; - const categories = {{ - 'Navigation': ['home', 'search', 'queue', 'config', 'logs'], - 'Actions': ['rescan', 'start_download', 'pause_download', 'cancel_download'], - 'Selection': ['select_all', 'deselect_all', 'next_item', 'prev_item'], - 'View': ['toggle_details', 'refresh_view', 'toggle_filters', 'toggle_sort'], - 'General': ['quick_help', 'settings', 'logout'] - }}; - - Object.entries(categories).forEach(([category, actions]) => {{ - html += `
-
${{category}}
- `; - - actions.forEach(action => {{ - const shortcuts = this.shortcuts[action] || []; - const description = this.descriptions[action] || action; - html += ` - - - `; - }}); - - html += '
${{shortcuts.join(' or ')}}${{description}}
'; - }}); - - html += '
'; - return html; - }} - - showHelpModal() {{ - const helpModal = new bootstrap.Modal(document.getElementById('keyboard-help-modal')); - helpModal.show(); - }} - - showKeyboardHints() {{ - // Add keyboard hint tooltips to buttons - document.querySelectorAll('[data-action]').forEach(btn => {{ - const action = btn.dataset.action; - const shortcuts = this.shortcuts[action]; - if (shortcuts && shortcuts.length > 0) {{ - const shortcut = shortcuts[0]; - const currentTitle = btn.title || ''; - btn.title = currentTitle + (currentTitle ? ' ' : '') + `(${{shortcut}})`; - }} - }}); - }} - - enable() {{ - this.enabled = true; - }} - - disable() {{ - this.enabled = false; - }} - - setEnabled(enabled) {{ - this.enabled = enabled; - }} - - updateShortcuts(newShortcuts) {{ - if (newShortcuts && typeof newShortcuts === 'object') {{ - Object.assign(this.shortcuts, newShortcuts); - }} - }} - - addCustomShortcut(action, keys, callback) {{ - this.shortcuts[action] = Array.isArray(keys) ? keys : [keys]; - this.customCallbacks = this.customCallbacks || {{}}; - this.customCallbacks[action] = callback; - }} -}} - -// Initialize keyboard shortcuts when DOM is loaded -document.addEventListener('DOMContentLoaded', () => {{ - window.keyboardManager = new KeyboardShortcutManager(); -}}); -""" - - def _format_shortcuts_for_js(self): - """Format shortcuts dictionary for JavaScript.""" - import json - return json.dumps(self.shortcuts) - - def _format_descriptions_for_js(self): - """Format descriptions dictionary for JavaScript.""" - import json - return json.dumps(self.descriptions) - - -# Export the keyboard shortcut manager -keyboard_manager = KeyboardShortcutManager() \ No newline at end of file diff --git a/src/server/web/middleware/mobile_middleware.py b/src/server/web/middleware/mobile_middleware.py deleted file mode 100644 index a1967df..0000000 --- a/src/server/web/middleware/mobile_middleware.py +++ /dev/null @@ -1,1048 +0,0 @@ -""" -Mobile Responsive Design Manager - -This module provides mobile-responsive design capabilities, adaptive layouts, -and mobile-optimized user interface components for the AniWorld web interface. -""" - -import json -from typing import Dict, List, Any, Optional -from flask import Blueprint, request, jsonify - -class MobileResponsiveManager: - """Manages mobile responsive design and adaptive layouts.""" - - def __init__(self, app=None): - self.app = app - self.breakpoints = { - 'xs': 0, # Extra small devices (phones) - 'sm': 576, # Small devices (landscape phones) - 'md': 768, # Medium devices (tablets) - 'lg': 992, # Large devices (desktops) - 'xl': 1200, # Extra large devices (large desktops) - 'xxl': 1400 # Extra extra large devices - } - - def init_app(self, app): - """Initialize with Flask app.""" - self.app = app - - def get_mobile_responsive_js(self): - """Generate JavaScript code for mobile responsive functionality.""" - return f""" -// AniWorld Mobile Responsive Manager -class MobileResponsiveManager {{ - constructor() {{ - this.breakpoints = {json.dumps(self.breakpoints)}; - this.currentBreakpoint = 'lg'; - this.isMobile = false; - this.isTablet = false; - this.isDesktop = true; - this.orientation = 'landscape'; - this.touchDevice = false; - - this.init(); - }} - - init() {{ - this.detectDevice(); - this.setupBreakpointListeners(); - this.optimizeForMobile(); - this.setupTouchGestures(); - this.setupViewportMeta(); - - // Initial responsive adjustments - this.handleBreakpointChange(); - }} - - detectDevice() {{ - // Detect if touch device - this.touchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0; - - // Detect device type based on screen size and capabilities - const width = window.innerWidth; - const height = window.innerHeight; - - this.isMobile = width < this.breakpoints.md || this.touchDevice && width < this.breakpoints.lg; - this.isTablet = width >= this.breakpoints.md && width < this.breakpoints.lg && this.touchDevice; - this.isDesktop = width >= this.breakpoints.lg && !this.touchDevice; - - // Detect orientation - this.orientation = width > height ? 'landscape' : 'portrait'; - - // Set CSS classes on body - document.body.classList.toggle('mobile-device', this.isMobile); - document.body.classList.toggle('tablet-device', this.isTablet); - document.body.classList.toggle('desktop-device', this.isDesktop); - document.body.classList.toggle('touch-device', this.touchDevice); - document.body.classList.add(`orientation-${{this.orientation}}`); - }} - - setupBreakpointListeners() {{ - // Listen for window resize - let resizeTimeout; - window.addEventListener('resize', () => {{ - clearTimeout(resizeTimeout); - resizeTimeout = setTimeout(() => {{ - this.updateBreakpoint(); - this.detectDevice(); - this.handleBreakpointChange(); - }}, 100); - }}); - - // Listen for orientation change - window.addEventListener('orientationchange', () => {{ - setTimeout(() => {{ - this.detectDevice(); - this.handleOrientationChange(); - }}, 100); - }}); - - // Set initial breakpoint - this.updateBreakpoint(); - }} - - updateBreakpoint() {{ - const width = window.innerWidth; - let newBreakpoint = 'xs'; - - for (const [bp, minWidth] of Object.entries(this.breakpoints)) {{ - if (width >= minWidth) {{ - newBreakpoint = bp; - }} - }} - - if (newBreakpoint !== this.currentBreakpoint) {{ - const oldBreakpoint = this.currentBreakpoint; - this.currentBreakpoint = newBreakpoint; - - // Update body classes - document.body.className = document.body.className.replace(/breakpoint-\\w+/g, ''); - document.body.classList.add(`breakpoint-${{newBreakpoint}}`); - - // Dispatch custom event - document.dispatchEvent(new CustomEvent('breakpointChange', {{ - detail: {{ - from: oldBreakpoint, - to: newBreakpoint, - width: width - }} - }})); - }} - }} - - handleBreakpointChange() {{ - this.adaptNavigationLayout(); - this.adaptContentLayout(); - this.adaptFormElements(); - this.adaptTables(); - this.adaptModals(); - this.adaptCards(); - this.adaptButtons(); - }} - - handleOrientationChange() {{ - // Remove old orientation class - document.body.classList.remove('orientation-landscape', 'orientation-portrait'); - - // Update orientation - const width = window.innerWidth; - const height = window.innerHeight; - this.orientation = width > height ? 'landscape' : 'portrait'; - - // Add new orientation class - document.body.classList.add(`orientation-${{this.orientation}}`); - - // Adjust layout for orientation - this.adaptForOrientation(); - }} - - adaptNavigationLayout() {{ - const navbar = document.querySelector('.navbar, .nav-header'); - const sidebar = document.querySelector('.sidebar, .nav-sidebar'); - - if (this.isMobile) {{ - // Collapse navigation on mobile - if (navbar) {{ - navbar.classList.add('mobile-nav'); - this.createMobileNavToggle(); - }} - - if (sidebar) {{ - sidebar.classList.add('mobile-sidebar'); - this.createMobileSidebar(); - }} - }} else {{ - // Restore desktop navigation - if (navbar) {{ - navbar.classList.remove('mobile-nav'); - }} - - if (sidebar) {{ - sidebar.classList.remove('mobile-sidebar'); - }} - }} - }} - - createMobileNavToggle() {{ - let existingToggle = document.querySelector('.mobile-nav-toggle'); - if (existingToggle) return; - - const navbar = document.querySelector('.navbar, .nav-header'); - if (!navbar) return; - - const toggle = document.createElement('button'); - toggle.className = 'mobile-nav-toggle btn btn-outline-secondary'; - toggle.innerHTML = ''; - toggle.setAttribute('data-bs-toggle', 'collapse'); - toggle.setAttribute('data-bs-target', '.navbar-collapse'); - - // Insert at beginning of navbar - navbar.insertBefore(toggle, navbar.firstChild); - }} - - createMobileSidebar() {{ - const sidebar = document.querySelector('.sidebar, .nav-sidebar'); - if (!sidebar) return; - - // Make sidebar collapsible - sidebar.classList.add('collapse'); - sidebar.id = 'mobile-sidebar'; - - // Create sidebar toggle if not exists - let sidebarToggle = document.querySelector('.sidebar-toggle'); - if (!sidebarToggle) {{ - sidebarToggle = document.createElement('button'); - sidebarToggle.className = 'sidebar-toggle btn btn-primary position-fixed'; - sidebarToggle.style.cssText = 'top: 10px; left: 10px; z-index: 1050;'; - sidebarToggle.innerHTML = ''; - sidebarToggle.setAttribute('data-bs-toggle', 'collapse'); - sidebarToggle.setAttribute('data-bs-target', '#mobile-sidebar'); - - document.body.appendChild(sidebarToggle); - }} - }} - - adaptContentLayout() {{ - const containers = document.querySelectorAll('.container, .container-fluid'); - const rows = document.querySelectorAll('.row'); - const cols = document.querySelectorAll('[class*="col-"]'); - - if (this.isMobile) {{ - // Stack columns on mobile - cols.forEach(col => {{ - col.classList.add('mobile-stacked'); - }}); - - // Adjust container padding - containers.forEach(container => {{ - container.classList.add('mobile-container'); - }}); - }} else {{ - // Restore desktop layout - cols.forEach(col => {{ - col.classList.remove('mobile-stacked'); - }}); - - containers.forEach(container => {{ - container.classList.remove('mobile-container'); - }}); - }} - }} - - adaptFormElements() {{ - const forms = document.querySelectorAll('form'); - const inputs = document.querySelectorAll('input, select, textarea'); - const inputGroups = document.querySelectorAll('.input-group'); - - if (this.isMobile) {{ - // Make form elements touch-friendly - inputs.forEach(input => {{ - input.classList.add('mobile-input'); - - // Increase touch target size - if (input.type === 'checkbox' || input.type === 'radio') {{ - input.classList.add('mobile-checkbox'); - }} - }}); - - // Stack input groups - inputGroups.forEach(group => {{ - group.classList.add('mobile-input-group'); - }}); - - // Add mobile form classes - forms.forEach(form => {{ - form.classList.add('mobile-form'); - }}); - }} else {{ - // Restore desktop form styling - inputs.forEach(input => {{ - input.classList.remove('mobile-input', 'mobile-checkbox'); - }}); - - inputGroups.forEach(group => {{ - group.classList.remove('mobile-input-group'); - }}); - - forms.forEach(form => {{ - form.classList.remove('mobile-form'); - }}); - }} - }} - - adaptTables() {{ - const tables = document.querySelectorAll('table'); - - tables.forEach(table => {{ - if (this.isMobile) {{ - // Make tables responsive - table.classList.add('mobile-table'); - - // Wrap in responsive container if not already - if (!table.closest('.table-responsive')) {{ - const wrapper = document.createElement('div'); - wrapper.className = 'table-responsive'; - table.parentNode.insertBefore(wrapper, table); - wrapper.appendChild(table); - }} - - // Convert to card layout for very small screens - if (this.currentBreakpoint === 'xs') {{ - this.convertTableToCards(table); - }} - }} else {{ - table.classList.remove('mobile-table'); - this.restoreTableLayout(table); - }} - }}); - }} - - convertTableToCards(table) {{ - if (table.classList.contains('card-converted')) return; - - const headers = Array.from(table.querySelectorAll('thead th')).map(th => th.textContent); - const rows = table.querySelectorAll('tbody tr'); - - const cardContainer = document.createElement('div'); - cardContainer.className = 'table-cards'; - - rows.forEach(row => {{ - const cells = row.querySelectorAll('td'); - const card = document.createElement('div'); - card.className = 'card mb-2 table-card'; - - let cardContent = '
'; - cells.forEach((cell, index) => {{ - if (headers[index]) {{ - cardContent += ` -
-
${{headers[index]}}:
-
${{cell.innerHTML}}
-
- `; - }} - }}); - cardContent += '
'; - - card.innerHTML = cardContent; - cardContainer.appendChild(card); - }}); - - table.style.display = 'none'; - table.parentNode.insertBefore(cardContainer, table.nextSibling); - table.classList.add('card-converted'); - }} - - restoreTableLayout(table) {{ - if (!table.classList.contains('card-converted')) return; - - const cardContainer = table.parentNode.querySelector('.table-cards'); - if (cardContainer) {{ - cardContainer.remove(); - }} - - table.style.display = ''; - table.classList.remove('card-converted'); - }} - - adaptModals() {{ - const modals = document.querySelectorAll('.modal'); - - modals.forEach(modal => {{ - const dialog = modal.querySelector('.modal-dialog'); - if (!dialog) return; - - if (this.isMobile) {{ - dialog.classList.add('mobile-modal'); - - // Full screen on small mobile - if (this.currentBreakpoint === 'xs') {{ - dialog.classList.add('modal-fullscreen'); - }} - }} else {{ - dialog.classList.remove('mobile-modal', 'modal-fullscreen'); - }} - }}); - }} - - adaptCards() {{ - const cards = document.querySelectorAll('.card'); - - cards.forEach(card => {{ - if (this.isMobile) {{ - card.classList.add('mobile-card'); - - // Stack card elements - const cardBody = card.querySelector('.card-body'); - if (cardBody) {{ - cardBody.classList.add('mobile-card-body'); - }} - }} else {{ - card.classList.remove('mobile-card'); - - const cardBody = card.querySelector('.card-body'); - if (cardBody) {{ - cardBody.classList.remove('mobile-card-body'); - }} - }} - }}); - }} - - adaptButtons() {{ - const buttons = document.querySelectorAll('.btn'); - const buttonGroups = document.querySelectorAll('.btn-group'); - - if (this.isMobile) {{ - // Make buttons touch-friendly - buttons.forEach(btn => {{ - btn.classList.add('mobile-btn'); - }}); - - // Stack button groups - buttonGroups.forEach(group => {{ - group.classList.add('mobile-btn-group'); - }}); - }} else {{ - buttons.forEach(btn => {{ - btn.classList.remove('mobile-btn'); - }}); - - buttonGroups.forEach(group => {{ - group.classList.remove('mobile-btn-group'); - }}); - }} - }} - - adaptForOrientation() {{ - if (this.isMobile) {{ - const content = document.querySelector('.main-content, .container-fluid'); - - if (this.orientation === 'landscape') {{ - // Optimize for landscape mobile - if (content) {{ - content.classList.add('mobile-landscape'); - content.classList.remove('mobile-portrait'); - }} - }} else {{ - // Optimize for portrait mobile - if (content) {{ - content.classList.add('mobile-portrait'); - content.classList.remove('mobile-landscape'); - }} - }} - }} - }} - - setupTouchGestures() {{ - if (!this.touchDevice) return; - - // Add touch gesture support - document.body.classList.add('touch-enabled'); - - // Prevent zoom on double tap (for better UX) - let lastTouchEnd = 0; - document.addEventListener('touchend', (e) => {{ - const now = (new Date()).getTime(); - if (now - lastTouchEnd <= 300) {{ - e.preventDefault(); - }} - lastTouchEnd = now; - }}, false); - - // Add swipe gesture detection - this.setupSwipeGestures(); - }} - - setupSwipeGestures() {{ - let startX, startY, endX, endY; - - document.addEventListener('touchstart', (e) => {{ - startX = e.touches[0].clientX; - startY = e.touches[0].clientY; - }}); - - document.addEventListener('touchend', (e) => {{ - if (!startX || !startY) return; - - endX = e.changedTouches[0].clientX; - endY = e.changedTouches[0].clientY; - - const diffX = startX - endX; - const diffY = startY - endY; - - // Minimum swipe distance - const minSwipeDistance = 50; - - if (Math.abs(diffX) > Math.abs(diffY)) {{ - // Horizontal swipe - if (Math.abs(diffX) > minSwipeDistance) {{ - if (diffX > 0) {{ - this.handleSwipeLeft(); - }} else {{ - this.handleSwipeRight(); - }} - }} - }} else {{ - // Vertical swipe - if (Math.abs(diffY) > minSwipeDistance) {{ - if (diffY > 0) {{ - this.handleSwipeUp(); - }} else {{ - this.handleSwipeDown(); - }} - }} - }} - - startX = startY = endX = endY = null; - }}); - }} - - handleSwipeLeft() {{ - // Handle left swipe (next page, close sidebar, etc.) - const sidebar = document.querySelector('#mobile-sidebar'); - if (sidebar && sidebar.classList.contains('show')) {{ - bootstrap.Collapse.getOrCreateInstance(sidebar).hide(); - }} - - // Dispatch custom event - document.dispatchEvent(new CustomEvent('swipeLeft')); - }} - - handleSwipeRight() {{ - // Handle right swipe (previous page, open sidebar, etc.) - const sidebar = document.querySelector('#mobile-sidebar'); - if (sidebar && !sidebar.classList.contains('show')) {{ - bootstrap.Collapse.getOrCreateInstance(sidebar).show(); - }} - - document.dispatchEvent(new CustomEvent('swipeRight')); - }} - - handleSwipeUp() {{ - // Handle up swipe (scroll to top, refresh, etc.) - document.dispatchEvent(new CustomEvent('swipeUp')); - }} - - handleSwipeDown() {{ - // Handle down swipe (refresh, show more content, etc.) - document.dispatchEvent(new CustomEvent('swipeDown')); - }} - - setupViewportMeta() {{ - // Ensure viewport meta tag is properly set - let viewport = document.querySelector('meta[name="viewport"]'); - - if (!viewport) {{ - viewport = document.createElement('meta'); - viewport.name = 'viewport'; - document.head.appendChild(viewport); - }} - - // Set responsive viewport - viewport.content = 'width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes'; - }} - - optimizeForMobile() {{ - if (!this.isMobile) return; - - // Add mobile-specific optimizations - this.optimizeImages(); - this.optimizeLoading(); - this.optimizeScrolling(); - }} - - optimizeImages() {{ - const images = document.querySelectorAll('img'); - - images.forEach(img => {{ - // Add loading="lazy" for better performance - if (!img.hasAttribute('loading')) {{ - img.setAttribute('loading', 'lazy'); - }} - - // Add mobile-responsive class - img.classList.add('mobile-img'); - }}); - }} - - optimizeLoading() {{ - // Show loading indicators for better perceived performance - const loadingElements = document.querySelectorAll('[data-loading]'); - - loadingElements.forEach(element => {{ - element.classList.add('mobile-loading'); - }}); - }} - - optimizeScrolling() {{ - // Smooth scrolling for better mobile experience - document.documentElement.style.scrollBehavior = 'smooth'; - - // Add momentum scrolling for iOS - document.body.style.webkitOverflowScrolling = 'touch'; - }} - - // Utility methods - getCurrentBreakpoint() {{ - return this.currentBreakpoint; - }} - - isMobileDevice() {{ - return this.isMobile; - }} - - isTabletDevice() {{ - return this.isTablet; - }} - - isDesktopDevice() {{ - return this.isDesktop; - }} - - isTouchDevice() {{ - return this.touchDevice; - }} - - getOrientation() {{ - return this.orientation; - }} - - getScreenInfo() {{ - return {{ - width: window.innerWidth, - height: window.innerHeight, - breakpoint: this.currentBreakpoint, - isMobile: this.isMobile, - isTablet: this.isTablet, - isDesktop: this.isDesktop, - touchDevice: this.touchDevice, - orientation: this.orientation - }}; - }} -}} - -// Initialize mobile responsive manager when DOM is loaded -document.addEventListener('DOMContentLoaded', () => {{ - window.mobileManager = new MobileResponsiveManager(); - - // Add global utility functions - window.isMobile = () => window.mobileManager.isMobileDevice(); - window.isTablet = () => window.mobileManager.isTabletDevice(); - window.isDesktop = () => window.mobileManager.isDesktopDevice(); - window.getCurrentBreakpoint = () => window.mobileManager.getCurrentBreakpoint(); -}}); -""" - - def get_css(self): - """Generate CSS for mobile responsive design.""" - return """ -/* Mobile Responsive Design Styles */ - -/* Base responsive utilities */ -.mobile-device { - --mobile-padding: 0.75rem; - --mobile-margin: 0.5rem; - --mobile-font-size: 1rem; - --touch-target-size: 44px; -} - -.tablet-device { - --mobile-padding: 1rem; - --mobile-margin: 0.75rem; - --mobile-font-size: 1.1rem; - --touch-target-size: 40px; -} - -.desktop-device { - --mobile-padding: 1.25rem; - --mobile-margin: 1rem; - --mobile-font-size: 1rem; - --touch-target-size: 32px; -} - -/* Touch device optimizations */ -.touch-device { - -webkit-tap-highlight-color: rgba(0, 0, 0, 0.1); - -webkit-touch-callout: none; - -webkit-user-select: none; - -khtml-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; -} - -.touch-enabled { - touch-action: manipulation; -} - -/* Breakpoint-specific styles */ -.breakpoint-xs { - --container-padding: 0.5rem; - --card-margin: 0.25rem; -} - -.breakpoint-sm { - --container-padding: 0.75rem; - --card-margin: 0.5rem; -} - -.breakpoint-md { - --container-padding: 1rem; - --card-margin: 0.75rem; -} - -/* Mobile navigation */ -.mobile-nav { - position: sticky; - top: 0; - z-index: 1030; - background: rgba(255, 255, 255, 0.95) !important; - backdrop-filter: blur(10px); - border-bottom: 1px solid var(--bs-border-color); -} - -.mobile-nav-toggle { - position: absolute; - top: 0.5rem; - left: 0.5rem; - z-index: 1031; -} - -.mobile-sidebar { - position: fixed; - top: 0; - left: 0; - height: 100vh; - width: 280px; - z-index: 1040; - background: var(--bs-body-bg); - border-right: 1px solid var(--bs-border-color); - transform: translateX(-100%); - transition: transform 0.3s ease; -} - -.mobile-sidebar.show { - transform: translateX(0); -} - -.sidebar-toggle { - min-width: var(--touch-target-size); - min-height: var(--touch-target-size); -} - -/* Mobile container adjustments */ -.mobile-container { - padding-left: var(--container-padding); - padding-right: var(--container-padding); -} - -.mobile-stacked { - flex: 1 1 100% !important; - max-width: 100% !important; - margin-bottom: var(--mobile-margin); -} - -/* Mobile form elements */ -.mobile-form { - padding: var(--mobile-padding); -} - -.mobile-input { - min-height: var(--touch-target-size); - font-size: var(--mobile-font-size); - padding: 0.75rem; -} - -.mobile-checkbox { - min-width: var(--touch-target-size); - min-height: var(--touch-target-size); - transform: scale(1.2); -} - -.mobile-input-group { - flex-direction: column; -} - -.mobile-input-group > .form-control, -.mobile-input-group > .form-select { - border-radius: 0.375rem !important; - margin-bottom: 0.25rem; -} - -.mobile-input-group > .btn { - border-radius: 0.375rem !important; -} - -/* Mobile buttons */ -.mobile-btn { - min-height: var(--touch-target-size); - padding: 0.75rem 1rem; - font-size: var(--mobile-font-size); - border-radius: 0.5rem; -} - -.mobile-btn-group { - flex-direction: column; - width: 100%; -} - -.mobile-btn-group > .btn { - margin-bottom: 0.25rem; - border-radius: 0.375rem !important; -} - -/* Mobile tables */ -.mobile-table { - font-size: 0.875rem; -} - -.table-cards .table-card { - border-left: 4px solid var(--bs-primary); -} - -.table-cards .table-card .row { - align-items: center; - min-height: 2rem; -} - -/* Mobile modals */ -.mobile-modal { - margin: 0.5rem; -} - -.modal-fullscreen .modal-content { - height: calc(100vh - 1rem); - border-radius: 0.5rem; -} - -/* Mobile cards */ -.mobile-card { - margin-bottom: var(--card-margin); - border-radius: 0.75rem; -} - -.mobile-card-body { - padding: var(--mobile-padding); -} - -/* Mobile images */ -.mobile-img { - max-width: 100%; - height: auto; - border-radius: 0.5rem; -} - -/* Orientation-specific styles */ -.orientation-portrait { - --orientation-padding: 1rem; -} - -.orientation-landscape { - --orientation-padding: 0.5rem; -} - -.mobile-landscape .container, -.mobile-landscape .container-fluid { - padding-left: var(--orientation-padding); - padding-right: var(--orientation-padding); -} - -.mobile-portrait .container, -.mobile-portrait .container-fluid { - padding-left: var(--orientation-padding); - padding-right: var(--orientation-padding); -} - -/* Loading states for mobile */ -.mobile-loading { - position: relative; -} - -.mobile-loading::after { - content: ''; - position: absolute; - top: 50%; - left: 50%; - width: 20px; - height: 20px; - margin: -10px 0 0 -10px; - border: 2px solid var(--bs-primary); - border-top: 2px solid transparent; - border-radius: 50%; - animation: mobile-spin 1s linear infinite; -} - -@keyframes mobile-spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } -} - -/* Responsive utilities */ -.mobile-only { - display: none; -} - -.desktop-only { - display: block; -} - -@media (max-width: 767.98px) { - .mobile-only { - display: block; - } - - .desktop-only { - display: none; - } -} - -/* Tablet-specific adjustments */ -@media (min-width: 768px) and (max-width: 991.98px) { - .tablet-only { - display: block; - } - - .mobile-only, - .desktop-only { - display: none; - } -} - -/* Large mobile landscape */ -@media (max-width: 767.98px) and (orientation: landscape) { - .mobile-landscape .navbar { - min-height: 50px; - } - - .mobile-landscape .mobile-container { - padding-top: 0.5rem; - padding-bottom: 0.5rem; - } -} - -/* Small mobile portrait */ -@media (max-width: 575.98px) and (orientation: portrait) { - .mobile-portrait .container, - .mobile-portrait .container-fluid { - padding-left: 0.5rem; - padding-right: 0.5rem; - } - - .mobile-portrait .modal-dialog { - margin: 0.25rem; - } - - .mobile-portrait .btn { - font-size: 0.875rem; - padding: 0.5rem 0.75rem; - } -} - -/* Dark theme mobile adjustments */ -[data-bs-theme="dark"] .mobile-nav { - background: rgba(33, 37, 41, 0.95) !important; -} - -[data-bs-theme="dark"] .mobile-sidebar { - background: var(--bs-dark); - border-color: var(--bs-border-color-translucent); -} - -/* Accessibility improvements for mobile */ -@media (prefers-reduced-motion: reduce) { - .mobile-sidebar { - transition: none; - } - - .mobile-loading::after { - animation: none; - } -} - -/* High contrast mode support */ -@media (prefers-contrast: high) { - .mobile-card { - border-width: 2px; - } - - .mobile-btn { - border-width: 2px; - } -} - -/* Print styles for mobile */ -@media print { - .mobile-nav-toggle, - .sidebar-toggle, - .mobile-sidebar { - display: none !important; - } - - .mobile-container { - padding: 0; - } -} - -/* Hover states only for non-touch devices */ -@media (hover: hover) and (pointer: fine) { - .mobile-btn:hover { - transform: translateY(-1px); - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); - } - - .mobile-card:hover { - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); - } -} - -/* Focus styles for keyboard navigation on mobile */ -.mobile-btn:focus, -.mobile-input:focus { - outline: 2px solid var(--bs-primary); - outline-offset: 2px; -} - -/* Ensure sufficient color contrast */ -.mobile-card { - --bs-card-bg: var(--bs-body-bg); - --bs-card-border-color: var(--bs-border-color); -} - -/* Safari mobile specific fixes */ -@supports (-webkit-touch-callout: none) { - .mobile-input { - -webkit-appearance: none; - border-radius: 0.375rem; - } - - .mobile-sidebar { - -webkit-overflow-scrolling: touch; - } -} -""" - - -# Export the mobile responsive manager -mobile_responsive_manager = MobileResponsiveManager() \ No newline at end of file diff --git a/src/server/web/middleware/multi_screen_middleware.py b/src/server/web/middleware/multi_screen_middleware.py deleted file mode 100644 index f1394d1..0000000 --- a/src/server/web/middleware/multi_screen_middleware.py +++ /dev/null @@ -1,1334 +0,0 @@ -""" -Multi-Screen Size Support System - -This module provides responsive design support for various screen sizes and orientations, -ensuring optimal user experience across all device types and resolutions. -""" - -import json -from typing import Dict, List, Any, Optional, Tuple -from flask import Blueprint, request, jsonify - -class MultiScreenManager: - """Manages multi-screen size support and responsive layouts.""" - - def __init__(self, app=None): - self.app = app - self.screen_sizes = { - 'xs': {'min': 0, 'max': 575.98, 'label': 'Extra Small'}, - 'sm': {'min': 576, 'max': 767.98, 'label': 'Small'}, - 'md': {'min': 768, 'max': 991.98, 'label': 'Medium'}, - 'lg': {'min': 992, 'max': 1199.98, 'label': 'Large'}, - 'xl': {'min': 1200, 'max': 1399.98, 'label': 'Extra Large'}, - 'xxl': {'min': 1400, 'max': float('inf'), 'label': 'Extra Extra Large'} - } - - def init_app(self, app): - """Initialize with Flask app.""" - self.app = app - - def get_multiscreen_js(self): - """Generate JavaScript code for multi-screen support.""" - return f""" -// AniWorld Multi-Screen Manager -class MultiScreenManager {{ - constructor() {{ - this.screenSizes = {json.dumps(self.screen_sizes)}; - this.currentBreakpoint = 'lg'; - this.currentOrientation = 'landscape'; - this.currentResolution = {{ width: 0, height: 0 }}; - this.devicePixelRatio = window.devicePixelRatio || 1; - this.viewportDimensions = {{ width: 0, height: 0 }}; - this.resizeObserver = null; - this.orientationChangeHandlers = []; - this.breakpointChangeHandlers = []; - - this.init(); - }} - - init() {{ - this.detectScreenProperties(); - this.setupViewportHandling(); - this.setupResizeHandling(); - this.setupOrientationHandling(); - this.applyResponsiveLayout(); - this.setupCustomBreakpoints(); - this.setupDynamicViewport(); - - console.log('Multi-screen manager initialized'); - }} - - detectScreenProperties() {{ - // Get screen dimensions - this.currentResolution = {{ - width: window.screen.width, - height: window.screen.height, - availWidth: window.screen.availWidth, - availHeight: window.screen.availHeight - }}; - - // Get viewport dimensions - this.updateViewportDimensions(); - - // Detect orientation - this.updateOrientation(); - - // Detect current breakpoint - this.updateBreakpoint(); - - // Set CSS custom properties - this.setCSSProperties(); - - // Add device classes - this.addDeviceClasses(); - - console.log('Screen properties:', {{ - resolution: this.currentResolution, - viewport: this.viewportDimensions, - breakpoint: this.currentBreakpoint, - orientation: this.currentOrientation, - pixelRatio: this.devicePixelRatio - }}); - }} - - updateViewportDimensions() {{ - this.viewportDimensions = {{ - width: window.innerWidth, - height: window.innerHeight, - documentWidth: document.documentElement.clientWidth, - documentHeight: document.documentElement.clientHeight - }}; - }} - - updateOrientation() {{ - const oldOrientation = this.currentOrientation; - - // Use orientation API if available - if (screen.orientation) {{ - this.currentOrientation = screen.orientation.angle === 0 || screen.orientation.angle === 180 - ? 'portrait' : 'landscape'; - }} else {{ - // Fallback to window dimensions - this.currentOrientation = this.viewportDimensions.width > this.viewportDimensions.height - ? 'landscape' : 'portrait'; - }} - - if (oldOrientation && oldOrientation !== this.currentOrientation) {{ - this.handleOrientationChange(oldOrientation, this.currentOrientation); - }} - }} - - updateBreakpoint() {{ - const oldBreakpoint = this.currentBreakpoint; - const width = this.viewportDimensions.width; - - for (const [breakpoint, config] of Object.entries(this.screenSizes)) {{ - if (width >= config.min && width <= config.max) {{ - this.currentBreakpoint = breakpoint; - break; - }} - }} - - if (oldBreakpoint && oldBreakpoint !== this.currentBreakpoint) {{ - this.handleBreakpointChange(oldBreakpoint, this.currentBreakpoint); - }} - }} - - setCSSProperties() {{ - const root = document.documentElement; - - // Set viewport dimensions - root.style.setProperty('--viewport-width', `${{this.viewportDimensions.width}}px`); - root.style.setProperty('--viewport-height', `${{this.viewportDimensions.height}}px`); - - // Set screen dimensions - root.style.setProperty('--screen-width', `${{this.currentResolution.width}}px`); - root.style.setProperty('--screen-height', `${{this.currentResolution.height}}px`); - - // Set pixel ratio - root.style.setProperty('--device-pixel-ratio', this.devicePixelRatio); - - // Set breakpoint - root.style.setProperty('--current-breakpoint', `'${{this.currentBreakpoint}}'`); - - // Set dynamic font size based on viewport - const baseFontSize = this.calculateResponsiveFontSize(); - root.style.setProperty('--responsive-font-size', `${{baseFontSize}}px`); - - // Set responsive spacing - const spacing = this.calculateResponsiveSpacing(); - root.style.setProperty('--responsive-spacing', `${{spacing}}rem`); - - // Set container max-widths for each breakpoint - Object.entries(this.screenSizes).forEach(([bp, config]) => {{ - if (config.max !== Infinity) {{ - root.style.setProperty(`--container-max-width-${{bp}}`, `${{config.max}}px`); - }} - }}); - }} - - calculateResponsiveFontSize() {{ - const baseSize = 16; // Base font size in pixels - const minSize = 14; - const maxSize = 20; - - // Scale font size based on viewport width - const vw = this.viewportDimensions.width; - let scaleFactor = 1; - - if (vw < 576) {{ - scaleFactor = 0.875; // Smaller font for mobile - }} else if (vw < 768) {{ - scaleFactor = 0.9375; // Slightly smaller for small tablets - }} else if (vw > 1400) {{ - scaleFactor = 1.125; // Larger font for large screens - }} - - const calculatedSize = baseSize * scaleFactor; - return Math.max(minSize, Math.min(maxSize, calculatedSize)); - }} - - calculateResponsiveSpacing() {{ - const baseSpacing = 1; // Base spacing in rem - const vw = this.viewportDimensions.width; - - if (vw < 576) {{ - return 0.75; // Tighter spacing on mobile - }} else if (vw < 768) {{ - return 0.875; - }} else if (vw > 1400) {{ - return 1.25; // More generous spacing on large screens - }} - - return baseSpacing; - }} - - addDeviceClasses() {{ - const body = document.body; - - // Remove existing classes - body.classList.remove( - 'xs-device', 'sm-device', 'md-device', 'lg-device', 'xl-device', 'xxl-device', - 'portrait-orientation', 'landscape-orientation', - 'high-dpi', 'standard-dpi', - 'mobile-device', 'tablet-device', 'desktop-device' - ); - - // Add current breakpoint class - body.classList.add(`${{this.currentBreakpoint}}-device`); - - // Add orientation class - body.classList.add(`${{this.currentOrientation}}-orientation`); - - // Add DPI class - if (this.devicePixelRatio > 1.5) {{ - body.classList.add('high-dpi'); - }} else {{ - body.classList.add('standard-dpi'); - }} - - // Add device type classes - const deviceType = this.getDeviceType(); - body.classList.add(`${{deviceType}}-device`); - - // Add aspect ratio class - const aspectRatio = this.getAspectRatioClass(); - body.classList.add(`aspect-${{aspectRatio}}`); - }} - - getDeviceType() {{ - const width = this.viewportDimensions.width; - const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0; - - if (width < 768) {{ - return 'mobile'; - }} else if (width < 1024 && isTouchDevice) {{ - return 'tablet'; - }} else {{ - return 'desktop'; - }} - }} - - getAspectRatioClass() {{ - const {{ width, height }} = this.viewportDimensions; - const ratio = width / height; - - if (ratio > 2.1) return 'ultrawide'; - if (ratio > 1.7) return 'wide'; - if (ratio > 1.4) return 'normal'; - if (ratio > 0.9) return 'square'; - return 'tall'; - }} - - setupViewportHandling() {{ - // Handle dynamic viewport changes (iOS Safari address bar, etc.) - this.setupDynamicViewportHeight(); - - // Handle viewport meta tag optimization - this.optimizeViewportMeta(); - - // Handle safe area insets - this.handleSafeAreaInsets(); - }} - - setupDynamicViewportHeight() {{ - // CSS custom property for real viewport height - const updateVH = () => {{ - const vh = this.viewportDimensions.height * 0.01; - document.documentElement.style.setProperty('--vh', `${{vh}}px`); - - // Also set full viewport height - document.documentElement.style.setProperty('--full-height', `${{this.viewportDimensions.height}}px`); - }}; - - updateVH(); - - // Update on resize and orientation change - window.addEventListener('resize', updateVH); - window.addEventListener('orientationchange', () => {{ - setTimeout(updateVH, 100); - }}); - }} - - optimizeViewportMeta() {{ - let viewport = document.querySelector('meta[name="viewport"]'); - - if (!viewport) {{ - viewport = document.createElement('meta'); - viewport.name = 'viewport'; - document.head.appendChild(viewport); - }} - - // Optimize viewport settings based on device - const deviceType = this.getDeviceType(); - - if (deviceType === 'mobile') {{ - viewport.content = 'width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes, viewport-fit=cover'; - }} else if (deviceType === 'tablet') {{ - viewport.content = 'width=device-width, initial-scale=1.0, maximum-scale=3.0, user-scalable=yes, viewport-fit=cover'; - }} else {{ - viewport.content = 'width=device-width, initial-scale=1.0, viewport-fit=cover'; - }} - }} - - handleSafeAreaInsets() {{ - // Handle notches, status bars, etc. - const root = document.documentElement; - - // Set CSS custom properties for safe area insets - root.style.setProperty('--safe-area-inset-top', 'env(safe-area-inset-top, 0px)'); - root.style.setProperty('--safe-area-inset-right', 'env(safe-area-inset-right, 0px)'); - root.style.setProperty('--safe-area-inset-bottom', 'env(safe-area-inset-bottom, 0px)'); - root.style.setProperty('--safe-area-inset-left', 'env(safe-area-inset-left, 0px)'); - }} - - setupResizeHandling() {{ - let resizeTimeout; - - const handleResize = () => {{ - clearTimeout(resizeTimeout); - resizeTimeout = setTimeout(() => {{ - this.updateViewportDimensions(); - this.updateBreakpoint(); - this.setCSSProperties(); - this.addDeviceClasses(); - this.applyResponsiveLayout(); - - // Dispatch custom resize event - document.dispatchEvent(new CustomEvent('responsiveResize', {{ - detail: {{ - breakpoint: this.currentBreakpoint, - viewport: this.viewportDimensions, - deviceType: this.getDeviceType() - }} - }})); - }}, 250); - }}; - - window.addEventListener('resize', handleResize); - - // Use ResizeObserver if available for more efficient monitoring - if (window.ResizeObserver) {{ - this.resizeObserver = new ResizeObserver(handleResize); - this.resizeObserver.observe(document.body); - }} - }} - - setupOrientationHandling() {{ - const handleOrientationChange = () => {{ - setTimeout(() => {{ - this.updateViewportDimensions(); - this.updateOrientation(); - this.updateBreakpoint(); - this.setCSSProperties(); - this.addDeviceClasses(); - this.applyResponsiveLayout(); - }}, 100); - }}; - - // Modern orientation change detection - if (screen.orientation) {{ - screen.orientation.addEventListener('change', handleOrientationChange); - }} else {{ - // Fallback for older browsers - window.addEventListener('orientationchange', handleOrientationChange); - }} - }} - - handleOrientationChange(oldOrientation, newOrientation) {{ - console.log(`Orientation changed: ${{oldOrientation}} → ${{newOrientation}}`); - - // Apply orientation-specific layouts - this.applyOrientationLayout(newOrientation); - - // Notify registered handlers - this.orientationChangeHandlers.forEach(handler => {{ - try {{ - handler(newOrientation, oldOrientation); - }} catch (error) {{ - console.error('Error in orientation change handler:', error); - }} - }}); - - // Dispatch event - document.dispatchEvent(new CustomEvent('orientationChanged', {{ - detail: {{ oldOrientation, newOrientation }} - }})); - }} - - handleBreakpointChange(oldBreakpoint, newBreakpoint) {{ - console.log(`Breakpoint changed: ${{oldBreakpoint}} → ${{newBreakpoint}}`); - - // Apply breakpoint-specific layouts - this.applyBreakpointLayout(newBreakpoint); - - // Notify registered handlers - this.breakpointChangeHandlers.forEach(handler => {{ - try {{ - handler(newBreakpoint, oldBreakpoint); - }} catch (error) {{ - console.error('Error in breakpoint change handler:', error); - }} - }}); - - // Dispatch event - document.dispatchEvent(new CustomEvent('breakpointChanged', {{ - detail: {{ oldBreakpoint, newBreakpoint }} - }})); - }} - - applyResponsiveLayout() {{ - this.adjustContainerSizes(); - this.adjustNavigationLayout(); - this.adjustContentLayout(); - this.adjustFormLayout(); - this.adjustTableLayout(); - this.adjustModalLayout(); - this.adjustImageLayout(); - }} - - adjustContainerSizes() {{ - const containers = document.querySelectorAll('.container, .container-fluid'); - - containers.forEach(container => {{ - const maxWidth = this.getContainerMaxWidth(); - if (maxWidth) {{ - container.style.maxWidth = maxWidth; - }} - }}); - }} - - getContainerMaxWidth() {{ - const config = this.screenSizes[this.currentBreakpoint]; - if (config && config.max !== Infinity) {{ - return `${{config.max * 0.9}}px`; // 90% of breakpoint max - }} - return null; - }} - - adjustNavigationLayout() {{ - const navbar = document.querySelector('.navbar'); - if (!navbar) return; - - if (['xs', 'sm'].includes(this.currentBreakpoint)) {{ - // Mobile navigation - navbar.classList.add('navbar-mobile'); - this.createMobileNavigation(navbar); - }} else {{ - // Desktop navigation - navbar.classList.remove('navbar-mobile'); - this.restoreDesktopNavigation(navbar); - }} - }} - - createMobileNavigation(navbar) {{ - // Create hamburger menu if not exists - let toggle = navbar.querySelector('.navbar-toggler'); - if (!toggle) {{ - toggle = document.createElement('button'); - toggle.className = 'navbar-toggler'; - toggle.innerHTML = ` - - - - - - `; - navbar.insertBefore(toggle, navbar.firstChild); - }} - - // Make nav items collapsible - const navItems = navbar.querySelector('.navbar-nav'); - if (navItems && !navItems.classList.contains('collapse')) {{ - navItems.classList.add('collapse', 'navbar-collapse'); - }} - }} - - restoreDesktopNavigation(navbar) {{ - const navItems = navbar.querySelector('.navbar-nav'); - if (navItems) {{ - navItems.classList.remove('collapse', 'navbar-collapse'); - }} - }} - - adjustContentLayout() {{ - const mainContent = document.querySelector('main, .main-content'); - if (!mainContent) return; - - // Adjust padding based on screen size - const padding = this.getContentPadding(); - mainContent.style.padding = padding; - - // Adjust grid layouts - this.adjustGridLayouts(); - - // Adjust card layouts - this.adjustCardLayouts(); - }} - - getContentPadding() {{ - switch (this.currentBreakpoint) {{ - case 'xs': return '0.5rem'; - case 'sm': return '1rem'; - case 'md': return '1.5rem'; - case 'lg': return '2rem'; - case 'xl': - case 'xxl': return '2.5rem'; - default: return '1.5rem'; - }} - }} - - adjustGridLayouts() {{ - const grids = document.querySelectorAll('.row, .series-grid'); - - grids.forEach(grid => {{ - const columns = this.getOptimalColumns(); - grid.style.setProperty('--grid-columns', columns); - }}); - }} - - getOptimalColumns() {{ - switch (this.currentBreakpoint) {{ - case 'xs': return '1'; - case 'sm': return '2'; - case 'md': return '3'; - case 'lg': return '4'; - case 'xl': return '5'; - case 'xxl': return '6'; - default: return '4'; - }} - }} - - adjustCardLayouts() {{ - const cards = document.querySelectorAll('.card, .series-card'); - - cards.forEach(card => {{ - if (['xs', 'sm'].includes(this.currentBreakpoint)) {{ - card.classList.add('card-mobile'); - }} else {{ - card.classList.remove('card-mobile'); - }} - }}); - }} - - adjustFormLayout() {{ - const forms = document.querySelectorAll('form'); - - forms.forEach(form => {{ - if (['xs', 'sm'].includes(this.currentBreakpoint)) {{ - form.classList.add('form-mobile'); - this.stackFormElements(form); - }} else {{ - form.classList.remove('form-mobile'); - this.unstackFormElements(form); - }} - }}); - }} - - stackFormElements(form) {{ - const inputGroups = form.querySelectorAll('.input-group'); - inputGroups.forEach(group => {{ - group.classList.add('input-group-stacked'); - }}); - - const formRows = form.querySelectorAll('.row'); - formRows.forEach(row => {{ - row.classList.add('row-stacked'); - }}); - }} - - unstackFormElements(form) {{ - const inputGroups = form.querySelectorAll('.input-group'); - inputGroups.forEach(group => {{ - group.classList.remove('input-group-stacked'); - }}); - - const formRows = form.querySelectorAll('.row'); - formRows.forEach(row => {{ - row.classList.remove('row-stacked'); - }}); - }} - - adjustTableLayout() {{ - const tables = document.querySelectorAll('table'); - - tables.forEach(table => {{ - if (['xs', 'sm'].includes(this.currentBreakpoint)) {{ - this.makeTableResponsive(table); - }} else {{ - this.restoreTableLayout(table); - }} - }}); - }} - - makeTableResponsive(table) {{ - if (!table.closest('.table-responsive')) {{ - const wrapper = document.createElement('div'); - wrapper.className = 'table-responsive'; - table.parentNode.insertBefore(wrapper, table); - wrapper.appendChild(table); - }} - - table.classList.add('table-mobile'); - }} - - restoreTableLayout(table) {{ - table.classList.remove('table-mobile'); - }} - - adjustModalLayout() {{ - const modals = document.querySelectorAll('.modal'); - - modals.forEach(modal => {{ - const dialog = modal.querySelector('.modal-dialog'); - if (!dialog) return; - - if (['xs', 'sm'].includes(this.currentBreakpoint)) {{ - dialog.classList.add('modal-dialog-mobile'); - }} else {{ - dialog.classList.remove('modal-dialog-mobile'); - }} - }}); - }} - - adjustImageLayout() {{ - const images = document.querySelectorAll('img'); - - images.forEach(img => {{ - if (!img.style.maxWidth) {{ - img.style.maxWidth = '100%'; - img.style.height = 'auto'; - }} - }}); - }} - - applyOrientationLayout(orientation) {{ - const body = document.body; - - if (orientation === 'landscape' && ['xs', 'sm'].includes(this.currentBreakpoint)) {{ - // Mobile landscape optimizations - body.classList.add('mobile-landscape'); - this.optimizeForMobileLandscape(); - }} else {{ - body.classList.remove('mobile-landscape'); - }} - - if (orientation === 'portrait' && ['xs', 'sm'].includes(this.currentBreakpoint)) {{ - // Mobile portrait optimizations - body.classList.add('mobile-portrait'); - this.optimizeForMobilePortrait(); - }} else {{ - body.classList.remove('mobile-portrait'); - }} - }} - - optimizeForMobileLandscape() {{ - // Reduce header height in landscape - const header = document.querySelector('header, .navbar'); - if (header) {{ - header.classList.add('header-compact'); - }} - - // Optimize form layouts for landscape - const forms = document.querySelectorAll('form'); - forms.forEach(form => {{ - form.classList.add('form-landscape'); - }}); - }} - - optimizeForMobilePortrait() {{ - // Restore header height - const header = document.querySelector('header, .navbar'); - if (header) {{ - header.classList.remove('header-compact'); - }} - - // Restore form layouts - const forms = document.querySelectorAll('form'); - forms.forEach(form => {{ - form.classList.remove('form-landscape'); - }}); - }} - - applyBreakpointLayout(breakpoint) {{ - // Apply breakpoint-specific CSS classes - document.body.className = document.body.className.replace(/breakpoint-\\w+/g, ''); - document.body.classList.add(`breakpoint-${{breakpoint}}`); - - // Adjust layouts based on breakpoint - this.applyResponsiveLayout(); - }} - - setupCustomBreakpoints() {{ - // Add CSS media query breakpoints - const style = document.createElement('style'); - style.textContent = this.generateBreakpointCSS(); - document.head.appendChild(style); - }} - - generateBreakpointCSS() {{ - let css = ''; - - Object.entries(this.screenSizes).forEach(([breakpoint, config]) => {{ - const minWidth = config.min; - const maxWidth = config.max === Infinity ? '' : ` and (max-width: ${{config.max}}px)`; - - css += ` - @media (min-width: ${{minWidth}}px)${{maxWidth}} {{ - .show-${{breakpoint}} {{ display: block !important; }} - .hide-${{breakpoint}} {{ display: none !important; }} - }} - `; - }}); - - return css; - }} - - setupDynamicViewport() {{ - // Handle dynamic viewport units - this.updateDynamicViewportUnits(); - - // Update on resize - window.addEventListener('resize', () => {{ - this.updateDynamicViewportUnits(); - }}); - }} - - updateDynamicViewportUnits() {{ - const vw = this.viewportDimensions.width / 100; - const vh = this.viewportDimensions.height / 100; - const vmin = Math.min(vw, vh); - const vmax = Math.max(vw, vh); - - const root = document.documentElement; - root.style.setProperty('--1vw', `${{vw}}px`); - root.style.setProperty('--1vh', `${{vh}}px`); - root.style.setProperty('--1vmin', `${{vmin}}px`); - root.style.setProperty('--1vmax', `${{vmax}}px`); - }} - - // Event handler registration - onOrientationChange(handler) {{ - this.orientationChangeHandlers.push(handler); - }} - - onBreakpointChange(handler) {{ - this.breakpointChangeHandlers.push(handler); - }} - - // Public API methods - getCurrentBreakpoint() {{ - return this.currentBreakpoint; - }} - - getCurrentOrientation() {{ - return this.currentOrientation; - }} - - getViewportDimensions() {{ - return {{ ...this.viewportDimensions }}; - }} - - getScreenDimensions() {{ - return {{ ...this.currentResolution }}; - }} - - getDevicePixelRatio() {{ - return this.devicePixelRatio; - }} - - isBreakpoint(breakpoint) {{ - return this.currentBreakpoint === breakpoint; - }} - - isOrientationPortrait() {{ - return this.currentOrientation === 'portrait'; - }} - - isOrientationLandscape() {{ - return this.currentOrientation === 'landscape'; - }} - - isMobileSize() {{ - return ['xs', 'sm'].includes(this.currentBreakpoint); - }} - - isTabletSize() {{ - return ['md'].includes(this.currentBreakpoint); - }} - - isDesktopSize() {{ - return ['lg', 'xl', 'xxl'].includes(this.currentBreakpoint); - }} - - getOptimalImageSize(baseWidth, baseHeight) {{ - const scaleFactor = this.devicePixelRatio; - const viewportScale = this.viewportDimensions.width / 1920; // Assume 1920 as base - - return {{ - width: Math.round(baseWidth * scaleFactor * viewportScale), - height: Math.round(baseHeight * scaleFactor * viewportScale) - }}; - }} - - refreshLayout() {{ - this.detectScreenProperties(); - this.applyResponsiveLayout(); - }} -}} - -// Initialize multi-screen manager when DOM is loaded -document.addEventListener('DOMContentLoaded', () => {{ - window.multiScreenManager = new MultiScreenManager(); - console.log('Multi-screen manager loaded'); -}}); -""" - - def get_multiscreen_css(self): - """Generate CSS for multi-screen support.""" - return """ -/* Multi-Screen Size Support Styles */ - -/* CSS Custom Properties for Dynamic Values */ -:root { - --viewport-width: 100vw; - --viewport-height: 100vh; - --screen-width: 100vw; - --screen-height: 100vh; - --device-pixel-ratio: 1; - --current-breakpoint: 'lg'; - --responsive-font-size: 16px; - --responsive-spacing: 1rem; - - /* Dynamic viewport units */ - --1vw: 1vw; - --1vh: 1vh; - --1vmin: 1vmin; - --1vmax: 1vmax; - --vh: 1vh; - --full-height: 100vh; - - /* Safe area insets */ - --safe-area-inset-top: env(safe-area-inset-top, 0px); - --safe-area-inset-right: env(safe-area-inset-right, 0px); - --safe-area-inset-bottom: env(safe-area-inset-bottom, 0px); - --safe-area-inset-left: env(safe-area-inset-left, 0px); - - /* Grid system */ - --grid-columns: 4; - --grid-gap: 1rem; - - /* Container max-widths */ - --container-max-width-xs: 540px; - --container-max-width-sm: 720px; - --container-max-width-md: 960px; - --container-max-width-lg: 1140px; - --container-max-width-xl: 1320px; - --container-max-width-xxl: 1536px; -} - -/* Base responsive typography */ -html { - font-size: var(--responsive-font-size); -} - -body { - margin: 0; - padding: 0; - overflow-x: hidden; -} - -/* Dynamic viewport height fix for mobile browsers */ -.full-height { - height: var(--full-height); - min-height: calc(var(--vh, 1vh) * 100); -} - -.min-full-height { - min-height: var(--full-height); - min-height: calc(var(--vh, 1vh) * 100); -} - -/* Safe area support */ -.safe-area-padding { - padding-top: var(--safe-area-inset-top); - padding-right: var(--safe-area-inset-right); - padding-bottom: var(--safe-area-inset-bottom); - padding-left: var(--safe-area-inset-left); -} - -.safe-area-margin { - margin-top: var(--safe-area-inset-top); - margin-right: var(--safe-area-inset-right); - margin-bottom: var(--safe-area-inset-bottom); - margin-left: var(--safe-area-inset-left); -} - -/* Responsive containers */ -.container-responsive { - width: 100%; - padding-left: var(--responsive-spacing); - padding-right: var(--responsive-spacing); - margin: 0 auto; -} - -.xs-device .container-responsive { max-width: var(--container-max-width-xs); } -.sm-device .container-responsive { max-width: var(--container-max-width-sm); } -.md-device .container-responsive { max-width: var(--container-max-width-md); } -.lg-device .container-responsive { max-width: var(--container-max-width-lg); } -.xl-device .container-responsive { max-width: var(--container-max-width-xl); } -.xxl-device .container-responsive { max-width: var(--container-max-width-xxl); } - -/* Responsive grid system */ -.responsive-grid { - display: grid; - grid-template-columns: repeat(var(--grid-columns), 1fr); - gap: var(--grid-gap); -} - -.xs-device .responsive-grid { --grid-columns: 1; --grid-gap: 0.5rem; } -.sm-device .responsive-grid { --grid-columns: 2; --grid-gap: 0.75rem; } -.md-device .responsive-grid { --grid-columns: 3; --grid-gap: 1rem; } -.lg-device .responsive-grid { --grid-columns: 4; --grid-gap: 1.25rem; } -.xl-device .responsive-grid { --grid-columns: 5; --grid-gap: 1.5rem; } -.xxl-device .responsive-grid { --grid-columns: 6; --grid-gap: 1.5rem; } - -/* Device-specific styles */ -.mobile-device { - --responsive-spacing: 0.75rem; - --grid-gap: 0.5rem; -} - -.tablet-device { - --responsive-spacing: 1rem; - --grid-gap: 1rem; -} - -.desktop-device { - --responsive-spacing: 1.25rem; - --grid-gap: 1.25rem; -} - -/* Orientation-specific styles */ -.portrait-orientation .responsive-grid { - grid-template-columns: repeat(min(var(--grid-columns), 2), 1fr); -} - -.landscape-orientation .mobile-landscape { - --responsive-spacing: 0.5rem; -} - -.mobile-landscape .navbar, -.mobile-landscape .header { - min-height: 48px; -} - -.mobile-landscape .header-compact { - padding: 0.25rem 0; -} - -.mobile-portrait .form-landscape { - display: flex; - flex-direction: column; -} - -/* Aspect ratio specific styles */ -.aspect-ultrawide { - --grid-columns: 6; -} - -.aspect-wide { - --grid-columns: 5; -} - -.aspect-normal { - --grid-columns: 4; -} - -.aspect-square { - --grid-columns: 3; -} - -.aspect-tall { - --grid-columns: 2; -} - -/* High DPI optimizations */ -.high-dpi { - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -.high-dpi img { - image-rendering: -webkit-optimize-contrast; - image-rendering: crisp-edges; -} - -/* Breakpoint-specific visibility utilities */ -.xs-device .hide-xs, -.sm-device .hide-sm, -.md-device .hide-md, -.lg-device .hide-lg, -.xl-device .hide-xl, -.xxl-device .hide-xxl { - display: none !important; -} - -.show-xs, -.show-sm, -.show-md, -.show-lg, -.show-xl, -.show-xxl { - display: none !important; -} - -.xs-device .show-xs, -.sm-device .show-sm, -.md-device .show-md, -.lg-device .show-lg, -.xl-device .show-xl, -.xxl-device .show-xxl { - display: block !important; -} - -/* Navigation responsive styles */ -.navbar-mobile { - flex-direction: column; - align-items: stretch; -} - -.navbar-toggler { - display: block; - border: none; - background: transparent; - padding: 0.5rem; - cursor: pointer; -} - -.navbar-toggler-icon { - display: block; - width: 24px; - height: 18px; - position: relative; -} - -.navbar-toggler-icon span { - display: block; - height: 2px; - width: 100%; - background: currentColor; - margin-bottom: 4px; - transition: transform 0.3s ease; -} - -.navbar-toggler[aria-expanded="true"] .navbar-toggler-icon span:nth-child(1) { - transform: rotate(45deg) translate(5px, 5px); -} - -.navbar-toggler[aria-expanded="true"] .navbar-toggler-icon span:nth-child(2) { - opacity: 0; -} - -.navbar-toggler[aria-expanded="true"] .navbar-toggler-icon span:nth-child(3) { - transform: rotate(-45deg) translate(7px, -6px); -} - -/* Form responsive styles */ -.form-mobile .form-control, -.form-mobile .form-select { - font-size: 16px; /* Prevent zoom on iOS */ - min-height: 44px; /* Touch-friendly */ -} - -.form-mobile .input-group-stacked { - flex-direction: column; -} - -.form-mobile .input-group-stacked > * { - width: 100% !important; - border-radius: 0.375rem !important; - margin-bottom: 0.5rem; -} - -.form-mobile .row-stacked { - flex-direction: column; -} - -.form-mobile .row-stacked > * { - width: 100% !important; - margin-bottom: 1rem; -} - -.form-landscape { - max-height: 60vh; - overflow-y: auto; -} - -/* Card responsive styles */ -.card-mobile { - margin-bottom: 1rem; -} - -.card-mobile .card-body { - padding: 1rem 0.75rem; -} - -.card-mobile .card-title { - font-size: 1rem; -} - -.card-mobile .card-text { - font-size: 0.875rem; -} - -/* Table responsive styles */ -.table-mobile { - font-size: 0.875rem; -} - -.table-mobile th, -.table-mobile td { - padding: 0.5rem 0.25rem; -} - -.table-responsive { - overflow-x: auto; - -webkit-overflow-scrolling: touch; -} - -/* Modal responsive styles */ -.modal-dialog-mobile { - margin: 0.5rem; - width: auto; - max-width: none; -} - -.modal-dialog-mobile .modal-content { - border-radius: 0.5rem; -} - -.xs-device .modal-dialog-mobile { - margin: 0.25rem; - height: calc(100vh - 0.5rem); -} - -.xs-device .modal-dialog-mobile .modal-content { - height: 100%; - display: flex; - flex-direction: column; -} - -.xs-device .modal-dialog-mobile .modal-body { - flex: 1; - overflow-y: auto; -} - -/* Image responsive optimizations */ -img { - max-width: 100%; - height: auto; -} - -.img-responsive { - width: 100%; - height: auto; - object-fit: cover; -} - -/* High resolution image support */ -@media (min-resolution: 192dpi), (-webkit-min-device-pixel-ratio: 2) { - .img-hidpi { - image-rendering: -webkit-optimize-contrast; - } -} - -/* Ultra-wide screen optimizations */ -@media (min-aspect-ratio: 21/9) { - .container-responsive { - max-width: 1600px; - } - - .responsive-grid { - --grid-columns: 6; - } - - .navbar { - padding-left: 2rem; - padding-right: 2rem; - } -} - -/* Very tall screen optimizations */ -@media (max-aspect-ratio: 9/16) { - .responsive-grid { - --grid-columns: 1; - } - - .card-mobile .card-body { - padding: 0.75rem; - } -} - -/* Small landscape phones */ -@media (max-height: 500px) and (orientation: landscape) { - .navbar { - min-height: 40px; - } - - .modal-dialog { - margin: 0.25rem; - } - - .form-control { - padding: 0.375rem 0.5rem; - } -} - -/* Large desktop screens */ -@media (min-width: 1600px) { - :root { - --responsive-font-size: 18px; - --responsive-spacing: 1.5rem; - } - - .container-responsive { - padding-left: 2rem; - padding-right: 2rem; - } -} - -/* 4K and higher resolution screens */ -@media (min-width: 2560px) { - :root { - --responsive-font-size: 20px; - --responsive-spacing: 2rem; - } - - .responsive-grid { - --grid-columns: 8; - --grid-gap: 2rem; - } -} - -/* Print styles */ -@media print { - .container-responsive { - max-width: none; - width: 100%; - } - - .responsive-grid { - --grid-columns: 2; - --grid-gap: 0.5rem; - } - - .hide-print { - display: none !important; - } -} - -/* Reduced motion preferences */ -@media (prefers-reduced-motion: reduce) { - .navbar-toggler-icon span { - transition: none; - } - - .modal-dialog { - animation: none; - } -} - -/* Force colors support */ -@media (forced-colors: active) { - .navbar-toggler-icon span { - background: ButtonText; - } - - .card-mobile { - border: 1px solid CanvasText; - } -} - -/* Custom breakpoint utilities */ -@container (min-width: 320px) { - .container-xs { display: block; } -} - -@container (min-width: 576px) { - .container-sm { display: block; } -} - -@container (min-width: 768px) { - .container-md { display: block; } -} - -@container (min-width: 992px) { - .container-lg { display: block; } -} - -@container (min-width: 1200px) { - .container-xl { display: block; } -} - -@container (min-width: 1400px) { - .container-xxl { display: block; } -} - -/* Experimental: CSS Container Queries support */ -@supports (container-type: inline-size) { - .responsive-container { - container-type: inline-size; - } - - @container (max-width: 500px) { - .responsive-container .card { - flex-direction: column; - } - } -} -""" - - -# Export the multi-screen manager -multi_screen_manager = MultiScreenManager() \ No newline at end of file diff --git a/src/server/web/middleware/screen_reader_middleware.py b/src/server/web/middleware/screen_reader_middleware.py deleted file mode 100644 index 179dbb6..0000000 --- a/src/server/web/middleware/screen_reader_middleware.py +++ /dev/null @@ -1,1667 +0,0 @@ -""" -Screen Reader Support System - -This module provides comprehensive screen reader support with semantic HTML, -ARIA labels, live regions, and dynamic content announcements. -""" - -from typing import Dict, List, Any, Optional -from flask import Blueprint, request, jsonify - -class ScreenReaderManager: - """Manages screen reader support and semantic content.""" - - def __init__(self, app=None): - self.app = app - self.announcement_queue = [] - self.live_regions = {} - - def init_app(self, app): - """Initialize with Flask app.""" - self.app = app - - def get_screen_reader_js(self): - """Generate JavaScript code for screen reader support.""" - return f""" -// AniWorld Screen Reader Manager -class ScreenReaderManager {{ - constructor() {{ - this.isScreenReaderActive = false; - this.announcements = []; - this.liveRegions = {{}}; - this.contentCache = new Map(); - this.pendingAnnouncements = []; - - // Screen reader specific settings - this.announcementDelay = 100; - this.maxAnnouncementLength = 150; - this.verbosityLevel = 'normal'; // 'minimal', 'normal', 'verbose' - - this.init(); - }} - - init() {{ - this.detectScreenReader(); - this.createLiveRegions(); - this.setupContentEnhancement(); - this.setupDynamicAnnouncements(); - this.setupFormSupport(); - this.setupNavigationSupport(); - this.setupProgressSupport(); - this.setupErrorHandling(); - - console.log('Screen reader manager initialized'); - }} - - detectScreenReader() {{ - // Multiple detection methods for better accuracy - const indicators = [ - // User agent detection - navigator.userAgent.includes('NVDA'), - navigator.userAgent.includes('JAWS'), - navigator.userAgent.includes('Dragon'), - navigator.userAgent.includes('ZoomText'), - - // Feature detection - !!window.speechSynthesis, - !!document.querySelector('[aria-live]'), - - // Behavior detection - this.detectScreenReaderBehavior(), - - // CSS media query - window.matchMedia('(prefers-reduced-motion: reduce)').matches, - window.matchMedia('(inverted-colors: inverted)').matches - ]; - - this.isScreenReaderActive = indicators.some(indicator => indicator); - - if (this.isScreenReaderActive) {{ - document.body.classList.add('screen-reader-active'); - this.enableScreenReaderMode(); - }} - - return this.isScreenReaderActive; - }} - - detectScreenReaderBehavior() {{ - // Test for screen reader specific behaviors - try {{ - const testElement = document.createElement('div'); - testElement.setAttribute('aria-hidden', 'true'); - testElement.style.cssText = 'position: absolute; left: -10000px;'; - testElement.textContent = 'Screen reader test'; - - document.body.appendChild(testElement); - - // Check if element affects focus behavior - const originalFocus = document.activeElement; - testElement.focus(); - const focusChanged = document.activeElement === testElement; - - document.body.removeChild(testElement); - - if (originalFocus && originalFocus.focus) {{ - originalFocus.focus(); - }} - - return focusChanged; - }} catch (error) {{ - return false; - }} - }} - - enableScreenReaderMode() {{ - // Enhanced screen reader optimizations - this.verbosityLevel = 'verbose'; - this.announcementDelay = 50; - - // Add screen reader specific styles - const style = document.createElement('style'); - style.textContent = ` - .sr-enhanced {{ - clip: auto !important; - width: auto !important; - height: auto !important; - overflow: visible !important; - position: static !important; - }} - - .sr-verbose .sr-context {{ - position: static; - clip: auto; - width: auto; - height: auto; - }} - `; - document.head.appendChild(style); - - document.body.classList.add('sr-enhanced'); - - // Announce screen reader mode activation - this.announce('Screen reader support activated', 'assertive'); - }} - - createLiveRegions() {{ - // Create comprehensive live regions - this.liveRegions = {{ - polite: this.createLiveRegion('polite'), - assertive: this.createLiveRegion('assertive'), - status: this.createLiveRegion('status'), - log: this.createLiveRegion('log'), - alert: this.createLiveRegion('alert') - }}; - - // Special regions for specific content - this.liveRegions.navigation = this.createLiveRegion('polite', 'navigation-announcements'); - this.liveRegions.progress = this.createLiveRegion('status', 'progress-announcements'); - this.liveRegions.errors = this.createLiveRegion('assertive', 'error-announcements'); - }} - - createLiveRegion(type, id = null) {{ - const region = document.createElement('div'); - region.id = id || `aria-live-${{type}}`; - - if (type === 'status') {{ - region.setAttribute('role', 'status'); - }} else if (type === 'alert') {{ - region.setAttribute('role', 'alert'); - }} else if (type === 'log') {{ - region.setAttribute('role', 'log'); - }} else {{ - region.setAttribute('aria-live', type); - }} - - region.setAttribute('aria-atomic', 'true'); - region.setAttribute('aria-relevant', 'additions text'); - - // Screen reader only styling - region.style.cssText = ` - position: absolute; - left: -10000px; - width: 1px; - height: 1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; - `; - - document.body.appendChild(region); - return region; - }} - - announce(message, priority = 'polite', context = '') {{ - if (!message || typeof message !== 'string') return; - - // Clean and optimize message - const cleanMessage = this.cleanMessage(message, context); - if (!cleanMessage) return; - - // Queue announcement to prevent spam - this.queueAnnouncement(cleanMessage, priority); - }} - - cleanMessage(message, context = '') {{ - // Remove HTML tags - let clean = message.replace(/<[^>]*>/g, ''); - - // Remove excessive whitespace - clean = clean.replace(/\\s+/g, ' ').trim(); - - // Add context if provided - if (context) {{ - clean = `${{context}}: ${{clean}}`; - }} - - // Truncate if too long - if (clean.length > this.maxAnnouncementLength) {{ - clean = clean.substring(0, this.maxAnnouncementLength - 3) + '...'; - }} - - // Skip empty or duplicate messages - if (!clean || this.announcements.slice(-3).includes(clean)) {{ - return null; - }} - - return clean; - }} - - queueAnnouncement(message, priority) {{ - this.pendingAnnouncements.push({{ message, priority, timestamp: Date.now() }}); - - // Process queue with delay to prevent overwhelming - setTimeout(() => {{ - this.processAnnouncementQueue(); - }}, this.announcementDelay); - }} - - processAnnouncementQueue() {{ - if (this.pendingAnnouncements.length === 0) return; - - // Get next announcement - const announcement = this.pendingAnnouncements.shift(); - const region = this.liveRegions[announcement.priority] || this.liveRegions.polite; - - // Clear and set new message - region.textContent = ''; - - setTimeout(() => {{ - region.textContent = announcement.message; - - // Track announcement - this.announcements.push({{ - ...announcement, - delivered: Date.now() - }}); - - // Limit announcement history - if (this.announcements.length > 50) {{ - this.announcements = this.announcements.slice(-25); - }} - }}, 50); - }} - - enhanceInteractiveElements() {{ - const elements = document.querySelectorAll(` - button, [role="button"], a, input, select, textarea, - [tabindex], .btn, .series-card, .dropdown-toggle - `); - - elements.forEach(element => {{ - this.enhanceElement(element); - }}); - }} - - addSemanticInformation() {{ - // Add missing semantic HTML and ARIA landmarks - this.addLandmarks(); - this.addHeadingHierarchy(); - this.addListSemantics(); - this.addTableSemantics(); - }} - - addContextInformation() {{ - // Add contextual information to elements for screen readers - this.addBreadcrumbContext(); - this.addCardContext(); - this.addButtonContext(); - this.addLinkContext(); - this.addDataContext(); - }} - - enhanceFormElements() {{ - // Enhance form elements with proper labels and descriptions - const forms = document.querySelectorAll('form'); - - forms.forEach(form => {{ - // Add form description if missing - if (!form.hasAttribute('aria-label') && !form.hasAttribute('aria-labelledby')) {{ - const legend = form.querySelector('legend'); - const heading = form.querySelector('h1, h2, h3, h4, h5, h6'); - if (legend) {{ - form.setAttribute('aria-labelledby', legend.id || this.generateId(legend)); - }} else if (heading) {{ - form.setAttribute('aria-labelledby', heading.id || this.generateId(heading)); - }} else {{ - form.setAttribute('aria-label', 'Form'); - }} - }} - - // Enhance input elements - const inputs = form.querySelectorAll('input:not([type="hidden"]), textarea, select'); - inputs.forEach(input => {{ - // Ensure each input has a label - const label = form.querySelector(`label[for="${{input.id}}"]`) || - input.closest('label'); - if (!label && !input.hasAttribute('aria-label') && !input.hasAttribute('aria-labelledby')) {{ - const placeholder = input.getAttribute('placeholder'); - if (placeholder) {{ - input.setAttribute('aria-label', placeholder); - }} - }} - - // Add required indicators - if (input.hasAttribute('required') && !input.hasAttribute('aria-required')) {{ - input.setAttribute('aria-required', 'true'); - }} - - // Add invalid state for validation - if (input.validity && !input.validity.valid && !input.hasAttribute('aria-invalid')) {{ - input.setAttribute('aria-invalid', 'true'); - }} - }}); - }}); - }} - - observeContentChanges() {{ - // Monitor DOM changes for dynamic content - const observer = new MutationObserver((mutations) => {{ - mutations.forEach(mutation => {{ - if (mutation.type === 'childList') {{ - mutation.addedNodes.forEach(node => {{ - if (node.nodeType === Node.ELEMENT_NODE) {{ - this.handleNewContent(node); - }} - }}); - }} - }}); - }}); - - observer.observe(document.body, {{ - childList: true, - subtree: true - }}); - }} - - setupContentEnhancement() {{ - // Enhance all interactive elements - this.enhanceInteractiveElements(); - - // Add semantic information - this.addSemanticInformation(); - - // Enhance form elements - this.enhanceFormElements(); - - // Add context information - this.addContextInformation(); - - // Monitor for new content - this.observeContentChanges(); - }} - - enhanceElement(element) {{ - // Ensure proper labeling - if (!this.hasAccessibleName(element)) {{ - const label = this.generateAccessibleName(element); - if (label) {{ - element.setAttribute('aria-label', label); - }} - }} - - // Add descriptions where helpful - this.addElementDescription(element); - - // Ensure proper roles - this.ensureProperRole(element); - - // Add state information - this.addStateInformation(element); - - // Add keyboard support - this.addKeyboardSupport(element); - }} - - hasAccessibleName(element) {{ - return element.hasAttribute('aria-label') || - element.hasAttribute('aria-labelledby') || - element.hasAttribute('title') || - (element.textContent && element.textContent.trim()) || - (element.querySelector('img') && element.querySelector('img').alt); - }} - - generateAccessibleName(element) {{ - // Try various strategies to generate a meaningful name - - // Check for nearby labels - const labelElement = document.querySelector(`label[for="${{element.id}}"]`); - if (labelElement) {{ - return labelElement.textContent.trim(); - }} - - // Check for parent headings - const heading = element.closest('section, article, div') - ?.querySelector('h1, h2, h3, h4, h5, h6'); - if (heading) {{ - return heading.textContent.trim(); - }} - - // Check for image alt text - const img = element.querySelector('img'); - if (img && img.alt) {{ - return img.alt; - }} - - // Check for data attributes - if (element.dataset.title) {{ - return element.dataset.title; - }} - - // Use class names as last resort - const meaningfulClass = Array.from(element.classList) - .find(cls => /^(download|play|pause|close|menu|search|filter)/i.test(cls)); - - if (meaningfulClass) {{ - return meaningfulClass.replace(/[_-]/g, ' ').toLowerCase(); - }} - - return null; - }} - - addElementDescription(element) {{ - // Add helpful descriptions for complex elements - if (element.classList.contains('series-card')) {{ - const description = this.generateSeriesDescription(element); - if (description) {{ - element.setAttribute('aria-describedby', - this.createDescriptionElement(description).id); - }} - }} - - // Add download progress descriptions - if (element.classList.contains('download-item')) {{ - const description = this.generateDownloadDescription(element); - if (description) {{ - element.setAttribute('aria-describedby', - this.createDescriptionElement(description).id); - }} - }} - }} - - generateSeriesDescription(card) {{ - const title = card.querySelector('.series-title')?.textContent?.trim(); - const genre = card.querySelector('.series-genre')?.textContent?.trim(); - const year = card.querySelector('.series-year')?.textContent?.trim(); - const rating = card.querySelector('.series-rating')?.textContent?.trim(); - const status = card.querySelector('.series-status')?.textContent?.trim(); - - let description = ''; - - if (title) description += `Series: ${{title}}. `; - if (genre) description += `Genre: ${{genre}}. `; - if (year) description += `Year: ${{year}}. `; - if (rating) description += `Rating: ${{rating}}. `; - if (status) description += `Status: ${{status}}. `; - - return description.trim(); - }} - - generateDownloadDescription(item) {{ - const filename = item.querySelector('.download-filename')?.textContent?.trim(); - const progress = item.querySelector('.progress-bar')?.style.width; - const status = item.querySelector('.download-status')?.textContent?.trim(); - const speed = item.querySelector('.download-speed')?.textContent?.trim(); - - let description = ''; - - if (filename) description += `Downloading: ${{filename}}. `; - if (progress) description += `Progress: ${{progress}}. `; - if (status) description += `Status: ${{status}}. `; - if (speed) description += `Speed: ${{speed}}. `; - - return description.trim(); - }} - - createDescriptionElement(text) {{ - const id = `desc-${{Date.now()}}-${{Math.random().toString(36).substr(2, 9)}}`; - const element = document.createElement('div'); - element.id = id; - element.className = 'sr-only'; - element.textContent = text; - document.body.appendChild(element); - return element; - }} - - ensureProperRole(element) {{ - // Ensure elements have appropriate ARIA roles - if (element.classList.contains('btn') && !element.hasAttribute('role')) {{ - element.setAttribute('role', 'button'); - }} - - if (element.classList.contains('series-card') && !element.hasAttribute('role')) {{ - element.setAttribute('role', 'article'); - }} - - if (element.classList.contains('dropdown-toggle') && !element.hasAttribute('role')) {{ - element.setAttribute('role', 'button'); - element.setAttribute('aria-haspopup', 'true'); - element.setAttribute('aria-expanded', 'false'); - }} - }} - - addStateInformation(element) {{ - // Add current state information for dynamic elements - if (element.classList.contains('active')) {{ - element.setAttribute('aria-current', 'true'); - }} - - if (element.classList.contains('disabled') || element.disabled) {{ - element.setAttribute('aria-disabled', 'true'); - }} - - if (element.classList.contains('loading')) {{ - element.setAttribute('aria-busy', 'true'); - }} - }} - - addKeyboardSupport(element) {{ - // Ensure non-native elements are keyboard accessible - if (!this.isNativelyFocusable(element) && - element.classList.contains('interactive')) {{ - element.setAttribute('tabindex', '0'); - - element.addEventListener('keydown', (e) => {{ - if (e.key === 'Enter' || e.key === ' ') {{ - e.preventDefault(); - element.click(); - }} - }}); - }} - }} - - isNativelyFocusable(element) {{ - const focusableElements = ['A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA']; - return focusableElements.includes(element.tagName) && !element.disabled; - }} - - addBreadcrumbContext() {{ - // Enhance breadcrumb navigation - const breadcrumbs = document.querySelectorAll('.breadcrumb, [aria-label*="breadcrumb"]'); - breadcrumbs.forEach(breadcrumb => {{ - if (!breadcrumb.hasAttribute('aria-label')) {{ - breadcrumb.setAttribute('aria-label', 'Navigation breadcrumb'); - }} - - const items = breadcrumb.querySelectorAll('li, .breadcrumb-item'); - items.forEach((item, index) => {{ - if (!item.hasAttribute('aria-current') && index === items.length - 1) {{ - item.setAttribute('aria-current', 'page'); - }} - }}); - }}); - }} - - addCardContext() {{ - // Enhance cards with context information - const cards = document.querySelectorAll('.card, .series-card, .episode-card'); - cards.forEach(card => {{ - const title = card.querySelector('h1, h2, h3, h4, h5, h6, .title, .card-title'); - const description = card.querySelector('.description, .card-text, .summary'); - - if (title && !card.hasAttribute('aria-labelledby')) {{ - if (!title.id) {{ - title.id = this.generateId('card-title'); - }} - card.setAttribute('aria-labelledby', title.id); - }} - - if (description && !card.hasAttribute('aria-describedby')) {{ - if (!description.id) {{ - description.id = this.generateId('card-desc'); - }} - card.setAttribute('aria-describedby', description.id); - }} - - if (!card.hasAttribute('role')) {{ - card.setAttribute('role', 'article'); - }} - }}); - }} - - addButtonContext() {{ - // Enhance buttons with better context - const buttons = document.querySelectorAll('button, [role="button"]'); - buttons.forEach(button => {{ - // Add context for icon-only buttons - if (!button.textContent.trim() && !button.hasAttribute('aria-label')) {{ - const icon = button.querySelector('i, .icon, svg'); - if (icon) {{ - const iconClass = icon.className; - if (iconClass.includes('play')) {{ - button.setAttribute('aria-label', 'Play'); - }} else if (iconClass.includes('pause')) {{ - button.setAttribute('aria-label', 'Pause'); - }} else if (iconClass.includes('download')) {{ - button.setAttribute('aria-label', 'Download'); - }} else if (iconClass.includes('favorite') || iconClass.includes('heart')) {{ - button.setAttribute('aria-label', 'Add to favorites'); - }} else if (iconClass.includes('share')) {{ - button.setAttribute('aria-label', 'Share'); - }} else {{ - button.setAttribute('aria-label', 'Button'); - }} - }} - }} - - // Add pressed state for toggle buttons - if (button.hasAttribute('data-toggle')) {{ - button.setAttribute('aria-pressed', 'false'); - }} - }}); - }} - - addLinkContext() {{ - // Enhance links with better context - const links = document.querySelectorAll('a'); - links.forEach(link => {{ - // Add context for external links - if (link.hostname && link.hostname !== location.hostname) {{ - const existingLabel = link.getAttribute('aria-label') || ''; - if (!existingLabel.includes('external')) {{ - link.setAttribute('aria-label', - (existingLabel + ' (opens in new window)').trim()); - }} - link.setAttribute('rel', 'noopener'); - }} - - // Add context for download links - if (link.download || link.href.match(/\.(pdf|doc|docx|xls|xlsx|zip|rar)$/i)) {{ - const existingLabel = link.getAttribute('aria-label') || link.textContent; - link.setAttribute('aria-label', - (existingLabel + ' (download)').trim()); - }} - }}); - }} - - addDataContext() {{ - // Add context to data tables and lists - const tables = document.querySelectorAll('table'); - tables.forEach(table => {{ - if (!table.hasAttribute('aria-label') && !table.hasAttribute('aria-labelledby')) {{ - const caption = table.querySelector('caption'); - if (caption) {{ - if (!caption.id) {{ - caption.id = this.generateId('table-caption'); - }} - table.setAttribute('aria-labelledby', caption.id); - }} else {{ - table.setAttribute('aria-label', 'Data table'); - }} - }} - }}); - - // Add context to definition lists - const definitionLists = document.querySelectorAll('dl'); - definitionLists.forEach(dl => {{ - if (!dl.hasAttribute('role')) {{ - dl.setAttribute('role', 'list'); - }} - - const terms = dl.querySelectorAll('dt'); - const descriptions = dl.querySelectorAll('dd'); - - terms.forEach((dt, index) => {{ - if (!dt.id) {{ - dt.id = this.generateId('term'); - }} - - const dd = descriptions[index]; - if (dd && !dd.hasAttribute('aria-labelledby')) {{ - dd.setAttribute('aria-labelledby', dt.id); - }} - }}); - }}); - }} - - addLandmarks() {{ - // Main content - const main = document.querySelector('main') || - document.querySelector('#main-content, .main-content'); - if (main && !main.hasAttribute('role')) {{ - main.setAttribute('role', 'main'); - main.setAttribute('aria-label', 'Main content'); - }} - - // Navigation - const nav = document.querySelector('nav, .navbar'); - if (nav && !nav.hasAttribute('role')) {{ - nav.setAttribute('role', 'navigation'); - nav.setAttribute('aria-label', 'Main navigation'); - }} - - // Search - const search = document.querySelector('.search-container, form[role="search"]'); - if (search && !search.hasAttribute('role')) {{ - search.setAttribute('role', 'search'); - search.setAttribute('aria-label', 'Search series'); - }} - - // Complementary content - const aside = document.querySelector('aside, .sidebar'); - if (aside && !aside.hasAttribute('role')) {{ - aside.setAttribute('role', 'complementary'); - aside.setAttribute('aria-label', 'Additional information'); - }} - - // Footer - const footer = document.querySelector('footer'); - if (footer && !footer.hasAttribute('role')) {{ - footer.setAttribute('role', 'contentinfo'); - }} - }} - - addHeadingHierarchy() {{ - // Ensure proper heading hierarchy - const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6'); - let expectedLevel = 1; - - headings.forEach(heading => {{ - const currentLevel = parseInt(heading.tagName.substring(1)); - - if (currentLevel > expectedLevel + 1) {{ - // Heading level jump detected - this.announce( - `Heading level skipped from ${{expectedLevel}} to ${{currentLevel}}`, - 'polite' - ); - }} - - expectedLevel = Math.max(expectedLevel, currentLevel); - }}); - }} - - addListSemantics() {{ - // Enhance lists with proper ARIA - const lists = document.querySelectorAll('.series-grid, .download-list'); - - lists.forEach(list => {{ - if (!list.hasAttribute('role')) {{ - list.setAttribute('role', 'list'); - - const items = list.children; - Array.from(items).forEach((item, index) => {{ - if (!item.hasAttribute('role')) {{ - item.setAttribute('role', 'listitem'); - item.setAttribute('aria-setsize', items.length); - item.setAttribute('aria-posinset', index + 1); - }} - }}); - }} - }}); - }} - - addTableSemantics() {{ - // Enhance tables with proper headers and descriptions - const tables = document.querySelectorAll('table'); - - tables.forEach(table => {{ - // Add table caption if missing - if (!table.querySelector('caption')) {{ - const caption = document.createElement('caption'); - caption.className = 'sr-only'; - caption.textContent = 'Data table'; - table.insertBefore(caption, table.firstChild); - }} - - // Ensure headers have scope attributes - const headers = table.querySelectorAll('th'); - headers.forEach(header => {{ - if (!header.hasAttribute('scope')) {{ - // Determine scope based on position - const isColumnHeader = header.parentElement === table.querySelector('thead tr'); - header.setAttribute('scope', isColumnHeader ? 'col' : 'row'); - }} - }}); - }}); - }} - - handleNewContent(element) {{ - // Enhance newly added content - if (element.classList?.contains('series-card')) {{ - this.enhanceElement(element); - this.announceNewSeries(element); - }} - - if (element.classList?.contains('alert')) {{ - this.announceAlert(element); - }} - - if (element.classList?.contains('modal')) {{ - this.announceModal(element); - }} - - // Enhance all interactive children - const interactiveElements = element.querySelectorAll?.( - 'button, [role="button"], a, input, select, textarea, [tabindex]' - ); - interactiveElements?.forEach(el => this.enhanceElement(el)); - }} - - announceNewSeries(element) {{ - const title = element.querySelector('.series-title')?.textContent?.trim(); - if (title) {{ - this.announce(`New series added: ${{title}}`, 'polite'); - }} - }} - - announceAlert(element) {{ - const message = element.textContent.trim(); - const type = this.getAlertType(element); - - this.announce(`${{type}}: ${{message}}`, 'assertive'); - }} - - getAlertType(element) {{ - if (element.classList.contains('alert-success')) return 'Success'; - if (element.classList.contains('alert-error')) return 'Error'; - if (element.classList.contains('alert-warning')) return 'Warning'; - if (element.classList.contains('alert-info')) return 'Information'; - return 'Alert'; - }} - - announceModal(element) {{ - const title = element.querySelector('.modal-title')?.textContent?.trim(); - if (title) {{ - this.announce(`Dialog opened: ${{title}}`, 'assertive'); - }} - }} - - setupDynamicAnnouncements() {{ - // Set up announcements for dynamic content changes - this.setupProgressAnnouncements(); - this.setupNavigationAnnouncements(); - this.setupLoadingAnnouncements(); - this.setupErrorAnnouncements(); - }} - - setupProgressAnnouncements() {{ - // Announce progress changes - document.addEventListener('progress-update', (e) => {{ - const {{ percentage, filename }} = e.detail; - this.announce( - `${{filename}} download progress: ${{percentage}}%`, - 'status' - ); - }}); - }} - - setupNavigationAnnouncements() {{ - // Announce page changes - let currentPath = window.location.pathname; - - const observer = new MutationObserver(() => {{ - if (window.location.pathname !== currentPath) {{ - currentPath = window.location.pathname; - this.announcePageChange(); - }} - }}); - - observer.observe(document, {{ subtree: true, childList: true }}); - }} - - announcePageChange() {{ - const title = document.title || 'New page'; - this.announce(`Navigated to: ${{title}}`, 'assertive'); - }} - - setupLoadingAnnouncements() {{ - // Announce loading states - document.addEventListener('loading-start', (e) => {{ - const action = e.detail?.action || 'content'; - this.announce(`Loading ${{action}}...`, 'status'); - }}); - - document.addEventListener('loading-complete', (e) => {{ - const action = e.detail?.action || 'content'; - this.announce(`${{action}} loaded`, 'status'); - }}); - }} - - setupErrorAnnouncements() {{ - // Announce errors - document.addEventListener('error-occurred', (e) => {{ - const message = e.detail?.message || 'An error occurred'; - this.announce(message, 'assertive'); - }}); - - window.addEventListener('error', (e) => {{ - this.announce('Page error occurred', 'assertive'); - }}); - }} - - setupFormSupport() {{ - // Enhanced form support for screen readers - this.enhanceFormLabels(); - this.setupFormValidation(); - this.setupFormNavigation(); - }} - - enhanceFormLabels() {{ - const inputs = document.querySelectorAll('input, select, textarea'); - - inputs.forEach(input => {{ - if (!this.hasAccessibleName(input)) {{ - const label = this.findFormLabel(input); - if (label) {{ - const labelId = label.id || `label-${{Date.now()}}-${{Math.random().toString(36).substr(2, 9)}}`; - label.id = labelId; - input.setAttribute('aria-labelledby', labelId); - }} - }} - - // Add required field announcements - if (input.required) {{ - input.setAttribute('aria-required', 'true'); - const label = this.findFormLabel(input); - if (label && !label.textContent.includes('required')) {{ - label.innerHTML += ' (required)'; - }} - }} - }}); - }} - - findFormLabel(input) {{ - // Find associated label - if (input.id) {{ - const label = document.querySelector(`label[for="${{input.id}}"]`); - if (label) return label; - }} - - // Look for parent label - const parentLabel = input.closest('label'); - if (parentLabel) return parentLabel; - - // Look for sibling label - const siblings = Array.from(input.parentElement.children); - return siblings.find(sibling => sibling.tagName === 'LABEL'); - }} - - setupFormValidation() {{ - document.addEventListener('invalid', (e) => {{ - const input = e.target; - const message = input.validationMessage; - const fieldName = this.getFieldName(input); - - this.announce(`${{fieldName}}: ${{message}}`, 'assertive'); - }}); - - document.addEventListener('input', (e) => {{ - const input = e.target; - if (input.checkValidity && !input.checkValidity()) {{ - input.setAttribute('aria-invalid', 'true'); - }} else {{ - input.removeAttribute('aria-invalid'); - }} - }}); - }} - - getFieldName(input) {{ - const label = this.findFormLabel(input); - if (label) return label.textContent.trim().replace('*', '').replace('(required)', ''); - - return input.name || input.placeholder || 'Field'; - }} - - setupFormNavigation() {{ - // Enhanced form navigation - document.addEventListener('focusin', (e) => {{ - if (e.target.matches('input, select, textarea')) {{ - this.announceFormField(e.target); - }} - }}); - }} - - announceFormField(input) {{ - const fieldName = this.getFieldName(input); - const fieldType = input.type || input.tagName.toLowerCase(); - const isRequired = input.required; - const value = input.value; - - let announcement = `${{fieldName}}, ${{fieldType}}`; - - if (isRequired) {{ - announcement += ', required'; - }} - - if (value && input.type !== 'password') {{ - announcement += `, current value: ${{value}}`; - }} - - this.announce(announcement, 'polite'); - }} - - setupNavigationSupport() {{ - // Enhanced navigation support - this.setupMenuNavigation(); - this.setupBreadcrumbSupport(); - this.setupPaginationSupport(); - }} - - setupMenuNavigation() {{ - document.addEventListener('focusin', (e) => {{ - const menuItem = e.target.closest('.dropdown-menu a, nav a'); - if (menuItem) {{ - this.announceMenuItem(menuItem); - }} - }}); - }} - - announceMenuItem(item) {{ - const text = item.textContent.trim(); - const position = this.getMenuItemPosition(item); - - this.announce(`${{text}}${{position}}`, 'polite'); - }} - - getMenuItemPosition(item) {{ - const menu = item.closest('.dropdown-menu, nav ul'); - if (!menu) return ''; - - const items = Array.from(menu.querySelectorAll('a')); - const index = items.indexOf(item); - const total = items.length; - - return `, item ${{index + 1}} of ${{total}}`; - }} - - setupBreadcrumbSupport() {{ - const breadcrumbs = document.querySelectorAll('.breadcrumb, [aria-label*="breadcrumb"]'); - - breadcrumbs.forEach(breadcrumb => {{ - if (!breadcrumb.hasAttribute('role')) {{ - breadcrumb.setAttribute('role', 'navigation'); - breadcrumb.setAttribute('aria-label', 'Breadcrumb navigation'); - }} - - const items = breadcrumb.querySelectorAll('a, span'); - items.forEach((item, index) => {{ - if (index === items.length - 1) {{ - item.setAttribute('aria-current', 'page'); - }} - }}); - }}); - }} - - setupPaginationSupport() {{ - const pagination = document.querySelectorAll('.pagination'); - - pagination.forEach(pager => {{ - if (!pager.hasAttribute('role')) {{ - pager.setAttribute('role', 'navigation'); - pager.setAttribute('aria-label', 'Pagination navigation'); - }} - - const currentPage = pager.querySelector('.page-item.active .page-link'); - if (currentPage) {{ - currentPage.setAttribute('aria-current', 'page'); - }} - }}); - }} - - setupProgressSupport() {{ - // Enhanced progress indication - const progressBars = document.querySelectorAll('.progress-bar'); - - progressBars.forEach(bar => {{ - this.enhanceProgressBar(bar); - }}); - - // Watch for new progress bars - const observer = new MutationObserver((mutations) => {{ - mutations.forEach(mutation => {{ - mutation.addedNodes.forEach(node => {{ - if (node.nodeType === Node.ELEMENT_NODE) {{ - const progressBars = node.querySelectorAll?.('.progress-bar'); - progressBars?.forEach(bar => this.enhanceProgressBar(bar)); - }} - }}); - }}); - }}); - - observer.observe(document.body, {{ childList: true, subtree: true }}); - }} - - enhanceProgressBar(bar) {{ - if (!bar.hasAttribute('role')) {{ - bar.setAttribute('role', 'progressbar'); - }} - - // Watch for progress updates - const observer = new MutationObserver(() => {{ - this.announceProgressUpdate(bar); - }}); - - observer.observe(bar, {{ - attributes: true, - attributeFilter: ['style', 'aria-valuenow'] - }}); - }} - - announceProgressUpdate(bar) {{ - const percent = bar.getAttribute('aria-valuenow') || - (parseFloat(bar.style.width) || 0); - const label = bar.getAttribute('aria-label') || 'Progress'; - - // Only announce significant changes (every 10%) - const lastPercent = parseInt(bar.dataset.lastAnnounced || '0'); - const currentPercent = parseInt(percent); - - if (currentPercent - lastPercent >= 10) {{ - this.announce(`${{label}}: ${{currentPercent}}%`, 'status'); - bar.dataset.lastAnnounced = currentPercent.toString(); - }} - }} - - setupErrorHandling() {{ - // Enhanced error reporting - window.addEventListener('error', (e) => {{ - if (this.isScreenReaderActive) {{ - this.announce('An error occurred on the page', 'assertive'); - }} - }}); - - document.addEventListener('click', (e) => {{ - const button = e.target.closest('button, [role="button"]'); - if (button && button.disabled) {{ - e.preventDefault(); - e.stopPropagation(); - this.announce('Button is disabled', 'assertive'); - }} - }}); - }} - - // Public API methods - isActive() {{ - return this.isScreenReaderActive; - }} - - setVerbosity(level) {{ - if (['minimal', 'normal', 'verbose'].includes(level)) {{ - this.verbosityLevel = level; - }} - }} - - getVerbosity() {{ - return this.verbosityLevel; - }} - - getAnnouncements() {{ - return [...this.announcements]; - }} - - clearAnnouncements() {{ - this.announcements = []; - }} - - announceCustom(message, options = {{}}) {{ - const {{ - priority = 'polite', - context = '', - delay = 0 - }} = options; - - if (delay > 0) {{ - setTimeout(() => {{ - this.announce(message, priority, context); - }}, delay); - }} else {{ - this.announce(message, priority, context); - }} - }} - - generateId(elementOrPrefix) {{ - // Generate a unique ID for an element or with a prefix - let prefix, element; - - if (typeof elementOrPrefix === 'string') {{ - // Called with a string prefix - prefix = elementOrPrefix; - element = null; - }} else if (elementOrPrefix && elementOrPrefix.tagName) {{ - // Called with a DOM element - element = elementOrPrefix; - prefix = element.tagName.toLowerCase(); - }} else {{ - // Fallback for invalid input - prefix = 'element'; - element = elementOrPrefix; - }} - - const timestamp = Date.now(); - const random = Math.floor(Math.random() * 1000); - const id = `${{prefix}}-${{timestamp}}-${{random}}`; - - // Set ID on element if provided and it's a DOM element - if (element && element.setAttribute) {{ - element.id = id; - }} - - return id; - }} -}} - -// Initialize screen reader manager when DOM is loaded -document.addEventListener('DOMContentLoaded', () => {{ - window.screenReaderManager = new ScreenReaderManager(); - console.log('Screen reader manager loaded'); -}}); -""" - - def get_css(self): - """Generate CSS for screen reader support.""" - return """ -/* Screen Reader Support Styles */ - -/* Screen reader only content */ -.sr-only { - position: absolute !important; - width: 1px !important; - height: 1px !important; - padding: 0 !important; - margin: -1px !important; - overflow: hidden !important; - clip: rect(0, 0, 0, 0) !important; - white-space: nowrap !important; - border: 0 !important; -} - -.sr-only-focusable:focus, -.sr-only-focusable:active { - position: static !important; - width: auto !important; - height: auto !important; - padding: inherit !important; - margin: inherit !important; - overflow: visible !important; - clip: auto !important; - white-space: normal !important; -} - -/* Enhanced screen reader mode */ -.screen-reader-active .sr-enhanced { - position: static !important; - width: auto !important; - height: auto !important; - clip: auto !important; - overflow: visible !important; -} - -.screen-reader-active .sr-verbose .sr-context { - position: static; - clip: auto; - width: auto; - height: auto; - margin: 0.25rem 0; - padding: 0.25rem; - background: rgba(var(--bs-info-rgb), 0.1); - border-left: 3px solid var(--bs-info); - font-size: 0.875rem; -} - -/* Live regions (hidden visually but available to screen readers) */ -[aria-live], -[role="status"], -[role="alert"], -[role="log"] { - position: absolute; - left: -10000px; - width: 1px; - height: 1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} - -/* Form enhancements for screen readers */ -label.required::after { - content: " (required)"; - color: var(--bs-danger); -} - -.form-control[aria-invalid="true"] { - border-color: var(--bs-danger); - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e"); - background-repeat: no-repeat; - background-position: right calc(0.375em + 0.1875rem) center; - background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); -} - -.invalid-feedback { - display: block; - width: 100%; - margin-top: 0.25rem; - font-size: 0.875rem; - color: var(--bs-danger); -} - -/* Enhanced focus indicators for screen readers */ -.screen-reader-active *:focus { - outline: 3px solid var(--bs-warning) !important; - outline-offset: 2px !important; - box-shadow: 0 0 0 5px rgba(var(--bs-warning-rgb), 0.3) !important; -} - -/* Table enhancements */ -table caption { - padding-top: 0.5rem; - padding-bottom: 0.5rem; - color: var(--bs-secondary-color); - text-align: left; - caption-side: top; -} - -table th[scope="col"]::before { - content: "Column header: "; - position: absolute; - left: -10000px; -} - -table th[scope="row"]::before { - content: "Row header: "; - position: absolute; - left: -10000px; -} - -table td[headers] { - position: relative; -} - -/* List enhancements */ -[role="list"] { - list-style: none; - padding: 0; -} - -[role="listitem"] { - position: relative; -} - -[role="listitem"]::before { - content: "Item " attr(aria-posinset) " of " attr(aria-setsize) ": "; - position: absolute; - left: -10000px; -} - -/* Navigation enhancements */ -nav[role="navigation"] { - position: relative; -} - -nav[role="navigation"]::before { - content: "Navigation: " attr(aria-label); - position: absolute; - left: -10000px; -} - -.breadcrumb[role="navigation"] .breadcrumb-item { - position: relative; -} - -.breadcrumb[role="navigation"] .breadcrumb-item[aria-current="page"]::after { - content: " (current page)"; - position: absolute; - left: -10000px; -} - -/* Progress bar enhancements */ -.progress-bar[role="progressbar"] { - position: relative; -} - -.progress-bar[role="progressbar"]::after { - content: attr(aria-label) ": " attr(aria-valuenow) "% complete"; - position: absolute; - left: -10000px; -} - -/* Button enhancements */ -button[aria-expanded="true"]::after, -[role="button"][aria-expanded="true"]::after { - content: " (expanded)"; - position: absolute; - left: -10000px; -} - -button[aria-expanded="false"]::after, -[role="button"][aria-expanded="false"]::after { - content: " (collapsed)"; - position: absolute; - left: -10000px; -} - -button[aria-pressed="true"]::after, -[role="button"][aria-pressed="true"]::after { - content: " (pressed)"; - position: absolute; - left: -10000px; -} - -/* Modal enhancements */ -.modal[role="dialog"] .modal-title { - position: relative; -} - -.modal[role="dialog"] .modal-title::before { - content: "Dialog: "; - position: absolute; - left: -10000px; -} - -/* Alert enhancements */ -.alert[role="alert"] { - position: relative; - border-left: 4px solid currentColor; -} - -.alert-success[role="alert"]::before { - content: "Success: "; - position: absolute; - left: -10000px; -} - -.alert-danger[role="alert"]::before, -.alert-error[role="alert"]::before { - content: "Error: "; - position: absolute; - left: -10000px; -} - -.alert-warning[role="alert"]::before { - content: "Warning: "; - position: absolute; - left: -10000px; -} - -.alert-info[role="alert"]::before { - content: "Information: "; - position: absolute; - left: -10000px; -} - -/* Series card enhancements */ -.series-card[role="article"] { - position: relative; -} - -.series-card[role="article"]::before { - content: "Series article"; - position: absolute; - left: -10000px; -} - -.series-card .sr-context { - position: absolute; - left: -10000px; -} - -.screen-reader-active .series-card .sr-context { - position: static; - clip: auto; - width: auto; - height: auto; - padding: 0.25rem; - background: rgba(var(--bs-info-rgb), 0.1); - font-size: 0.75rem; - color: var(--bs-info); -} - -/* Download item enhancements */ -.download-item { - position: relative; -} - -.download-item::before { - content: "Download item"; - position: absolute; - left: -10000px; -} - -/* Landmark enhancements */ -main[role="main"]::before { - content: "Main content"; - position: absolute; - left: -10000px; -} - -aside[role="complementary"]::before { - content: "Complementary content"; - position: absolute; - left: -10000px; -} - -footer[role="contentinfo"]::before { - content: "Footer information"; - position: absolute; - left: -10000px; -} - -/* Status and error indicators */ -.loading[aria-busy="true"]::after { - content: " (loading)"; - position: absolute; - left: -10000px; -} - -.disabled[aria-disabled="true"]::after, -[disabled]::after { - content: " (disabled)"; - position: absolute; - left: -10000px; -} - -/* Pagination enhancements */ -.pagination[role="navigation"] .page-item.active .page-link::after { - content: " (current page)"; - position: absolute; - left: -10000px; -} - -/* Skip links styling */ -.skip-links a:focus { - position: fixed; - top: 0; - left: 0; - background: var(--bs-dark); - color: var(--bs-light); - padding: 1rem; - text-decoration: none; - font-weight: bold; - z-index: 2000; - border-radius: 0 0 8px 0; -} - -/* High contrast adjustments */ -@media (prefers-contrast: high) { - .screen-reader-active *:focus { - outline: 4px solid var(--bs-warning) !important; - background: rgba(var(--bs-warning-rgb), 0.2) !important; - } - - .sr-context { - border-width: 2px !important; - } - - .form-control[aria-invalid="true"] { - border-width: 2px !important; - } -} - -/* Reduced motion preferences */ -@media (prefers-reduced-motion: reduce) { - .screen-reader-active *:focus { - transition: none !important; - } - - .sr-enhanced { - animation: none !important; - } -} - -/* Print accessibility */ -@media print { - .sr-only, - [aria-live], - [role="status"], - [role="alert"] { - position: static !important; - width: auto !important; - height: auto !important; - clip: auto !important; - overflow: visible !important; - margin: 0.25rem 0 !important; - padding: 0.25rem !important; - border: 1px solid #000 !important; - font-size: 0.8rem !important; - } - - .sr-context { - position: static !important; - background: #f0f0f0 !important; - border: 1px solid #000 !important; - } -} - -/* Dark theme adjustments */ -[data-bs-theme="dark"] .screen-reader-active .sr-context { - background: rgba(var(--bs-info-rgb), 0.2); - border-color: var(--bs-info); - color: var(--bs-info-text-emphasis); -} - -[data-bs-theme="dark"] .skip-links a:focus { - background: var(--bs-light); - color: var(--bs-dark); -} - -/* Language direction support */ -[dir="rtl"] .sr-only { - left: auto; - right: -10000px; -} - -[dir="rtl"] [aria-live], -[dir="rtl"] [role="status"], -[dir="rtl"] [role="alert"], -[dir="rtl"] [role="log"] { - left: auto; - right: -10000px; -} - -/* Voice control support */ -.screen-reader-active [data-voice-command] { - position: relative; -} - -.screen-reader-active [data-voice-command]::after { - content: "Voice command: " attr(data-voice-command); - position: absolute; - left: -10000px; -} - -/* Enhanced keyboard navigation indicators */ -.keyboard-navigation-active .focusable:focus { - outline: 2px solid var(--bs-primary) !important; - outline-offset: 2px !important; - box-shadow: 0 0 0 4px rgba(var(--bs-primary-rgb), 0.25) !important; -} - -/* Screen reader debugging (development only) */ -.sr-debug .sr-only { - position: static !important; - width: auto !important; - height: auto !important; - clip: auto !important; - overflow: visible !important; - background: yellow !important; - color: black !important; - border: 1px solid red !important; - padding: 2px !important; - margin: 2px !important; -} -""" - - -# Export the screen reader manager -screen_reader_manager = ScreenReaderManager() \ No newline at end of file diff --git a/src/server/web/middleware/touch_middleware.py b/src/server/web/middleware/touch_middleware.py deleted file mode 100644 index 8979239..0000000 --- a/src/server/web/middleware/touch_middleware.py +++ /dev/null @@ -1,1244 +0,0 @@ -""" -Touch Gesture Support System - -This module provides comprehensive touch gesture recognition and handling -for mobile devices, including swipe, pinch, tap, and custom gestures. -""" - -from typing import Dict, List, Any, Optional, Callable -from flask import Blueprint, request, jsonify - -class TouchGestureManager: - """Manages touch gestures and mobile device interactions.""" - - def __init__(self, app=None): - self.app = app - self.gesture_handlers = {} - - def init_app(self, app): - """Initialize with Flask app.""" - self.app = app - - def get_touch_gesture_js(self): - """Generate JavaScript code for touch gesture functionality.""" - return f""" -// AniWorld Touch Gesture Manager -class TouchGestureManager {{ - constructor() {{ - this.touchStartX = 0; - this.touchStartY = 0; - this.touchEndX = 0; - this.touchEndY = 0; - this.touchStartTime = 0; - this.touchEndTime = 0; - - this.gestureHandlers = new Map(); - this.activeGestures = new Set(); - - // Gesture thresholds - this.swipeThreshold = 50; // Minimum distance for swipe - this.tapThreshold = 10; // Maximum movement for tap - this.longPressThreshold = 500; // Minimum time for long press - this.doubleTapThreshold = 300; // Maximum time between taps - - // Pinch/zoom variables - this.initialDistance = 0; - this.currentDistance = 0; - this.isZooming = false; - - // Multi-touch tracking - this.touches = []; - this.lastTapTime = 0; - this.tapCount = 0; - - this.init(); - }} - - init() {{ - if (!this.isTouchDevice()) {{ - console.log('Touch gestures not available - not a touch device'); - return; - }} - - this.setupTouchListeners(); - this.setupDefaultGestures(); - this.setupContextualGestures(); - - // Prevent default touch behaviors that interfere with gestures - this.preventDefaultTouchBehaviors(); - - console.log('Touch gesture manager initialized'); - }} - - isTouchDevice() {{ - return 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0; - }} - - setupTouchListeners() {{ - // Passive event listeners for better performance - const options = {{ passive: false }}; - - document.addEventListener('touchstart', (e) => this.handleTouchStart(e), options); - document.addEventListener('touchmove', (e) => this.handleTouchMove(e), options); - document.addEventListener('touchend', (e) => this.handleTouchEnd(e), options); - document.addEventListener('touchcancel', (e) => this.handleTouchCancel(e), options); - - // Pointer events for better multi-device support - if (window.PointerEvent) {{ - document.addEventListener('pointerdown', (e) => this.handlePointerDown(e)); - document.addEventListener('pointermove', (e) => this.handlePointerMove(e)); - document.addEventListener('pointerup', (e) => this.handlePointerUp(e)); - }} - }} - - handleTouchStart(e) {{ - this.touchStartTime = Date.now(); - this.touches = Array.from(e.touches); - - if (e.touches.length === 1) {{ - // Single touch - const touch = e.touches[0]; - this.touchStartX = touch.clientX; - this.touchStartY = touch.clientY; - - // Start long press detection - this.startLongPressDetection(e); - - }} else if (e.touches.length === 2) {{ - // Multi-touch (pinch/zoom) - this.handlePinchStart(e); - }} - - this.dispatchGestureEvent('touchstart', {{ - touches: this.touches, - target: e.target - }}); - }} - - handleTouchMove(e) {{ - if (e.touches.length === 1) {{ - const touch = e.touches[0]; - const deltaX = touch.clientX - this.touchStartX; - const deltaY = touch.clientY - this.touchStartY; - - // Cancel long press if moved too much - if (Math.abs(deltaX) > this.tapThreshold || Math.abs(deltaY) > this.tapThreshold) {{ - this.cancelLongPress(); - }} - - // Detect swipe direction - this.detectSwipeDirection(deltaX, deltaY, e); - - }} else if (e.touches.length === 2) {{ - // Handle pinch/zoom - this.handlePinchMove(e); - }} - - // Update touches array - this.touches = Array.from(e.touches); - }} - - handleTouchEnd(e) {{ - this.touchEndTime = Date.now(); - const touchDuration = this.touchEndTime - this.touchStartTime; - - if (e.changedTouches.length === 1) {{ - const touch = e.changedTouches[0]; - this.touchEndX = touch.clientX; - this.touchEndY = touch.clientY; - - const deltaX = this.touchEndX - this.touchStartX; - const deltaY = this.touchEndY - this.touchStartY; - const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); - - // Determine gesture type - if (distance < this.tapThreshold) {{ - this.handleTap(e, touchDuration); - }} else if (distance > this.swipeThreshold) {{ - this.handleSwipe(deltaX, deltaY, e); - }} - }} - - // Handle pinch end - if (this.isZooming) {{ - this.handlePinchEnd(e); - }} - - // Clean up - this.cancelLongPress(); - this.touches = Array.from(e.touches); - - this.dispatchGestureEvent('touchend', {{ - touches: this.touches, - target: e.target, - duration: touchDuration - }}); - }} - - handleTouchCancel(e) {{ - this.cancelLongPress(); - this.isZooming = false; - this.touches = []; - - this.dispatchGestureEvent('touchcancel', {{ - target: e.target - }}); - }} - - handleTap(e, duration) {{ - const now = Date.now(); - - // Check for double tap - if (now - this.lastTapTime < this.doubleTapThreshold) {{ - this.tapCount++; - - if (this.tapCount === 2) {{ - this.dispatchGestureEvent('doubletap', {{ - target: e.target, - x: this.touchEndX, - y: this.touchEndY - }}); - this.tapCount = 0; - return; - }} - }} else {{ - this.tapCount = 1; - }} - - this.lastTapTime = now; - - // Single tap (with delay to check for double tap) - setTimeout(() => {{ - if (this.tapCount === 1) {{ - this.dispatchGestureEvent('tap', {{ - target: e.target, - x: this.touchEndX, - y: this.touchEndY, - duration: duration - }}); - this.tapCount = 0; - }} - }}, this.doubleTapThreshold); - }} - - startLongPressDetection(e) {{ - this.longPressTimer = setTimeout(() => {{ - this.dispatchGestureEvent('longpress', {{ - target: e.target, - x: this.touchStartX, - y: this.touchStartY - }}); - }}, this.longPressThreshold); - }} - - cancelLongPress() {{ - if (this.longPressTimer) {{ - clearTimeout(this.longPressTimer); - this.longPressTimer = null; - }} - }} - - detectSwipeDirection(deltaX, deltaY, e) {{ - const absDeltaX = Math.abs(deltaX); - const absDeltaY = Math.abs(deltaY); - - // Determine primary direction - let direction = null; - if (absDeltaX > absDeltaY) {{ - direction = deltaX > 0 ? 'right' : 'left'; - }} else {{ - direction = deltaY > 0 ? 'down' : 'up'; - }} - - // Dispatch swipe in progress event - this.dispatchGestureEvent('swipemove', {{ - target: e.target, - direction: direction, - deltaX: deltaX, - deltaY: deltaY, - distance: Math.sqrt(deltaX * deltaX + deltaY * deltaY) - }}); - }} - - handleSwipe(deltaX, deltaY, e) {{ - const absDeltaX = Math.abs(deltaX); - const absDeltaY = Math.abs(deltaY); - - let direction; - let velocity = 0; - - const duration = this.touchEndTime - this.touchStartTime; - - // Determine swipe direction and calculate velocity - if (absDeltaX > absDeltaY) {{ - direction = deltaX > 0 ? 'right' : 'left'; - velocity = absDeltaX / duration; - }} else {{ - direction = deltaY > 0 ? 'down' : 'up'; - velocity = absDeltaY / duration; - }} - - this.dispatchGestureEvent('swipe', {{ - target: e.target, - direction: direction, - deltaX: deltaX, - deltaY: deltaY, - velocity: velocity, - distance: Math.sqrt(deltaX * deltaX + deltaY * deltaY) - }}); - - // Dispatch direction-specific event - this.dispatchGestureEvent(`swipe${{direction}}`, {{ - target: e.target, - delta: direction === 'left' || direction === 'right' ? deltaX : deltaY, - velocity: velocity - }}); - }} - - handlePinchStart(e) {{ - if (e.touches.length !== 2) return; - - const touch1 = e.touches[0]; - const touch2 = e.touches[1]; - - this.initialDistance = this.getDistance(touch1, touch2); - this.isZooming = true; - - this.dispatchGestureEvent('pinchstart', {{ - target: e.target, - distance: this.initialDistance, - center: this.getCenter(touch1, touch2) - }}); - }} - - handlePinchMove(e) {{ - if (e.touches.length !== 2 || !this.isZooming) return; - - const touch1 = e.touches[0]; - const touch2 = e.touches[1]; - - this.currentDistance = this.getDistance(touch1, touch2); - const scale = this.currentDistance / this.initialDistance; - - this.dispatchGestureEvent('pinchmove', {{ - target: e.target, - scale: scale, - distance: this.currentDistance, - center: this.getCenter(touch1, touch2) - }}); - - // Prevent default zoom behavior - e.preventDefault(); - }} - - handlePinchEnd(e) {{ - if (!this.isZooming) return; - - const scale = this.currentDistance / this.initialDistance; - - this.dispatchGestureEvent('pinchend', {{ - target: e.target, - scale: scale, - finalDistance: this.currentDistance - }}); - - this.isZooming = false; - this.initialDistance = 0; - this.currentDistance = 0; - }} - - getDistance(touch1, touch2) {{ - const dx = touch2.clientX - touch1.clientX; - const dy = touch2.clientY - touch1.clientY; - return Math.sqrt(dx * dx + dy * dy); - }} - - getCenter(touch1, touch2) {{ - return {{ - x: (touch1.clientX + touch2.clientX) / 2, - y: (touch1.clientY + touch2.clientY) / 2 - }}; - }} - - handlePointerDown(e) {{ - if (e.pointerType === 'touch') {{ - // Handle as touch event - this.handlePointerTouch(e, 'down'); - }} - }} - - handlePointerMove(e) {{ - if (e.pointerType === 'touch') {{ - this.handlePointerTouch(e, 'move'); - }} - }} - - handlePointerUp(e) {{ - if (e.pointerType === 'touch') {{ - this.handlePointerTouch(e, 'up'); - }} - }} - - handlePointerTouch(e, type) {{ - // Convert pointer event to touch-like event - const pointerEvent = {{ - target: e.target, - clientX: e.clientX, - clientY: e.clientY, - pointerId: e.pointerId, - pressure: e.pressure || 0.5 - }}; - - this.dispatchGestureEvent(`pointer${{type}}`, pointerEvent); - }} - - setupDefaultGestures() {{ - // Navigation gestures - this.registerGestureHandler('swipeleft', (e) => {{ - this.handleNavigationSwipe('next', e); - }}); - - this.registerGestureHandler('swiperight', (e) => {{ - this.handleNavigationSwipe('previous', e); - }}); - - // Menu gestures - this.registerGestureHandler('swipedown', (e) => {{ - this.handleMenuGesture('show', e); - }}); - - this.registerGestureHandler('swipeup', (e) => {{ - this.handleMenuGesture('hide', e); - }}); - - // Zoom gestures - this.registerGestureHandler('doubletap', (e) => {{ - this.handleZoomGesture(e); - }}); - - this.registerGestureHandler('pinchmove', (e) => {{ - this.handlePinchZoom(e); - }}); - - // Context menu - this.registerGestureHandler('longpress', (e) => {{ - this.handleContextMenu(e); - }}); - }} - - setupContextualGestures() {{ - // Series list specific gestures - this.setupSeriesListGestures(); - - // Download manager gestures - this.setupDownloadGestures(); - - // Player gestures - this.setupPlayerGestures(); - }} - - setupSeriesListGestures() {{ - // Swipe to refresh series list - this.registerContextualHandler('.series-container', 'swipedown', (e) => {{ - if (window.scrollY === 0) {{ - this.refreshSeriesList(); - }} - }}); - - // Swipe to select multiple series - this.registerContextualHandler('.series-card', 'swiperight', (e) => {{ - this.toggleSeriesSelection(e.target); - }}); - - // Long press to show series options - this.registerContextualHandler('.series-card', 'longpress', (e) => {{ - this.showSeriesContextMenu(e.target, e.x, e.y); - }}); - }} - - setupDownloadGestures() {{ - // Swipe to cancel download - this.registerContextualHandler('.download-item', 'swipeleft', (e) => {{ - this.cancelDownload(e.target); - }}); - - // Swipe to retry download - this.registerContextualHandler('.download-error', 'swiperight', (e) => {{ - this.retryDownload(e.target); - }}); - }} - - setupPlayerGestures() {{ - // Player container gestures - this.registerContextualHandler('.video-player', 'tap', (e) => {{ - this.togglePlayPause(); - }}); - - this.registerContextualHandler('.video-player', 'doubletap', (e) => {{ - this.toggleFullscreen(); - }}); - - this.registerContextualHandler('.video-player', 'swipeleft', (e) => {{ - this.seekForward(10); - }}); - - this.registerContextualHandler('.video-player', 'swiperight', (e) => {{ - this.seekBackward(10); - }}); - - this.registerContextualHandler('.video-player', 'swipeup', (e) => {{ - this.increaseVolume(); - }}); - - this.registerContextualHandler('.video-player', 'swipedown', (e) => {{ - this.decreaseVolume(); - }}); - }} - - registerGestureHandler(gesture, handler) {{ - if (!this.gestureHandlers.has(gesture)) {{ - this.gestureHandlers.set(gesture, []); - }} - this.gestureHandlers.get(gesture).push(handler); - }} - - registerContextualHandler(selector, gesture, handler) {{ - this.registerGestureHandler(gesture, (e) => {{ - const target = e.target.closest(selector); - if (target) {{ - handler({{...e, target}}); - }} - }}); - }} - - dispatchGestureEvent(type, data) {{ - // Call registered handlers - if (this.gestureHandlers.has(type)) {{ - this.gestureHandlers.get(type).forEach(handler => {{ - try {{ - handler(data); - }} catch (error) {{ - console.error(`Error in gesture handler for ${{type}}:`, error); - }} - }}); - }} - - // Dispatch DOM event - const event = new CustomEvent(type, {{ - detail: data, - bubbles: true, - cancelable: true - }}); - - if (data.target) {{ - data.target.dispatchEvent(event); - }} else {{ - document.dispatchEvent(event); - }} - }} - - preventDefaultTouchBehaviors() {{ - // Prevent pull-to-refresh on mobile browsers - let preventDefault = false; - - document.addEventListener('touchstart', () => {{ - preventDefault = window.scrollY === 0; - }}); - - document.addEventListener('touchmove', (e) => {{ - if (preventDefault) {{ - e.preventDefault(); - }} - }}, {{ passive: false }}); - - // Prevent double-tap zoom on specific elements - const preventZoomElements = document.querySelectorAll('.prevent-zoom, .btn, .form-control'); - preventZoomElements.forEach(element => {{ - element.addEventListener('touchend', (e) => {{ - e.preventDefault(); - }}); - }}); - }} - - // Gesture action implementations - handleNavigationSwipe(direction, e) {{ - console.log(`Navigation swipe: ${{direction}}`); - - if (direction === 'next') {{ - // Navigate to next page or item - const nextButton = document.querySelector('.pagination .page-link[rel="next"]'); - if (nextButton) {{ - nextButton.click(); - }} - }} else if (direction === 'previous') {{ - // Navigate to previous page or item - const prevButton = document.querySelector('.pagination .page-link[rel="prev"]'); - if (prevButton) {{ - prevButton.click(); - }} - }} - }} - - handleMenuGesture(action, e) {{ - const sidebar = document.querySelector('.sidebar, #mobile-sidebar'); - - if (action === 'show' && sidebar) {{ - if (window.bootstrap && bootstrap.Collapse) {{ - const collapse = bootstrap.Collapse.getOrCreateInstance(sidebar); - collapse.show(); - }} - }} else if (action === 'hide' && sidebar) {{ - if (window.bootstrap && bootstrap.Collapse) {{ - const collapse = bootstrap.Collapse.getOrCreateInstance(sidebar); - collapse.hide(); - }} - }} - }} - - handleZoomGesture(e) {{ - // Handle double-tap zoom - const zoomable = e.target.closest('.zoomable, img, .series-poster'); - - if (zoomable) {{ - if (zoomable.classList.contains('zoomed')) {{ - zoomable.classList.remove('zoomed'); - zoomable.style.transform = ''; - }} else {{ - zoomable.classList.add('zoomed'); - zoomable.style.transform = 'scale(2)'; - }} - }} - }} - - handlePinchZoom(e) {{ - const zoomable = e.target.closest('.zoomable, img, .series-poster'); - - if (zoomable && e.scale) {{ - const scale = Math.max(0.5, Math.min(3, e.scale)); - zoomable.style.transform = `scale(${{scale}})`; - - if (scale === 1) {{ - zoomable.classList.remove('zoomed'); - }} else {{ - zoomable.classList.add('zoomed'); - }} - }} - }} - - handleContextMenu(e) {{ - e.preventDefault?.(); - - const contextMenu = document.querySelector('.context-menu'); - if (contextMenu) {{ - contextMenu.style.display = 'block'; - contextMenu.style.left = `${{e.x}}px`; - contextMenu.style.top = `${{e.y}}px`; - }} - }} - - refreshSeriesList() {{ - console.log('Refreshing series list...'); - - // Show refresh indicator - const refreshIndicator = document.querySelector('.refresh-indicator'); - if (refreshIndicator) {{ - refreshIndicator.style.display = 'block'; - }} - - // Trigger refresh - if (window.seriesManager && window.seriesManager.refreshSeries) {{ - window.seriesManager.refreshSeries(); - }} - - // Provide haptic feedback if available - this.provideFeedback('light'); - }} - - toggleSeriesSelection(element) {{ - const seriesCard = element.closest('.series-card'); - if (!seriesCard) return; - - seriesCard.classList.toggle('selected'); - - // Update selection counter - const selected = document.querySelectorAll('.series-card.selected').length; - const counter = document.querySelector('.selection-counter'); - if (counter) {{ - counter.textContent = `${{selected}} selected`; - counter.style.display = selected > 0 ? 'block' : 'none'; - }} - - this.provideFeedback('selection'); - }} - - showSeriesContextMenu(element, x, y) {{ - const seriesCard = element.closest('.series-card'); - if (!seriesCard) return; - - // Create or show context menu - let contextMenu = document.querySelector('.series-context-menu'); - if (!contextMenu) {{ - contextMenu = this.createSeriesContextMenu(); - }} - - contextMenu.style.display = 'block'; - contextMenu.style.left = `${{x}}px`; - contextMenu.style.top = `${{y}}px`; - contextMenu.dataset.seriesId = seriesCard.dataset.seriesId; - - // Hide menu when clicking elsewhere - document.addEventListener('click', (e) => {{ - if (!contextMenu.contains(e.target)) {{ - contextMenu.style.display = 'none'; - }} - }}, {{ once: true }}); - - this.provideFeedback('selection'); - }} - - createSeriesContextMenu() {{ - const menu = document.createElement('div'); - menu.className = 'series-context-menu dropdown-menu show'; - menu.innerHTML = ` - - Download - - - Add to Favorites - - - Add to Watchlist - - - - View Details - - `; - - // Add event listeners - menu.addEventListener('click', (e) => {{ - e.preventDefault(); - const action = e.target.closest('[data-action]')?.dataset.action; - const seriesId = menu.dataset.seriesId; - - if (action && seriesId) {{ - this.handleSeriesContextAction(action, seriesId); - }} - - menu.style.display = 'none'; - }}); - - document.body.appendChild(menu); - return menu; - }} - - handleSeriesContextAction(action, seriesId) {{ - console.log(`Series context action: ${{action}} for series ${{seriesId}}`); - - switch (action) {{ - case 'download': - if (window.downloadManager) {{ - window.downloadManager.downloadSeries(seriesId); - }} - break; - case 'favorite': - if (window.favoritesManager) {{ - window.favoritesManager.addToFavorites(seriesId); - }} - break; - case 'watchlist': - if (window.watchlistManager) {{ - window.watchlistManager.addToWatchlist(seriesId); - }} - break; - case 'details': - window.location.href = `/series/${{seriesId}}`; - break; - }} - }} - - cancelDownload(element) {{ - const downloadItem = element.closest('.download-item'); - if (!downloadItem) return; - - const downloadId = downloadItem.dataset.downloadId; - if (downloadId && window.downloadManager) {{ - window.downloadManager.cancelDownload(downloadId); - }} - - this.provideFeedback('impact'); - }} - - retryDownload(element) {{ - const downloadItem = element.closest('.download-error'); - if (!downloadItem) return; - - const downloadId = downloadItem.dataset.downloadId; - if (downloadId && window.downloadManager) {{ - window.downloadManager.retryDownload(downloadId); - }} - - this.provideFeedback('light'); - }} - - // Video player gesture handlers - togglePlayPause() {{ - const video = document.querySelector('video'); - if (video) {{ - if (video.paused) {{ - video.play(); - }} else {{ - video.pause(); - }} - }} - }} - - toggleFullscreen() {{ - const video = document.querySelector('video'); - if (video) {{ - if (document.fullscreenElement) {{ - document.exitFullscreen(); - }} else {{ - video.requestFullscreen(); - }} - }} - }} - - seekForward(seconds) {{ - const video = document.querySelector('video'); - if (video) {{ - video.currentTime += seconds; - }} - }} - - seekBackward(seconds) {{ - const video = document.querySelector('video'); - if (video) {{ - video.currentTime -= seconds; - }} - }} - - increaseVolume() {{ - const video = document.querySelector('video'); - if (video) {{ - video.volume = Math.min(1, video.volume + 0.1); - }} - }} - - decreaseVolume() {{ - const video = document.querySelector('video'); - if (video) {{ - video.volume = Math.max(0, video.volume - 0.1); - }} - }} - - // Haptic feedback - provideFeedback(type = 'light') {{ - if (navigator.vibrate) {{ - switch (type) {{ - case 'light': - navigator.vibrate(10); - break; - case 'selection': - navigator.vibrate(20); - break; - case 'impact': - navigator.vibrate([10, 10, 10]); - break; - default: - navigator.vibrate(10); - }} - }} - }} - - // Public API methods - enableGesture(gesture) {{ - this.activeGestures.add(gesture); - }} - - disableGesture(gesture) {{ - this.activeGestures.delete(gesture); - }} - - isGestureEnabled(gesture) {{ - return this.activeGestures.has(gesture); - }} - - addCustomGesture(name, handler) {{ - this.registerGestureHandler(name, handler); - }} - - removeGestureHandler(gesture, handler) {{ - if (this.gestureHandlers.has(gesture)) {{ - const handlers = this.gestureHandlers.get(gesture); - const index = handlers.indexOf(handler); - if (index > -1) {{ - handlers.splice(index, 1); - }} - }} - }} - - getActiveGestures() {{ - return Array.from(this.activeGestures); - }} -}} - -// Initialize touch gesture manager when DOM is loaded -document.addEventListener('DOMContentLoaded', () => {{ - if ('ontouchstart' in window || navigator.maxTouchPoints > 0) {{ - window.touchGestureManager = new TouchGestureManager(); - console.log('Touch gesture manager loaded'); - }} -}}); -""" - - def get_css(self): - """Generate CSS for touch gesture enhancements.""" - return """ -/* Touch Gesture Support Styles */ - -/* Gesture feedback animations */ -@keyframes tap-feedback { - 0% { transform: scale(1); opacity: 0.7; } - 50% { transform: scale(0.95); opacity: 1; } - 100% { transform: scale(1); opacity: 1; } -} - -@keyframes long-press-feedback { - 0% { transform: scale(1); } - 100% { transform: scale(1.05); filter: brightness(1.1); } -} - -@keyframes swipe-feedback { - 0% { transform: translateX(0); } - 50% { transform: translateX(5px); } - 100% { transform: translateX(0); } -} - -/* Touch-responsive elements */ -.touch-target { - min-width: 44px; - min-height: 44px; - cursor: pointer; - -webkit-tap-highlight-color: rgba(0, 0, 0, 0.1); -} - -.touch-feedback { - transition: transform 0.1s ease, opacity 0.1s ease; -} - -.touch-feedback:active { - animation: tap-feedback 0.1s ease; -} - -.long-press-target:active { - animation: long-press-feedback 0.5s ease forwards; -} - -/* Series selection styles */ -.series-card.selected { - border: 2px solid var(--bs-primary); - background: rgba(var(--bs-primary-rgb), 0.1); - transform: scale(0.98); -} - -.series-card.selecting { - animation: swipe-feedback 0.3s ease; -} - -.selection-counter { - position: fixed; - top: 70px; - right: 20px; - background: var(--bs-primary); - color: white; - padding: 8px 16px; - border-radius: 20px; - font-weight: bold; - z-index: 1030; - display: none; - animation: slideInRight 0.3s ease; -} - -@keyframes slideInRight { - from { transform: translateX(100%); } - to { transform: translateX(0); } -} - -/* Context menu styles */ -.series-context-menu { - position: fixed; - z-index: 1050; - min-width: 200px; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); - border-radius: 8px; - overflow: hidden; -} - -.series-context-menu .dropdown-item { - padding: 12px 16px; - font-size: 14px; - display: flex; - align-items: center; - gap: 8px; -} - -.series-context-menu .dropdown-item:hover { - background: var(--bs-primary); - color: white; -} - -/* Zoomable content */ -.zoomable { - transition: transform 0.3s ease; - cursor: zoom-in; -} - -.zoomable.zoomed { - cursor: zoom-out; - transform-origin: center; - z-index: 1040; -} - -.zoomable img { - max-width: 100%; - height: auto; -} - -/* Swipeable download items */ -.download-item { - position: relative; - overflow: hidden; - background: var(--bs-body-bg); - transition: transform 0.2s ease; -} - -.download-item.swiping { - transform: translateX(-10px); -} - -.download-item .swipe-action { - position: absolute; - right: -100px; - top: 0; - height: 100%; - width: 100px; - display: flex; - align-items: center; - justify-content: center; - background: var(--bs-danger); - color: white; - transition: right 0.2s ease; -} - -.download-item.swiping .swipe-action { - right: 0; -} - -.download-error .swipe-action { - background: var(--bs-success); -} - -/* Pull to refresh indicator */ -.refresh-indicator { - position: fixed; - top: 0; - left: 50%; - transform: translateX(-50%); - background: var(--bs-primary); - color: white; - padding: 8px 16px; - border-radius: 0 0 8px 8px; - display: none; - z-index: 1030; - animation: pullRefresh 0.3s ease; -} - -@keyframes pullRefresh { - 0% { transform: translateX(-50%) translateY(-100%); } - 100% { transform: translateX(-50%) translateY(0); } -} - -.refresh-indicator .spinner-border { - width: 16px; - height: 16px; - margin-right: 8px; -} - -/* Touch-optimized video player */ -.video-player { - position: relative; - -webkit-tap-highlight-color: transparent; - user-select: none; -} - -.video-player .touch-overlay { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: 10; - background: transparent; -} - -.video-player .gesture-indicator { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - background: rgba(0, 0, 0, 0.7); - color: white; - padding: 16px; - border-radius: 8px; - font-size: 24px; - pointer-events: none; - opacity: 0; - transition: opacity 0.2s ease; -} - -.video-player .gesture-indicator.show { - opacity: 1; -} - -/* Mobile-specific touch improvements */ -@media (hover: none) and (pointer: coarse) { - .btn, - .form-control, - .dropdown-toggle { - min-height: 44px; - font-size: 16px; /* Prevent zoom on iOS */ - } - - .series-card { - cursor: default; - -webkit-tap-highlight-color: rgba(var(--bs-primary-rgb), 0.2); - } - - .series-card:active { - background: rgba(var(--bs-primary-rgb), 0.05); - } -} - -/* Prevent text selection during gestures */ -.gesture-active { - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; -} - -/* Touch-friendly navigation */ -.touch-nav { - padding: 12px 0; -} - -.touch-nav .nav-link { - min-height: 44px; - display: flex; - align-items: center; - padding: 8px 16px; -} - -/* Gesture help overlay */ -.gesture-help { - position: fixed; - bottom: 20px; - right: 20px; - background: rgba(0, 0, 0, 0.8); - color: white; - padding: 12px; - border-radius: 8px; - font-size: 12px; - z-index: 1050; - max-width: 250px; - display: none; -} - -.gesture-help.show { - display: block; - animation: fadeInUp 0.3s ease; -} - -@keyframes fadeInUp { - from { - opacity: 0; - transform: translateY(20px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.gesture-help h6 { - margin: 0 0 8px 0; - font-weight: bold; -} - -.gesture-help ul { - margin: 0; - padding-left: 16px; -} - -.gesture-help li { - margin-bottom: 4px; -} - -/* High contrast mode adjustments */ -@media (prefers-contrast: high) { - .series-card.selected { - border-width: 3px; - } - - .touch-feedback:active { - outline: 2px solid var(--bs-primary); - } -} - -/* Reduced motion preferences */ -@media (prefers-reduced-motion: reduce) { - .touch-feedback, - .zoomable, - .download-item, - .refresh-indicator, - .gesture-indicator { - transition: none; - animation: none; - } - - .zoomable.zoomed { - transform: scale(2); - } -} - -/* Dark theme adjustments */ -[data-bs-theme="dark"] .series-context-menu { - background: var(--bs-dark); - border: 1px solid var(--bs-border-color); -} - -[data-bs-theme="dark"] .gesture-help { - background: rgba(33, 37, 41, 0.95); - border: 1px solid var(--bs-border-color); -} - -/* RTL support for gestures */ -[dir="rtl"] .swipe-action { - left: -100px; - right: auto; -} - -[dir="rtl"] .download-item.swiping .swipe-action { - left: 0; - right: auto; -} - -[dir="rtl"] .download-item.swiping { - transform: translateX(10px); -} -""" - - -# Export the touch gesture manager -touch_gesture_manager = TouchGestureManager() \ No newline at end of file diff --git a/src/server/web/routes/__init__.py b/src/server/web/routes/__init__.py deleted file mode 100644 index e831b9e..0000000 --- a/src/server/web/routes/__init__.py +++ /dev/null @@ -1,42 +0,0 @@ -""" -Routes package for Aniworld web application. -""" - -# Import blueprints that are available -__all__ = [] - -try: - from .auth_routes import auth_bp, auth_api_bp - __all__.extend(['auth_bp', 'auth_api_bp']) -except ImportError: - pass - -try: - from .api_routes import api_bp - __all__.append('api_bp') -except ImportError: - pass - -try: - from .main_routes import main_bp - __all__.append('main_bp') -except ImportError: - pass - -try: - from .static_routes import static_bp - __all__.append('static_bp') -except ImportError: - pass - -try: - from .diagnostic_routes import diagnostic_bp - __all__.append('diagnostic_bp') -except ImportError: - pass - -try: - from .config_routes import config_bp - __all__.append('config_bp') -except ImportError: - pass \ No newline at end of file