765 lines
23 KiB
Python
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() |