manage-your-printer/utils/drag_drop_system.py
2025-06-04 10:03:22 +02:00

1479 lines
52 KiB
Python
Raw Permalink 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, 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 = `
<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 */
"""