""" Drag & Drop System für das MYP-System ==================================== Dieses Modul stellt umfassende Drag & Drop Funktionalität bereit: - Job-Reihenfolge per Drag & Drop ändern - Multi-File-Upload mit Drag & Drop - Visuelles Feedback und Validierung - Progress-Tracking für Uploads - Barrierefreie Alternative-Eingaben - Touch-Support für mobile Geräte """ import os import json import mimetypes from datetime import datetime from typing import Dict, List, Any, Optional, Tuple, Union from dataclasses import dataclass, asdict from flask import request, jsonify, current_app from flask_login import current_user from utils.logging_config import get_logger from models import Job, Printer, JobOrder, get_db_session from utils.file_manager import save_job_file, save_temp_file from config.settings import ALLOWED_EXTENSIONS, MAX_FILE_SIZE, UPLOAD_FOLDER logger = get_logger("drag_drop") @dataclass class DragDropConfig: """Konfiguration für Drag & Drop Bereiche""" element_id: str accepted_types: List[str] max_files: int = 10 max_file_size: int = MAX_FILE_SIZE multiple: bool = True auto_upload: bool = False preview_mode: str = "thumbnail" # thumbnail, list, grid validation_rules: Dict[str, Any] = None @dataclass class FileUploadProgress: """Upload-Progress für eine Datei""" file_id: str filename: str size: int uploaded_bytes: int = 0 status: str = "pending" # pending, uploading, completed, error error_message: str = None upload_speed: float = 0.0 eta_seconds: int = 0 class DragDropManager: """Manager für Drag & Drop Operationen""" def __init__(self): self.upload_sessions: Dict[str, Dict[str, FileUploadProgress]] = {} self.job_order_cache: Dict[int, List[int]] = {} # printer_id -> job_ids order def create_upload_session(self, session_id: str) -> str: """Erstellt eine neue Upload-Session""" self.upload_sessions[session_id] = {} logger.info(f"Upload-Session erstellt: {session_id}") return session_id def add_file_to_session(self, session_id: str, file_progress: FileUploadProgress): """Fügt eine Datei zur Upload-Session hinzu""" if session_id not in self.upload_sessions: self.upload_sessions[session_id] = {} self.upload_sessions[session_id][file_progress.file_id] = file_progress def update_file_progress(self, session_id: str, file_id: str, uploaded_bytes: int, status: str = None, error_message: str = None): """Aktualisiert Upload-Progress""" if session_id in self.upload_sessions and file_id in self.upload_sessions[session_id]: progress = self.upload_sessions[session_id][file_id] progress.uploaded_bytes = uploaded_bytes if status: progress.status = status if error_message: progress.error_message = error_message # Berechne Upload-Geschwindigkeit und ETA if progress.size > 0: progress_percent = uploaded_bytes / progress.size if progress_percent > 0: progress.eta_seconds = int((progress.size - uploaded_bytes) / max(progress.upload_speed, 1)) def get_session_progress(self, session_id: str) -> Dict[str, Any]: """Holt Progress-Informationen für eine Session""" if session_id not in self.upload_sessions: return {'files': [], 'total_progress': 0} files = list(self.upload_sessions[session_id].values()) total_size = sum(f.size for f in files) total_uploaded = sum(f.uploaded_bytes for f in files) total_progress = (total_uploaded / total_size * 100) if total_size > 0 else 0 return { 'files': [asdict(f) for f in files], 'total_progress': total_progress, 'total_size': total_size, 'total_uploaded': total_uploaded, 'files_completed': len([f for f in files if f.status == 'completed']), 'files_error': len([f for f in files if f.status == 'error']) } def cleanup_session(self, session_id: str): """Bereinigt eine Upload-Session""" if session_id in self.upload_sessions: del self.upload_sessions[session_id] logger.info(f"Upload-Session bereinigt: {session_id}") def update_job_order(self, printer_id: int, job_ids: List[int]) -> bool: """Aktualisiert die Job-Reihenfolge für einen Drucker""" try: # Aktuelle Benutzer-ID für Audit-Trail user_id = current_user.id if current_user.is_authenticated else None # Validierung der Eingaben if not isinstance(printer_id, int) or printer_id <= 0: logger.error(f"Ungültige Drucker-ID: {printer_id}") return False if not isinstance(job_ids, list) or not job_ids: logger.error(f"Ungültige Job-IDs Liste: {job_ids}") return False # Duplikate entfernen und Reihenfolge beibehalten unique_job_ids = [] seen = set() for job_id in job_ids: if job_id not in seen: unique_job_ids.append(job_id) seen.add(job_id) if len(unique_job_ids) != len(job_ids): logger.warning(f"Duplikate in Job-IDs entfernt: {job_ids} -> {unique_job_ids}") job_ids = unique_job_ids # Datenbank-Implementierung mit JobOrder-Tabelle success = JobOrder.update_printer_order( printer_id=printer_id, job_ids=job_ids, modified_by_user_id=user_id ) if success: # Cache aktualisieren self.job_order_cache[printer_id] = job_ids logger.info(f"Job-Reihenfolge für Drucker {printer_id} erfolgreich aktualisiert: {job_ids}") logger.info(f"Aktualisiert von Benutzer: {user_id}") # Optional: Bereinigung ungültiger Einträge im Hintergrund self._schedule_cleanup() return True else: logger.error(f"Fehler beim Speichern der Job-Reihenfolge in der Datenbank") return False except Exception as e: logger.error(f"Unerwarteter Fehler beim Aktualisieren der Job-Reihenfolge: {str(e)}") return False def get_job_order(self, printer_id: int) -> List[int]: """Holt die aktuelle Job-Reihenfolge für einen Drucker""" try: # Erst aus Cache versuchen if printer_id in self.job_order_cache: cached_order = self.job_order_cache[printer_id] logger.debug(f"Job-Reihenfolge aus Cache für Drucker {printer_id}: {cached_order}") return cached_order # Aus Datenbank laden job_ids = JobOrder.get_ordered_job_ids(printer_id) # Cache aktualisieren self.job_order_cache[printer_id] = job_ids logger.debug(f"Job-Reihenfolge aus Datenbank geladen für Drucker {printer_id}: {job_ids}") return job_ids except Exception as e: logger.error(f"Fehler beim Laden der Job-Reihenfolge für Drucker {printer_id}: {str(e)}") return [] def get_ordered_jobs_for_printer(self, printer_id: int) -> List[Job]: """ Holt die Jobs für einen Drucker in der korrekten Reihenfolge. Args: printer_id: ID des Druckers Returns: List[Job]: Jobs sortiert nach der benutzerdefinierten Reihenfolge """ try: # Job-IDs in der korrekten Reihenfolge holen ordered_job_ids = self.get_job_order(printer_id) if not ordered_job_ids: # Fallback: Jobs nach Standard-Kriterien sortieren with get_db_session() as db_session: jobs = db_session.query(Job).filter( Job.printer_id == printer_id, Job.status.in_(['scheduled', 'paused']) ).order_by(Job.created_at).all() return jobs # Jobs in der definierten Reihenfolge laden with get_db_session() as db_session: # Alle relevanten Jobs laden all_jobs = db_session.query(Job).filter( Job.printer_id == printer_id, Job.status.in_(['scheduled', 'paused']) ).all() # Dictionary für schnelle Zugriffe jobs_dict = {job.id: job for job in all_jobs} # Jobs in der korrekten Reihenfolge zusammenstellen ordered_jobs = [] for job_id in ordered_job_ids: if job_id in jobs_dict: ordered_jobs.append(jobs_dict[job_id]) # Jobs hinzufügen, die nicht in der Reihenfolge sind (neue Jobs) ordered_job_ids_set = set(ordered_job_ids) unordered_jobs = [job for job in all_jobs if job.id not in ordered_job_ids_set] if unordered_jobs: # Neue Jobs nach Erstellungsdatum sortieren und anhängen unordered_jobs.sort(key=lambda x: x.created_at) ordered_jobs.extend(unordered_jobs) # Reihenfolge automatisch aktualisieren für neue Jobs new_order = [job.id for job in ordered_jobs] self.update_job_order(printer_id, new_order) logger.debug(f"Jobs für Drucker {printer_id} in Reihenfolge geladen: {len(ordered_jobs)} Jobs") return ordered_jobs except Exception as e: logger.error(f"Fehler beim Laden der sortierten Jobs für Drucker {printer_id}: {str(e)}") # Fallback: Unsortierte Jobs zurückgeben try: with get_db_session() as db_session: jobs = db_session.query(Job).filter( Job.printer_id == printer_id, Job.status.in_(['scheduled', 'paused']) ).order_by(Job.created_at).all() return jobs except Exception as fallback_error: logger.error(f"Auch Fallback fehlgeschlagen: {str(fallback_error)}") return [] def remove_job_from_order(self, job_id: int) -> bool: """ Entfernt einen Job aus allen Drucker-Reihenfolgen. Args: job_id: ID des zu entfernenden Jobs Returns: bool: True wenn erfolgreich """ try: # Aus Datenbank entfernen JobOrder.remove_job_from_orders(job_id) # Cache aktualisieren: Job aus allen Caches entfernen for printer_id in list(self.job_order_cache.keys()): if job_id in self.job_order_cache[printer_id]: self.job_order_cache[printer_id].remove(job_id) logger.debug(f"Job {job_id} aus Cache für Drucker {printer_id} entfernt") logger.info(f"Job {job_id} erfolgreich aus allen Reihenfolgen entfernt") return True except Exception as e: logger.error(f"Fehler beim Entfernen des Jobs {job_id} aus Reihenfolgen: {str(e)}") return False def cleanup_invalid_orders(self): """Bereinigt ungültige Job-Reihenfolgen""" try: # Datenbank-Bereinigung JobOrder.cleanup_invalid_orders() # Cache komplett leeren (wird bei Bedarf neu geladen) self.job_order_cache.clear() logger.info("Job-Reihenfolgen bereinigt") except Exception as e: logger.error(f"Fehler bei der Bereinigung der Job-Reihenfolgen: {str(e)}") def _schedule_cleanup(self): """Plant eine Bereinigung für später (non-blocking)""" try: # In produktiver Umgebung könnte hier ein Background-Task gestartet werden # Für jetzt führen wir eine schnelle Bereinigung durch import threading def cleanup_worker(): try: self.cleanup_invalid_orders() except Exception as e: logger.error(f"Hintergrund-Bereinigung fehlgeschlagen: {str(e)}") cleanup_thread = threading.Thread(target=cleanup_worker, daemon=True) cleanup_thread.start() except Exception as e: logger.debug(f"Konnte Hintergrund-Bereinigung nicht starten: {str(e)}") def get_printer_summary(self, printer_id: int) -> Dict[str, Any]: """ Erstellt eine Zusammenfassung der Job-Reihenfolge für einen Drucker. Args: printer_id: ID des Druckers Returns: Dict: Zusammenfassung mit Jobs, Reihenfolge, Statistiken """ try: ordered_jobs = self.get_ordered_jobs_for_printer(printer_id) # Statistiken berechnen total_duration = sum(job.duration_minutes for job in ordered_jobs) total_jobs = len(ordered_jobs) # Nächster Job next_job = ordered_jobs[0] if ordered_jobs else None # Job-Details für die Ausgabe job_details = [] for position, job in enumerate(ordered_jobs): job_details.append({ 'position': position, 'job_id': job.id, 'name': job.name, 'duration_minutes': job.duration_minutes, 'user_name': job.user.name if job.user else 'Unbekannt', 'created_at': job.created_at.isoformat() if job.created_at else None, 'status': job.status }) return { 'printer_id': printer_id, 'total_jobs': total_jobs, 'total_duration_minutes': total_duration, 'estimated_completion': self._calculate_completion_time(ordered_jobs), 'next_job': { 'id': next_job.id, 'name': next_job.name, 'user': next_job.user.name if next_job and next_job.user else None } if next_job else None, 'jobs': job_details, 'last_updated': datetime.now().isoformat() } except Exception as e: logger.error(f"Fehler beim Erstellen der Drucker-Zusammenfassung für {printer_id}: {str(e)}") return { 'printer_id': printer_id, 'total_jobs': 0, 'total_duration_minutes': 0, 'error': str(e) } def _calculate_completion_time(self, jobs: List[Job]) -> Optional[str]: """Berechnet die geschätzte Fertigstellungszeit""" try: if not jobs: return None total_minutes = sum(job.duration_minutes for job in jobs) completion_time = datetime.now() completion_time = completion_time.replace( minute=(completion_time.minute + total_minutes) % 60, hour=(completion_time.hour + (completion_time.minute + total_minutes) // 60) % 24 ) return completion_time.isoformat() except Exception: return None # Globale Instanz drag_drop_manager = DragDropManager() def validate_file_upload(file_data: Dict[str, Any], config: DragDropConfig) -> Tuple[bool, str]: """Validiert eine Datei für Upload""" filename = file_data.get('name', '') file_size = file_data.get('size', 0) file_type = file_data.get('type', '') # Dateiname prüfen if not filename: return False, "Dateiname ist erforderlich" # Dateierweiterung prüfen file_extension = filename.rsplit('.', 1)[-1].lower() if '.' in filename else '' if file_extension not in config.accepted_types: return False, f"Dateityp '{file_extension}' nicht erlaubt. Erlaubt: {', '.join(config.accepted_types)}" # Dateigröße prüfen if file_size > config.max_file_size: max_mb = config.max_file_size / (1024 * 1024) return False, f"Datei zu groß. Maximum: {max_mb:.1f} MB" if file_size <= 0: return False, "Datei ist leer" # MIME-Type validieren expected_mime = mimetypes.guess_type(filename)[0] if expected_mime and file_type and not file_type.startswith(expected_mime.split('/')[0]): return False, "MIME-Type stimmt nicht mit Dateierweiterung überein" # Custom Validierung if config.validation_rules: for rule_name, rule_value in config.validation_rules.items(): if rule_name == 'min_size' and file_size < rule_value: return False, f"Datei zu klein. Minimum: {rule_value} Bytes" elif rule_name == 'max_name_length' and len(filename) > rule_value: return False, f"Dateiname zu lang. Maximum: {rule_value} Zeichen" return True, "" def get_drag_drop_javascript() -> str: """Generiert JavaScript für Drag & Drop Funktionalität""" return """ class DragDropManager { constructor() { this.dropZones = new Map(); this.uploadSessions = new Map(); this.sortableContainers = new Map(); this.setupGlobalEventListeners(); } setupGlobalEventListeners() { // Verhindere Standard-Drag-Verhalten für das gesamte Dokument document.addEventListener('dragover', (e) => { e.preventDefault(); }); document.addEventListener('drop', (e) => { e.preventDefault(); }); } createDropZone(elementId, config = {}) { const element = document.getElementById(elementId); if (!element) { console.error(`Drop-Zone Element nicht gefunden: ${elementId}`); return null; } const dropZone = new DropZone(element, config); this.dropZones.set(elementId, dropZone); return dropZone; } createSortableContainer(elementId, config = {}) { const element = document.getElementById(elementId); if (!element) { console.error(`Sortable Container nicht gefunden: ${elementId}`); return null; } const sortable = new SortableContainer(element, config); this.sortableContainers.set(elementId, sortable); return sortable; } createUploadSession(sessionId) { const session = new UploadSession(sessionId); this.uploadSessions.set(sessionId, session); return session; } getUploadSession(sessionId) { return this.uploadSessions.get(sessionId); } } class DropZone { constructor(element, config = {}) { this.element = element; this.config = { acceptedTypes: ['*'], maxFiles: 10, maxFileSize: 10 * 1024 * 1024, // 10MB multiple: true, autoUpload: false, previewMode: 'thumbnail', uploadUrl: '/api/upload/drag-drop', onFilesAdded: null, onUploadProgress: null, onUploadComplete: null, onError: null, ...config }; this.files = []; this.dragCounter = 0; this.setupElement(); this.setupEventListeners(); } setupElement() { this.element.classList.add('drag-drop-zone'); if (!this.element.innerHTML.trim()) { this.element.innerHTML = `
📁
Dateien hierher ziehen oder
${this.getAcceptedTypesText()}
`; } this.fileInput = this.element.querySelector('.file-input'); this.previewContainer = this.element.querySelector('.file-preview-container'); this.selectButton = this.element.querySelector('.file-select-btn'); } setupEventListeners() { // Drag & Drop Events this.element.addEventListener('dragenter', (e) => { e.preventDefault(); this.dragCounter++; this.element.classList.add('drag-over'); }); this.element.addEventListener('dragleave', (e) => { e.preventDefault(); this.dragCounter--; if (this.dragCounter === 0) { this.element.classList.remove('drag-over'); } }); this.element.addEventListener('dragover', (e) => { e.preventDefault(); }); this.element.addEventListener('drop', (e) => { e.preventDefault(); this.dragCounter = 0; this.element.classList.remove('drag-over'); const files = Array.from(e.dataTransfer.files); this.handleFiles(files); }); // File Input Events this.selectButton?.addEventListener('click', () => { this.fileInput.click(); }); this.fileInput.addEventListener('change', (e) => { const files = Array.from(e.target.files); this.handleFiles(files); }); // Keyboard Support this.selectButton?.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.fileInput.click(); } }); } handleFiles(files) { // Validierung const validFiles = []; const errors = []; for (const file of files) { const validation = this.validateFile(file); if (validation.valid) { validFiles.push(file); } else { errors.push(`${file.name}: ${validation.error}`); } } // Fehler anzeigen if (errors.length > 0) { this.showErrors(errors); } // Gültige Dateien hinzufügen if (validFiles.length > 0) { this.addFiles(validFiles); } } validateFile(file) { // Anzahl Dateien prüfen if (!this.config.multiple && this.files.length >= 1) { return { valid: false, error: 'Nur eine Datei erlaubt' }; } if (this.files.length >= this.config.maxFiles) { return { valid: false, error: `Maximum ${this.config.maxFiles} Dateien erlaubt` }; } // Dateigröße prüfen if (file.size > this.config.maxFileSize) { const maxMB = this.config.maxFileSize / (1024 * 1024); return { valid: false, error: `Datei zu groß. Maximum: ${maxMB.toFixed(1)} MB` }; } // Dateityp prüfen if (!this.isFileTypeAccepted(file)) { return { valid: false, error: `Dateityp nicht erlaubt: ${file.type}` }; } return { valid: true }; } isFileTypeAccepted(file) { if (this.config.acceptedTypes.includes('*')) { return true; } const fileExtension = file.name.split('.').pop().toLowerCase(); return this.config.acceptedTypes.includes(fileExtension) || this.config.acceptedTypes.some(type => file.type.includes(type)); } addFiles(files) { files.forEach(file => { const fileObj = { id: this.generateFileId(), file: file, name: file.name, size: file.size, type: file.type, preview: null, uploadProgress: 0, status: 'pending' // pending, uploading, completed, error }; this.files.push(fileObj); this.createFilePreview(fileObj); }); this.updateUI(); // Callback aufrufen if (this.config.onFilesAdded) { this.config.onFilesAdded(files, this.files); } // Auto-Upload if (this.config.autoUpload) { this.uploadFiles(); } } createFilePreview(fileObj) { const preview = document.createElement('div'); preview.className = 'file-preview'; preview.dataset.fileId = fileObj.id; // Preview basierend auf Modus if (this.config.previewMode === 'thumbnail') { preview.innerHTML = this.createThumbnailPreview(fileObj); } else { preview.innerHTML = this.createListPreview(fileObj); } this.previewContainer.appendChild(preview); // Remove Button const removeBtn = preview.querySelector('.remove-file-btn'); removeBtn?.addEventListener('click', () => { this.removeFile(fileObj.id); }); // Thumbnail generieren (für Bilder) if (fileObj.file.type.startsWith('image/')) { this.generateThumbnail(fileObj, preview); } } createThumbnailPreview(fileObj) { return `
${this.getFileIcon(fileObj.type)}
${fileObj.name}
${this.formatFileSize(fileObj.size)}
${fileObj.uploadProgress}%
`; } createListPreview(fileObj) { return `
${this.getFileIcon(fileObj.type)} ${fileObj.name} ${this.formatFileSize(fileObj.size)}
`; } generateThumbnail(fileObj, previewElement) { const reader = new FileReader(); reader.onload = (e) => { const img = previewElement.querySelector('.file-icon'); if (img) { img.innerHTML = `${fileObj.name}`; } }; reader.readAsDataURL(fileObj.file); } getFileIcon(fileType) { if (fileType.startsWith('image/')) return '🖼️'; if (fileType.startsWith('video/')) return '🎬'; if (fileType.startsWith('audio/')) return '🎵'; if (fileType.includes('pdf')) return '📄'; if (fileType.includes('text/') || fileType.includes('document')) return '📝'; if (fileType.includes('zip') || fileType.includes('archive')) return '🗜️'; return '📁'; } formatFileSize(bytes) { if (bytes === 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } removeFile(fileId) { this.files = this.files.filter(f => f.id !== fileId); const preview = this.previewContainer.querySelector(`[data-file-id="${fileId}"]`); if (preview) { preview.remove(); } this.updateUI(); } updateUI() { // UI-Updates basierend auf Dateien-Status if (this.files.length === 0) { this.element.classList.remove('has-files'); } else { this.element.classList.add('has-files'); } } async uploadFiles() { const sessionId = this.generateSessionId(); for (const fileObj of this.files) { if (fileObj.status === 'pending') { await this.uploadFile(fileObj, sessionId); } } } async uploadFile(fileObj, sessionId) { fileObj.status = 'uploading'; const formData = new FormData(); formData.append('file', fileObj.file); formData.append('session_id', sessionId); formData.append('file_id', fileObj.id); try { const response = await fetch(this.config.uploadUrl, { method: 'POST', body: formData, // Progress tracking würde XMLHttpRequest benötigen }); if (response.ok) { const result = await response.json(); fileObj.status = 'completed'; fileObj.uploadProgress = 100; fileObj.uploadResult = result; this.updateFilePreview(fileObj); if (this.config.onUploadComplete) { this.config.onUploadComplete(fileObj, result); } } else { throw new Error(`Upload fehlgeschlagen: ${response.status}`); } } catch (error) { fileObj.status = 'error'; fileObj.error = error.message; this.updateFilePreview(fileObj); if (this.config.onError) { this.config.onError(fileObj, error); } } } updateFilePreview(fileObj) { const preview = this.previewContainer.querySelector(`[data-file-id="${fileObj.id}"]`); if (preview) { const progressFill = preview.querySelector('.progress-fill'); const progressText = preview.querySelector('.progress-text'); if (progressFill) { progressFill.style.width = `${fileObj.uploadProgress}%`; } if (progressText) { progressText.textContent = `${fileObj.uploadProgress}%`; } preview.classList.remove('status-pending', 'status-uploading', 'status-completed', 'status-error'); preview.classList.add(`status-${fileObj.status}`); } } getAcceptedTypesText() { if (this.config.acceptedTypes.includes('*')) { return 'Alle Dateitypen erlaubt'; } return `Erlaubt: ${this.config.acceptedTypes.join(', ')}`; } getAcceptAttribute() { if (this.config.acceptedTypes.includes('*')) { return '*/*'; } return this.config.acceptedTypes.map(type => `.${type}`).join(','); } showErrors(errors) { const errorContainer = document.createElement('div'); errorContainer.className = 'upload-errors'; errorContainer.innerHTML = `
Upload-Fehler:
`; this.element.appendChild(errorContainer); errorContainer.querySelector('.close-errors-btn').addEventListener('click', () => { errorContainer.remove(); }); // Auto-remove nach 5 Sekunden setTimeout(() => { if (errorContainer.parentNode) { errorContainer.remove(); } }, 5000); } generateFileId() { return 'file_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); } generateSessionId() { return 'session_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); } } class SortableContainer { constructor(element, config = {}) { this.element = element; this.config = { handle: null, placeholder: 'sortable-placeholder', ghostClass: 'sortable-ghost', chosenClass: 'sortable-chosen', dragClass: 'sortable-drag', onUpdate: null, onSort: null, disabled: false, animation: 150, ...config }; this.dragElement = null; this.placeholder = null; this.isDragging = false; this.startIndex = -1; this.endIndex = -1; this.setupSortable(); } setupSortable() { this.element.classList.add('sortable-container'); // Event Listeners für alle draggable Items this.updateEventListeners(); } updateEventListeners() { const items = this.getSortableItems(); items.forEach((item, index) => { item.draggable = true; item.dataset.sortableIndex = index; // Remove existing listeners item.removeEventListener('dragstart', this.handleDragStart); item.removeEventListener('dragend', this.handleDragEnd); item.removeEventListener('dragover', this.handleDragOver); item.removeEventListener('drop', this.handleDrop); // Add new listeners item.addEventListener('dragstart', this.handleDragStart.bind(this)); item.addEventListener('dragend', this.handleDragEnd.bind(this)); item.addEventListener('dragover', this.handleDragOver.bind(this)); item.addEventListener('drop', this.handleDrop.bind(this)); }); } getSortableItems() { return Array.from(this.element.children).filter(child => !child.classList.contains('sortable-placeholder') && !child.classList.contains('non-sortable') ); } handleDragStart(e) { if (this.config.disabled) { e.preventDefault(); return; } this.isDragging = true; this.dragElement = e.target.closest('[draggable="true"]'); this.startIndex = parseInt(this.dragElement.dataset.sortableIndex); this.dragElement.classList.add(this.config.chosenClass); // Create placeholder this.createPlaceholder(); // Set drag data e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/html', this.dragElement.outerHTML); // Add ghost class after a delay to allow proper drag image setTimeout(() => { if (this.dragElement) { this.dragElement.classList.add(this.config.ghostClass); } }, 0); } handleDragEnd(e) { if (!this.isDragging) return; this.isDragging = false; // Clean up classes if (this.dragElement) { this.dragElement.classList.remove( this.config.chosenClass, this.config.ghostClass, this.config.dragClass ); } // Remove placeholder this.removePlaceholder(); // Update positions this.endIndex = this.findElementIndex(this.dragElement); if (this.startIndex !== this.endIndex) { // Trigger callbacks if (this.config.onUpdate) { this.config.onUpdate({ item: this.dragElement, oldIndex: this.startIndex, newIndex: this.endIndex, from: this.element, to: this.element }); } if (this.config.onSort) { this.config.onSort(this.getSortOrder()); } } // Reset this.dragElement = null; this.startIndex = -1; this.endIndex = -1; // Update event listeners for new order this.updateEventListeners(); } handleDragOver(e) { if (!this.isDragging) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; const afterElement = this.getDragAfterElement(e.clientY); if (afterElement == null) { this.element.appendChild(this.placeholder); } else { this.element.insertBefore(this.placeholder, afterElement); } } handleDrop(e) { if (!this.isDragging) return; e.preventDefault(); // Insert the dragged element at the placeholder position if (this.placeholder && this.dragElement) { this.element.insertBefore(this.dragElement, this.placeholder); } } createPlaceholder() { this.placeholder = document.createElement('div'); this.placeholder.className = this.config.placeholder; this.placeholder.style.height = this.dragElement.offsetHeight + 'px'; this.placeholder.innerHTML = '
Drop hier einfügen
'; } removePlaceholder() { if (this.placeholder && this.placeholder.parentNode) { this.placeholder.parentNode.removeChild(this.placeholder); } this.placeholder = null; } getDragAfterElement(y) { const draggableElements = [...this.element.querySelectorAll('[draggable="true"]:not(.sortable-ghost)')]; return draggableElements.reduce((closest, child) => { const box = child.getBoundingClientRect(); const offset = y - box.top - box.height / 2; if (offset < 0 && offset > closest.offset) { return { offset: offset, element: child }; } else { return closest; } }, { offset: Number.NEGATIVE_INFINITY }).element; } findElementIndex(element) { const items = this.getSortableItems(); return items.indexOf(element); } getSortOrder() { return this.getSortableItems().map(item => ({ element: item, id: item.dataset.id || item.id, index: this.findElementIndex(item) })); } disable() { this.config.disabled = true; this.element.classList.add('sortable-disabled'); } enable() { this.config.disabled = false; this.element.classList.remove('sortable-disabled'); } } class UploadSession { constructor(sessionId) { this.sessionId = sessionId; this.files = new Map(); this.totalSize = 0; this.uploadedSize = 0; } addFile(fileId, fileSize) { this.files.set(fileId, { id: fileId, size: fileSize, uploaded: 0, status: 'pending' }); this.totalSize += fileSize; } updateFileProgress(fileId, uploadedBytes, status = null) { const file = this.files.get(fileId); if (file) { const previousUploaded = file.uploaded; file.uploaded = uploadedBytes; if (status) { file.status = status; } // Update total uploaded this.uploadedSize += (uploadedBytes - previousUploaded); } } getProgress() { return { totalSize: this.totalSize, uploadedSize: this.uploadedSize, percentage: this.totalSize > 0 ? (this.uploadedSize / this.totalSize) * 100 : 0, files: Array.from(this.files.values()) }; } } // CSS für Drag & Drop (als String für Injection) const dragDropCSS = ` .drag-drop-zone { border: 2px dashed #d1d5db; border-radius: 8px; padding: 2rem; text-align: center; transition: all 0.3s ease; background-color: #f9fafb; position: relative; } .drag-drop-zone.drag-over { border-color: #3b82f6; background-color: #eff6ff; transform: scale(1.02); } .drag-drop-zone.has-files { border-style: solid; background-color: white; } .drop-zone-content { margin-bottom: 1rem; } .drop-zone-icon { font-size: 3rem; margin-bottom: 1rem; } .drop-zone-text { font-size: 1.1rem; color: #374151; margin-bottom: 0.5rem; } .file-select-btn { color: #3b82f6; text-decoration: underline; background: none; border: none; cursor: pointer; font-size: inherit; } .file-select-btn:hover { color: #1d4ed8; } .drop-zone-info { font-size: 0.875rem; color: #6b7280; } .file-preview-container { margin-top: 1rem; } .file-preview { margin-bottom: 0.5rem; animation: slideIn 0.3s ease; } .file-thumbnail { display: flex; align-items: center; background: white; border: 1px solid #e5e7eb; border-radius: 6px; padding: 0.75rem; position: relative; } .file-icon { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; font-size: 1.5rem; margin-right: 0.75rem; border-radius: 4px; background: #f3f4f6; } .file-info { flex: 1; min-width: 0; } .file-name { font-weight: 500; color: #111827; truncate: true; margin-bottom: 0.25rem; } .file-size { font-size: 0.875rem; color: #6b7280; margin-bottom: 0.5rem; } .file-progress { display: flex; align-items: center; gap: 0.5rem; } .progress-bar { flex: 1; height: 4px; background: #e5e7eb; border-radius: 2px; overflow: hidden; } .progress-fill { height: 100%; background: #3b82f6; transition: width 0.3s ease; } .progress-text { font-size: 0.75rem; color: #6b7280; min-width: 3rem; text-align: right; } .remove-file-btn { position: absolute; top: 0.5rem; right: 0.5rem; width: 1.5rem; height: 1.5rem; border: none; background: #ef4444; color: white; border-radius: 50%; cursor: pointer; font-size: 1rem; line-height: 1; display: flex; align-items: center; justify-content: center; } .remove-file-btn:hover { background: #dc2626; } .file-preview.status-completed .progress-fill { background: #10b981; } .file-preview.status-error .progress-fill { background: #ef4444; } .file-preview.status-error { border-color: #fecaca; background: #fef2f2; } .upload-errors { margin-top: 1rem; padding: 1rem; background: #fef2f2; border: 1px solid #fecaca; border-radius: 6px; color: #dc2626; } .error-title { font-weight: 600; margin-bottom: 0.5rem; } .close-errors-btn { margin-top: 0.5rem; padding: 0.25rem 0.5rem; background: #dc2626; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 0.875rem; } /* Sortable Styles */ .sortable-container { min-height: 2rem; } .sortable-container [draggable="true"] { cursor: move; transition: all 0.15s ease; } .sortable-container [draggable="true"]:hover { transform: translateY(-1px); box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); } .sortable-placeholder { background: #eff6ff; border: 2px dashed #3b82f6; border-radius: 6px; margin: 0.25rem 0; opacity: 0.8; display: flex; align-items: center; justify-content: center; color: #3b82f6; font-weight: 500; } .placeholder-content { padding: 1rem; } .sortable-ghost { opacity: 0.4; transform: scale(0.98); } .sortable-chosen { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); } .sortable-disabled [draggable="true"] { cursor: not-allowed; opacity: 0.6; } @keyframes slideIn { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } } /* Mobile Touch Support */ @media (max-width: 768px) { .drag-drop-zone { padding: 1rem; } .drop-zone-text { font-size: 1rem; } .file-thumbnail { padding: 0.5rem; } .file-icon { width: 32px; height: 32px; font-size: 1.25rem; } } `; // CSS automatisch injizieren if (!document.getElementById('drag-drop-styles')) { const style = document.createElement('style'); style.id = 'drag-drop-styles'; style.textContent = dragDropCSS; document.head.appendChild(style); } // Globale Instanz erstellen window.dragDropManager = new DragDropManager(); // Auto-Initialize für vorhandene Drop-Zones document.addEventListener('DOMContentLoaded', function() { // Auto-init für Elemente mit data-drag-drop Attribut document.querySelectorAll('[data-drag-drop]').forEach(element => { const config = JSON.parse(element.dataset.dragDrop || '{}'); window.dragDropManager.createDropZone(element.id, config); }); // Auto-init für Sortable Container document.querySelectorAll('[data-sortable]').forEach(element => { const config = JSON.parse(element.dataset.sortable || '{}'); window.dragDropManager.createSortableContainer(element.id, config); }); }); """ def get_drag_drop_css() -> str: """Gibt CSS für Drag & Drop zurück""" return """ /* Drag & Drop CSS ist bereits im JavaScript enthalten */ """