Projektarbeit-MYP/backend/utils/drag_drop_system.py

1231 lines
42 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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 = `
<div class="drop-zone-content">
<div class="drop-zone-icon">📁</div>
<div class="drop-zone-text">
Dateien hierher ziehen oder
<button type="button" class="file-select-btn">durchsuchen</button>
</div>
<div class="drop-zone-info">
${this.getAcceptedTypesText()}
</div>
</div>
<div class="file-preview-container"></div>
<input type="file" class="file-input" style="display: none;"
${this.config.multiple ? 'multiple' : ''}
accept="${this.getAcceptAttribute()}">
`;
}
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 `
<div class="file-thumbnail">
<div class="file-icon" data-type="${fileObj.type}">
${this.getFileIcon(fileObj.type)}
</div>
<div class="file-info">
<div class="file-name" title="${fileObj.name}">${fileObj.name}</div>
<div class="file-size">${this.formatFileSize(fileObj.size)}</div>
<div class="file-progress">
<div class="progress-bar">
<div class="progress-fill" style="width: ${fileObj.uploadProgress}%"></div>
</div>
<span class="progress-text">${fileObj.uploadProgress}%</span>
</div>
</div>
<button class="remove-file-btn" type="button" aria-label="Datei entfernen">×</button>
</div>
`;
}
createListPreview(fileObj) {
return `
<div class="file-list-item">
<span class="file-icon">${this.getFileIcon(fileObj.type)}</span>
<span class="file-name">${fileObj.name}</span>
<span class="file-size">${this.formatFileSize(fileObj.size)}</span>
<div class="file-progress">
<div class="progress-bar">
<div class="progress-fill" style="width: ${fileObj.uploadProgress}%"></div>
</div>
</div>
<button class="remove-file-btn" type="button">Entfernen</button>
</div>
`;
}
generateThumbnail(fileObj, previewElement) {
const reader = new FileReader();
reader.onload = (e) => {
const img = previewElement.querySelector('.file-icon');
if (img) {
img.innerHTML = `<img src="${e.target.result}" alt="${fileObj.name}" style="width: 100%; height: 100%; object-fit: cover;">`;
}
};
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 = `
<div class="error-title">Upload-Fehler:</div>
<ul>
${errors.map(error => `<li>${error}</li>`).join('')}
</ul>
<button class="close-errors-btn">Schließen</button>
`;
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 = '<div class="placeholder-content">Drop hier einfügen</div>';
}
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 */
"""