Aniworld/src/server/drag_drop.py

765 lines
23 KiB
Python

"""
Drag and Drop Functionality for File Operations
This module provides drag-and-drop capabilities for the AniWorld web interface,
including file uploads, series reordering, and batch operations.
"""
class DragDropManager:
"""Manages drag and drop operations for the web interface."""
def __init__(self):
self.supported_files = ['.mp4', '.mkv', '.avi', '.mov', '.wmv', '.flv', '.webm']
self.max_file_size = 50 * 1024 * 1024 * 1024 # 50GB
def get_drag_drop_js(self):
"""Generate JavaScript code for drag and drop functionality."""
return f"""
// AniWorld Drag & Drop Manager
class DragDropManager {{
constructor() {{
this.supportedFiles = {self.supported_files};
this.maxFileSize = {self.max_file_size};
this.dropZones = new Map();
this.dragData = null;
this.init();
}}
init() {{
this.setupGlobalDragDrop();
this.setupSeriesReordering();
this.setupBatchOperations();
this.createDropZoneOverlay();
}}
setupGlobalDragDrop() {{
// Prevent default drag behaviors on document
document.addEventListener('dragenter', this.handleDragEnter.bind(this));
document.addEventListener('dragover', this.handleDragOver.bind(this));
document.addEventListener('dragleave', this.handleDragLeave.bind(this));
document.addEventListener('drop', this.handleDrop.bind(this));
// Setup file drop zones
this.initializeDropZones();
}}
initializeDropZones() {{
// Main content area drop zone
const mainContent = document.querySelector('.main-content, .container-fluid');
if (mainContent) {{
this.createDropZone(mainContent, {{
types: ['files'],
accept: this.supportedFiles,
multiple: true,
callback: this.handleFileUpload.bind(this)
}});
}}
// Series list drop zone for reordering
const seriesList = document.querySelector('.series-list, .anime-grid');
if (seriesList) {{
this.createDropZone(seriesList, {{
types: ['series'],
callback: this.handleSeriesReorder.bind(this)
}});
}}
// Queue drop zone
const queueArea = document.querySelector('.queue-area, .download-queue');
if (queueArea) {{
this.createDropZone(queueArea, {{
types: ['series', 'episodes'],
callback: this.handleQueueOperation.bind(this)
}});
}}
}}
createDropZone(element, options) {{
const dropZone = {{
element: element,
options: options,
active: false
}};
this.dropZones.set(element, dropZone);
// Add drop zone event listeners
element.addEventListener('dragenter', (e) => this.onDropZoneEnter(e, dropZone));
element.addEventListener('dragover', (e) => this.onDropZoneOver(e, dropZone));
element.addEventListener('dragleave', (e) => this.onDropZoneLeave(e, dropZone));
element.addEventListener('drop', (e) => this.onDropZoneDrop(e, dropZone));
// Add visual indicators
element.classList.add('drop-zone');
return dropZone;
}}
setupSeriesReordering() {{
const seriesItems = document.querySelectorAll('.series-item, .anime-card');
seriesItems.forEach(item => {{
item.draggable = true;
item.addEventListener('dragstart', this.handleSeriesDragStart.bind(this));
item.addEventListener('dragend', this.handleSeriesDragEnd.bind(this));
}});
}}
setupBatchOperations() {{
// Enable dragging of selected series for batch operations
const selectionArea = document.querySelector('.series-selection, .selection-controls');
if (selectionArea) {{
selectionArea.addEventListener('dragstart', this.handleBatchDragStart.bind(this));
}}
}}
handleDragEnter(e) {{
e.preventDefault();
e.stopPropagation();
if (this.hasFiles(e)) {{
this.showDropOverlay();
}}
}}
handleDragOver(e) {{
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'copy';
}}
handleDragLeave(e) {{
e.preventDefault();
e.stopPropagation();
// Only hide overlay if leaving the window
if (e.clientX === 0 && e.clientY === 0) {{
this.hideDropOverlay();
}}
}}
handleDrop(e) {{
e.preventDefault();
e.stopPropagation();
this.hideDropOverlay();
if (this.hasFiles(e)) {{
this.handleFileUpload(e.dataTransfer.files);
}}
}}
onDropZoneEnter(e, dropZone) {{
e.preventDefault();
e.stopPropagation();
if (this.canAcceptDrop(e, dropZone)) {{
dropZone.element.classList.add('drag-over');
dropZone.active = true;
}}
}}
onDropZoneOver(e, dropZone) {{
e.preventDefault();
e.stopPropagation();
if (dropZone.active) {{
e.dataTransfer.dropEffect = 'copy';
}}
}}
onDropZoneLeave(e, dropZone) {{
e.preventDefault();
// Check if we're actually leaving the drop zone
const rect = dropZone.element.getBoundingClientRect();
const x = e.clientX;
const y = e.clientY;
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {{
dropZone.element.classList.remove('drag-over');
dropZone.active = false;
}}
}}
onDropZoneDrop(e, dropZone) {{
e.preventDefault();
e.stopPropagation();
dropZone.element.classList.remove('drag-over');
dropZone.active = false;
if (dropZone.options.callback) {{
if (this.hasFiles(e)) {{
dropZone.options.callback(e.dataTransfer.files, 'files');
}} else {{
dropZone.options.callback(this.dragData, 'data');
}}
}}
}}
canAcceptDrop(e, dropZone) {{
const types = dropZone.options.types || [];
if (this.hasFiles(e) && types.includes('files')) {{
return this.validateFiles(e.dataTransfer.files, dropZone.options);
}}
if (this.dragData && types.includes(this.dragData.type)) {{
return true;
}}
return false;
}}
hasFiles(e) {{
return e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length > 0;
}}
validateFiles(files, options) {{
const accept = options.accept || [];
const maxSize = options.maxSize || this.maxFileSize;
const multiple = options.multiple !== false;
if (!multiple && files.length > 1) {{
return false;
}}
for (let file of files) {{
// Check file size
if (file.size > maxSize) {{
return false;
}}
// Check file extension
if (accept.length > 0) {{
const ext = '.' + file.name.split('.').pop().toLowerCase();
if (!accept.includes(ext)) {{
return false;
}}
}}
}}
return true;
}}
handleSeriesDragStart(e) {{
const seriesItem = e.target.closest('.series-item, .anime-card');
if (!seriesItem) return;
this.dragData = {{
type: 'series',
element: seriesItem,
data: {{
id: seriesItem.dataset.seriesId || seriesItem.dataset.id,
name: seriesItem.dataset.seriesName || seriesItem.querySelector('.series-name, .anime-title')?.textContent,
folder: seriesItem.dataset.folder
}}
}};
// Create drag image
const dragImage = this.createDragImage(seriesItem);
e.dataTransfer.setDragImage(dragImage, 0, 0);
e.dataTransfer.effectAllowed = 'move';
seriesItem.classList.add('dragging');
}}
handleSeriesDragEnd(e) {{
const seriesItem = e.target.closest('.series-item, .anime-card');
if (seriesItem) {{
seriesItem.classList.remove('dragging');
}}
this.dragData = null;
}}
handleBatchDragStart(e) {{
const selectedItems = document.querySelectorAll('.series-item.selected, .anime-card.selected');
if (selectedItems.length === 0) return;
this.dragData = {{
type: 'batch',
count: selectedItems.length,
items: Array.from(selectedItems).map(item => ({{
id: item.dataset.seriesId || item.dataset.id,
name: item.dataset.seriesName || item.querySelector('.series-name, .anime-title')?.textContent,
folder: item.dataset.folder
}}))
}};
// Create batch drag image
const dragImage = this.createBatchDragImage(selectedItems.length);
e.dataTransfer.setDragImage(dragImage, 0, 0);
e.dataTransfer.effectAllowed = 'move';
}}
handleFileUpload(files, type = 'files') {{
if (files.length === 0) return;
const validFiles = [];
const errors = [];
// Validate each file
for (let file of files) {{
const ext = '.' + file.name.split('.').pop().toLowerCase();
if (!this.supportedFiles.includes(ext)) {{
errors.push(`Unsupported file type: ${{file.name}}`);
continue;
}}
if (file.size > this.maxFileSize) {{
errors.push(`File too large: ${{file.name}} (${{this.formatFileSize(file.size)}})`);
continue;
}}
validFiles.push(file);
}}
// Show errors if any
if (errors.length > 0) {{
this.showUploadErrors(errors);
}}
// Process valid files
if (validFiles.length > 0) {{
this.showUploadProgress(validFiles);
this.uploadFiles(validFiles);
}}
}}
handleSeriesReorder(data, type) {{
if (type !== 'data' || !data || data.type !== 'series') return;
// Find drop position
const seriesList = document.querySelector('.series-list, .anime-grid');
const items = seriesList.querySelectorAll('.series-item, .anime-card');
// Implement reordering logic
this.reorderSeries(data.data.id, items);
}}
handleQueueOperation(data, type) {{
if (type === 'files') {{
// Handle file drops to queue
this.addFilesToQueue(data);
}} else if (type === 'data') {{
// Handle series/episode drops to queue
this.addToQueue(data);
}}
}}
createDropZoneOverlay() {{
const overlay = document.createElement('div');
overlay.id = 'drop-overlay';
overlay.className = 'drop-overlay';
overlay.innerHTML = `
<div class="drop-message">
<i class="fas fa-cloud-upload-alt"></i>
<h3>Drop Files Here</h3>
<p>Supported formats: ${{this.supportedFiles.join(', ')}}</p>
<p>Maximum size: ${{this.formatFileSize(this.maxFileSize)}}</p>
</div>
`;
document.body.appendChild(overlay);
}}
showDropOverlay() {{
const overlay = document.getElementById('drop-overlay');
if (overlay) {{
overlay.style.display = 'flex';
}}
}}
hideDropOverlay() {{
const overlay = document.getElementById('drop-overlay');
if (overlay) {{
overlay.style.display = 'none';
}}
}}
createDragImage(element) {{
const clone = element.cloneNode(true);
clone.style.position = 'absolute';
clone.style.top = '-1000px';
clone.style.opacity = '0.8';
clone.style.transform = 'rotate(5deg)';
document.body.appendChild(clone);
setTimeout(() => document.body.removeChild(clone), 100);
return clone;
}}
createBatchDragImage(count) {{
const dragImage = document.createElement('div');
dragImage.className = 'batch-drag-image';
dragImage.innerHTML = `
<i class="fas fa-files"></i>
<span>${{count}} items</span>
`;
dragImage.style.position = 'absolute';
dragImage.style.top = '-1000px';
document.body.appendChild(dragImage);
setTimeout(() => document.body.removeChild(dragImage), 100);
return dragImage;
}}
formatFileSize(bytes) {{
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
if (bytes === 0) return '0 Bytes';
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
}}
showUploadErrors(errors) {{
const errorModal = document.createElement('div');
errorModal.className = 'modal fade';
errorModal.innerHTML = `
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Upload Errors</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<ul class="list-unstyled">
${{errors.map(error => `<li class="text-danger"><i class="fas fa-exclamation-triangle"></i> ${{error}}</li>`).join('')}}
</ul>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
`;
document.body.appendChild(errorModal);
const modal = new bootstrap.Modal(errorModal);
modal.show();
errorModal.addEventListener('hidden.bs.modal', () => {{
document.body.removeChild(errorModal);
}});
}}
showUploadProgress(files) {{
// Create upload progress modal
const progressModal = document.createElement('div');
progressModal.className = 'modal fade';
progressModal.id = 'upload-progress-modal';
progressModal.setAttribute('data-bs-backdrop', 'static');
progressModal.innerHTML = `
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Uploading Files</h5>
</div>
<div class="modal-body">
<div id="upload-progress-list"></div>
<div class="mt-3">
<div class="progress">
<div class="progress-bar" id="overall-progress" style="width: 0%"></div>
</div>
<small class="text-muted">Overall progress</small>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" id="cancel-upload">Cancel</button>
</div>
</div>
</div>
`;
document.body.appendChild(progressModal);
const modal = new bootstrap.Modal(progressModal);
modal.show();
return modal;
}}
uploadFiles(files) {{
// This would implement the actual file upload logic
// For now, just simulate upload progress
const progressModal = this.showUploadProgress(files);
files.forEach((file, index) => {{
this.simulateFileUpload(file, index, files.length);
}});
}}
simulateFileUpload(file, index, total) {{
const progressList = document.getElementById('upload-progress-list');
const fileProgress = document.createElement('div');
fileProgress.className = 'mb-2';
fileProgress.innerHTML = `
<div class="d-flex justify-content-between">
<span class="text-truncate">${{file.name}}</span>
<span class="text-muted">${{this.formatFileSize(file.size)}}</span>
</div>
<div class="progress progress-sm">
<div class="progress-bar" style="width: 0%"></div>
</div>
`;
progressList.appendChild(fileProgress);
// Simulate progress
const progressBar = fileProgress.querySelector('.progress-bar');
let progress = 0;
const interval = setInterval(() => {{
progress += Math.random() * 15;
if (progress > 100) progress = 100;
progressBar.style.width = progress + '%';
if (progress >= 100) {{
clearInterval(interval);
progressBar.classList.add('bg-success');
// Update overall progress
this.updateOverallProgress(index + 1, total);
}}
}}, 200);
}}
updateOverallProgress(completed, total) {{
const overallProgress = document.getElementById('overall-progress');
const percentage = (completed / total) * 100;
overallProgress.style.width = percentage + '%';
if (completed === total) {{
setTimeout(() => {{
const modal = bootstrap.Modal.getInstance(document.getElementById('upload-progress-modal'));
modal.hide();
}}, 1000);
}}
}}
reorderSeries(seriesId, items) {{
// Implement series reordering logic
console.log('Reordering series:', seriesId);
// This would send an API request to update the order
fetch('/api/series/reorder', {{
method: 'POST',
headers: {{
'Content-Type': 'application/json'
}},
body: JSON.stringify({{
seriesId: seriesId,
newPosition: Array.from(items).findIndex(item =>
item.classList.contains('drag-over'))
}})
}})
.then(response => response.json())
.then(data => {{
if (data.success) {{
this.showToast('Series reordered successfully', 'success');
}} else {{
this.showToast('Failed to reorder series', 'error');
}}
}})
.catch(error => {{
console.error('Reorder error:', error);
this.showToast('Error reordering series', 'error');
}});
}}
addToQueue(data) {{
// Add series or episodes to download queue
let items = [];
if (data.type === 'series') {{
items = [data.data];
}} else if (data.type === 'batch') {{
items = data.items;
}}
fetch('/api/queue/add', {{
method: 'POST',
headers: {{
'Content-Type': 'application/json'
}},
body: JSON.stringify({{
items: items
}})
}})
.then(response => response.json())
.then(result => {{
if (result.success) {{
this.showToast(`Added ${{items.length}} item(s) to queue`, 'success');
}} else {{
this.showToast('Failed to add to queue', 'error');
}}
}})
.catch(error => {{
console.error('Queue add error:', error);
this.showToast('Error adding to queue', 'error');
}});
}}
showToast(message, type = 'info') {{
// Create and show a toast notification
const toast = document.createElement('div');
toast.className = `toast align-items-center text-white bg-${{type === 'error' ? 'danger' : type}}`;
toast.innerHTML = `
<div class="d-flex">
<div class="toast-body">${{message}}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
`;
let toastContainer = document.querySelector('.toast-container');
if (!toastContainer) {{
toastContainer = document.createElement('div');
toastContainer.className = 'toast-container position-fixed bottom-0 end-0 p-3';
document.body.appendChild(toastContainer);
}}
toastContainer.appendChild(toast);
const bsToast = new bootstrap.Toast(toast);
bsToast.show();
toast.addEventListener('hidden.bs.toast', () => {{
toastContainer.removeChild(toast);
}});
}}
}}
// Initialize drag and drop when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {{
window.dragDropManager = new DragDropManager();
}});
"""
def get_css(self):
"""Generate CSS styles for drag and drop functionality."""
return """
/* Drag and Drop Styles */
.drop-zone {
transition: all 0.3s ease;
position: relative;
}
.drop-zone.drag-over {
background-color: rgba(13, 110, 253, 0.1);
border: 2px dashed #0d6efd;
border-radius: 8px;
}
.drop-zone.drag-over::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(13, 110, 253, 0.05);
border-radius: 6px;
z-index: 1;
}
.drop-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: none;
justify-content: center;
align-items: center;
z-index: 9999;
}
.drop-message {
text-align: center;
color: white;
padding: 2rem;
border: 3px dashed #0d6efd;
border-radius: 15px;
background: rgba(13, 110, 253, 0.1);
backdrop-filter: blur(10px);
}
.drop-message i {
font-size: 4rem;
margin-bottom: 1rem;
color: #0d6efd;
}
.drop-message h3 {
margin-bottom: 0.5rem;
}
.drop-message p {
margin-bottom: 0.25rem;
opacity: 0.8;
}
.series-item.dragging,
.anime-card.dragging {
opacity: 0.5;
transform: rotate(2deg);
z-index: 1000;
}
.batch-drag-image {
background: #0d6efd;
color: white;
padding: 0.5rem 1rem;
border-radius: 20px;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
}
.progress-sm {
height: 0.5rem;
}
.toast-container {
z-index: 9999;
}
/* Drag handle for reorderable items */
.drag-handle {
cursor: grab;
color: #6c757d;
padding: 0.25rem;
}
.drag-handle:hover {
color: #0d6efd;
}
.drag-handle:active {
cursor: grabbing;
}
/* Drop indicators */
.drop-indicator {
height: 3px;
background: #0d6efd;
margin: 0.25rem 0;
opacity: 0;
transition: opacity 0.2s;
}
.drop-indicator.active {
opacity: 1;
}
/* Accessibility */
@media (prefers-reduced-motion: reduce) {
.drop-zone,
.series-item.dragging,
.anime-card.dragging {
transition: none;
}
}
"""
# Export the drag drop manager
drag_drop_manager = DragDropManager()