wip
This commit is contained in:
parent
34afc48816
commit
4e781ba819
8 changed files with 855 additions and 160 deletions
|
@ -19,46 +19,43 @@ bp = Blueprint('files', __name__, url_prefix='/files')
|
|||
@bp.route('/browser/<int:folder_id>')
|
||||
@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/<int:folder_id>', methods=['GET', 'POST'])
|
||||
@bp.route('/upload')
|
||||
@bp.route('/upload/<int:folder_id>')
|
||||
@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
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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';
|
||||
});
|
||||
});
|
136
app/static/js/drag-drop.js
Normal file
136
app/static/js/drag-drop.js
Normal file
|
@ -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');
|
||||
});
|
||||
});
|
182
app/static/js/selection.js
Normal file
182
app/static/js/selection.js
Normal file
|
@ -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();
|
||||
};
|
||||
});
|
16
app/templates/errors/404.html
Normal file
16
app/templates/errors/404.html
Normal file
|
@ -0,0 +1,16 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Page Not Found - Flask Files{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="error-container">
|
||||
<div class="error-icon">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
</div>
|
||||
<h1>404 Not Found</h1>
|
||||
<p>The page you're looking for doesn't exist.</p>
|
||||
<a href="{{ url_for('dashboard.index') }}" class="btn primary">
|
||||
<i class="fas fa-home"></i> Return to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -72,19 +72,19 @@
|
|||
<div id="new-folder-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Create New Folder</h3>
|
||||
<h2>Create New Folder</h2>
|
||||
<button class="modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="new-folder-form" action="{{ url_for('files.create_folder') }}" method="post">
|
||||
<form action="{{ url_for('files.create_folder') }}" method="post">
|
||||
<div class="form-group">
|
||||
<label for="folder-name">Folder Name</label>
|
||||
<input type="text" id="folder-name" name="name" required placeholder="Enter folder name">
|
||||
<input type="hidden" id="parent-folder-id" name="parent_id"
|
||||
value="{% if current_folder %}{{ current_folder.id }}{% endif %}">
|
||||
<label for="folder_name">Folder Name</label>
|
||||
<input type="text" id="folder_name" name="folder_name" required>
|
||||
</div>
|
||||
<input type="hidden" id="parent_id" name="parent_id"
|
||||
value="{{ current_folder.id if current_folder else '' }}">
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn modal-cancel">Cancel</button>
|
||||
<button type="button" class="btn secondary modal-cancel">Cancel</button>
|
||||
<button type="submit" class="btn primary">Create Folder</button>
|
||||
</div>
|
||||
</form>
|
||||
|
@ -139,4 +139,21 @@
|
|||
|
||||
<!-- Context Menu -->
|
||||
<div id="context-menu" class="context-menu"></div>
|
||||
|
||||
<!-- Add this before the closing content block -->
|
||||
<div id="selection-actions" class="selection-actions">
|
||||
<span class="selection-count">0 items selected</span>
|
||||
<button class="action-btn" id="selection-download">
|
||||
<i class="fas fa-download"></i> Download
|
||||
</button>
|
||||
<button class="action-btn" id="selection-move">
|
||||
<i class="fas fa-cut"></i> Cut
|
||||
</button>
|
||||
<button class="action-btn" id="selection-copy">
|
||||
<i class="fas fa-copy"></i> Copy
|
||||
</button>
|
||||
<button class="action-btn danger" id="selection-delete">
|
||||
<i class="fas fa-trash"></i> Delete
|
||||
</button>
|
||||
</div>
|
||||
{% endblock %}
|
Loading…
Add table
Add a link
Reference in a new issue