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>')
|
@bp.route('/browser/<int:folder_id>')
|
||||||
@login_required
|
@login_required
|
||||||
def browser(folder_id=None):
|
def browser(folder_id=None):
|
||||||
"""Display file browser interface"""
|
"""File browser page"""
|
||||||
|
# Get the current folder
|
||||||
current_folder = None
|
current_folder = None
|
||||||
breadcrumbs = []
|
|
||||||
|
|
||||||
if folder_id:
|
if folder_id:
|
||||||
current_folder = Folder.query.filter_by(id=folder_id, user_id=current_user.id).first_or_404()
|
current_folder = Folder.query.get_or_404(folder_id)
|
||||||
|
# Check if user has permission
|
||||||
# Generate breadcrumbs
|
if current_folder.user_id != current_user.id:
|
||||||
breadcrumbs = []
|
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
|
parent = current_folder
|
||||||
while parent:
|
while parent:
|
||||||
breadcrumbs.append(parent)
|
breadcrumbs.insert(0, parent)
|
||||||
parent = parent.parent
|
if parent.parent_id:
|
||||||
breadcrumbs.reverse()
|
parent = Folder.query.get(parent.parent_id)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
# For initial load - only get folders and files if it's not an AJAX request
|
# Get subfolders and files
|
||||||
if not request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
if current_folder:
|
||||||
if current_folder:
|
subfolders = Folder.query.filter_by(parent_id=current_folder.id, user_id=current_user.id).all()
|
||||||
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()
|
||||||
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)
|
|
||||||
else:
|
else:
|
||||||
# For AJAX request, return just the folder contents
|
# Root level - show folders and files without parent folder
|
||||||
# Implement this if needed
|
subfolders = Folder.query.filter_by(parent_id=None, user_id=current_user.id).all()
|
||||||
pass
|
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')
|
@bp.route('/contents')
|
||||||
@login_required
|
@login_required
|
||||||
|
@ -150,106 +147,14 @@ def folder_contents():
|
||||||
else:
|
else:
|
||||||
return redirect(url_for('files.browser'))
|
return redirect(url_for('files.browser'))
|
||||||
|
|
||||||
@bp.route('/upload', methods=['GET', 'POST'])
|
@bp.route('/upload')
|
||||||
@bp.route('/upload/<int:folder_id>', methods=['GET', 'POST'])
|
@bp.route('/upload/<int:folder_id>')
|
||||||
@login_required
|
@login_required
|
||||||
def upload(folder_id=None):
|
def upload(folder_id=None):
|
||||||
"""Page for uploading files"""
|
"""Redirect to browser with upload modal visible"""
|
||||||
if request.method == 'POST':
|
if folder_id:
|
||||||
# Handle file upload
|
return redirect(url_for('files.browser', folder_id=folder_id, show_upload=True))
|
||||||
if 'file' not in request.files:
|
return redirect(url_for('files.browser', show_upload=True))
|
||||||
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)
|
|
||||||
|
|
||||||
@bp.route('/upload_folder', methods=['POST'])
|
@bp.route('/upload_folder', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
|
@ -414,33 +319,91 @@ def download(file_id):
|
||||||
@login_required
|
@login_required
|
||||||
def create_folder():
|
def create_folder():
|
||||||
"""Create a new folder"""
|
"""Create a new folder"""
|
||||||
name = request.form.get('name')
|
# Check if request is AJAX
|
||||||
parent_id = request.form.get('parent_id')
|
is_ajax = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
|
||||||
|
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
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.add(folder)
|
||||||
db.session.commit()
|
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:
|
except Exception as e:
|
||||||
db.session.rollback()
|
|
||||||
current_app.logger.error(f"Error creating folder: {str(e)}")
|
current_app.logger.error(f"Error creating folder: {str(e)}")
|
||||||
flash('Error creating folder', 'danger')
|
if is_ajax:
|
||||||
|
return jsonify({'success': False, 'error': f'Error creating folder: {str(e)}'}), 500
|
||||||
# Redirect to appropriate location
|
flash(f'Error creating folder: {str(e)}', 'error')
|
||||||
if parent_id:
|
return redirect(url_for('files.browser', folder_id=parent_id if parent_id else None))
|
||||||
return redirect(url_for('files.browser', folder_id=parent_id))
|
|
||||||
return redirect(url_for('files.browser'))
|
|
||||||
|
|
||||||
@bp.route('/rename', methods=['POST'])
|
@bp.route('/rename', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
|
@ -742,5 +705,72 @@ def upload_xhr():
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
return jsonify({'error': str(e)}), 500
|
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
|
# Import the helper functions from __init__.py
|
||||||
from app import get_file_icon, format_file_size
|
from app import get_file_icon, format_file_size
|
|
@ -803,4 +803,76 @@
|
||||||
.browser-actions {
|
.browser-actions {
|
||||||
margin-top: 1rem;
|
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 {
|
.upload-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
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
|
// Initialize context menu
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
window.contextMenu = new ContextMenu();
|
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 id="new-folder-modal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3>Create New Folder</h3>
|
<h2>Create New Folder</h2>
|
||||||
<button class="modal-close">×</button>
|
<button class="modal-close">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<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">
|
<div class="form-group">
|
||||||
<label for="folder-name">Folder Name</label>
|
<label for="folder_name">Folder Name</label>
|
||||||
<input type="text" id="folder-name" name="name" required placeholder="Enter folder name">
|
<input type="text" id="folder_name" name="folder_name" required>
|
||||||
<input type="hidden" id="parent-folder-id" name="parent_id"
|
|
||||||
value="{% if current_folder %}{{ current_folder.id }}{% endif %}">
|
|
||||||
</div>
|
</div>
|
||||||
|
<input type="hidden" id="parent_id" name="parent_id"
|
||||||
|
value="{{ current_folder.id if current_folder else '' }}">
|
||||||
<div class="form-actions">
|
<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>
|
<button type="submit" class="btn primary">Create Folder</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
@ -139,4 +139,21 @@
|
||||||
|
|
||||||
<!-- Context Menu -->
|
<!-- Context Menu -->
|
||||||
<div id="context-menu" class="context-menu"></div>
|
<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 %}
|
{% endblock %}
|
Loading…
Add table
Add a link
Reference in a new issue