""" 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, 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: with get_db_session() as db_session: # Validiere dass alle Jobs existieren und zum Drucker gehören jobs = db_session.query(Job).filter( Job.id.in_(job_ids), Job.printer_id == printer_id, Job.status.in_(['scheduled', 'paused']) ).all() if len(jobs) != len(job_ids): logger.warning(f"Nicht alle Jobs gefunden oder gehören zu Drucker {printer_id}") return False # Cache aktualisieren self.job_order_cache[printer_id] = job_ids # Optional: In Datenbank speichern (erweiterte Implementierung) # Hier könnte man ein separates Job-Order-Table verwenden logger.info(f"Job-Reihenfolge für Drucker {printer_id} aktualisiert: {job_ids}") return True except Exception as e: logger.error(f"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""" return self.job_order_cache.get(printer_id, []) # 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 */ """