1479 lines
52 KiB
Python
1479 lines
52 KiB
Python
"""
|
||
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 */
|
||
""" |