From 4e781ba819a32f1d0fca404a96ef8465cd64e57d Mon Sep 17 00:00:00 2001 From: pika Date: Mon, 24 Mar 2025 20:34:42 +0100 Subject: [PATCH] wip --- app/routes/files.py | 336 +++++++++++++++++-------------- app/static/css/browser.css | 72 +++++++ app/static/css/styles.css | 94 +++++++++ app/static/js/context-menu.js | 148 ++++++++++++++ app/static/js/drag-drop.js | 136 +++++++++++++ app/static/js/selection.js | 182 +++++++++++++++++ app/templates/errors/404.html | 16 ++ app/templates/files/browser.html | 31 ++- 8 files changed, 855 insertions(+), 160 deletions(-) create mode 100644 app/static/js/drag-drop.js create mode 100644 app/static/js/selection.js create mode 100644 app/templates/errors/404.html diff --git a/app/routes/files.py b/app/routes/files.py index d13b435..1370a10 100644 --- a/app/routes/files.py +++ b/app/routes/files.py @@ -19,46 +19,43 @@ bp = Blueprint('files', __name__, url_prefix='/files') @bp.route('/browser/') @login_required def browser(folder_id=None): - """Display file browser interface""" + """File browser page""" + # Get the current folder current_folder = None - breadcrumbs = [] - if folder_id: - current_folder = Folder.query.filter_by(id=folder_id, user_id=current_user.id).first_or_404() - - # Generate breadcrumbs - breadcrumbs = [] + current_folder = Folder.query.get_or_404(folder_id) + # Check if user has permission + if current_folder.user_id != current_user.id: + flash('You do not have permission to access this folder', 'error') + return redirect(url_for('files.browser')) + + # Get breadcrumb navigation + breadcrumbs = [] + if current_folder: + # Get parent folders for breadcrumb parent = current_folder while parent: - breadcrumbs.append(parent) - parent = parent.parent - breadcrumbs.reverse() + breadcrumbs.insert(0, parent) + if parent.parent_id: + parent = Folder.query.get(parent.parent_id) + else: + break - # For initial load - only get folders and files if it's not an AJAX request - if not request.headers.get('X-Requested-With') == 'XMLHttpRequest': - if current_folder: - folders = Folder.query.filter_by(parent_id=current_folder.id, user_id=current_user.id).all() - files = File.query.filter_by(folder_id=current_folder.id, user_id=current_user.id).all() - else: - folders = Folder.query.filter_by(parent_id=None, user_id=current_user.id).all() - files = File.query.filter_by(folder_id=None, user_id=current_user.id).all() - - # Get the search query if provided - query = request.args.get('q', '') - if query: - # Filter folders and files by name containing the query - folders = [f for f in folders if query.lower() in f.name.lower()] - files = [f for f in files if query.lower() in f.name.lower()] - - return render_template('files/browser.html', - current_folder=current_folder, - breadcrumbs=breadcrumbs, - folders=folders, - files=files) + # Get subfolders and files + if current_folder: + subfolders = Folder.query.filter_by(parent_id=current_folder.id, user_id=current_user.id).all() + files = File.query.filter_by(folder_id=current_folder.id, user_id=current_user.id).all() else: - # For AJAX request, return just the folder contents - # Implement this if needed - pass + # Root level - show folders and files without parent folder + subfolders = Folder.query.filter_by(parent_id=None, user_id=current_user.id).all() + files = File.query.filter_by(folder_id=None, user_id=current_user.id).all() + + # Always return a valid response + return render_template('files/browser.html', + current_folder=current_folder, + breadcrumbs=breadcrumbs, + subfolders=subfolders, + files=files) @bp.route('/contents') @login_required @@ -150,106 +147,14 @@ def folder_contents(): else: return redirect(url_for('files.browser')) -@bp.route('/upload', methods=['GET', 'POST']) -@bp.route('/upload/', methods=['GET', 'POST']) +@bp.route('/upload') +@bp.route('/upload/') @login_required def upload(folder_id=None): - """Page for uploading files""" - if request.method == 'POST': - # Handle file upload - if 'file' not in request.files: - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return jsonify({'error': 'No file part'}), 400 - flash('No file part', 'error') - return redirect(request.url) - - file = request.files['file'] - - # Validate filename - if file.filename == '': - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return jsonify({'error': 'No selected file'}), 400 - flash('No selected file', 'error') - return redirect(request.url) - - # Get the parent folder - parent_folder = None - if folder_id: - parent_folder = Folder.query.get_or_404(folder_id) - # Check if user has permission - if parent_folder.user_id != current_user.id: - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return jsonify({'error': 'You do not have permission to upload to this folder'}), 403 - flash('You do not have permission to upload to this folder', 'error') - return redirect(url_for('files.browser')) - - # Process the file - if file and allowed_file(file.filename): - filename = secure_filename(file.filename) - - # Generate a unique filename - file_uuid = str(uuid.uuid4()) - _, file_extension = os.path.splitext(filename) - storage_name = f"{file_uuid}{file_extension}" - - # Create storage path - upload_folder = current_app.config['UPLOAD_FOLDER'] - user_folder = os.path.join(upload_folder, str(current_user.id)) - os.makedirs(user_folder, exist_ok=True) - storage_path = os.path.join(user_folder, storage_name) - - try: - # Save file to storage location - file.save(storage_path) - - # Get file info - file_size = os.path.getsize(storage_path) - mime_type, _ = mimetypes.guess_type(filename) - if not mime_type: - mime_type = 'application/octet-stream' - - # Create file record - db_file = File( - name=filename, - original_name=filename, - path=storage_path, - size=file_size, - type=mime_type, - user_id=current_user.id, - folder_id=parent_folder.id if parent_folder else None - ) - db.session.add(db_file) - db.session.commit() - - # Return success response with file info - return jsonify({ - 'success': True, - 'file': { - 'id': db_file.id, - 'name': db_file.name, - 'size': db_file.size, - 'formatted_size': format_file_size(db_file.size), - 'type': db_file.type, - 'icon': db_file.icon_class - } - }) - - except Exception as e: - # Make sure to remove the file if there was an error - if os.path.exists(storage_path): - os.remove(storage_path) - - current_app.logger.error(f"Upload error: {str(e)}") - return jsonify({'error': str(e)}), 500 - - else: - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return jsonify({'error': 'File type not allowed'}), 400 - flash('File type not allowed', 'error') - return redirect(request.url) - - # GET request - show upload form - return render_template('files/upload.html', folder_id=folder_id) + """Redirect to browser with upload modal visible""" + if folder_id: + return redirect(url_for('files.browser', folder_id=folder_id, show_upload=True)) + return redirect(url_for('files.browser', show_upload=True)) @bp.route('/upload_folder', methods=['POST']) @login_required @@ -414,33 +319,91 @@ def download(file_id): @login_required def create_folder(): """Create a new folder""" - name = request.form.get('name') - parent_id = request.form.get('parent_id') - - if not name: - flash('Folder name is required', 'danger') - return redirect(url_for('files.browser')) - - # Create folder - folder = Folder( - name=name, - user_id=current_user.id, - parent_id=parent_id if parent_id else None - ) + # Check if request is AJAX + is_ajax = request.headers.get('X-Requested-With') == 'XMLHttpRequest' try: + # Get data from form or JSON depending on request type + if is_ajax and request.is_json: + data = request.get_json() + folder_name = data.get('folder_name') + parent_id = data.get('parent_id') + else: + folder_name = request.form.get('folder_name') + parent_id = request.form.get('parent_id') + + if not folder_name: + if is_ajax: + return jsonify({'success': False, 'error': 'Folder name is required'}), 400 + flash('Folder name is required', 'error') + return redirect(url_for('files.browser', folder_id=parent_id if parent_id else None)) + + # Validate folder name + if not is_valid_filename(folder_name): + if is_ajax: + return jsonify({'success': False, 'error': 'Invalid folder name'}), 400 + flash('Invalid folder name. Please use only letters, numbers, spaces, and common punctuation.', 'error') + return redirect(url_for('files.browser', folder_id=parent_id if parent_id else None)) + + # Check if folder already exists + parent_folder = None + if parent_id: + parent_folder = Folder.query.get_or_404(parent_id) + # Check if user has permission + if parent_folder.user_id != current_user.id: + if is_ajax: + return jsonify({'success': False, 'error': 'Permission denied'}), 403 + flash('You do not have permission to create a folder here', 'error') + return redirect(url_for('files.browser')) + + # Check if folder with same name exists in parent + existing = Folder.query.filter_by( + name=folder_name, + parent_id=parent_id, + user_id=current_user.id + ).first() + else: + # Check if folder with same name exists at root level + existing = Folder.query.filter_by( + name=folder_name, + parent_id=None, + user_id=current_user.id + ).first() + + if existing: + if is_ajax: + return jsonify({'success': False, 'error': f'A folder named "{folder_name}" already exists'}), 400 + flash(f'A folder named "{folder_name}" already exists', 'error') + return redirect(url_for('files.browser', folder_id=parent_id if parent_id else None)) + + # Create folder + folder = Folder( + name=folder_name, + parent_id=parent_id if parent_id else None, + user_id=current_user.id + ) db.session.add(folder) db.session.commit() - flash(f'Folder "{name}" created successfully', 'success') + + if is_ajax: + return jsonify({ + 'success': True, + 'folder': { + 'id': folder.id, + 'name': folder.name, + 'created_at': folder.created_at.strftime('%Y-%m-%d %H:%M:%S') + } + }) + + flash(f'Folder "{folder_name}" created successfully', 'success') + return redirect(url_for('files.browser', folder_id=parent_id if parent_id else None)) + except Exception as e: - db.session.rollback() current_app.logger.error(f"Error creating folder: {str(e)}") - flash('Error creating folder', 'danger') - - # Redirect to appropriate location - if parent_id: - return redirect(url_for('files.browser', folder_id=parent_id)) - return redirect(url_for('files.browser')) + if is_ajax: + return jsonify({'success': False, 'error': f'Error creating folder: {str(e)}'}), 500 + flash(f'Error creating folder: {str(e)}', 'error') + return redirect(url_for('files.browser', folder_id=parent_id if parent_id else None)) @bp.route('/rename', methods=['POST']) @login_required @@ -742,5 +705,72 @@ def upload_xhr(): db.session.rollback() return jsonify({'error': str(e)}), 500 +@bp.route('/move', methods=['POST']) +@login_required +def move_item(): + """Move a file or folder to another folder""" + data = request.get_json() + + item_id = data.get('item_id') + item_type = data.get('item_type') + target_folder_id = data.get('target_folder_id') + + if not item_id or not item_type or not target_folder_id: + return jsonify({'success': False, 'error': 'Missing parameters'}), 400 + + # Get target folder + target_folder = Folder.query.get_or_404(target_folder_id) + + # Check if user has permission + if target_folder.user_id != current_user.id: + return jsonify({'success': False, 'error': 'Permission denied'}), 403 + + # Move file or folder + if item_type == 'file': + file = File.query.get_or_404(item_id) + + # Check if user has permission + if file.user_id != current_user.id: + return jsonify({'success': False, 'error': 'Permission denied'}), 403 + + # Check for circular reference + if file.id == target_folder.id: + return jsonify({'success': False, 'error': 'Cannot move a file to itself'}), 400 + + # Update file's folder_id + file.folder_id = target_folder.id + + elif item_type == 'folder': + folder = Folder.query.get_or_404(item_id) + + # Check if user has permission + if folder.user_id != current_user.id: + return jsonify({'success': False, 'error': 'Permission denied'}), 403 + + # Check for circular reference + if folder.id == target_folder.id or target_folder.id == folder.id: + return jsonify({'success': False, 'error': 'Cannot move a folder to itself'}), 400 + + # Check if target is a descendant of the folder + if is_descendant(target_folder, folder): + return jsonify({'success': False, 'error': 'Cannot move a folder to its descendant'}), 400 + + # Update folder's parent_id + folder.parent_id = target_folder.id + + # Save changes + db.session.commit() + + return jsonify({'success': True}) + +def is_descendant(folder, potential_ancestor): + """Check if a folder is a descendant of another folder""" + current = folder + while current.parent_id is not None: + if current.parent_id == potential_ancestor.id: + return True + current = Folder.query.get(current.parent_id) + return False + # Import the helper functions from __init__.py from app import get_file_icon, format_file_size \ No newline at end of file diff --git a/app/static/css/browser.css b/app/static/css/browser.css index 11bb4d9..7c1fd9e 100644 --- a/app/static/css/browser.css +++ b/app/static/css/browser.css @@ -803,4 +803,76 @@ .browser-actions { margin-top: 1rem; } +} + +/* Selection box */ +.selection-box { + position: fixed; + background-color: rgba(var(--primary-rgb), 0.2); + border: 1px solid var(--primary-color); + z-index: 100; + pointer-events: none; +} + +/* Selected item styling */ +.file-item.selected, +.folder-item.selected { + background-color: rgba(var(--primary-rgb), 0.1); + border: 1px solid var(--primary-color); +} + +/* Dragging item styling */ +.file-item.dragging, +.folder-item.dragging { + opacity: 0.6; +} + +/* Drag target styling */ +.folder-item.drag-over { + background-color: rgba(var(--primary-rgb), 0.2); + border: 2px dashed var(--primary-color); +} + +/* Selection action bar */ +.selection-actions { + display: none; + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + background-color: var(--card-bg); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + padding: 10px 15px; + z-index: 1000; + animation: slide-up 0.3s ease; +} + +.selection-actions.active { + display: flex; + align-items: center; +} + +.selection-count { + margin-right: 15px; + color: var(--text-color); + font-weight: 500; +} + +.selection-actions .action-btn { + margin-left: 5px; + padding: 8px 12px; + font-size: 14px; +} + +@keyframes slide-up { + from { + transform: translate(-50%, 20px); + opacity: 0; + } + + to { + transform: translate(-50%, 0); + opacity: 1; + } } \ No newline at end of file diff --git a/app/static/css/styles.css b/app/static/css/styles.css index 5b56afd..68697ae 100644 --- a/app/static/css/styles.css +++ b/app/static/css/styles.css @@ -1264,4 +1264,98 @@ body { .upload-actions { display: flex; justify-content: space-between; +} + +/* Error messages and toasts */ +.error-toast, +.success-toast { + position: fixed; + top: 20px; + right: 20px; + padding: 12px 20px; + border-radius: 6px; + color: white; + font-weight: 500; + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2); + z-index: 9999; + opacity: 1; + transition: opacity 0.3s ease, transform 0.3s ease; + transform: translateX(0); + display: flex; + align-items: center; + min-width: 300px; + max-width: 500px; + animation: slide-in 0.3s ease; +} + +.error-toast { + background-color: var(--danger-color); + border-left: 4px solid #c82333; +} + +.success-toast { + background-color: var(--success-color); + border-left: 4px solid #218838; +} + +.toast-close { + font-size: 18px; + margin-left: auto; + background: none; + border: none; + color: white; + cursor: pointer; + opacity: 0.7; + transition: opacity 0.2s ease; +} + +.toast-close:hover { + opacity: 1; +} + +.toast-icon { + margin-right: 12px; + font-size: 1.2em; +} + +.toast-content { + flex: 1; +} + +@keyframes slide-in { + from { + transform: translateX(100%); + opacity: 0; + } + + to { + transform: translateX(0); + opacity: 1; + } +} + +/* Error page styling */ +.error-container { + text-align: center; + padding: 3rem 1rem; + max-width: 600px; + margin: 0 auto; +} + +.error-icon { + font-size: 4rem; + color: var(--danger-color); + margin-bottom: 1rem; +} + +.error-container h1 { + font-size: 2.5rem; + margin-bottom: 1rem; + color: var(--text-color); +} + +.error-container p { + font-size: 1.2rem; + color: var(--text-muted); + margin-bottom: 2rem; } \ No newline at end of file diff --git a/app/static/js/context-menu.js b/app/static/js/context-menu.js index df1fcff..bad62d9 100644 --- a/app/static/js/context-menu.js +++ b/app/static/js/context-menu.js @@ -141,4 +141,152 @@ class ContextMenu { // Initialize context menu document.addEventListener('DOMContentLoaded', function () { window.contextMenu = new ContextMenu(); +}); + +// Context menu for files browser +document.addEventListener('DOMContentLoaded', function () { + const filesContainer = document.getElementById('files-container'); + const contextMenu = document.getElementById('context-menu'); + + if (!filesContainer || !contextMenu) return; + + // Track if click was on empty space + let clickedOnEmptySpace = false; + + // Context menu options for empty space + const emptySpaceOptions = [ + { + label: 'New Folder', + icon: 'fa-folder-plus', + action: function () { + // Get the current folder ID from the URL or data attribute + const urlParams = new URLSearchParams(window.location.search); + const currentFolderId = urlParams.get('folder_id'); + + // Show the new folder modal + const newFolderModal = document.getElementById('new-folder-modal'); + const parentIdInput = document.getElementById('parent_id'); + + if (newFolderModal) { + // Set the parent folder ID if available + if (parentIdInput && currentFolderId) { + parentIdInput.value = currentFolderId; + } else if (parentIdInput) { + parentIdInput.value = ''; + } + + newFolderModal.classList.add('active'); + + // Focus on the folder name input + const folderNameInput = document.getElementById('folder_name'); + if (folderNameInput) { + folderNameInput.focus(); + } + } + } + }, + { + label: 'Upload Files', + icon: 'fa-upload', + action: function () { + const fileInput = document.getElementById('file-upload'); + if (fileInput) { + fileInput.click(); + } + } + }, + { + label: 'Paste', + icon: 'fa-paste', + action: function () { + pasteItems(); + }, + condition: function () { + // Only show if items are in clipboard + return sessionStorage.getItem('clipboard') !== null; + } + }, + { + label: 'Select All', + icon: 'fa-object-group', + action: function () { + selectAllItems(); + } + }, + { + label: 'Refresh', + icon: 'fa-sync-alt', + action: function () { + window.location.reload(); + } + } + ]; + + // File/folder specific options + const itemOptions = [ + // ... existing item options ... + ]; + + // Handle right click on container + filesContainer.addEventListener('contextmenu', function (e) { + // Check if click was on empty space + clickedOnEmptySpace = e.target === filesContainer || e.target.classList.contains('files-container-inner'); + + if (clickedOnEmptySpace) { + e.preventDefault(); + const options = emptySpaceOptions.filter(option => + typeof option.condition !== 'function' || option.condition()); + + showContextMenu(e.clientX, e.clientY, options); + } + }); + + // Show context menu with provided options + function showContextMenu(x, y, options) { + // Clear existing menu + contextMenu.innerHTML = ''; + + // Create menu items + options.forEach(option => { + const menuItem = document.createElement('div'); + menuItem.className = 'context-menu-item'; + + const icon = document.createElement('i'); + icon.className = `fas ${option.icon}`; + + const label = document.createElement('span'); + label.textContent = option.label; + + menuItem.appendChild(icon); + menuItem.appendChild(label); + + menuItem.addEventListener('click', function () { + // Hide menu + contextMenu.style.display = 'none'; + // Execute action + option.action(); + }); + + contextMenu.appendChild(menuItem); + }); + + // Position menu + contextMenu.style.left = `${x}px`; + contextMenu.style.top = `${y}px`; + contextMenu.style.display = 'block'; + + // Adjust position if menu is out of viewport + const rect = contextMenu.getBoundingClientRect(); + if (rect.right > window.innerWidth) { + contextMenu.style.left = `${x - rect.width}px`; + } + if (rect.bottom > window.innerHeight) { + contextMenu.style.top = `${y - rect.height}px`; + } + } + + // Close context menu when clicking outside + document.addEventListener('click', function () { + contextMenu.style.display = 'none'; + }); }); \ No newline at end of file diff --git a/app/static/js/drag-drop.js b/app/static/js/drag-drop.js new file mode 100644 index 0000000..08fa4bc --- /dev/null +++ b/app/static/js/drag-drop.js @@ -0,0 +1,136 @@ +document.addEventListener('DOMContentLoaded', function () { + // Get elements + const filesContainer = document.getElementById('files-container'); + const globalDropzone = document.getElementById('global-dropzone'); + + if (!filesContainer || !globalDropzone) return; + + // Track if we're dragging items from within the app + let internalDrag = false; + + // Handle drag start on files/folders + filesContainer.addEventListener('dragstart', function (e) { + const item = e.target.closest('.file-item, .folder-item'); + if (item) { + // Set data for drag operation + e.dataTransfer.setData('text/plain', JSON.stringify({ + id: item.dataset.id, + type: item.classList.contains('folder-item') ? 'folder' : 'file', + name: item.querySelector('.item-name').textContent + })); + + // Mark as internal drag + internalDrag = true; + + // Add dragging class for styling + item.classList.add('dragging'); + } + }); + + // Handle drag end + filesContainer.addEventListener('dragend', function (e) { + const item = e.target.closest('.file-item, .folder-item'); + if (item) { + item.classList.remove('dragging'); + internalDrag = false; + } + }); + + // Handle drag enter on folders + filesContainer.addEventListener('dragenter', function (e) { + const folderItem = e.target.closest('.folder-item'); + if (folderItem && internalDrag) { + folderItem.classList.add('drag-over'); + } + }); + + // Handle drag leave on folders + filesContainer.addEventListener('dragleave', function (e) { + const folderItem = e.target.closest('.folder-item'); + if (folderItem) { + folderItem.classList.remove('drag-over'); + } + }); + + // Handle drag over (prevent default to allow drop) + filesContainer.addEventListener('dragover', function (e) { + e.preventDefault(); + // If dragging from within app, don't show global dropzone + if (internalDrag) { + globalDropzone.classList.remove('active'); + } + }); + + // Handle drop on folders + filesContainer.addEventListener('drop', function (e) { + e.preventDefault(); + + const folderItem = e.target.closest('.folder-item'); + if (folderItem && internalDrag) { + folderItem.classList.remove('drag-over'); + + try { + const dragData = JSON.parse(e.dataTransfer.getData('text/plain')); + const sourceId = dragData.id; + const sourceType = dragData.type; + const targetId = folderItem.dataset.id; + + // Make AJAX call to move item + moveItem(sourceId, sourceType, targetId); + } catch (err) { + console.error('Error processing drop:', err); + } + } + }); + + // Function to move item via AJAX + function moveItem(sourceId, sourceType, targetFolderId) { + fetch('/files/move', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest' + }, + body: JSON.stringify({ + item_id: sourceId, + item_type: sourceType, + target_folder_id: targetFolderId + }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + // Refresh the page or update the UI + window.location.reload(); + } else { + alert('Error: ' + data.error); + } + }) + .catch(error => { + console.error('Error moving item:', error); + alert('Failed to move item. Please try again.'); + }); + } + + // Global window drag events - only show dropzone for external files + window.addEventListener('dragover', function (e) { + e.preventDefault(); + // Only show global dropzone if not dragging internally + if (!internalDrag) { + globalDropzone.classList.add('active'); + } + }); + + window.addEventListener('dragleave', function (e) { + // Check if mouse left the window + if (e.clientX <= 0 || e.clientY <= 0 || + e.clientX >= window.innerWidth || e.clientY >= window.innerHeight) { + globalDropzone.classList.remove('active'); + } + }); + + window.addEventListener('drop', function (e) { + e.preventDefault(); + globalDropzone.classList.remove('active'); + }); +}); \ No newline at end of file diff --git a/app/static/js/selection.js b/app/static/js/selection.js new file mode 100644 index 0000000..f283512 --- /dev/null +++ b/app/static/js/selection.js @@ -0,0 +1,182 @@ +document.addEventListener('DOMContentLoaded', function () { + const filesContainer = document.getElementById('files-container'); + + if (!filesContainer) return; + + // Create selection box element + const selectionBox = document.createElement('div'); + selectionBox.className = 'selection-box'; + selectionBox.style.display = 'none'; + document.body.appendChild(selectionBox); + + // Selection variables + let isSelecting = false; + let startX, startY; + let selectedItems = []; + + // Handle mouse down on container (start selection) + filesContainer.addEventListener('mousedown', function (e) { + // Only start selection if clicking on empty space with left mouse button + if (e.target === filesContainer && e.button === 0) { + isSelecting = true; + startX = e.clientX; + startY = e.clientY; + + // Position selection box + selectionBox.style.left = `${startX}px`; + selectionBox.style.top = `${startY}px`; + selectionBox.style.width = '0px'; + selectionBox.style.height = '0px'; + selectionBox.style.display = 'block'; + + // Clear existing selection if not holding shift + if (!e.shiftKey) { + clearSelection(); + } + + // Prevent default behavior + e.preventDefault(); + } + }); + + // Handle mouse move (update selection box) + document.addEventListener('mousemove', function (e) { + if (!isSelecting) return; + + // Calculate new dimensions + const width = Math.abs(e.clientX - startX); + const height = Math.abs(e.clientY - startY); + + // Calculate top-left corner + const left = e.clientX < startX ? e.clientX : startX; + const top = e.clientY < startY ? e.clientY : startY; + + // Update selection box + selectionBox.style.left = `${left}px`; + selectionBox.style.top = `${top}px`; + selectionBox.style.width = `${width}px`; + selectionBox.style.height = `${height}px`; + + // Check which items are in the selection + const selectionRect = selectionBox.getBoundingClientRect(); + const items = filesContainer.querySelectorAll('.file-item, .folder-item'); + + items.forEach(item => { + const itemRect = item.getBoundingClientRect(); + + // Check if item intersects with selection box + const intersects = !( + itemRect.right < selectionRect.left || + itemRect.left > selectionRect.right || + itemRect.bottom < selectionRect.top || + itemRect.top > selectionRect.bottom + ); + + if (intersects) { + item.classList.add('selected'); + if (!selectedItems.includes(item)) { + selectedItems.push(item); + } + } else if (!e.shiftKey && !item.classList.contains('manually-selected')) { + item.classList.remove('selected'); + const index = selectedItems.indexOf(item); + if (index > -1) { + selectedItems.splice(index, 1); + } + } + }); + }); + + // Handle mouse up (end selection) + document.addEventListener('mouseup', function () { + if (isSelecting) { + isSelecting = false; + selectionBox.style.display = 'none'; + + // Mark selected items + selectedItems.forEach(item => { + item.classList.add('manually-selected'); + }); + + // Update selection actions + updateSelectionActions(); + } + }); + + // Handle item click (toggle selection) + filesContainer.addEventListener('click', function (e) { + const item = e.target.closest('.file-item, .folder-item'); + + if (item) { + // If holding Ctrl or Shift, toggle selection + if (e.ctrlKey || e.shiftKey) { + e.preventDefault(); + + item.classList.toggle('selected'); + item.classList.toggle('manually-selected'); + + const index = selectedItems.indexOf(item); + if (index > -1) { + selectedItems.splice(index, 1); + } else { + selectedItems.push(item); + } + + updateSelectionActions(); + } else { + // If not holding Ctrl/Shift, clear selection and select only this item + if (!item.classList.contains('selected')) { + clearSelection(); + item.classList.add('selected'); + item.classList.add('manually-selected'); + selectedItems = [item]; + updateSelectionActions(); + } + } + } else if (e.target === filesContainer) { + // Clicking on empty space clears selection + clearSelection(); + } + }); + + // Clear selection + function clearSelection() { + const items = filesContainer.querySelectorAll('.file-item, .folder-item'); + items.forEach(item => { + item.classList.remove('selected', 'manually-selected'); + }); + selectedItems = []; + updateSelectionActions(); + } + + // Update selection actions (e.g., show action bar) + function updateSelectionActions() { + const actionBar = document.getElementById('selection-actions'); + + if (actionBar) { + if (selectedItems.length > 0) { + actionBar.classList.add('active'); + + // Update count + const countElement = actionBar.querySelector('.selection-count'); + if (countElement) { + countElement.textContent = `${selectedItems.length} item(s) selected`; + } + } else { + actionBar.classList.remove('active'); + } + } + } + + // Select all items + window.selectAllItems = function () { + const items = filesContainer.querySelectorAll('.file-item, .folder-item'); + items.forEach(item => { + item.classList.add('selected', 'manually-selected'); + if (!selectedItems.includes(item)) { + selectedItems.push(item); + } + }); + updateSelectionActions(); + }; +}); \ No newline at end of file diff --git a/app/templates/errors/404.html b/app/templates/errors/404.html new file mode 100644 index 0000000..5640fd1 --- /dev/null +++ b/app/templates/errors/404.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} + +{% block title %}Page Not Found - Flask Files{% endblock %} + +{% block content %} +
+
+ +
+

404 Not Found

+

The page you're looking for doesn't exist.

+ + Return to Dashboard + +
+{% endblock %} \ No newline at end of file diff --git a/app/templates/files/browser.html b/app/templates/files/browser.html index f8c47b7..029a6b6 100644 --- a/app/templates/files/browser.html +++ b/app/templates/files/browser.html @@ -72,19 +72,19 @@