working folder creation
This commit is contained in:
parent
ea3e92b8b7
commit
b9a82af12f
11 changed files with 2791 additions and 1552 deletions
221
app/__init__.py
221
app/__init__.py
|
@ -5,6 +5,7 @@ from config import Config
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import logging
|
||||||
|
|
||||||
# Initialize extensions
|
# Initialize extensions
|
||||||
db = SQLAlchemy()
|
db = SQLAlchemy()
|
||||||
|
@ -13,61 +14,185 @@ login_manager.login_view = 'auth.login'
|
||||||
login_manager.login_message_category = 'info'
|
login_manager.login_message_category = 'info'
|
||||||
|
|
||||||
def initialize_database(app):
|
def initialize_database(app):
|
||||||
"""Create database tables if they don't exist"""
|
"""Create and initialize database tables if they don't exist"""
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
|
app.logger.info("Initializing database...")
|
||||||
try:
|
try:
|
||||||
# Create all tables
|
# Create all tables (this is safe to call even if tables exist)
|
||||||
db.create_all()
|
db.create_all()
|
||||||
app.logger.info("Database tables created successfully")
|
|
||||||
|
# Check if we need to add the Share and Download models
|
||||||
|
inspector = db.inspect(db.engine)
|
||||||
|
if not inspector.has_table('share'):
|
||||||
|
app.logger.info("Creating Share table...")
|
||||||
|
# Import models to ensure they're registered with SQLAlchemy
|
||||||
|
from app.models import Share
|
||||||
|
if not inspector.has_table('share'):
|
||||||
|
# Create the Share table
|
||||||
|
Share.__table__.create(db.engine)
|
||||||
|
|
||||||
|
if not inspector.has_table('download'):
|
||||||
|
app.logger.info("Creating Download table...")
|
||||||
|
from app.models import Download
|
||||||
|
if not inspector.has_table('download'):
|
||||||
|
# Create the Download table
|
||||||
|
Download.__table__.create(db.engine)
|
||||||
|
|
||||||
|
# Check for existing users - create admin if none
|
||||||
|
from app.models import User
|
||||||
|
if User.query.count() == 0:
|
||||||
|
app.logger.info("No users found, creating default admin user...")
|
||||||
|
admin = User(username='admin', email='admin@example.com')
|
||||||
|
admin.set_password('adminpassword')
|
||||||
|
db.session.add(admin)
|
||||||
|
db.session.commit()
|
||||||
|
app.logger.info("Default admin user created")
|
||||||
|
|
||||||
|
app.logger.info("Database initialization complete")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
app.logger.error(f"Error creating database tables: {str(e)}")
|
app.logger.error(f"Error initializing database: {str(e)}")
|
||||||
|
# Don't raise the exception to prevent app startup failure
|
||||||
|
# But log it for debugging purposes
|
||||||
|
|
||||||
def run_migrations(app):
|
def run_migrations(app):
|
||||||
"""Apply any necessary database migrations"""
|
"""Apply any necessary database migrations automatically"""
|
||||||
db_path = app.config['SQLALCHEMY_DATABASE_URI'].replace('sqlite:///', '')
|
with app.app_context():
|
||||||
|
try:
|
||||||
if not os.path.exists(db_path):
|
app.logger.info("Running database migrations...")
|
||||||
app.logger.info(f"Database file does not exist: {db_path}")
|
# Get database path
|
||||||
return
|
db_path = app.config['SQLALCHEMY_DATABASE_URI'].replace('sqlite:///', '')
|
||||||
|
|
||||||
try:
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
# Check if storage_name column exists in file table
|
|
||||||
cursor.execute("PRAGMA table_info(file)")
|
|
||||||
columns = [column[1] for column in cursor.fetchall()]
|
|
||||||
|
|
||||||
if 'storage_name' not in columns:
|
|
||||||
app.logger.info("Adding storage_name column to file table")
|
|
||||||
cursor.execute("ALTER TABLE file ADD COLUMN storage_name TEXT")
|
|
||||||
|
|
||||||
# Update existing records to use filename as storage_name
|
# Check if we're using SQLite and if the database file exists
|
||||||
cursor.execute("UPDATE file SET storage_name = name WHERE storage_name IS NULL AND is_folder = 0")
|
if db_path.startswith('/'): # Absolute path
|
||||||
conn.commit()
|
if not os.path.exists(db_path):
|
||||||
|
app.logger.info(f"Database file does not exist: {db_path}")
|
||||||
conn.close()
|
return
|
||||||
app.logger.info("Database migrations completed successfully")
|
|
||||||
except sqlite3.OperationalError as e:
|
# Use SQLAlchemy to check and add missing columns
|
||||||
if "no such table: file" in str(e):
|
inspector = db.inspect(db.engine)
|
||||||
app.logger.info("File table doesn't exist yet, will be created with db.create_all()")
|
|
||||||
else:
|
# Check for 'file' table columns
|
||||||
app.logger.error(f"Error during migration: {str(e)}")
|
if inspector.has_table('file'):
|
||||||
except Exception as e:
|
columns = [col['name'] for col in inspector.get_columns('file')]
|
||||||
app.logger.error(f"Error during migration: {str(e)}")
|
|
||||||
|
# Add storage_name column if it doesn't exist
|
||||||
|
if 'storage_name' not in columns:
|
||||||
|
app.logger.info("Adding storage_name column to file table")
|
||||||
|
if db.engine.name == 'sqlite':
|
||||||
|
# For SQLite, use direct SQL as it doesn't support ALTER TABLE ADD COLUMN with default
|
||||||
|
db.session.execute('ALTER TABLE file ADD COLUMN storage_name TEXT')
|
||||||
|
# Update existing records
|
||||||
|
db.session.execute('UPDATE file SET storage_name = uuid() WHERE storage_name IS NULL AND is_folder = 0')
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Check for user table columns
|
||||||
|
if inspector.has_table('user'):
|
||||||
|
columns = [col['name'] for col in inspector.get_columns('user')]
|
||||||
|
|
||||||
|
# Add last_login column if it doesn't exist
|
||||||
|
if 'last_login' not in columns:
|
||||||
|
app.logger.info("Adding last_login column to user table")
|
||||||
|
if db.engine.name == 'sqlite':
|
||||||
|
db.session.execute('ALTER TABLE user ADD COLUMN last_login DATETIME')
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
app.logger.info("Database migrations complete")
|
||||||
|
except Exception as e:
|
||||||
|
app.logger.error(f"Error during database migration: {str(e)}")
|
||||||
|
# Log error but don't crash the app
|
||||||
|
|
||||||
|
def get_file_icon(mime_type, filename):
|
||||||
|
"""Return Font Awesome icon class based on file type"""
|
||||||
|
if mime_type:
|
||||||
|
if mime_type.startswith('image/'):
|
||||||
|
return 'fa-file-image'
|
||||||
|
elif mime_type.startswith('video/'):
|
||||||
|
return 'fa-file-video'
|
||||||
|
elif mime_type.startswith('audio/'):
|
||||||
|
return 'fa-file-audio'
|
||||||
|
elif mime_type.startswith('text/'):
|
||||||
|
return 'fa-file-alt'
|
||||||
|
elif mime_type.startswith('application/pdf'):
|
||||||
|
return 'fa-file-pdf'
|
||||||
|
elif 'spreadsheet' in mime_type or 'excel' in mime_type:
|
||||||
|
return 'fa-file-excel'
|
||||||
|
elif 'presentation' in mime_type or 'powerpoint' in mime_type:
|
||||||
|
return 'fa-file-powerpoint'
|
||||||
|
elif 'document' in mime_type or 'word' in mime_type:
|
||||||
|
return 'fa-file-word'
|
||||||
|
|
||||||
|
# Check by extension
|
||||||
|
ext = os.path.splitext(filename)[1].lower()[1:]
|
||||||
|
if ext in ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp']:
|
||||||
|
return 'fa-file-image'
|
||||||
|
elif ext in ['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv']:
|
||||||
|
return 'fa-file-video'
|
||||||
|
elif ext in ['mp3', 'wav', 'ogg', 'flac', 'm4a']:
|
||||||
|
return 'fa-file-audio'
|
||||||
|
elif ext in ['doc', 'docx', 'odt']:
|
||||||
|
return 'fa-file-word'
|
||||||
|
elif ext in ['xls', 'xlsx', 'ods', 'csv']:
|
||||||
|
return 'fa-file-excel'
|
||||||
|
elif ext in ['ppt', 'pptx', 'odp']:
|
||||||
|
return 'fa-file-powerpoint'
|
||||||
|
elif ext == 'pdf':
|
||||||
|
return 'fa-file-pdf'
|
||||||
|
elif ext in ['zip', 'rar', '7z', 'tar', 'gz']:
|
||||||
|
return 'fa-file-archive'
|
||||||
|
elif ext in ['txt', 'rtf', 'md']:
|
||||||
|
return 'fa-file-alt'
|
||||||
|
elif ext in ['html', 'css', 'js', 'py', 'java', 'php', 'c', 'cpp', 'json', 'xml']:
|
||||||
|
return 'fa-file-code'
|
||||||
|
|
||||||
|
return 'fa-file'
|
||||||
|
|
||||||
|
def format_file_size(size):
|
||||||
|
"""Format file size in bytes to human-readable format"""
|
||||||
|
if not size:
|
||||||
|
return "0 B"
|
||||||
|
|
||||||
|
size_names = ("B", "KB", "MB", "GB", "TB")
|
||||||
|
i = 0
|
||||||
|
while size >= 1024 and i < len(size_names) - 1:
|
||||||
|
size /= 1024
|
||||||
|
i += 1
|
||||||
|
return f"{size:.1f} {size_names[i]}"
|
||||||
|
|
||||||
def create_app(config_class=Config):
|
def create_app(config_class=Config):
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config.from_object(config_class)
|
app.config.from_object(config_class)
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
if not app.debug:
|
||||||
|
# Set up file handler
|
||||||
|
if not os.path.exists('logs'):
|
||||||
|
os.mkdir('logs')
|
||||||
|
file_handler = logging.FileHandler('logs/flask_files.log')
|
||||||
|
file_handler.setFormatter(logging.Formatter(
|
||||||
|
'%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'
|
||||||
|
))
|
||||||
|
file_handler.setLevel(logging.INFO)
|
||||||
|
app.logger.addHandler(file_handler)
|
||||||
|
|
||||||
|
# Set log level
|
||||||
|
app.logger.setLevel(logging.INFO)
|
||||||
|
app.logger.info('Flask Files startup')
|
||||||
|
|
||||||
# Initialize extensions
|
# Initialize extensions
|
||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
login_manager.init_app(app)
|
login_manager.init_app(app)
|
||||||
|
|
||||||
# Initialize the upload folder
|
# Initialize the upload folder
|
||||||
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
upload_folder = app.config.get('UPLOAD_FOLDER', 'uploads')
|
||||||
|
if not os.path.isabs(upload_folder):
|
||||||
|
# If it's a relative path, make it relative to the app instance folder
|
||||||
|
upload_folder = os.path.join(app.instance_path, upload_folder)
|
||||||
|
app.config['UPLOAD_FOLDER'] = upload_folder
|
||||||
|
|
||||||
# Auto initialize database if it doesn't exist
|
os.makedirs(upload_folder, exist_ok=True)
|
||||||
|
app.logger.info(f"Upload folder initialized at: {upload_folder}")
|
||||||
|
|
||||||
|
# Auto initialize database and run migrations on startup
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
initialize_database(app)
|
initialize_database(app)
|
||||||
run_migrations(app)
|
run_migrations(app)
|
||||||
|
@ -83,9 +208,27 @@ def create_app(config_class=Config):
|
||||||
|
|
||||||
# Add context processor for template variables
|
# Add context processor for template variables
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
def inject_now():
|
def inject_global_variables():
|
||||||
return {'now': datetime.now()}
|
return {
|
||||||
|
'now': datetime.now(),
|
||||||
|
'file_icon': get_file_icon,
|
||||||
|
'format_size': format_file_size,
|
||||||
|
'app_version': '1.0.0', # Add version number for caching
|
||||||
|
}
|
||||||
|
|
||||||
|
# Handle 404 errors
|
||||||
|
@app.errorhandler(404)
|
||||||
|
def not_found_error(error):
|
||||||
|
return render_template('errors/404.html'), 404
|
||||||
|
|
||||||
|
# Handle 500 errors
|
||||||
|
@app.errorhandler(500)
|
||||||
|
def internal_error(error):
|
||||||
|
db.session.rollback() # Rollback any failed database transactions
|
||||||
|
return render_template('errors/500.html'), 500
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
# Import must come after create_app to avoid circular imports
|
||||||
|
from flask import render_template # For error handlers
|
||||||
from app import models
|
from app import models
|
||||||
|
|
|
@ -21,6 +21,10 @@ class User(UserMixin, db.Model):
|
||||||
# Relationships
|
# Relationships
|
||||||
files = db.relationship('File', backref='owner', lazy='dynamic',
|
files = db.relationship('File', backref='owner', lazy='dynamic',
|
||||||
foreign_keys='File.user_id', cascade='all, delete-orphan')
|
foreign_keys='File.user_id', cascade='all, delete-orphan')
|
||||||
|
shares = db.relationship('Share', backref='owner', lazy='dynamic',
|
||||||
|
foreign_keys='Share.user_id', cascade='all, delete-orphan')
|
||||||
|
downloads = db.relationship('Download', backref='user', lazy='dynamic',
|
||||||
|
foreign_keys='Download.user_id', cascade='all, delete-orphan')
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<User {self.username}>'
|
return f'<User {self.username}>'
|
||||||
|
@ -48,6 +52,8 @@ class File(db.Model):
|
||||||
# Relationships
|
# Relationships
|
||||||
children = db.relationship('File', backref=db.backref('parent', remote_side=[id]),
|
children = db.relationship('File', backref=db.backref('parent', remote_side=[id]),
|
||||||
lazy='dynamic', cascade='all, delete-orphan')
|
lazy='dynamic', cascade='all, delete-orphan')
|
||||||
|
shares = db.relationship('Share', backref='file', lazy='dynamic', cascade='all, delete-orphan')
|
||||||
|
downloads = db.relationship('Download', backref='file', lazy='dynamic', cascade='all, delete-orphan')
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<File {self.name} {"(Folder)" if self.is_folder else ""}>'
|
return f'<File {self.name} {"(Folder)" if self.is_folder else ""}>'
|
||||||
|
@ -62,17 +68,42 @@ class File(db.Model):
|
||||||
return f"{uuid.uuid4().hex}.{ext}" if ext else f"{uuid.uuid4().hex}"
|
return f"{uuid.uuid4().hex}.{ext}" if ext else f"{uuid.uuid4().hex}"
|
||||||
|
|
||||||
class Share(db.Model):
|
class Share(db.Model):
|
||||||
|
__tablename__ = 'share'
|
||||||
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
file_id = db.Column(db.Integer, db.ForeignKey('file.id'))
|
file_id = db.Column(db.Integer, db.ForeignKey('file.id', ondelete='CASCADE'), nullable=False)
|
||||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
|
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||||
token = db.Column(db.String(64), unique=True)
|
share_token = db.Column(db.String(64), unique=True)
|
||||||
|
is_public = db.Column(db.Boolean, default=False)
|
||||||
|
is_password_protected = db.Column(db.Boolean, default=False)
|
||||||
|
password_hash = db.Column(db.String(128))
|
||||||
|
expiry_date = db.Column(db.DateTime, nullable=True)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
expires_at = db.Column(db.DateTime, nullable=True)
|
|
||||||
downloads = db.relationship('Download', backref='share', lazy='dynamic')
|
def __repr__(self):
|
||||||
|
return f'<Share {self.share_token}>'
|
||||||
|
|
||||||
|
def set_password(self, password):
|
||||||
|
self.password_hash = generate_password_hash(password)
|
||||||
|
self.is_password_protected = True
|
||||||
|
|
||||||
|
def check_password(self, password):
|
||||||
|
return check_password_hash(self.password_hash, password)
|
||||||
|
|
||||||
|
def generate_token(self):
|
||||||
|
"""Generate a unique token for sharing"""
|
||||||
|
return uuid.uuid4().hex
|
||||||
|
|
||||||
class Download(db.Model):
|
class Download(db.Model):
|
||||||
|
__tablename__ = 'download'
|
||||||
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
file_id = db.Column(db.Integer, db.ForeignKey('file.id'))
|
file_id = db.Column(db.Integer, db.ForeignKey('file.id', ondelete='CASCADE'), nullable=False)
|
||||||
share_id = db.Column(db.Integer, db.ForeignKey('share.id'))
|
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True) # Nullable for anonymous downloads
|
||||||
ip_address = db.Column(db.String(45))
|
ip_address = db.Column(db.String(64))
|
||||||
timestamp = db.Column(db.DateTime, default=datetime.utcnow)
|
user_agent = db.Column(db.String(255))
|
||||||
|
share_id = db.Column(db.Integer, db.ForeignKey('share.id', ondelete='SET NULL'), nullable=True)
|
||||||
|
downloaded_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Download of File {self.file_id} at {self.downloaded_at}>'
|
||||||
|
|
1054
app/routes/files.py
1054
app/routes/files.py
File diff suppressed because it is too large
Load diff
162
app/static/js/common.js
Normal file
162
app/static/js/common.js
Normal file
|
@ -0,0 +1,162 @@
|
||||||
|
/**
|
||||||
|
* Common JavaScript functions used across the application
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Modal handling
|
||||||
|
function openModal(modalId) {
|
||||||
|
const modal = typeof modalId === 'string' ? document.getElementById(modalId) : modalId;
|
||||||
|
if (modal) {
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
document.body.classList.add('modal-open');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal(modalId) {
|
||||||
|
const modal = typeof modalId === 'string' ? document.getElementById(modalId) : modalId;
|
||||||
|
if (modal) {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
document.body.classList.remove('modal-open');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide all modals on page load
|
||||||
|
function setupModals() {
|
||||||
|
document.querySelectorAll('.modal').forEach(modal => {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modals when clicking outside or on close button
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
if (e.target.classList.contains('modal')) {
|
||||||
|
closeModal(e.target);
|
||||||
|
} else if (e.target.classList.contains('modal-close') || e.target.classList.contains('modal-cancel')) {
|
||||||
|
const modal = e.target.closest('.modal');
|
||||||
|
closeModal(modal);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Escape key to close modals
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
document.querySelectorAll('.modal.visible').forEach(modal => {
|
||||||
|
closeModal(modal);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alerts
|
||||||
|
function showAlert(message, type = 'info') {
|
||||||
|
// Create alerts container if it doesn't exist
|
||||||
|
let alertsContainer = document.querySelector('.alerts');
|
||||||
|
if (!alertsContainer) {
|
||||||
|
alertsContainer = document.createElement('div');
|
||||||
|
alertsContainer.className = 'alerts';
|
||||||
|
document.body.appendChild(alertsContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create alert
|
||||||
|
const alert = document.createElement('div');
|
||||||
|
alert.className = `alert ${type}`;
|
||||||
|
alert.innerHTML = `
|
||||||
|
<div class="alert-content">${message}</div>
|
||||||
|
<button class="close" aria-label="Close">×</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add to container
|
||||||
|
alertsContainer.appendChild(alert);
|
||||||
|
|
||||||
|
// Setup dismiss
|
||||||
|
const closeBtn = alert.querySelector('.close');
|
||||||
|
closeBtn.addEventListener('click', function () {
|
||||||
|
alert.classList.add('fade-out');
|
||||||
|
setTimeout(() => {
|
||||||
|
alert.remove();
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto dismiss
|
||||||
|
setTimeout(() => {
|
||||||
|
if (alert.parentNode) {
|
||||||
|
alert.classList.add('fade-out');
|
||||||
|
setTimeout(() => {
|
||||||
|
if (alert.parentNode) {
|
||||||
|
alert.remove();
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
function formatSize(bytes) {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString) {
|
||||||
|
if (!dateString) return 'Unknown';
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(unsafe) {
|
||||||
|
return unsafe
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileIconClass(fileName) {
|
||||||
|
if (!fileName) return 'fa-file';
|
||||||
|
|
||||||
|
const ext = fileName.split('.').pop().toLowerCase();
|
||||||
|
|
||||||
|
// Images
|
||||||
|
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp'].includes(ext)) {
|
||||||
|
return 'fa-file-image';
|
||||||
|
}
|
||||||
|
// Videos
|
||||||
|
else if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv'].includes(ext)) {
|
||||||
|
return 'fa-file-video';
|
||||||
|
}
|
||||||
|
// Audio
|
||||||
|
else if (['mp3', 'wav', 'ogg', 'flac', 'm4a'].includes(ext)) {
|
||||||
|
return 'fa-file-audio';
|
||||||
|
}
|
||||||
|
// Documents
|
||||||
|
else if (['doc', 'docx', 'odt'].includes(ext)) {
|
||||||
|
return 'fa-file-word';
|
||||||
|
}
|
||||||
|
// Spreadsheets
|
||||||
|
else if (['xls', 'xlsx', 'ods', 'csv'].includes(ext)) {
|
||||||
|
return 'fa-file-excel';
|
||||||
|
}
|
||||||
|
// Presentations
|
||||||
|
else if (['ppt', 'pptx', 'odp'].includes(ext)) {
|
||||||
|
return 'fa-file-powerpoint';
|
||||||
|
}
|
||||||
|
// PDFs
|
||||||
|
else if (['pdf'].includes(ext)) {
|
||||||
|
return 'fa-file-pdf';
|
||||||
|
}
|
||||||
|
// Archives
|
||||||
|
else if (['zip', 'rar', '7z', 'tar', 'gz', 'bz2'].includes(ext)) {
|
||||||
|
return 'fa-file-archive';
|
||||||
|
}
|
||||||
|
// Text
|
||||||
|
else if (['txt', 'rtf', 'md', 'log'].includes(ext)) {
|
||||||
|
return 'fa-file-alt';
|
||||||
|
}
|
||||||
|
// Code
|
||||||
|
else if (['html', 'css', 'js', 'php', 'py', 'java', 'c', 'cpp', 'h', 'xml', 'json', 'sql'].includes(ext)) {
|
||||||
|
return 'fa-file-code';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'fa-file';
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Profile - Flask Files{% endblock %}
|
{% block title %}User Profile - Flask Files{% endblock %}
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<style>
|
<style>
|
||||||
|
@ -46,192 +46,620 @@
|
||||||
padding-bottom: 0.5rem;
|
padding-bottom: 0.5rem;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Modal styles */
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 1000;
|
||||||
|
overflow: auto;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.visible {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
margin: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Danger zone */
|
||||||
|
.dangerous-zone {
|
||||||
|
background-color: rgba(var(--danger-color-rgb), 0.1);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.dangerous {
|
||||||
|
background-color: var(--danger-color);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.dangerous:hover {
|
||||||
|
background-color: var(--danger-hover-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme toggle */
|
||||||
|
.theme-options {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
background: var(--secondary-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-btn.active {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="profile-container">
|
<div class="container">
|
||||||
<h2>User Profile</h2>
|
<div class="profile-card">
|
||||||
|
<div class="profile-header">
|
||||||
<div class="profile-tabs">
|
<h2>User Profile</h2>
|
||||||
<button class="profile-tab active" data-tab="profile">Profile</button>
|
<div class="theme-toggle">
|
||||||
<button class="profile-tab" data-tab="appearance">Appearance</button>
|
<span>Theme:</span>
|
||||||
<button class="profile-tab" data-tab="security">Security</button>
|
<div class="theme-options">
|
||||||
</div>
|
<button class="theme-btn {% if theme_preference == 'light' %}active{% endif %}" data-theme="light">
|
||||||
|
<i class="fas fa-sun"></i> Light
|
||||||
<!-- Profile Tab -->
|
</button>
|
||||||
<div class="tab-content active" id="profile-tab">
|
<button class="theme-btn {% if theme_preference == 'dark' %}active{% endif %}" data-theme="dark">
|
||||||
<div class="profile-card">
|
<i class="fas fa-moon"></i> Dark
|
||||||
<div class="profile-header">
|
</button>
|
||||||
<div class="avatar">
|
<button class="theme-btn {% if theme_preference == 'system' %}active{% endif %}"
|
||||||
{{ current_user.username[0].upper() }}
|
data-theme="system">
|
||||||
</div>
|
<i class="fas fa-desktop"></i> System
|
||||||
<div class="user-info">
|
</button>
|
||||||
<h3>{{ current_user.username }}</h3>
|
|
||||||
<p>Member since {{ current_user.created_at.strftime('%B %Y') if current_user.created_at else
|
|
||||||
'Unknown' }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="profile-stats">
|
<!-- Tab navigation -->
|
||||||
<div class="stat-item">
|
<div class="profile-tabs">
|
||||||
<span class="stat-value">{{ current_user.files.filter_by(is_folder=False).count() }}</span>
|
<button class="profile-tab active" data-tab="account">Account</button>
|
||||||
<span class="stat-label">Files</span>
|
<button class="profile-tab" data-tab="settings">Settings</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Account Tab -->
|
||||||
|
<div class="tab-content active" id="account-tab">
|
||||||
|
<div class="profile-content">
|
||||||
|
<div class="profile-stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<i class="fas fa-file"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3 class="stat-title">Files</h3>
|
||||||
|
<span class="stat-value">{{ current_user.files.filter_by(is_folder=False).count() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<i class="fas fa-folder"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3 class="stat-title">Folders</h3>
|
||||||
|
<span class="stat-value">{{ current_user.files.filter_by(is_folder=True).count() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<i class="fas fa-share-alt"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3 class="stat-title">Shares</h3>
|
||||||
|
<span class="stat-value">{{ current_user.shares.count() if hasattr(current_user, 'shares')
|
||||||
|
else 0 }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<i class="fas fa-calendar-alt"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3 class="stat-title">Member Since</h3>
|
||||||
|
<span class="stat-value">{{ current_user.created_at.strftime('%b %d, %Y') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-item">
|
|
||||||
<span class="stat-value">{{ current_user.files.filter_by(is_folder=True).count() }}</span>
|
<div class="profile-form">
|
||||||
<span class="stat-label">Folders</span>
|
<h3>Account Information</h3>
|
||||||
</div>
|
<form action="{{ url_for('auth.update_profile') }}" method="post">
|
||||||
<div class="stat-item">
|
<div class="form-group">
|
||||||
<span class="stat-value">{{ current_user.shares.count() }}</span>
|
<label for="username">Username</label>
|
||||||
<span class="stat-label">Shares</span>
|
<input type="text" id="username" name="username" value="{{ current_user.username }}"
|
||||||
|
required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email Address</label>
|
||||||
|
<input type="email" id="email" name="email" value="{{ current_user.email }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn primary">Save Changes</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h3>Change Password</h3>
|
||||||
|
<form action="{{ url_for('auth.change_password') }}" method="post">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="current_password">Current Password</label>
|
||||||
|
<input type="password" id="current_password" name="current_password" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new_password">New Password</label>
|
||||||
|
<input type="password" id="new_password" name="new_password" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="confirm_password">Confirm New Password</label>
|
||||||
|
<input type="password" id="confirm_password" name="confirm_password" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn primary">Change Password</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="setting-group">
|
<!-- Settings Tab -->
|
||||||
<h3>Edit Profile</h3>
|
<div class="tab-content" id="settings-tab">
|
||||||
<form id="username-form" method="POST" action="{{ url_for('auth.update_profile') }}">
|
<div class="settings-content">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<div class="settings-section">
|
||||||
<div class="form-group">
|
<h3>Appearance</h3>
|
||||||
<label for="username">Username</label>
|
<div class="setting-group">
|
||||||
<input type="text" id="username" name="username" class="form-control"
|
<div class="setting-label">File View</div>
|
||||||
value="{{ current_user.username }}">
|
<div class="setting-controls">
|
||||||
|
<div class="view-options">
|
||||||
|
<button class="view-btn {% if view_preference == 'grid' %}active{% endif %}"
|
||||||
|
data-view="grid">
|
||||||
|
<i class="fas fa-th"></i> Grid
|
||||||
|
</button>
|
||||||
|
<button class="view-btn {% if view_preference == 'list' %}active{% endif %}"
|
||||||
|
data-view="list">
|
||||||
|
<i class="fas fa-list"></i> List
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions">
|
</div>
|
||||||
<button type="submit" class="btn primary">Save Changes</button>
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>Notifications</h3>
|
||||||
|
<div class="setting-group">
|
||||||
|
<div class="setting-label">Email Notifications</div>
|
||||||
|
<div class="setting-controls">
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" id="email-notifications" {% if notifications and
|
||||||
|
notifications.email %}checked{% endif %}>
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-description">
|
||||||
|
Receive email notifications about file shares and new comments
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>Privacy</h3>
|
||||||
|
<div class="setting-group">
|
||||||
|
<div class="setting-label">Public Profile</div>
|
||||||
|
<div class="setting-controls">
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" id="public-profile" {% if privacy and privacy.public_profile
|
||||||
|
%}checked{% endif %}>
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-description">
|
||||||
|
Allow others to see your profile and shared files
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-group">
|
||||||
|
<div class="setting-label">Share Statistics</div>
|
||||||
|
<div class="setting-controls">
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" id="share-statistics" {% if privacy and privacy.share_statistics
|
||||||
|
%}checked{% endif %}>
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-description">
|
||||||
|
Collect anonymous usage statistics to improve the service
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section dangerous-zone">
|
||||||
|
<h3>Danger Zone</h3>
|
||||||
|
<p class="warning-text">
|
||||||
|
These actions are permanent and cannot be undone
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="danger-actions">
|
||||||
|
<button id="delete-files-btn" class="btn dangerous">
|
||||||
|
<i class="fas fa-trash-alt"></i> Delete All Files
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button id="delete-account-btn" class="btn dangerous">
|
||||||
|
<i class="fas fa-user-times"></i> Delete Account
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Appearance Tab -->
|
<!-- Delete Files Confirmation Modal -->
|
||||||
<div class="tab-content" id="appearance-tab">
|
<div id="delete-files-modal" class="modal">
|
||||||
<div class="setting-group">
|
<div class="modal-content">
|
||||||
<h3>Theme Settings</h3>
|
<div class="modal-header">
|
||||||
<form id="theme-form" method="POST" action="{{ url_for('auth.update_preferences') }}">
|
<h3>Delete All Files</h3>
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<button class="modal-close">×</button>
|
||||||
<div class="form-group">
|
</div>
|
||||||
<label>Default Theme</label>
|
<div class="modal-body">
|
||||||
<div class="theme-options">
|
<p class="warning-text">
|
||||||
<label class="radio-container">
|
Are you sure you want to delete all your files? This action cannot be undone.
|
||||||
<input type="radio" name="theme_preference" value="light" {% if theme_preference=='light'
|
</p>
|
||||||
%}checked{% endif %}>
|
<div class="form-group">
|
||||||
<span class="radio-label">Light</span>
|
<label for="delete-files-confirm">Type "DELETE" to confirm</label>
|
||||||
</label>
|
<input type="text" id="delete-files-confirm">
|
||||||
<label class="radio-container">
|
</div>
|
||||||
<input type="radio" name="theme_preference" value="dark" {% if theme_preference=='dark'
|
<div class="form-actions">
|
||||||
%}checked{% endif %}>
|
<button class="btn secondary modal-cancel">Cancel</button>
|
||||||
<span class="radio-label">Dark</span>
|
<button id="confirm-delete-files" class="btn dangerous" disabled>Delete All Files</button>
|
||||||
</label>
|
</div>
|
||||||
<label class="radio-container">
|
|
||||||
<input type="radio" name="theme_preference" value="system" {% if theme_preference=='system'
|
|
||||||
or not theme_preference %}checked{% endif %}>
|
|
||||||
<span class="radio-label">Use System Preference</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-actions">
|
|
||||||
<button type="submit" class="btn primary">Save Preferences</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Security Tab -->
|
<!-- Delete Account Confirmation Modal -->
|
||||||
<div class="tab-content" id="security-tab">
|
<div id="delete-account-modal" class="modal">
|
||||||
<div class="setting-group">
|
<div class="modal-content">
|
||||||
<h3>Change Password</h3>
|
<div class="modal-header">
|
||||||
<form id="password-form" method="POST" action="{{ url_for('auth.change_password') }}">
|
<h3>Delete Account</h3>
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<button class="modal-close">×</button>
|
||||||
<div class="form-group">
|
</div>
|
||||||
<label for="current_password">Current Password</label>
|
<div class="modal-body">
|
||||||
<input type="password" id="current_password" name="current_password" class="form-control" required>
|
<p class="warning-text">
|
||||||
</div>
|
Are you sure you want to delete your account? All your files will be permanently deleted.
|
||||||
<div class="form-group">
|
This action cannot be undone.
|
||||||
<label for="new_password">New Password</label>
|
</p>
|
||||||
<input type="password" id="new_password" name="new_password" class="form-control" required>
|
<div class="form-group">
|
||||||
<div class="password-strength" id="password-strength"></div>
|
<label for="delete-account-confirm">Type your username to confirm</label>
|
||||||
</div>
|
<input type="text" id="delete-account-confirm">
|
||||||
<div class="form-group">
|
</div>
|
||||||
<label for="confirm_password">Confirm New Password</label>
|
<div class="form-actions">
|
||||||
<input type="password" id="confirm_password" name="confirm_password" class="form-control" required>
|
<button class="btn secondary modal-cancel">Cancel</button>
|
||||||
</div>
|
<button id="confirm-delete-account" class="btn dangerous" disabled>Delete Account</button>
|
||||||
<div class="form-actions">
|
</div>
|
||||||
<button type="submit" class="btn primary">Change Password</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
// Make sure modals are hidden on load
|
||||||
|
document.querySelectorAll('.modal').forEach(modal => {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
// Tab switching
|
// Tab switching
|
||||||
const tabs = document.querySelectorAll('.profile-tab');
|
const tabs = document.querySelectorAll('.profile-tab');
|
||||||
const tabContents = document.querySelectorAll('.tab-content');
|
const tabContents = document.querySelectorAll('.tab-content');
|
||||||
|
|
||||||
tabs.forEach(tab => {
|
tabs.forEach(tab => {
|
||||||
tab.addEventListener('click', function () {
|
tab.addEventListener('click', function () {
|
||||||
const tabId = this.getAttribute('data-tab');
|
const tabId = this.dataset.tab;
|
||||||
|
|
||||||
|
// Hide all tab contents
|
||||||
|
tabContents.forEach(content => {
|
||||||
|
content.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show selected tab content
|
||||||
|
document.getElementById(`${tabId}-tab`).classList.add('active');
|
||||||
|
|
||||||
// Update active tab
|
// Update active tab
|
||||||
tabs.forEach(t => t.classList.remove('active'));
|
tabs.forEach(t => t.classList.remove('active'));
|
||||||
this.classList.add('active');
|
this.classList.add('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Update active content
|
// Theme switching functionality
|
||||||
tabContents.forEach(content => {
|
const themeButtons = document.querySelectorAll('.theme-btn');
|
||||||
content.classList.remove('active');
|
|
||||||
if (content.id === `${tabId}-tab`) {
|
themeButtons.forEach(button => {
|
||||||
content.classList.add('active');
|
button.addEventListener('click', function () {
|
||||||
}
|
const theme = this.dataset.theme;
|
||||||
|
setTheme(theme);
|
||||||
|
|
||||||
|
// Update active button
|
||||||
|
themeButtons.forEach(btn => btn.classList.remove('active'));
|
||||||
|
this.classList.add('active');
|
||||||
|
|
||||||
|
// Save preference
|
||||||
|
fetch('/auth/set_theme', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ theme: theme })
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Password strength meter
|
function setTheme(theme) {
|
||||||
const passwordInput = document.getElementById('new_password');
|
const html = document.documentElement;
|
||||||
const strengthIndicator = document.getElementById('password-strength');
|
|
||||||
|
|
||||||
if (passwordInput && strengthIndicator) {
|
if (theme === 'light') {
|
||||||
passwordInput.addEventListener('input', function () {
|
html.setAttribute('data-theme', 'light');
|
||||||
const password = this.value;
|
} else if (theme === 'dark') {
|
||||||
let strength = 0;
|
html.setAttribute('data-theme', 'dark');
|
||||||
|
} else if (theme === 'system') {
|
||||||
if (password.length >= 8) strength += 1;
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
if (password.match(/[a-z]/) && password.match(/[A-Z]/)) strength += 1;
|
html.setAttribute('data-theme', prefersDark ? 'dark' : 'light');
|
||||||
if (password.match(/\d/)) strength += 1;
|
}
|
||||||
if (password.match(/[^a-zA-Z\d]/)) strength += 1;
|
|
||||||
|
|
||||||
// Update the strength indicator
|
|
||||||
strengthIndicator.className = 'password-strength';
|
|
||||||
if (password.length === 0) {
|
|
||||||
strengthIndicator.textContent = '';
|
|
||||||
} else if (strength < 2) {
|
|
||||||
strengthIndicator.textContent = 'Weak';
|
|
||||||
strengthIndicator.classList.add('weak');
|
|
||||||
} else if (strength < 4) {
|
|
||||||
strengthIndicator.textContent = 'Moderate';
|
|
||||||
strengthIndicator.classList.add('moderate');
|
|
||||||
} else {
|
|
||||||
strengthIndicator.textContent = 'Strong';
|
|
||||||
strengthIndicator.classList.add('strong');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Password confirmation matching
|
// View preference
|
||||||
const confirmInput = document.getElementById('confirm_password');
|
const viewButtons = document.querySelectorAll('.view-btn');
|
||||||
if (passwordInput && confirmInput) {
|
|
||||||
confirmInput.addEventListener('input', function () {
|
viewButtons.forEach(button => {
|
||||||
if (passwordInput.value !== this.value) {
|
button.addEventListener('click', function () {
|
||||||
this.setCustomValidity('Passwords must match');
|
const view = this.dataset.view;
|
||||||
} else {
|
|
||||||
this.setCustomValidity('');
|
// Update active button
|
||||||
}
|
viewButtons.forEach(btn => btn.classList.remove('active'));
|
||||||
|
this.classList.add('active');
|
||||||
|
|
||||||
|
// Save preference
|
||||||
|
fetch('/auth/set_view_preference', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ view: view })
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle settings
|
||||||
|
const toggles = document.querySelectorAll('.toggle input[type="checkbox"]');
|
||||||
|
|
||||||
|
toggles.forEach(toggle => {
|
||||||
|
toggle.addEventListener('change', function () {
|
||||||
|
const setting = this.id;
|
||||||
|
const value = this.checked;
|
||||||
|
|
||||||
|
fetch('/auth/update_setting', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
setting: setting,
|
||||||
|
value: value
|
||||||
|
})
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Password validation
|
||||||
|
const passwordForm = document.querySelector('form[action*="change_password"]');
|
||||||
|
passwordForm.addEventListener('submit', function (e) {
|
||||||
|
const newPassword = document.getElementById('new_password').value;
|
||||||
|
const confirmPassword = document.getElementById('confirm_password').value;
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
e.preventDefault();
|
||||||
|
showAlert('New password and confirmation do not match', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete files functionality
|
||||||
|
const deleteFilesBtn = document.getElementById('delete-files-btn');
|
||||||
|
const deleteFilesModal = document.getElementById('delete-files-modal');
|
||||||
|
const deleteFilesConfirmInput = document.getElementById('delete-files-confirm');
|
||||||
|
const confirmDeleteFilesBtn = document.getElementById('confirm-delete-files');
|
||||||
|
|
||||||
|
deleteFilesBtn.addEventListener('click', function () {
|
||||||
|
openModal(deleteFilesModal);
|
||||||
|
deleteFilesConfirmInput.value = '';
|
||||||
|
confirmDeleteFilesBtn.disabled = true;
|
||||||
|
deleteFilesConfirmInput.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
deleteFilesConfirmInput.addEventListener('input', function () {
|
||||||
|
confirmDeleteFilesBtn.disabled = this.value !== 'DELETE';
|
||||||
|
});
|
||||||
|
|
||||||
|
confirmDeleteFilesBtn.addEventListener('click', function () {
|
||||||
|
if (deleteFilesConfirmInput.value === 'DELETE') {
|
||||||
|
fetch('/auth/delete_all_files', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
closeModal(deleteFilesModal);
|
||||||
|
if (data.success) {
|
||||||
|
showAlert('All files have been deleted', 'success');
|
||||||
|
// Update file count
|
||||||
|
document.querySelectorAll('.stat-value')[0].textContent = '0';
|
||||||
|
document.querySelectorAll('.stat-value')[1].textContent = '0';
|
||||||
|
} else {
|
||||||
|
showAlert(data.error || 'Failed to delete files', 'error');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
showAlert('An error occurred: ' + error, 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete account functionality
|
||||||
|
const deleteAccountBtn = document.getElementById('delete-account-btn');
|
||||||
|
const deleteAccountModal = document.getElementById('delete-account-modal');
|
||||||
|
const deleteAccountConfirmInput = document.getElementById('delete-account-confirm');
|
||||||
|
const confirmDeleteAccountBtn = document.getElementById('confirm-delete-account');
|
||||||
|
const usernameToConfirm = "{{ current_user.username }}";
|
||||||
|
|
||||||
|
deleteAccountBtn.addEventListener('click', function () {
|
||||||
|
openModal(deleteAccountModal);
|
||||||
|
deleteAccountConfirmInput.value = '';
|
||||||
|
confirmDeleteAccountBtn.disabled = true;
|
||||||
|
deleteAccountConfirmInput.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
deleteAccountConfirmInput.addEventListener('input', function () {
|
||||||
|
confirmDeleteAccountBtn.disabled = this.value !== usernameToConfirm;
|
||||||
|
});
|
||||||
|
|
||||||
|
confirmDeleteAccountBtn.addEventListener('click', function () {
|
||||||
|
if (deleteAccountConfirmInput.value === usernameToConfirm) {
|
||||||
|
fetch('/auth/delete_account', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
closeModal(deleteAccountModal);
|
||||||
|
if (data.success) {
|
||||||
|
showAlert('Your account has been deleted', 'success');
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '/auth/logout';
|
||||||
|
}, 1500);
|
||||||
|
} else {
|
||||||
|
showAlert(data.error || 'Failed to delete account', 'error');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
showAlert('An error occurred: ' + error, 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modal utilities
|
||||||
|
function openModal(modal) {
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
document.body.classList.add('modal-open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal(modal) {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
document.body.classList.remove('modal-open');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modals when clicking outside or on close button
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
if (e.target.classList.contains('modal')) {
|
||||||
|
closeModal(e.target);
|
||||||
|
} else if (e.target.classList.contains('modal-close') || e.target.classList.contains('modal-cancel')) {
|
||||||
|
const modal = e.target.closest('.modal');
|
||||||
|
closeModal(modal);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Escape key to close modals
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
document.querySelectorAll('.modal').forEach(modal => {
|
||||||
|
closeModal(modal);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function to show alerts
|
||||||
|
function showAlert(message, type = 'info') {
|
||||||
|
// Create alerts container if it doesn't exist
|
||||||
|
let alertsContainer = document.querySelector('.alerts');
|
||||||
|
if (!alertsContainer) {
|
||||||
|
alertsContainer = document.createElement('div');
|
||||||
|
alertsContainer.className = 'alerts';
|
||||||
|
document.body.appendChild(alertsContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create alert
|
||||||
|
const alert = document.createElement('div');
|
||||||
|
alert.className = `alert ${type}`;
|
||||||
|
alert.innerHTML = `
|
||||||
|
<div class="alert-content">${message}</div>
|
||||||
|
<button class="close" aria-label="Close">×</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add to container
|
||||||
|
alertsContainer.appendChild(alert);
|
||||||
|
|
||||||
|
// Setup dismiss
|
||||||
|
const closeBtn = alert.querySelector('.close');
|
||||||
|
closeBtn.addEventListener('click', function () {
|
||||||
|
alert.classList.add('fade-out');
|
||||||
|
setTimeout(() => {
|
||||||
|
alert.remove();
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto dismiss
|
||||||
|
setTimeout(() => {
|
||||||
|
if (alert.parentNode) {
|
||||||
|
alert.classList.add('fade-out');
|
||||||
|
setTimeout(() => {
|
||||||
|
if (alert.parentNode) {
|
||||||
|
alert.remove();
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
436
app/templates/auth/settings.html
Normal file
436
app/templates/auth/settings.html
Normal file
|
@ -0,0 +1,436 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Account Settings - Flask Files{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container">
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-header">
|
||||||
|
<h2>Account Settings</h2>
|
||||||
|
<a href="{{ url_for('auth.profile') }}" class="btn secondary">
|
||||||
|
<i class="fas fa-user"></i> Back to Profile
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-content">
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>Appearance</h3>
|
||||||
|
<div class="theme-settings">
|
||||||
|
<div class="setting-label">Theme</div>
|
||||||
|
<div class="theme-options">
|
||||||
|
<button class="theme-btn {% if theme_preference == 'light' %}active{% endif %}"
|
||||||
|
data-theme="light">
|
||||||
|
<i class="fas fa-sun"></i> Light
|
||||||
|
</button>
|
||||||
|
<button class="theme-btn {% if theme_preference == 'dark' %}active{% endif %}"
|
||||||
|
data-theme="dark">
|
||||||
|
<i class="fas fa-moon"></i> Dark
|
||||||
|
</button>
|
||||||
|
<button class="theme-btn {% if theme_preference == 'system' %}active{% endif %}"
|
||||||
|
data-theme="system">
|
||||||
|
<i class="fas fa-desktop"></i> System
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="display-settings">
|
||||||
|
<div class="setting-group">
|
||||||
|
<div class="setting-label">File View</div>
|
||||||
|
<div class="setting-controls">
|
||||||
|
<div class="view-options">
|
||||||
|
<button class="view-btn {% if view_preference == 'grid' %}active{% endif %}"
|
||||||
|
data-view="grid">
|
||||||
|
<i class="fas fa-th"></i> Grid
|
||||||
|
</button>
|
||||||
|
<button class="view-btn {% if view_preference == 'list' %}active{% endif %}"
|
||||||
|
data-view="list">
|
||||||
|
<i class="fas fa-list"></i> List
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>Notifications</h3>
|
||||||
|
<div class="setting-group">
|
||||||
|
<div class="setting-label">Email Notifications</div>
|
||||||
|
<div class="setting-controls">
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" id="email-notifications" {% if notifications.email %}checked{% endif
|
||||||
|
%}>
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-description">
|
||||||
|
Receive email notifications about file shares and new comments
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>Privacy</h3>
|
||||||
|
<div class="setting-group">
|
||||||
|
<div class="setting-label">Public Profile</div>
|
||||||
|
<div class="setting-controls">
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" id="public-profile" {% if privacy.public_profile %}checked{% endif
|
||||||
|
%}>
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-description">
|
||||||
|
Allow others to see your profile and shared files
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-group">
|
||||||
|
<div class="setting-label">Share Statistics</div>
|
||||||
|
<div class="setting-controls">
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" id="share-statistics" {% if privacy.share_statistics %}checked{%
|
||||||
|
endif %}>
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-description">
|
||||||
|
Collect anonymous usage statistics to improve the service
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section dangerous-zone">
|
||||||
|
<h3>Danger Zone</h3>
|
||||||
|
<p class="warning-text">
|
||||||
|
These actions are permanent and cannot be undone
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="danger-actions">
|
||||||
|
<button id="delete-files-btn" class="btn dangerous">
|
||||||
|
<i class="fas fa-trash-alt"></i> Delete All Files
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button id="delete-account-btn" class="btn dangerous">
|
||||||
|
<i class="fas fa-user-times"></i> Delete Account
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Files Confirmation Modal -->
|
||||||
|
<div id="delete-files-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Delete All Files</h3>
|
||||||
|
<button class="modal-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="warning-text">
|
||||||
|
Are you sure you want to delete all your files? This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="delete-files-confirm">Type "DELETE" to confirm</label>
|
||||||
|
<input type="text" id="delete-files-confirm">
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn secondary modal-cancel">Cancel</button>
|
||||||
|
<button id="confirm-delete-files" class="btn dangerous" disabled>Delete All Files</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Account Confirmation Modal -->
|
||||||
|
<div id="delete-account-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Delete Account</h3>
|
||||||
|
<button class="modal-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="warning-text">
|
||||||
|
Are you sure you want to delete your account? This will permanently delete all your files and personal
|
||||||
|
data.
|
||||||
|
</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="delete-account-confirm">Type "{{ current_user.username }}" to confirm</label>
|
||||||
|
<input type="text" id="delete-account-confirm">
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn secondary modal-cancel">Cancel</button>
|
||||||
|
<button id="confirm-delete-account" class="btn dangerous" disabled>Delete Account</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
// Theme settings
|
||||||
|
const themeButtons = document.querySelectorAll('.theme-btn');
|
||||||
|
|
||||||
|
themeButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', function () {
|
||||||
|
const theme = this.dataset.theme;
|
||||||
|
setTheme(theme);
|
||||||
|
|
||||||
|
// Update active button
|
||||||
|
themeButtons.forEach(btn => btn.classList.remove('active'));
|
||||||
|
this.classList.add('active');
|
||||||
|
|
||||||
|
// Save preference
|
||||||
|
fetch('{{ url_for("auth.set_theme") }}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ theme: theme })
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function setTheme(theme) {
|
||||||
|
const html = document.documentElement;
|
||||||
|
|
||||||
|
if (theme === 'light') {
|
||||||
|
html.setAttribute('data-theme', 'light');
|
||||||
|
} else if (theme === 'dark') {
|
||||||
|
html.setAttribute('data-theme', 'dark');
|
||||||
|
} else if (theme === 'system') {
|
||||||
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
html.setAttribute('data-theme', prefersDark ? 'dark' : 'light');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// View settings
|
||||||
|
const viewButtons = document.querySelectorAll('.view-btn');
|
||||||
|
|
||||||
|
viewButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', function () {
|
||||||
|
const view = this.dataset.view;
|
||||||
|
|
||||||
|
// Update active button
|
||||||
|
viewButtons.forEach(btn => btn.classList.remove('active'));
|
||||||
|
this.classList.add('active');
|
||||||
|
|
||||||
|
// Save preference
|
||||||
|
localStorage.setItem('files_view', view);
|
||||||
|
|
||||||
|
// Send to server for persistence
|
||||||
|
fetch('{{ url_for("auth.set_view_preference") }}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ view: view })
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle settings
|
||||||
|
const toggles = document.querySelectorAll('.toggle input[type="checkbox"]');
|
||||||
|
|
||||||
|
toggles.forEach(toggle => {
|
||||||
|
toggle.addEventListener('change', function () {
|
||||||
|
const setting = this.id;
|
||||||
|
const value = this.checked;
|
||||||
|
|
||||||
|
// Save preference
|
||||||
|
fetch('{{ url_for("auth.update_setting") }}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
setting: setting,
|
||||||
|
value: value
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
showAlert('Setting updated successfully', 'success');
|
||||||
|
} else {
|
||||||
|
showAlert(data.error || 'Failed to update setting', 'error');
|
||||||
|
// Revert toggle
|
||||||
|
this.checked = !value;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
showAlert('An error occurred: ' + error, 'error');
|
||||||
|
// Revert toggle
|
||||||
|
this.checked = !value;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete files functionality
|
||||||
|
const deleteFilesBtn = document.getElementById('delete-files-btn');
|
||||||
|
const deleteFilesModal = document.getElementById('delete-files-modal');
|
||||||
|
const deleteFilesConfirmInput = document.getElementById('delete-files-confirm');
|
||||||
|
const confirmDeleteFilesBtn = document.getElementById('confirm-delete-files');
|
||||||
|
|
||||||
|
deleteFilesBtn.addEventListener('click', function () {
|
||||||
|
openModal(deleteFilesModal);
|
||||||
|
deleteFilesConfirmInput.value = '';
|
||||||
|
confirmDeleteFilesBtn.disabled = true;
|
||||||
|
deleteFilesConfirmInput.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
deleteFilesConfirmInput.addEventListener('input', function () {
|
||||||
|
confirmDeleteFilesBtn.disabled = this.value !== 'DELETE';
|
||||||
|
});
|
||||||
|
|
||||||
|
confirmDeleteFilesBtn.addEventListener('click', function () {
|
||||||
|
if (deleteFilesConfirmInput.value === 'DELETE') {
|
||||||
|
fetch('{{ url_for("auth.delete_all_files") }}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
closeModal(deleteFilesModal);
|
||||||
|
if (data.success) {
|
||||||
|
showAlert('All files have been deleted', 'success');
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '{{ url_for("files.browser") }}';
|
||||||
|
}, 1500);
|
||||||
|
} else {
|
||||||
|
showAlert(data.error || 'Failed to delete files', 'error');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
showAlert('An error occurred: ' + error, 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete account functionality
|
||||||
|
const deleteAccountBtn = document.getElementById('delete-account-btn');
|
||||||
|
const deleteAccountModal = document.getElementById('delete-account-modal');
|
||||||
|
const deleteAccountConfirmInput = document.getElementById('delete-account-confirm');
|
||||||
|
const confirmDeleteAccountBtn = document.getElementById('confirm-delete-account');
|
||||||
|
const usernameToConfirm = "{{ current_user.username }}";
|
||||||
|
|
||||||
|
deleteAccountBtn.addEventListener('click', function () {
|
||||||
|
openModal(deleteAccountModal);
|
||||||
|
deleteAccountConfirmInput.value = '';
|
||||||
|
confirmDeleteAccountBtn.disabled = true;
|
||||||
|
deleteAccountConfirmInput.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
deleteAccountConfirmInput.addEventListener('input', function () {
|
||||||
|
confirmDeleteAccountBtn.disabled = this.value !== usernameToConfirm;
|
||||||
|
});
|
||||||
|
|
||||||
|
confirmDeleteAccountBtn.addEventListener('click', function () {
|
||||||
|
if (deleteAccountConfirmInput.value === usernameToConfirm) {
|
||||||
|
fetch('{{ url_for("auth.delete_account") }}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
closeModal(deleteAccountModal);
|
||||||
|
if (data.success) {
|
||||||
|
showAlert('Your account has been deleted', 'success');
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '{{ url_for("auth.logout") }}';
|
||||||
|
}, 1500);
|
||||||
|
} else {
|
||||||
|
showAlert(data.error || 'Failed to delete account', 'error');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
showAlert('An error occurred: ' + error, 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modal utilities
|
||||||
|
function openModal(modal) {
|
||||||
|
modal.classList.add('visible');
|
||||||
|
document.body.classList.add('modal-open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal(modal) {
|
||||||
|
modal.classList.remove('visible');
|
||||||
|
document.body.classList.remove('modal-open');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modals when clicking outside or on close button
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
if (e.target.classList.contains('modal')) {
|
||||||
|
closeModal(e.target);
|
||||||
|
} else if (e.target.classList.contains('modal-close') || e.target.classList.contains('modal-cancel')) {
|
||||||
|
const modal = e.target.closest('.modal');
|
||||||
|
closeModal(modal);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Escape key to close modals
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
document.querySelectorAll('.modal.visible').forEach(modal => {
|
||||||
|
closeModal(modal);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function to show alerts
|
||||||
|
function showAlert(message, type = 'info') {
|
||||||
|
// Create alerts container if it doesn't exist
|
||||||
|
let alertsContainer = document.querySelector('.alerts');
|
||||||
|
if (!alertsContainer) {
|
||||||
|
alertsContainer = document.createElement('div');
|
||||||
|
alertsContainer.className = 'alerts';
|
||||||
|
document.body.appendChild(alertsContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create alert
|
||||||
|
const alert = document.createElement('div');
|
||||||
|
alert.className = `alert ${type}`;
|
||||||
|
alert.innerHTML = `
|
||||||
|
<div class="alert-content">${message}</div>
|
||||||
|
<button class="close" aria-label="Close">×</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add to container
|
||||||
|
alertsContainer.appendChild(alert);
|
||||||
|
|
||||||
|
// Setup dismiss
|
||||||
|
const closeBtn = alert.querySelector('.close');
|
||||||
|
closeBtn.addEventListener('click', function () {
|
||||||
|
alert.classList.add('fade-out');
|
||||||
|
setTimeout(() => {
|
||||||
|
alert.remove();
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto dismiss
|
||||||
|
setTimeout(() => {
|
||||||
|
if (alert.parentNode) {
|
||||||
|
alert.classList.add('fade-out');
|
||||||
|
setTimeout(() => {
|
||||||
|
if (alert.parentNode) {
|
||||||
|
alert.remove();
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
|
@ -1,5 +1,5 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" data-theme="light">
|
<html lang="en" data-theme="{{ theme_preference or 'system' }}">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
|
@ -50,27 +50,38 @@
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<header id="main-header">
|
<header class="navbar">
|
||||||
<nav>
|
<div class="navbar-brand">
|
||||||
<div class="logo">
|
<a href="{{ url_for('dashboard.index') }}">Flask Files</a>
|
||||||
<h1><a href="{{ url_for('dashboard.index') }}">Flask Files</a></h1>
|
</div>
|
||||||
|
<nav class="navbar-menu">
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<a href="{{ url_for('dashboard.index') }}" class="nav-item">
|
||||||
|
<i class="fas fa-tachometer-alt"></i> Dashboard
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('files.browser') }}" class="nav-item">
|
||||||
|
<i class="fas fa-folder"></i> Files
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('auth.profile') }}" class="nav-item">
|
||||||
|
<i class="fas fa-user"></i> {{ current_user.username }}
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('auth.logout') }}" class="nav-item">
|
||||||
|
<i class="fas fa-sign-out-alt"></i> Logout
|
||||||
|
</a>
|
||||||
|
<div class="theme-toggle-icon">
|
||||||
|
<i class="fas fa-moon"></i>
|
||||||
</div>
|
</div>
|
||||||
<ul>
|
{% else %}
|
||||||
<li><a href="{{ url_for('dashboard.index') }}"><i class="fas fa-chart-pie"></i> Dashboard</a></li>
|
<a href="{{ url_for('auth.login') }}" class="nav-item">
|
||||||
<li><a href="{{ url_for('files.browser') }}"><i class="fas fa-folder"></i> Files</a></li>
|
<i class="fas fa-sign-in-alt"></i> Login
|
||||||
{% if current_user.is_authenticated %}
|
</a>
|
||||||
<li><a href="{{ url_for('auth.profile') }}"><i class="fas fa-user"></i> {{ current_user.username }}</a>
|
<a href="{{ url_for('auth.register') }}" class="nav-item">
|
||||||
</li>
|
<i class="fas fa-user-plus"></i> Register
|
||||||
<li><a href="{{ url_for('auth.logout') }}"><i class="fas fa-sign-out-alt"></i> Logout</a></li>
|
</a>
|
||||||
{% else %}
|
<div class="theme-toggle-icon">
|
||||||
<li><a href="{{ url_for('auth.login') }}"><i class="fas fa-sign-in-alt"></i> Login</a></li>
|
<i class="fas fa-moon"></i>
|
||||||
{% endif %}
|
</div>
|
||||||
<li>
|
{% endif %}
|
||||||
<button id="darkModeToggle" class="toggle-button">
|
|
||||||
<i class="fas fa-moon"></i>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
@ -94,7 +105,9 @@
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<p>© {{ now.year }} Flask Files - A Simple File Manager</p>
|
<div class="container">
|
||||||
|
<p>© {{ now.year }} Flask Files. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<!-- Add this right before the closing </body> tag -->
|
<!-- Add this right before the closing </body> tag -->
|
||||||
|
@ -145,6 +158,11 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Common JS file with shared functions -->
|
||||||
|
<script src="{{ url_for('static', filename='js/common.js') }}"></script>
|
||||||
|
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
|
@ -2,225 +2,617 @@
|
||||||
|
|
||||||
{% block title %}File Browser - Flask Files{% endblock %}
|
{% block title %}File Browser - Flask Files{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block extra_css %}
|
||||||
<section class="file-browser">
|
<style>
|
||||||
<div class="browser-header">
|
.browser-container {
|
||||||
<h2>File Browser</h2>
|
background: var(--card-bg);
|
||||||
<!-- <div class="browser-actions"> -->
|
border-radius: var(--border-radius);
|
||||||
<!-- <a href="{{ url_for('files.upload', folder=current_folder.id if current_folder else None) }}"
|
padding: 1.5rem;
|
||||||
class="btn primary">
|
margin-bottom: 2rem;
|
||||||
<i class="fas fa-cloud-upload-alt"></i> Upload
|
box-shadow: var(--shadow-sm);
|
||||||
</a> -->
|
}
|
||||||
<!-- <button class="btn" id="new-folder-btn">
|
|
||||||
<i class="fas fa-folder-plus"></i> New Folder
|
|
||||||
</button> -->
|
|
||||||
<!-- </div> -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="path-nav">
|
.browser-header {
|
||||||
<a href="{{ url_for('files.browser') }}" class="path-item">
|
display: flex;
|
||||||
<i class="fas fa-home"></i> Home
|
justify-content: space-between;
|
||||||
</a>
|
align-items: center;
|
||||||
{% for folder in breadcrumbs %}
|
margin-bottom: 1.5rem;
|
||||||
<span class="path-separator">/</span>
|
flex-wrap: wrap;
|
||||||
<a href="{{ url_for('files.browser', folder=folder.id) }}" class="path-item">{{ folder.name }}</a>
|
gap: 1rem;
|
||||||
{% endfor %}
|
}
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if folders or files %}
|
.browser-title {
|
||||||
<div class="files-container">
|
display: flex;
|
||||||
{% if folders %}
|
align-items: center;
|
||||||
<div class="folder-section">
|
gap: 0.5rem;
|
||||||
<h3>Folders</h3>
|
}
|
||||||
<div class="files-list">
|
|
||||||
{% for folder in folders %}
|
|
||||||
<div class="file-item folder">
|
|
||||||
<a href="{{ url_for('files.browser', folder=folder.id) }}" class="file-link">
|
|
||||||
<div class="file-icon">
|
|
||||||
<i class="fas fa-folder fa-2x"></i>
|
|
||||||
</div>
|
|
||||||
<div class="file-name">{{ folder.name }}</div>
|
|
||||||
</a>
|
|
||||||
<div class="file-actions">
|
|
||||||
<button class="action-btn rename" data-id="{{ folder.id }}" title="Rename">
|
|
||||||
<i class="fas fa-edit"></i>
|
|
||||||
</button>
|
|
||||||
<button class="action-btn delete" data-id="{{ folder.id }}" title="Delete">
|
|
||||||
<i class="fas fa-trash"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if files %}
|
.browser-actions {
|
||||||
<div class="file-section">
|
display: flex;
|
||||||
<h3>Files</h3>
|
gap: 0.5rem;
|
||||||
<div class="files-list">
|
align-items: center;
|
||||||
{% for file in files %}
|
}
|
||||||
<div class="file-item">
|
|
||||||
<div class="file-link">
|
.breadcrumbs {
|
||||||
<div class="file-icon">
|
display: flex;
|
||||||
{% if file.name.endswith('.pdf') %}<i class="fas fa-file-pdf fa-2x"></i>
|
flex-wrap: wrap;
|
||||||
{% elif file.name.endswith(('.jpg', '.jpeg', '.png', '.gif')) %}<i
|
margin-bottom: 1.5rem;
|
||||||
class="fas fa-file-image fa-2x"></i>
|
background: var(--bg-light);
|
||||||
{% elif file.name.endswith(('.mp3', '.wav', '.flac')) %}<i
|
padding: 0.5rem 1rem;
|
||||||
class="fas fa-file-audio fa-2x"></i>
|
border-radius: var(--border-radius-sm);
|
||||||
{% elif file.name.endswith(('.mp4', '.mov', '.avi')) %}<i
|
}
|
||||||
class="fas fa-file-video fa-2x"></i>
|
|
||||||
{% elif file.name.endswith(('.doc', '.docx')) %}<i class="fas fa-file-word fa-2x"></i>
|
.breadcrumb-item {
|
||||||
{% elif file.name.endswith(('.xls', '.xlsx')) %}<i class="fas fa-file-excel fa-2x"></i>
|
display: flex;
|
||||||
{% elif file.name.endswith(('.ppt', '.pptx')) %}<i class="fas fa-file-powerpoint fa-2x"></i>
|
align-items: center;
|
||||||
{% elif file.name.endswith('.zip') %}<i class="fas fa-file-archive fa-2x"></i>
|
}
|
||||||
{% else %}<i class="fas fa-file fa-2x"></i>{% endif %}
|
|
||||||
</div>
|
.breadcrumb-separator {
|
||||||
<div class="file-details">
|
margin: 0 0.5rem;
|
||||||
<div class="file-name">{{ file.name }}</div>
|
color: var(--text-muted);
|
||||||
<div class="file-meta">
|
}
|
||||||
<span class="file-size">{{ (file.size / 1024)|round(1) }} KB</span>
|
|
||||||
<span class="file-date">{{ file.updated_at.strftime('%b %d, %Y') }}</span>
|
.view-toggle {
|
||||||
</div>
|
display: flex;
|
||||||
</div>
|
border: 1px solid var(--border-color);
|
||||||
</div>
|
border-radius: var(--border-radius-sm);
|
||||||
<div class="file-actions">
|
overflow: hidden;
|
||||||
<a href="#" class="action-btn download" data-id="{{ file.id }}" title="Download">
|
margin-left: 0.5rem;
|
||||||
<i class="fas fa-download"></i>
|
}
|
||||||
</a>
|
|
||||||
<a href="#" class="action-btn share" data-id="{{ file.id }}" title="Share">
|
.view-btn {
|
||||||
<i class="fas fa-share-alt"></i>
|
border: none;
|
||||||
</a>
|
background: var(--card-bg);
|
||||||
<button class="action-btn rename" data-id="{{ file.id }}" title="Rename">
|
padding: 0.5rem;
|
||||||
<i class="fas fa-edit"></i>
|
cursor: pointer;
|
||||||
</button>
|
color: var(--text-muted);
|
||||||
<button class="action-btn delete" data-id="{{ file.id }}" title="Delete">
|
}
|
||||||
<i class="fas fa-trash"></i>
|
|
||||||
</button>
|
.view-btn.active {
|
||||||
</div>
|
background: var(--primary-color);
|
||||||
</div>
|
color: white;
|
||||||
{% endfor %}
|
}
|
||||||
</div>
|
|
||||||
</div>
|
.filter-bar {
|
||||||
{% endif %}
|
display: flex;
|
||||||
</div>
|
margin-bottom: 1rem;
|
||||||
{% else %}
|
gap: 0.5rem;
|
||||||
<div class="empty-state">
|
flex-wrap: wrap;
|
||||||
<div class="empty-icon">
|
}
|
||||||
<i class="fas fa-folder-open fa-3x"></i>
|
|
||||||
</div>
|
.search-bar {
|
||||||
<p>This folder is empty</p>
|
flex-grow: 1;
|
||||||
<p>Upload files or create a new folder to get started</p>
|
position: relative;
|
||||||
<div class="empty-actions">
|
}
|
||||||
<a href="{{ url_for('files.upload', folder=current_folder.id if current_folder else None) }}"
|
|
||||||
class="btn primary">
|
.search-bar input {
|
||||||
<i class=" fas fa-cloud-upload-alt"></i> Upload
|
width: 100%;
|
||||||
</a>
|
padding: 0.5rem 1rem 0.5rem 2.5rem;
|
||||||
<button class="btn" id="empty-new-folder-btn">
|
border: 1px solid var(--border-color);
|
||||||
<i class="fas fa-folder-plus"></i> New Folder
|
border-radius: var(--border-radius-sm);
|
||||||
</button>
|
background: var(--bg-light);
|
||||||
</div>
|
}
|
||||||
</div>
|
|
||||||
{% endif %}
|
.search-icon {
|
||||||
</section>
|
position: absolute;
|
||||||
|
left: 0.75rem;
|
||||||
|
top: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dropdown {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dropdown-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
background: var(--bg-light);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
min-width: 200px;
|
||||||
|
z-index: 10;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
display: none;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dropdown-menu.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-option {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-option:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-option.active {
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-container {
|
||||||
|
min-height: 200px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-indicator {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(var(--card-bg-rgb), 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 5;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-indicator.show {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 4px solid rgba(var(--primary-color-rgb), 0.3);
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top-color: var(--primary-color);
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid view */
|
||||||
|
.files-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-item,
|
||||||
|
.file-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
position: relative;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-item:hover,
|
||||||
|
.file-item:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-item .item-icon {
|
||||||
|
color: #ffc107;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-name {
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
word-break: break-word;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-details {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* List view */
|
||||||
|
.files-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-view .folder-item,
|
||||||
|
.list-view .file-item {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-view .item-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
margin-right: 1rem;
|
||||||
|
width: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-view .item-info {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-view .item-name {
|
||||||
|
margin-bottom: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-view .item-details {
|
||||||
|
margin-left: auto;
|
||||||
|
min-width: 200px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-view .item-date {
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty folder state */
|
||||||
|
.empty-folder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-message h3 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-message p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Context menu */
|
||||||
|
.context-menu {
|
||||||
|
position: fixed;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
z-index: 100;
|
||||||
|
min-width: 180px;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-item {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-item.danger {
|
||||||
|
color: var(--danger-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.browser-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser-actions {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-view .item-info {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-view .item-details {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal fixes - ensure they're hidden by default */
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 1000;
|
||||||
|
overflow: auto;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.visible {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
margin: 2rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block content %}
|
||||||
<script src="{{ url_for('static', filename='js/upload.js') }}"></script>
|
<div class="container">
|
||||||
|
<div class="browser-container">
|
||||||
|
<div class="browser-header">
|
||||||
|
<div class="browser-title">
|
||||||
|
<i class="fas fa-folder-open"></i>
|
||||||
|
<h2>{% if current_folder %}{{ current_folder.name }}{% else %}My Files{% endif %}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="browser-actions">
|
||||||
|
<input type="text" id="search-input" class="search-input" placeholder="Search files...">
|
||||||
|
<button id="search-btn" class="btn">
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
</button>
|
||||||
|
<a href="{% if current_folder %}{{ url_for('files.upload', folder_id=current_folder.id) }}{% else %}{{ url_for('files.upload') }}{% endif %}"
|
||||||
|
class="btn primary">
|
||||||
|
<i class="fas fa-upload"></i> Upload
|
||||||
|
</a>
|
||||||
|
<button id="new-folder-btn" class="btn secondary">
|
||||||
|
<i class="fas fa-folder-plus"></i> New Folder
|
||||||
|
</button>
|
||||||
|
<div class="view-toggle">
|
||||||
|
<button id="grid-view-btn" class="view-btn active" title="Grid View">
|
||||||
|
<i class="fas fa-th"></i>
|
||||||
|
</button>
|
||||||
|
<button id="list-view-btn" class="view-btn" title="List View">
|
||||||
|
<i class="fas fa-list"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="breadcrumbs">
|
||||||
|
<a href="{{ url_for('files.browser') }}" class="breadcrumb-item">
|
||||||
|
<i class="fas fa-home"></i> Home
|
||||||
|
</a>
|
||||||
|
{% if breadcrumbs %}
|
||||||
|
{% for folder in breadcrumbs %}
|
||||||
|
<span class="breadcrumb-separator">/</span>
|
||||||
|
<a href="{{ url_for('files.browser', folder_id=folder.id) }}" class="breadcrumb-item">
|
||||||
|
{{ folder.name }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="files-container" class="files-container grid-view">
|
||||||
|
{% include 'files/partials/folder_contents.html' %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New Folder Modal -->
|
||||||
|
<div id="new-folder-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Create New Folder</h3>
|
||||||
|
<button class="modal-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="new-folder-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>
|
||||||
|
<input type="hidden" id="parent-folder-id" name="parent_id"
|
||||||
|
value="{% if current_folder %}{{ current_folder.id }}{% endif %}">
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn secondary modal-cancel">Cancel</button>
|
||||||
|
<button type="submit" class="btn primary">Create Folder</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- File Actions Modal -->
|
||||||
|
<div id="file-actions-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="file-name-header">File Actions</h3>
|
||||||
|
<button class="modal-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="file-actions-list">
|
||||||
|
<a id="download-action" href="#" class="file-action">
|
||||||
|
<i class="fas fa-download"></i> Download
|
||||||
|
</a>
|
||||||
|
<a id="share-action" href="#" class="file-action">
|
||||||
|
<i class="fas fa-share-alt"></i> Share
|
||||||
|
</a>
|
||||||
|
<button id="rename-action" class="file-action">
|
||||||
|
<i class="fas fa-edit"></i> Rename
|
||||||
|
</button>
|
||||||
|
<button id="delete-action" class="file-action dangerous">
|
||||||
|
<i class="fas fa-trash-alt"></i> Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rename Modal -->
|
||||||
|
<div id="rename-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Rename Item</h3>
|
||||||
|
<button class="modal-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="rename-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new-name">New Name</label>
|
||||||
|
<input type="text" id="new-name" name="name" required>
|
||||||
|
<input type="hidden" id="rename-item-id" name="item_id">
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn secondary modal-cancel">Cancel</button>
|
||||||
|
<button type="submit" class="btn primary">Rename</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Modal -->
|
||||||
|
<div id="delete-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Confirm Deletion</h3>
|
||||||
|
<button class="modal-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p id="delete-confirmation-message">Are you sure you want to delete this item? This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn secondary modal-cancel">Cancel</button>
|
||||||
|
<button id="confirm-delete" class="btn dangerous">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
// New folder functionality
|
// Setup modals on page load
|
||||||
|
setupModals();
|
||||||
|
|
||||||
|
// Initialize variables
|
||||||
|
const filesContainer = document.getElementById('files-container');
|
||||||
|
const gridViewBtn = document.getElementById('grid-view-btn');
|
||||||
|
const listViewBtn = document.getElementById('list-view-btn');
|
||||||
const newFolderBtn = document.getElementById('new-folder-btn');
|
const newFolderBtn = document.getElementById('new-folder-btn');
|
||||||
const emptyNewFolderBtn = document.getElementById('empty-new-folder-btn');
|
let selectedItemId = null;
|
||||||
|
|
||||||
function showNewFolderPrompt() {
|
// Button event listeners
|
||||||
const folderName = prompt('Enter folder name:');
|
if (gridViewBtn && listViewBtn) {
|
||||||
if (folderName) {
|
gridViewBtn.addEventListener('click', function () {
|
||||||
createNewFolder(folderName);
|
filesContainer.className = 'files-container grid-view';
|
||||||
|
gridViewBtn.classList.add('active');
|
||||||
|
listViewBtn.classList.remove('active');
|
||||||
|
|
||||||
|
// Save preference
|
||||||
|
localStorage.setItem('view_preference', 'grid');
|
||||||
|
});
|
||||||
|
|
||||||
|
listViewBtn.addEventListener('click', function () {
|
||||||
|
filesContainer.className = 'files-container list-view';
|
||||||
|
listViewBtn.classList.add('active');
|
||||||
|
gridViewBtn.classList.remove('active');
|
||||||
|
|
||||||
|
// Save preference
|
||||||
|
localStorage.setItem('view_preference', 'list');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply saved view preference
|
||||||
|
const savedView = localStorage.getItem('view_preference');
|
||||||
|
if (savedView === 'list') {
|
||||||
|
filesContainer.className = 'files-container list-view';
|
||||||
|
if (listViewBtn && gridViewBtn) {
|
||||||
|
listViewBtn.classList.add('active');
|
||||||
|
gridViewBtn.classList.remove('active');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createNewFolder(name) {
|
// New folder button
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('name', name);
|
|
||||||
|
|
||||||
{% if current_folder %}
|
|
||||||
formData.append('parent_id', '{{ current_folder.id }}');
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
fetch('/files/create_folder', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
headers: {
|
|
||||||
'X-Requested-With': 'XMLHttpRequest'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.success) {
|
|
||||||
window.location.reload();
|
|
||||||
} else {
|
|
||||||
alert(data.error || 'Failed to create folder');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error:', error);
|
|
||||||
alert('An error occurred while creating the folder');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newFolderBtn) {
|
if (newFolderBtn) {
|
||||||
newFolderBtn.addEventListener('click', showNewFolderPrompt);
|
newFolderBtn.addEventListener('click', function () {
|
||||||
|
openModal('new-folder-modal');
|
||||||
|
document.getElementById('folder-name').focus();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (emptyNewFolderBtn) {
|
// Setup file item event listeners
|
||||||
emptyNewFolderBtn.addEventListener('click', showNewFolderPrompt);
|
function setupFileListeners() {
|
||||||
|
// ... your existing file listeners ...
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup dismiss
|
// Initial setup
|
||||||
const closeBtn = alert.querySelector('.close');
|
setupFileListeners();
|
||||||
closeBtn.addEventListener('click', function () {
|
|
||||||
alert.classList.add('fade-out');
|
|
||||||
setTimeout(() => {
|
|
||||||
alert.remove();
|
|
||||||
}, 300);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto dismiss
|
|
||||||
setTimeout(() => {
|
|
||||||
if (alert.parentNode) {
|
|
||||||
alert.classList.add('fade-out');
|
|
||||||
setTimeout(() => {
|
|
||||||
if (alert.parentNode) {
|
|
||||||
alert.remove();
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper functions
|
|
||||||
function formatSize(bytes) {
|
|
||||||
if (bytes === 0) return '0 Bytes';
|
|
||||||
const k = 1024;
|
|
||||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateString) {
|
|
||||||
if (!dateString) return 'Unknown';
|
|
||||||
const date = new Date(dateString);
|
|
||||||
return date.toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(unsafe) {
|
|
||||||
return unsafe
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """)
|
|
||||||
.replace(/'/g, "'");
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
|
@ -1,5 +1,5 @@
|
||||||
{% if folders or files %}
|
{% if folders or files %}
|
||||||
<div class="files-grid grid-view">
|
<div class="files-grid">
|
||||||
{% if folders %}
|
{% if folders %}
|
||||||
{% for folder in folders %}
|
{% for folder in folders %}
|
||||||
<a href="{{ url_for('files.browser', folder_id=folder.id) }}" class="folder-item" data-id="{{ folder.id }}">
|
<a href="{{ url_for('files.browser', folder_id=folder.id) }}" class="folder-item" data-id="{{ folder.id }}">
|
||||||
|
@ -9,8 +9,8 @@
|
||||||
<div class="item-info">
|
<div class="item-info">
|
||||||
<div class="item-name">{{ folder.name }}</div>
|
<div class="item-name">{{ folder.name }}</div>
|
||||||
<div class="item-details">
|
<div class="item-details">
|
||||||
<span class="item-count">{{ folder.files.count() }} items</span>
|
<span class="item-count">{{ folder.children.count() }} items</span>
|
||||||
<span class="item-date">{{ folder.created_at.strftime('%Y-%m-%d') }}</span>
|
<span class="item-date">{{ folder.updated_at.strftime('%Y-%m-%d') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
@ -27,7 +27,7 @@
|
||||||
<div class="item-name">{{ file.name }}</div>
|
<div class="item-name">{{ file.name }}</div>
|
||||||
<div class="item-details">
|
<div class="item-details">
|
||||||
<span class="item-size">{{ format_size(file.size) }}</span>
|
<span class="item-size">{{ format_size(file.size) }}</span>
|
||||||
<span class="item-date">{{ file.created_at.strftime('%Y-%m-%d') }}</span>
|
<span class="item-date">{{ file.updated_at.strftime('%Y-%m-%d') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
@ -43,5 +43,14 @@
|
||||||
<h3>This folder is empty</h3>
|
<h3>This folder is empty</h3>
|
||||||
<p>Upload files or create a folder to get started</p>
|
<p>Upload files or create a folder to get started</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="empty-actions">
|
||||||
|
<a href="{{ url_for('files.upload', folder_id=current_folder.id if current_folder else None) }}"
|
||||||
|
class="btn primary">
|
||||||
|
<i class="fas fa-upload"></i> Upload
|
||||||
|
</a>
|
||||||
|
<button class="btn" id="empty-new-folder-btn">
|
||||||
|
<i class="fas fa-folder-plus"></i> New Folder
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
|
@ -3,562 +3,222 @@
|
||||||
{% block title %}Upload Files - Flask Files{% endblock %}
|
{% block title %}Upload Files - Flask Files{% endblock %}
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/upload.css') }}">
|
<style>
|
||||||
|
.upload-container {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropzone {
|
||||||
|
border: 2px dashed var(--border-color);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropzone.highlight {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
background-color: var(--primary-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropzone-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-inputs {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-container {
|
||||||
|
display: none;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-info {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-container {
|
||||||
|
height: 8px;
|
||||||
|
background-color: var(--bg-light);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
width: 0%;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-files-list {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item-icon {
|
||||||
|
margin-right: 1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item-details {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item-name {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item-meta {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item-status {
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pending {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-uploading {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-success {
|
||||||
|
color: var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-error {
|
||||||
|
color: var(--danger-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="upload-container">
|
<div class="container">
|
||||||
<div class="upload-header">
|
<div class="upload-container">
|
||||||
<h2>Upload Files</h2>
|
<div class="upload-header">
|
||||||
<div class="upload-location">
|
<h2>Upload Files</h2>
|
||||||
<span>Uploading to:</span>
|
<div>
|
||||||
{% if parent_folder %}
|
<a href="{{ url_for('files.browser', folder_id=parent_folder.id if parent_folder else None) }}"
|
||||||
<a href="{{ url_for('files.browser', folder_id=parent_folder.id) }}">{{ parent_folder.name }}</a>
|
class="btn secondary">
|
||||||
{% else %}
|
<i class="fas fa-arrow-left"></i> Back to Files
|
||||||
<a href="{{ url_for('files.browser') }}">Root</a>
|
</a>
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="upload-dropzone" id="dropzone">
|
|
||||||
<div class="upload-icon-wrapper">
|
|
||||||
<i class="fas fa-cloud-upload-alt upload-icon"></i>
|
|
||||||
</div>
|
|
||||||
<div class="upload-text">
|
|
||||||
<p class="upload-primary-text">Drag & drop files or folders here</p>
|
|
||||||
<p class="upload-secondary-text">or</p>
|
|
||||||
<div class="upload-buttons">
|
|
||||||
<label class="btn primary">
|
|
||||||
<i class="fas fa-file"></i> Select Files
|
|
||||||
<input type="file" name="files[]" multiple id="file-input" style="display: none">
|
|
||||||
</label>
|
|
||||||
<label class="btn">
|
|
||||||
<i class="fas fa-folder"></i> Select Folder
|
|
||||||
<input type="file" name="folders[]" webkitdirectory directory id="folder-input"
|
|
||||||
style="display: none">
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<p class="upload-hint">Files will upload automatically when dropped or selected</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="upload-progress-container" id="progress-container" style="display: none;">
|
|
||||||
<h3>Upload Progress</h3>
|
|
||||||
<div class="progress-overall">
|
|
||||||
<div class="progress-header">
|
|
||||||
<span>Overall Progress</span>
|
|
||||||
<span id="progress-percentage">0%</span>
|
|
||||||
</div>
|
|
||||||
<div class="progress-bar-container">
|
|
||||||
<div class="progress-bar" id="progress-bar"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="upload-stats">
|
<div class="upload-tabs">
|
||||||
<div class="stat-item">
|
<button class="upload-tab active" data-tab="file-upload-tab">
|
||||||
<i class="fas fa-tachometer-alt"></i>
|
<i class="fas fa-file-upload"></i> File Upload
|
||||||
<span id="upload-speed">0 KB/s</span>
|
</button>
|
||||||
|
<button class="upload-tab" data-tab="folder-upload-tab">
|
||||||
|
<i class="fas fa-folder-upload"></i> Folder Upload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-content active" id="file-upload-tab">
|
||||||
|
<div id="file-dropzone" class="dropzone">
|
||||||
|
<div class="dropzone-icon">
|
||||||
|
<i class="fas fa-cloud-upload-alt"></i>
|
||||||
|
</div>
|
||||||
|
<h3>Drag & Drop Files Here</h3>
|
||||||
|
<p>Or click to browse your device</p>
|
||||||
|
<button id="file-select-btn" class="btn primary">Select Files</button>
|
||||||
|
<form id="file-upload-form" method="post" enctype="multipart/form-data">
|
||||||
|
<input type="hidden" name="folder_id"
|
||||||
|
value="{% if parent_folder %}{{ parent_folder.id }}{% endif %}">
|
||||||
|
<input type="file" id="file-input" name="files[]" multiple style="display: none;">
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-item">
|
|
||||||
<i class="fas fa-file-upload"></i>
|
<div class="upload-progress">
|
||||||
<span id="uploaded-size">0 KB / 0 KB</span>
|
<div class="progress-info">
|
||||||
|
<div class="progress-text">Upload Progress</div>
|
||||||
|
<div class="progress-percentage" id="progress-percentage">0%</div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar-container">
|
||||||
|
<div class="progress-bar" id="progress-bar"></div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-details">
|
||||||
|
<div class="progress-speed" id="upload-speed">0 KB/s</div>
|
||||||
|
<div class="progress-size" id="uploaded-size">0 KB / 0 KB</div>
|
||||||
|
<div class="progress-time" id="time-remaining">Calculating...</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-item">
|
|
||||||
<i class="fas fa-clock"></i>
|
<div id="file-list" class="file-list"></div>
|
||||||
<span id="time-remaining">calculating...</span>
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-content" id="folder-upload-tab">
|
||||||
|
<div id="folder-dropzone" class="dropzone">
|
||||||
|
<div class="dropzone-icon">
|
||||||
|
<i class="fas fa-folder-open"></i>
|
||||||
|
</div>
|
||||||
|
<h3>Drag & Drop a Folder Here</h3>
|
||||||
|
<p>Or click to browse your device</p>
|
||||||
|
<button id="folder-select-btn" class="btn primary">Select Folder</button>
|
||||||
|
<form id="folder-upload-form" method="post" enctype="multipart/form-data">
|
||||||
|
<input type="hidden" name="folder_id"
|
||||||
|
value="{% if parent_folder %}{{ parent_folder.id }}{% endif %}">
|
||||||
|
<input type="file" id="folder-input" name="files[]" webkitdirectory directory multiple
|
||||||
|
style="display: none;">
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="upload-progress">
|
||||||
|
<div class="progress-info">
|
||||||
|
<div class="progress-text">Upload Progress</div>
|
||||||
|
<div class="progress-percentage" id="folder-progress-percentage">0%</div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar-container">
|
||||||
|
<div class="progress-bar" id="folder-progress-bar"></div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-details">
|
||||||
|
<div class="progress-speed" id="folder-upload-speed">0 KB/s</div>
|
||||||
|
<div class="progress-size" id="folder-uploaded-size">0 KB / 0 KB</div>
|
||||||
|
<div class="progress-time" id="folder-time-remaining">Calculating...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="folder-file-list" class="file-list"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="upload-list" id="upload-list">
|
|
||||||
<h3>Files (<span id="file-count">0</span>)</h3>
|
|
||||||
<div id="file-items" class="file-items"></div>
|
|
||||||
<div id="empty-message" class="empty-message">
|
|
||||||
<p>No files selected yet</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="upload-actions">
|
|
||||||
<a href="{{ url_for('files.browser', folder_id=parent_folder.id if parent_folder else None) }}" class="btn">
|
|
||||||
Cancel
|
|
||||||
</a>
|
|
||||||
<button id="clear-button" class="btn" disabled>Clear All</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block scripts %}
|
||||||
<script>
|
<script src="{{ url_for('static', filename='js/upload.js') }}"></script>
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
// DOM Elements
|
|
||||||
const dropzone = document.getElementById('dropzone');
|
|
||||||
const fileInput = document.getElementById('file-input');
|
|
||||||
const folderInput = document.getElementById('folder-input');
|
|
||||||
const uploadList = document.getElementById('file-items');
|
|
||||||
const emptyMessage = document.getElementById('empty-message');
|
|
||||||
const fileCount = document.getElementById('file-count');
|
|
||||||
const progressContainer = document.getElementById('progress-container');
|
|
||||||
const progressBar = document.getElementById('progress-bar');
|
|
||||||
const progressPercentage = document.getElementById('progress-percentage');
|
|
||||||
const uploadSpeed = document.getElementById('upload-speed');
|
|
||||||
const uploadedSize = document.getElementById('uploaded-size');
|
|
||||||
const timeRemaining = document.getElementById('time-remaining');
|
|
||||||
const clearButton = document.getElementById('clear-button');
|
|
||||||
|
|
||||||
// Upload tracking
|
|
||||||
let uploadQueue = [];
|
|
||||||
let currentUploads = 0;
|
|
||||||
let totalUploaded = 0;
|
|
||||||
let totalSize = 0;
|
|
||||||
let uploadStartTime = 0;
|
|
||||||
let lastUploadedBytes = 0;
|
|
||||||
let uploadUpdateInterval = null;
|
|
||||||
const MAX_CONCURRENT_UPLOADS = 3;
|
|
||||||
const folderId = {{ parent_folder.id if parent_folder else 'null' }
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Setup event listeners
|
|
||||||
dropzone.addEventListener('dragover', function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
this.classList.add('highlight');
|
|
||||||
});
|
|
||||||
|
|
||||||
dropzone.addEventListener('dragleave', function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
this.classList.remove('highlight');
|
|
||||||
});
|
|
||||||
|
|
||||||
dropzone.addEventListener('drop', function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
this.classList.remove('highlight');
|
|
||||||
|
|
||||||
// Handle dropped files
|
|
||||||
const items = e.dataTransfer.items;
|
|
||||||
if (items && items.length > 0) {
|
|
||||||
// Check if this is a folder drop from file explorer
|
|
||||||
const containsDirectories = Array.from(items).some(item => {
|
|
||||||
return item.webkitGetAsEntry && item.webkitGetAsEntry().isDirectory;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (containsDirectories) {
|
|
||||||
processDroppedItems(items);
|
|
||||||
} else {
|
|
||||||
handleFiles(e.dataTransfer.files);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
fileInput.addEventListener('change', function () {
|
|
||||||
handleFiles(this.files);
|
|
||||||
});
|
|
||||||
|
|
||||||
folderInput.addEventListener('change', function () {
|
|
||||||
handleFolderUpload(this.files);
|
|
||||||
});
|
|
||||||
|
|
||||||
clearButton.addEventListener('click', function () {
|
|
||||||
resetUploadState();
|
|
||||||
});
|
|
||||||
|
|
||||||
function processDroppedItems(items) {
|
|
||||||
const fileList = [];
|
|
||||||
let pendingDirectories = 0;
|
|
||||||
|
|
||||||
function traverseFileTree(entry, path = '') {
|
|
||||||
if (entry.isFile) {
|
|
||||||
entry.file(file => {
|
|
||||||
file.relativePath = path + file.name;
|
|
||||||
fileList.push(file);
|
|
||||||
if (pendingDirectories === 0 && entry.isFile) {
|
|
||||||
handleFiles(fileList);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if (entry.isDirectory) {
|
|
||||||
pendingDirectories++;
|
|
||||||
const dirReader = entry.createReader();
|
|
||||||
const readEntries = () => {
|
|
||||||
dirReader.readEntries(entries => {
|
|
||||||
if (entries.length > 0) {
|
|
||||||
for (let i = 0; i < entries.length; i++) {
|
|
||||||
traverseFileTree(entries[i], path + entry.name + '/');
|
|
||||||
}
|
|
||||||
readEntries(); // Continue reading if there might be more entries
|
|
||||||
} else {
|
|
||||||
pendingDirectories--;
|
|
||||||
if (pendingDirectories === 0) {
|
|
||||||
handleFiles(fileList);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
readEntries();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
|
||||||
const entry = items[i].webkitGetAsEntry();
|
|
||||||
if (entry) {
|
|
||||||
traverseFileTree(entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFiles(files) {
|
|
||||||
if (!files || files.length === 0) return;
|
|
||||||
|
|
||||||
clearButton.disabled = false;
|
|
||||||
emptyMessage.style.display = 'none';
|
|
||||||
|
|
||||||
for (let i = 0; i < files.length; i++) {
|
|
||||||
const file = files[i];
|
|
||||||
|
|
||||||
// Add to queue
|
|
||||||
uploadQueue.push({
|
|
||||||
file: file,
|
|
||||||
relativePath: file.relativePath || null,
|
|
||||||
status: 'queued',
|
|
||||||
progress: 0
|
|
||||||
});
|
|
||||||
|
|
||||||
totalSize += file.size;
|
|
||||||
|
|
||||||
// Add to UI
|
|
||||||
const fileItem = document.createElement('div');
|
|
||||||
fileItem.className = 'file-item';
|
|
||||||
fileItem.dataset.index = uploadQueue.length - 1;
|
|
||||||
|
|
||||||
// Determine icon based on file type
|
|
||||||
const fileIcon = getFileIcon(file.name);
|
|
||||||
|
|
||||||
fileItem.innerHTML = `
|
|
||||||
<div class="file-icon">
|
|
||||||
<i class="fas ${fileIcon}"></i>
|
|
||||||
</div>
|
|
||||||
<div class="file-info">
|
|
||||||
<div class="file-name">${file.name}</div>
|
|
||||||
<div class="file-path">${file.relativePath || 'No path'}</div>
|
|
||||||
<div class="file-size">${formatSize(file.size)}</div>
|
|
||||||
<div class="file-progress">
|
|
||||||
<div class="progress-bar-small" style="width: 0%"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="file-status">
|
|
||||||
<span class="status-indicator queued">Queued</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
uploadList.appendChild(fileItem);
|
|
||||||
}
|
|
||||||
|
|
||||||
fileCount.textContent = uploadQueue.length;
|
|
||||||
|
|
||||||
// Start upload process
|
|
||||||
startUpload();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFolderUpload(files) {
|
|
||||||
if (!files || files.length === 0) return;
|
|
||||||
|
|
||||||
const fileArray = Array.from(files);
|
|
||||||
for (let i = 0; i < fileArray.length; i++) {
|
|
||||||
const file = fileArray[i];
|
|
||||||
file.relativePath = file.webkitRelativePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleFiles(fileArray);
|
|
||||||
}
|
|
||||||
|
|
||||||
function startUpload() {
|
|
||||||
if (uploadQueue.length === 0 || currentUploads >= MAX_CONCURRENT_UPLOADS) return;
|
|
||||||
|
|
||||||
if (currentUploads === 0) {
|
|
||||||
// First upload - initialize tracking
|
|
||||||
uploadStartTime = Date.now();
|
|
||||||
lastUploadedBytes = 0;
|
|
||||||
progressContainer.style.display = 'block';
|
|
||||||
|
|
||||||
// Start progress update interval
|
|
||||||
uploadUpdateInterval = setInterval(updateUploadStats, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find next queued file
|
|
||||||
const nextIndex = uploadQueue.findIndex(item => item.status === 'queued');
|
|
||||||
if (nextIndex === -1) return;
|
|
||||||
|
|
||||||
// Start uploading this file
|
|
||||||
uploadQueue[nextIndex].status = 'uploading';
|
|
||||||
currentUploads++;
|
|
||||||
|
|
||||||
// Update UI
|
|
||||||
const fileItem = document.querySelector(`.file-item[data-index="${nextIndex}"]`);
|
|
||||||
const statusIndicator = fileItem.querySelector('.status-indicator');
|
|
||||||
statusIndicator.className = 'status-indicator uploading';
|
|
||||||
statusIndicator.textContent = 'Uploading';
|
|
||||||
|
|
||||||
// Create FormData
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', uploadQueue[nextIndex].file);
|
|
||||||
formData.append('folder_id', folderId || '');
|
|
||||||
|
|
||||||
if (uploadQueue[nextIndex].relativePath) {
|
|
||||||
formData.append('relative_path', uploadQueue[nextIndex].relativePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create and configure XHR
|
|
||||||
const xhr = new XMLHttpRequest();
|
|
||||||
|
|
||||||
xhr.upload.addEventListener('progress', function (e) {
|
|
||||||
if (e.lengthComputable) {
|
|
||||||
const percentComplete = Math.round((e.loaded / e.total) * 100);
|
|
||||||
|
|
||||||
// Update file progress
|
|
||||||
uploadQueue[nextIndex].progress = percentComplete;
|
|
||||||
|
|
||||||
// Update file UI
|
|
||||||
const progressBar = fileItem.querySelector('.progress-bar-small');
|
|
||||||
progressBar.style.width = percentComplete + '%';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
xhr.addEventListener('load', function () {
|
|
||||||
if (xhr.status === 200) {
|
|
||||||
try {
|
|
||||||
const response = JSON.parse(xhr.responseText);
|
|
||||||
|
|
||||||
if (response.success) {
|
|
||||||
// Upload successful
|
|
||||||
uploadQueue[nextIndex].status = 'complete';
|
|
||||||
statusIndicator.className = 'status-indicator complete';
|
|
||||||
statusIndicator.textContent = 'Complete';
|
|
||||||
|
|
||||||
totalUploaded += uploadQueue[nextIndex].file.size;
|
|
||||||
} else {
|
|
||||||
// Upload failed on server
|
|
||||||
uploadQueue[nextIndex].status = 'error';
|
|
||||||
statusIndicator.className = 'status-indicator error';
|
|
||||||
statusIndicator.textContent = 'Error: ' + (response.error || 'Server Error');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// JSON parse error
|
|
||||||
uploadQueue[nextIndex].status = 'error';
|
|
||||||
statusIndicator.className = 'status-indicator error';
|
|
||||||
statusIndicator.textContent = 'Error: Invalid response';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// HTTP error
|
|
||||||
uploadQueue[nextIndex].status = 'error';
|
|
||||||
statusIndicator.className = 'status-indicator error';
|
|
||||||
statusIndicator.textContent = 'Error: ' + xhr.status;
|
|
||||||
}
|
|
||||||
|
|
||||||
// One upload completed
|
|
||||||
currentUploads--;
|
|
||||||
|
|
||||||
// Check if all uploads complete
|
|
||||||
if (uploadQueue.every(item => item.status !== 'queued' && item.status !== 'uploading')) {
|
|
||||||
// All uploads complete
|
|
||||||
clearInterval(uploadUpdateInterval);
|
|
||||||
|
|
||||||
// Show completion notification
|
|
||||||
const successCount = uploadQueue.filter(item => item.status === 'complete').length;
|
|
||||||
const errorCount = uploadQueue.filter(item => item.status === 'error').length;
|
|
||||||
|
|
||||||
// Show notification message
|
|
||||||
if (errorCount === 0) {
|
|
||||||
showNotification(`Successfully uploaded ${successCount} files`, 'success');
|
|
||||||
} else {
|
|
||||||
showNotification(`Uploaded ${successCount} files, ${errorCount} failed`, 'warning');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Start next upload
|
|
||||||
startUpload();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
xhr.addEventListener('error', function () {
|
|
||||||
// Network error
|
|
||||||
uploadQueue[nextIndex].status = 'error';
|
|
||||||
statusIndicator.className = 'status-indicator error';
|
|
||||||
statusIndicator.textContent = 'Error: Network error';
|
|
||||||
|
|
||||||
currentUploads--;
|
|
||||||
startUpload(); // Try the next file
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send the request
|
|
||||||
xhr.open('POST', '/files/upload_file');
|
|
||||||
xhr.send(formData);
|
|
||||||
|
|
||||||
// Try to start more uploads if possible
|
|
||||||
startUpload();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateUploadStats() {
|
|
||||||
if (currentUploads === 0) return;
|
|
||||||
|
|
||||||
// Calculate overall progress
|
|
||||||
let totalProgress = 0;
|
|
||||||
|
|
||||||
uploadQueue.forEach(item => {
|
|
||||||
if (item.status === 'complete') {
|
|
||||||
totalProgress += item.file.size;
|
|
||||||
} else if (item.status === 'uploading') {
|
|
||||||
totalProgress += (item.file.size * (item.progress / 100));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const overallPercentage = Math.round((totalProgress / totalSize) * 100);
|
|
||||||
|
|
||||||
// Update progress bar
|
|
||||||
progressBar.style.width = overallPercentage + '%';
|
|
||||||
progressPercentage.textContent = overallPercentage + '%';
|
|
||||||
|
|
||||||
// Calculate upload speed
|
|
||||||
const elapsed = (Date.now() - uploadStartTime) / 1000; // seconds
|
|
||||||
const bytesPerSecond = totalProgress / elapsed;
|
|
||||||
|
|
||||||
uploadSpeed.textContent = formatSize(bytesPerSecond) + '/s';
|
|
||||||
|
|
||||||
// Update uploaded size
|
|
||||||
uploadedSize.textContent = `${formatSize(totalProgress)} / ${formatSize(totalSize)}`;
|
|
||||||
|
|
||||||
// Calculate time remaining
|
|
||||||
const remainingBytes = totalSize - totalProgress;
|
|
||||||
if (bytesPerSecond > 0) {
|
|
||||||
const secondsRemaining = Math.round(remainingBytes / bytesPerSecond);
|
|
||||||
timeRemaining.textContent = formatTime(secondsRemaining);
|
|
||||||
} else {
|
|
||||||
timeRemaining.textContent = 'calculating...';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetUploadState() {
|
|
||||||
// Reset all upload tracking variables
|
|
||||||
uploadQueue = [];
|
|
||||||
currentUploads = 0;
|
|
||||||
totalUploaded = 0;
|
|
||||||
totalSize = 0;
|
|
||||||
clearInterval(uploadUpdateInterval);
|
|
||||||
|
|
||||||
// Reset UI
|
|
||||||
uploadList.innerHTML = '';
|
|
||||||
emptyMessage.style.display = 'block';
|
|
||||||
progressContainer.style.display = 'none';
|
|
||||||
fileCount.textContent = '0';
|
|
||||||
progressBar.style.width = '0%';
|
|
||||||
progressPercentage.textContent = '0%';
|
|
||||||
uploadSpeed.textContent = '0 KB/s';
|
|
||||||
uploadedSize.textContent = '0 KB / 0 KB';
|
|
||||||
timeRemaining.textContent = 'calculating...';
|
|
||||||
clearButton.disabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function showNotification(message, type) {
|
|
||||||
// Create alerts container if it doesn't exist
|
|
||||||
let alertsContainer = document.querySelector('.alerts');
|
|
||||||
if (!alertsContainer) {
|
|
||||||
alertsContainer = document.createElement('div');
|
|
||||||
alertsContainer.className = 'alerts';
|
|
||||||
document.body.appendChild(alertsContainer);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create alert
|
|
||||||
const alert = document.createElement('div');
|
|
||||||
alert.className = `alert ${type || 'info'}`;
|
|
||||||
alert.innerHTML = `
|
|
||||||
<div class="alert-content">${message}</div>
|
|
||||||
<button class="close" aria-label="Close">×</button>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Add to container
|
|
||||||
alertsContainer.appendChild(alert);
|
|
||||||
|
|
||||||
// Setup dismiss
|
|
||||||
const closeBtn = alert.querySelector('.close');
|
|
||||||
closeBtn.addEventListener('click', function () {
|
|
||||||
alert.classList.add('fade-out');
|
|
||||||
setTimeout(() => {
|
|
||||||
alert.remove();
|
|
||||||
}, 300);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto dismiss
|
|
||||||
setTimeout(() => {
|
|
||||||
if (alert.parentNode) {
|
|
||||||
alert.classList.add('fade-out');
|
|
||||||
setTimeout(() => {
|
|
||||||
if (alert.parentNode) {
|
|
||||||
alert.remove();
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSize(bytes) {
|
|
||||||
if (bytes === 0) return '0 Bytes';
|
|
||||||
const k = 1024;
|
|
||||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(seconds) {
|
|
||||||
if (seconds < 60) {
|
|
||||||
return seconds + ' seconds';
|
|
||||||
} else if (seconds < 3600) {
|
|
||||||
return Math.floor(seconds / 60) + ' minutes';
|
|
||||||
} else {
|
|
||||||
const hours = Math.floor(seconds / 3600);
|
|
||||||
const minutes = Math.floor((seconds % 3600) / 60);
|
|
||||||
return hours + ' hours ' + minutes + ' minutes';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFileIcon(fileName) {
|
|
||||||
const extension = fileName.split('.').pop().toLowerCase();
|
|
||||||
|
|
||||||
// Images
|
|
||||||
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp'].includes(extension)) {
|
|
||||||
return 'fa-file-image';
|
|
||||||
}
|
|
||||||
// Videos
|
|
||||||
else if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm'].includes(extension)) {
|
|
||||||
return 'fa-file-video';
|
|
||||||
}
|
|
||||||
// Audio
|
|
||||||
else if (['mp3', 'wav', 'ogg', 'flac', 'm4a'].includes(extension)) {
|
|
||||||
return 'fa-file-audio';
|
|
||||||
}
|
|
||||||
// Documents
|
|
||||||
else if (['doc', 'docx', 'dot', 'dotx'].includes(extension)) {
|
|
||||||
return 'fa-file-word';
|
|
||||||
}
|
|
||||||
else if (['xls', 'xlsx', 'csv'].includes(extension)) {
|
|
||||||
return 'fa-file-excel';
|
|
||||||
}
|
|
||||||
else if (['ppt', 'pptx'].includes(extension)) {
|
|
||||||
return 'fa-file-powerpoint';
|
|
||||||
}
|
|
||||||
else if (['pdf'].includes(extension)) {
|
|
||||||
return 'fa-file-pdf';
|
|
||||||
}
|
|
||||||
// Archives
|
|
||||||
else if (['zip', 'rar', '7z', 'tar', 'gz', 'bz2'].includes(extension)) {
|
|
||||||
return 'fa-file-archive';
|
|
||||||
}
|
|
||||||
// Text
|
|
||||||
else if (['txt', 'rtf', 'md', 'log'].includes(extension)) {
|
|
||||||
return 'fa-file-alt';
|
|
||||||
}
|
|
||||||
// Code
|
|
||||||
else if (['html', 'css', 'js', 'php', 'py', 'java', 'c', 'cpp', 'h', 'xml', 'json', 'sql'].includes(extension)) {
|
|
||||||
return 'fa-file-code';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'fa-file';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
84
template_checker.py
Normal file
84
template_checker.py
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
def check_template_syntax(directory):
|
||||||
|
"""Check all template files for common Jinja2 syntax errors"""
|
||||||
|
template_pattern = re.compile(r'{{.*?}}|{%.*?%}|{#.*?#}')
|
||||||
|
problem_files = []
|
||||||
|
|
||||||
|
for root, _, files in os.walk(directory):
|
||||||
|
for file in files:
|
||||||
|
if file.endswith('.html'):
|
||||||
|
filepath = os.path.join(root, file)
|
||||||
|
with open(filepath, 'r', encoding='utf-8') as f:
|
||||||
|
try:
|
||||||
|
content = f.read()
|
||||||
|
# Check for unbalanced Jinja2 brackets
|
||||||
|
open_curly = content.count('{{')
|
||||||
|
close_curly = content.count('}}')
|
||||||
|
open_block = content.count('{%')
|
||||||
|
close_block = content.count('%}')
|
||||||
|
open_comment = content.count('{#')
|
||||||
|
close_comment = content.count('#}')
|
||||||
|
|
||||||
|
if open_curly != close_curly or open_block != close_block or open_comment != close_comment:
|
||||||
|
problem_files.append({
|
||||||
|
'file': filepath,
|
||||||
|
'issues': {
|
||||||
|
'curly': (open_curly, close_curly),
|
||||||
|
'block': (open_block, close_block),
|
||||||
|
'comment': (open_comment, close_comment)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# Find incomplete Jinja2 expressions
|
||||||
|
line_num = 1
|
||||||
|
for line in content.split('\n'):
|
||||||
|
# Check for unclosed Jinja expressions
|
||||||
|
line_open_curly = line.count('{{')
|
||||||
|
line_close_curly = line.count('}}')
|
||||||
|
if line_open_curly != line_close_curly and '{{' in line:
|
||||||
|
problem_files.append({
|
||||||
|
'file': filepath,
|
||||||
|
'line': line_num,
|
||||||
|
'content': line.strip(),
|
||||||
|
'issue': f"Unbalanced curly braces: {line_open_curly} opening vs {line_close_curly} closing"
|
||||||
|
})
|
||||||
|
line_num += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
problem_files.append({
|
||||||
|
'file': filepath,
|
||||||
|
'error': str(e)
|
||||||
|
})
|
||||||
|
|
||||||
|
return problem_files
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
directory = sys.argv[1]
|
||||||
|
else:
|
||||||
|
directory = 'app/templates'
|
||||||
|
|
||||||
|
problems = check_template_syntax(directory)
|
||||||
|
|
||||||
|
if problems:
|
||||||
|
print(f"Found {len(problems)} potential issues:")
|
||||||
|
for problem in problems:
|
||||||
|
print(f"\nFile: {problem['file']}")
|
||||||
|
if 'line' in problem:
|
||||||
|
print(f"Line {problem['line']}: {problem['content']}")
|
||||||
|
print(f"Issue: {problem['issue']}")
|
||||||
|
elif 'issues' in problem:
|
||||||
|
issues = problem['issues']
|
||||||
|
if issues['curly'][0] != issues['curly'][1]:
|
||||||
|
print(f"Unbalanced curly braces: {issues['curly'][0]} opening vs {issues['curly'][1]} closing")
|
||||||
|
if issues['block'][0] != issues['block'][1]:
|
||||||
|
print(f"Unbalanced block tags: {issues['block'][0]} opening vs {issues['block'][1]} closing")
|
||||||
|
if issues['comment'][0] != issues['comment'][1]:
|
||||||
|
print(f"Unbalanced comments: {issues['comment'][0]} opening vs {issues['comment'][1]} closing")
|
||||||
|
else:
|
||||||
|
print(f"Error: {problem['error']}")
|
||||||
|
else:
|
||||||
|
print("No issues found!")
|
Loading…
Add table
Add a link
Reference in a new issue