Compare commits
2 commits
ea3e92b8b7
...
6dda02141e
Author | SHA1 | Date | |
---|---|---|---|
![]() |
6dda02141e | ||
![]() |
b9a82af12f |
34 changed files with 5705 additions and 3101 deletions
9
.cursor/rules/my-custom-rule.mdc
Normal file
9
.cursor/rules/my-custom-rule.mdc
Normal file
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
description:
|
||||
globs:
|
||||
---
|
||||
|
||||
# Your rule content
|
||||
|
||||
- You can @ files here
|
||||
- You can use markdown but dont have to
|
248
app/__init__.py
248
app/__init__.py
|
@ -1,10 +1,18 @@
|
|||
from flask import Flask, current_app
|
||||
from flask import Flask, current_app, render_template
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import LoginManager
|
||||
from config import Config
|
||||
import os
|
||||
from datetime import datetime
|
||||
import sqlite3
|
||||
import logging
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Initialize extensions
|
||||
db = SQLAlchemy()
|
||||
|
@ -13,79 +21,253 @@ login_manager.login_view = 'auth.login'
|
|||
login_manager.login_message_category = 'info'
|
||||
|
||||
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():
|
||||
app.logger.info("Initializing database...")
|
||||
try:
|
||||
# Create all tables
|
||||
# Check if tables exist before creating them
|
||||
from sqlalchemy import inspect
|
||||
inspector = inspect(db.engine)
|
||||
existing_tables = inspector.get_table_names()
|
||||
|
||||
# Only create tables that don't exist
|
||||
if not existing_tables:
|
||||
app.logger.info("Creating database tables...")
|
||||
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")
|
||||
else:
|
||||
app.logger.info(f"Database already initialized with tables: {existing_tables}")
|
||||
|
||||
# Check for missing tables
|
||||
from app.models import User, File, Folder, Download
|
||||
required_tables = ['users', 'files', 'folders', 'downloads']
|
||||
missing_tables = [table for table in required_tables if table not in existing_tables]
|
||||
|
||||
if missing_tables:
|
||||
app.logger.info(f"Creating missing tables: {missing_tables}")
|
||||
# Create only the missing tables
|
||||
db.create_all()
|
||||
|
||||
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):
|
||||
"""Apply any necessary database migrations"""
|
||||
"""Apply any necessary database migrations automatically"""
|
||||
with app.app_context():
|
||||
try:
|
||||
app.logger.info("Running database migrations...")
|
||||
# Get database path
|
||||
db_path = app.config['SQLALCHEMY_DATABASE_URI'].replace('sqlite:///', '')
|
||||
|
||||
# Check if we're using SQLite and if the database file exists
|
||||
if db_path.startswith('/'): # Absolute path
|
||||
if not os.path.exists(db_path):
|
||||
app.logger.info(f"Database file does not exist: {db_path}")
|
||||
return
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
# Use SQLAlchemy to check and add missing columns
|
||||
inspector = db.inspect(db.engine)
|
||||
|
||||
# Check if storage_name column exists in file table
|
||||
cursor.execute("PRAGMA table_info(file)")
|
||||
columns = [column[1] for column in cursor.fetchall()]
|
||||
# Check for 'file' table columns
|
||||
if inspector.has_table('file'):
|
||||
columns = [col['name'] for col in inspector.get_columns('file')]
|
||||
|
||||
# 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")
|
||||
cursor.execute("ALTER TABLE file ADD COLUMN storage_name TEXT")
|
||||
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()
|
||||
|
||||
# Update existing records to use filename as storage_name
|
||||
cursor.execute("UPDATE file SET storage_name = name WHERE storage_name IS NULL AND is_folder = 0")
|
||||
conn.commit()
|
||||
# Check for user table columns
|
||||
if inspector.has_table('user'):
|
||||
columns = [col['name'] for col in inspector.get_columns('user')]
|
||||
|
||||
conn.close()
|
||||
app.logger.info("Database migrations completed successfully")
|
||||
except sqlite3.OperationalError as e:
|
||||
if "no such table: file" in str(e):
|
||||
app.logger.info("File table doesn't exist yet, will be created with db.create_all()")
|
||||
else:
|
||||
app.logger.error(f"Error during migration: {str(e)}")
|
||||
# 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 migration: {str(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):
|
||||
app = Flask(__name__)
|
||||
|
||||
# Configure app
|
||||
if config_class:
|
||||
app.config.from_object(config_class)
|
||||
else:
|
||||
# Use default configuration
|
||||
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev-key-change-in-production')
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URL', 'sqlite:///app.db')
|
||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
app.config['UPLOAD_FOLDER'] = os.path.join(os.path.dirname(os.path.abspath(__file__)), '../uploads')
|
||||
app.config['MAX_CONTENT_LENGTH'] = 100 * 1024 * 1024 # 100 MB max upload
|
||||
|
||||
# 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
|
||||
db.init_app(app)
|
||||
login_manager.init_app(app)
|
||||
|
||||
# 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():
|
||||
initialize_database(app)
|
||||
run_migrations(app)
|
||||
|
||||
# Register blueprints
|
||||
from app.routes.auth import auth_bp
|
||||
from app.routes.files import files_bp
|
||||
from app.routes.dashboard import dashboard_bp
|
||||
from app.routes.auth import bp as auth_bp
|
||||
from app.routes.files import bp as files_bp
|
||||
from app.routes.dashboard import bp as dashboard_bp
|
||||
from app.routes.admin import bp as admin_bp
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(files_bp)
|
||||
app.register_blueprint(auth_bp, url_prefix='/auth')
|
||||
app.register_blueprint(files_bp, url_prefix='/files')
|
||||
app.register_blueprint(dashboard_bp)
|
||||
app.register_blueprint(admin_bp, url_prefix='/admin')
|
||||
|
||||
# Add context processor for template variables
|
||||
@app.context_processor
|
||||
def inject_now():
|
||||
return {'now': datetime.now()}
|
||||
def inject_global_variables():
|
||||
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
|
||||
|
||||
logger.info("Flask Files startup")
|
||||
return app
|
||||
|
||||
# Import must come after create_app to avoid circular imports
|
||||
from app import models
|
||||
|
|
81
app/migrations/__init__.py
Normal file
81
app/migrations/__init__.py
Normal file
|
@ -0,0 +1,81 @@
|
|||
"""
|
||||
Automatic database migrations system
|
||||
"""
|
||||
import logging
|
||||
from sqlalchemy import inspect
|
||||
from .. import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class Migration:
|
||||
"""Base migration class"""
|
||||
# Higher version numbers run later
|
||||
version = 0
|
||||
description = "Base migration"
|
||||
|
||||
def should_run(self, inspector):
|
||||
"""Determine if this migration should run"""
|
||||
return True
|
||||
|
||||
def run(self):
|
||||
"""Execute the migration"""
|
||||
raise NotImplementedError
|
||||
|
||||
class AddFolderIdToFiles(Migration):
|
||||
"""Add folder_id column to files table"""
|
||||
version = 1
|
||||
description = "Add folder_id column to files table"
|
||||
|
||||
def should_run(self, inspector):
|
||||
"""Check if folder_id column exists in files table"""
|
||||
if 'files' not in inspector.get_table_names():
|
||||
return False
|
||||
|
||||
columns = [col['name'] for col in inspector.get_columns('files')]
|
||||
return 'folder_id' not in columns
|
||||
|
||||
def run(self):
|
||||
"""Add the folder_id column and foreign key constraint"""
|
||||
try:
|
||||
db.engine.execute('ALTER TABLE files ADD COLUMN folder_id INTEGER;')
|
||||
db.engine.execute('ALTER TABLE files ADD CONSTRAINT fk_files_folder_id FOREIGN KEY (folder_id) REFERENCES folders (id);')
|
||||
logger.info("Added folder_id column to files table")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding folder_id column: {str(e)}")
|
||||
return False
|
||||
|
||||
# Add all migrations here
|
||||
MIGRATIONS = [
|
||||
AddFolderIdToFiles(),
|
||||
# Remove the Share table migration since we're not using it
|
||||
]
|
||||
|
||||
def run_migrations():
|
||||
"""Run all pending migrations"""
|
||||
logger.info("Checking for pending database migrations...")
|
||||
inspector = inspect(db.engine)
|
||||
|
||||
# Sort migrations by version
|
||||
pending_migrations = sorted([m for m in MIGRATIONS if m.should_run(inspector)],
|
||||
key=lambda m: m.version)
|
||||
|
||||
if not pending_migrations:
|
||||
logger.info("No pending migrations found.")
|
||||
return
|
||||
|
||||
logger.info(f"Found {len(pending_migrations)} pending migrations.")
|
||||
|
||||
success_count = 0
|
||||
for migration in pending_migrations:
|
||||
logger.info(f"Running migration {migration.version}: {migration.description}")
|
||||
try:
|
||||
success = migration.run()
|
||||
if success:
|
||||
success_count += 1
|
||||
else:
|
||||
logger.warning(f"Migration {migration.version} reported failure")
|
||||
except Exception as e:
|
||||
logger.error(f"Error in migration {migration.version}: {str(e)}")
|
||||
|
||||
logger.info(f"Migration complete. {success_count}/{len(pending_migrations)} migrations successful.")
|
29
app/migrations/add_folder_id_to_files.py
Normal file
29
app/migrations/add_folder_id_to_files.py
Normal file
|
@ -0,0 +1,29 @@
|
|||
"""
|
||||
Migration script to add folder_id column to files table
|
||||
"""
|
||||
from flask import Flask
|
||||
from app import create_app, db
|
||||
from app.models import File, Folder
|
||||
|
||||
def run_migration():
|
||||
"""Add folder_id column to files table if it doesn't exist"""
|
||||
app = create_app()
|
||||
|
||||
with app.app_context():
|
||||
# Check if the column exists
|
||||
from sqlalchemy import inspect
|
||||
inspector = inspect(db.engine)
|
||||
columns = [col['name'] for col in inspector.get_columns('files')]
|
||||
|
||||
if 'folder_id' not in columns:
|
||||
print("Adding folder_id column to files table...")
|
||||
# Add the column
|
||||
db.engine.execute('ALTER TABLE files ADD COLUMN folder_id INTEGER;')
|
||||
# Add foreign key constraint
|
||||
db.engine.execute('ALTER TABLE files ADD CONSTRAINT fk_files_folder_id FOREIGN KEY (folder_id) REFERENCES folders (id);')
|
||||
print("Column added successfully!")
|
||||
else:
|
||||
print("folder_id column already exists")
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_migration()
|
36
app/migrations/add_share_table.py
Normal file
36
app/migrations/add_share_table.py
Normal file
|
@ -0,0 +1,36 @@
|
|||
"""
|
||||
Add Share table for file and folder sharing
|
||||
"""
|
||||
from sqlalchemy import inspect
|
||||
from .. import db
|
||||
|
||||
class AddShareTable:
|
||||
"""Migration to add the shares table"""
|
||||
version = 2
|
||||
description = "Add Share table for file/folder sharing"
|
||||
|
||||
def should_run(self, inspector):
|
||||
"""Check if the shares table exists"""
|
||||
return 'shares' not in inspector.get_table_names()
|
||||
|
||||
def run(self):
|
||||
"""Create the shares table"""
|
||||
try:
|
||||
db.engine.execute('''
|
||||
CREATE TABLE shares (
|
||||
id INTEGER PRIMARY KEY,
|
||||
item_type VARCHAR(10) NOT NULL,
|
||||
item_id INTEGER NOT NULL,
|
||||
owner_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
permission VARCHAR(10) NOT NULL DEFAULT 'view',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (owner_id) REFERENCES users (id),
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
)
|
||||
''')
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error creating shares table: {str(e)}")
|
||||
return False
|
183
app/models.py
183
app/models.py
|
@ -1,78 +1,181 @@
|
|||
from datetime import datetime
|
||||
import logging
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from flask_login import UserMixin
|
||||
from app import db, login_manager
|
||||
import uuid
|
||||
import os
|
||||
import mimetypes
|
||||
|
||||
# Add debug logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info("Loading models.py module")
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(id):
|
||||
return User.query.get(int(id))
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
__tablename__ = 'user'
|
||||
__tablename__ = 'users'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(64), index=True, unique=True)
|
||||
email = db.Column(db.String(120), index=True, unique=True)
|
||||
username = db.Column(db.String(64), unique=True, index=True)
|
||||
email = db.Column(db.String(120), unique=True, index=True)
|
||||
password_hash = db.Column(db.String(128))
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
last_login = db.Column(db.DateTime)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
is_admin = db.Column(db.Boolean, default=False)
|
||||
|
||||
# Relationships
|
||||
files = db.relationship('File', backref='owner', lazy='dynamic',
|
||||
foreign_keys='File.user_id', cascade='all, delete-orphan')
|
||||
# Define relationships without circular backrefs
|
||||
folders = db.relationship('Folder', foreign_keys='Folder.user_id',
|
||||
backref=db.backref('owner', lazy='joined'),
|
||||
lazy='dynamic', cascade="all, delete-orphan")
|
||||
|
||||
files = db.relationship('File', foreign_keys='File.user_id',
|
||||
backref=db.backref('owner', lazy='joined'),
|
||||
lazy='dynamic', cascade="all, delete-orphan")
|
||||
|
||||
downloads = db.relationship('Download', foreign_keys='Download.user_id',
|
||||
backref=db.backref('downloader', lazy='joined'),
|
||||
lazy='dynamic', cascade="all, delete-orphan")
|
||||
|
||||
# Add shares relationship for consistency with existing database
|
||||
shares_created = db.relationship('Share', foreign_keys='Share.user_id',
|
||||
backref=db.backref('creator', lazy='joined'),
|
||||
lazy='dynamic', cascade="all, delete-orphan")
|
||||
|
||||
@property
|
||||
def password(self):
|
||||
raise AttributeError('password is not a readable attribute')
|
||||
|
||||
@password.setter
|
||||
def password(self, password):
|
||||
self.password_hash = generate_password_hash(password)
|
||||
logger.info(f"Password set for user {self.username}")
|
||||
|
||||
# Add set_password method for compatibility with existing code
|
||||
def set_password(self, password):
|
||||
"""Set user password - compatibility method"""
|
||||
logger.info(f"set_password called for {self.username}")
|
||||
self.password_hash = generate_password_hash(password)
|
||||
|
||||
def verify_password(self, password):
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<User {self.username}>'
|
||||
|
||||
def set_password(self, password):
|
||||
self.password_hash = generate_password_hash(password)
|
||||
# Log that the User class is defined
|
||||
logger.info("User class defined with set_password method")
|
||||
|
||||
def check_password(self, password):
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
class File(db.Model):
|
||||
__tablename__ = 'file'
|
||||
class Folder(db.Model):
|
||||
__tablename__ = 'folders'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(255), nullable=False)
|
||||
storage_name = db.Column(db.String(255)) # Used for storing files with unique names
|
||||
is_folder = db.Column(db.Boolean, default=False)
|
||||
mime_type = db.Column(db.String(128))
|
||||
size = db.Column(db.Integer, default=0) # Size in bytes
|
||||
parent_id = db.Column(db.Integer, db.ForeignKey('file.id', ondelete='CASCADE'), nullable=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
parent_id = db.Column(db.Integer, db.ForeignKey('folders.id'))
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
children = db.relationship('File', backref=db.backref('parent', remote_side=[id]),
|
||||
lazy='dynamic', cascade='all, delete-orphan')
|
||||
# Define relationship to parent folder with proper backref
|
||||
parent = db.relationship('Folder', remote_side=[id],
|
||||
backref=db.backref('children', lazy='dynamic'))
|
||||
|
||||
# Define relationship to files in this folder
|
||||
files = db.relationship('File', foreign_keys='File.folder_id',
|
||||
backref=db.backref('folder', lazy='joined'),
|
||||
lazy='dynamic', cascade="all, delete-orphan")
|
||||
|
||||
def get_path(self):
|
||||
"""Get the full path of the folder"""
|
||||
if self.parent is None:
|
||||
return "/" + self.name
|
||||
return self.parent.get_path() + "/" + self.name
|
||||
|
||||
def __repr__(self):
|
||||
return f'<File {self.name} {"(Folder)" if self.is_folder else ""}>'
|
||||
return f'<Folder {self.name}>'
|
||||
|
||||
def generate_storage_name(self):
|
||||
"""Generate a unique name for file storage to prevent conflicts"""
|
||||
if self.is_folder:
|
||||
return None
|
||||
class File(db.Model):
|
||||
__tablename__ = 'files'
|
||||
|
||||
# Generate a unique filename using UUID
|
||||
ext = self.name.rsplit('.', 1)[1].lower() if '.' in self.name else ''
|
||||
return f"{uuid.uuid4().hex}.{ext}" if ext else f"{uuid.uuid4().hex}"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(255), nullable=False)
|
||||
original_name = db.Column(db.String(255), nullable=False)
|
||||
path = db.Column(db.String(255), nullable=False)
|
||||
size = db.Column(db.Integer, nullable=False)
|
||||
type = db.Column(db.String(128))
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
folder_id = db.Column(db.Integer, db.ForeignKey('folders.id'))
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Define relationships
|
||||
shares = db.relationship('Share', backref='file', lazy='dynamic')
|
||||
|
||||
@property
|
||||
def icon_class(self):
|
||||
"""Get the appropriate Font Awesome icon class based on file extension"""
|
||||
extension = os.path.splitext(self.name)[1].lower()
|
||||
|
||||
# Map extensions to icon classes
|
||||
icon_map = {
|
||||
'.pdf': 'fa-file-pdf',
|
||||
'.doc': 'fa-file-word', '.docx': 'fa-file-word',
|
||||
'.xls': 'fa-file-excel', '.xlsx': 'fa-file-excel',
|
||||
'.ppt': 'fa-file-powerpoint', '.pptx': 'fa-file-powerpoint',
|
||||
'.jpg': 'fa-file-image', '.jpeg': 'fa-file-image', '.png': 'fa-file-image',
|
||||
'.gif': 'fa-file-image', '.svg': 'fa-file-image',
|
||||
'.mp3': 'fa-file-audio', '.wav': 'fa-file-audio', '.ogg': 'fa-file-audio',
|
||||
'.mp4': 'fa-file-video', '.avi': 'fa-file-video', '.mov': 'fa-file-video',
|
||||
'.zip': 'fa-file-archive', '.rar': 'fa-file-archive', '.tar': 'fa-file-archive',
|
||||
'.gz': 'fa-file-archive',
|
||||
'.txt': 'fa-file-alt', '.md': 'fa-file-alt',
|
||||
'.html': 'fa-file-code', '.css': 'fa-file-code', '.js': 'fa-file-code',
|
||||
'.py': 'fa-file-code', '.java': 'fa-file-code', '.c': 'fa-file-code'
|
||||
}
|
||||
|
||||
return icon_map.get(extension, 'fa-file')
|
||||
|
||||
def get_mime_type(self):
|
||||
"""Get the MIME type based on the file extension"""
|
||||
mime_type, _ = mimetypes.guess_type(self.name)
|
||||
return mime_type or 'application/octet-stream'
|
||||
|
||||
def __repr__(self):
|
||||
return f'<File {self.name}>'
|
||||
|
||||
class Share(db.Model):
|
||||
__tablename__ = 'shares'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
file_id = db.Column(db.Integer, db.ForeignKey('file.id'))
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
|
||||
token = db.Column(db.String(64), unique=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
file_id = db.Column(db.Integer, db.ForeignKey('files.id'), nullable=False)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
access_key = db.Column(db.String(64), unique=True, index=True, nullable=False)
|
||||
expires_at = db.Column(db.DateTime, nullable=True)
|
||||
downloads = db.relationship('Download', backref='share', lazy='dynamic')
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
download_count = db.Column(db.Integer, default=0)
|
||||
|
||||
def is_expired(self):
|
||||
"""Check if the share has expired"""
|
||||
if self.expires_at is None:
|
||||
return False
|
||||
return datetime.utcnow() > self.expires_at
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Share {self.access_key}>'
|
||||
|
||||
class Download(db.Model):
|
||||
__tablename__ = 'downloads'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
file_id = db.Column(db.Integer, db.ForeignKey('file.id'))
|
||||
share_id = db.Column(db.Integer, db.ForeignKey('share.id'))
|
||||
ip_address = db.Column(db.String(45))
|
||||
timestamp = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
file_id = db.Column(db.Integer, db.ForeignKey('files.id'), nullable=False)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
download_date = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
# Define relationship to file
|
||||
file = db.relationship('File', foreign_keys=[file_id],
|
||||
backref=db.backref('file_downloads', lazy='dynamic'))
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Download {self.id} by {self.user_id}>'
|
||||
|
|
47
app/routes/admin.py
Normal file
47
app/routes/admin.py
Normal file
|
@ -0,0 +1,47 @@
|
|||
from flask import Blueprint, render_template, jsonify, redirect, url_for, flash, current_app
|
||||
from flask_login import login_required, current_user
|
||||
from ..models import User
|
||||
from ..migrations import run_migrations
|
||||
from ..utils.reset_db import reset_database
|
||||
|
||||
bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||
|
||||
@bp.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
"""Admin panel home"""
|
||||
if not current_user.is_admin:
|
||||
flash('Access denied. Admin privileges required.', 'error')
|
||||
return redirect(url_for('dashboard.index'))
|
||||
|
||||
return render_template('admin/panel.html')
|
||||
|
||||
@bp.route('/run-migrations', methods=['POST'])
|
||||
@login_required
|
||||
def trigger_migrations():
|
||||
"""Manually trigger database migrations (admin only)"""
|
||||
# Check if user is admin
|
||||
if not current_user.is_admin:
|
||||
return jsonify({'error': 'Unauthorized. Admin privileges required'}), 403
|
||||
|
||||
# Run migrations
|
||||
with current_app.app_context():
|
||||
run_migrations()
|
||||
|
||||
return jsonify({'success': True, 'message': 'Migrations completed successfully'})
|
||||
|
||||
@bp.route('/reset-database', methods=['POST'])
|
||||
@login_required
|
||||
def reset_db():
|
||||
"""Reset the entire database (admin only)"""
|
||||
# Check if user is admin
|
||||
if not current_user.is_admin:
|
||||
return jsonify({'error': 'Unauthorized. Admin privileges required'}), 403
|
||||
|
||||
# Reset database
|
||||
success = reset_database()
|
||||
|
||||
if success:
|
||||
return jsonify({'success': True, 'message': 'Database reset successfully. You will be logged out.'})
|
||||
else:
|
||||
return jsonify({'error': 'Failed to reset database. Check logs for details.'}), 500
|
|
@ -1,4 +1,4 @@
|
|||
from flask import render_template, redirect, url_for, flash, request, current_app, jsonify, session
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, current_app, jsonify, session
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from urllib.parse import urlparse
|
||||
from app import db
|
||||
|
@ -8,6 +8,13 @@ from flask_wtf import FlaskForm
|
|||
from wtforms import StringField, PasswordField, BooleanField, SubmitField
|
||||
from wtforms.validators import DataRequired, Length, EqualTo, ValidationError
|
||||
from werkzeug.exceptions import BadRequest
|
||||
from werkzeug.security import generate_password_hash
|
||||
import logging
|
||||
|
||||
# Setup logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('auth', __name__)
|
||||
|
||||
# Login form
|
||||
class LoginForm(FlaskForm):
|
||||
|
@ -28,51 +35,77 @@ class RegistrationForm(FlaskForm):
|
|||
if user is not None:
|
||||
raise ValidationError('Please use a different username.')
|
||||
|
||||
@auth_bp.route('/login', methods=['GET', 'POST'])
|
||||
@bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
"""User login page"""
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('dashboard.index'))
|
||||
|
||||
form = LoginForm()
|
||||
if form.validate_on_submit():
|
||||
user = User.query.filter_by(username=form.username.data).first()
|
||||
|
||||
if user is None or not user.check_password(form.password.data):
|
||||
if user is None or not user.verify_password(form.password.data):
|
||||
flash('Invalid username or password', 'error')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
login_user(user, remember=form.remember_me.data)
|
||||
|
||||
# Redirect to requested page or dashboard
|
||||
next_page = request.args.get('next')
|
||||
if not next_page or urlparse(next_page).netloc != '':
|
||||
next_page = url_for('dashboard.index')
|
||||
|
||||
flash('Login successful!', 'success')
|
||||
return redirect(next_page)
|
||||
|
||||
return render_template('auth/login.html', title='Sign In', form=form)
|
||||
return render_template('auth/login.html', form=form)
|
||||
|
||||
@auth_bp.route('/logout')
|
||||
@bp.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
"""User logout"""
|
||||
logout_user()
|
||||
flash('You have been logged out', 'info')
|
||||
flash('You have been logged out.', 'info')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
@auth_bp.route('/register', methods=['GET', 'POST'])
|
||||
@bp.route('/register', methods=['GET', 'POST'])
|
||||
def register():
|
||||
"""User registration page"""
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('dashboard.index'))
|
||||
|
||||
form = RegistrationForm()
|
||||
if form.validate_on_submit():
|
||||
user = User(username=form.username.data)
|
||||
# Create new user
|
||||
user = User(
|
||||
username=form.username.data,
|
||||
)
|
||||
|
||||
# Try both ways to set password
|
||||
try:
|
||||
# First try with set_password method
|
||||
logger.info("Trying to set password with set_password method")
|
||||
if hasattr(user, 'set_password'):
|
||||
user.set_password(form.password.data)
|
||||
else:
|
||||
# Fall back to property setter
|
||||
logger.info("set_password not found, using password property instead")
|
||||
user.password = form.password.data
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting password: {e}")
|
||||
# Ensure we set the password somehow
|
||||
user.password_hash = generate_password_hash(form.password.data)
|
||||
logger.info("Set password_hash directly")
|
||||
|
||||
# Save to database
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
logger.info(f"User {user.username} registered successfully")
|
||||
|
||||
flash('Registration successful! You can now log in.', 'success')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
return render_template('auth/register.html', title='Register', form=form)
|
||||
return render_template('auth/register.html', form=form)
|
||||
|
||||
@auth_bp.route('/update_profile', methods=['POST'])
|
||||
@login_required
|
||||
|
@ -136,12 +169,8 @@ def update_preferences():
|
|||
flash('Preferences updated successfully', 'success')
|
||||
return redirect(url_for('auth.profile'))
|
||||
|
||||
@auth_bp.route('/profile')
|
||||
@bp.route('/profile')
|
||||
@login_required
|
||||
def profile():
|
||||
# Get theme preference from session or default to system
|
||||
theme_preference = session.get('theme_preference', 'system')
|
||||
|
||||
return render_template('auth/profile.html',
|
||||
title='User Profile',
|
||||
theme_preference=theme_preference)
|
||||
"""User profile page"""
|
||||
return render_template('auth/profile.html', user=current_user)
|
||||
|
|
|
@ -1,59 +1,46 @@
|
|||
from flask import Blueprint, render_template
|
||||
from flask import Blueprint, render_template, redirect, url_for
|
||||
from flask_login import login_required, current_user
|
||||
from datetime import datetime, timedelta
|
||||
from app.models import File, Share, Download
|
||||
from ..models import File, Folder
|
||||
import os
|
||||
|
||||
dashboard_bp = Blueprint('dashboard', __name__)
|
||||
# Create blueprint with the name expected by __init__.py
|
||||
bp = Blueprint('dashboard', __name__)
|
||||
|
||||
@dashboard_bp.route('/')
|
||||
@bp.route('/')
|
||||
@bp.route('/index')
|
||||
@login_required
|
||||
def index():
|
||||
# Get some stats for the dashboard
|
||||
total_files = File.query.filter_by(user_id=current_user.id, is_folder=False).count()
|
||||
total_folders = File.query.filter_by(user_id=current_user.id, is_folder=True).count()
|
||||
"""Dashboard index page"""
|
||||
# Count user's files and folders
|
||||
file_count = File.query.filter_by(user_id=current_user.id).count()
|
||||
folder_count = Folder.query.filter_by(user_id=current_user.id).count()
|
||||
|
||||
# Recent files for quick access
|
||||
recent_files = File.query.filter_by(user_id=current_user.id, is_folder=False)\
|
||||
.order_by(File.updated_at.desc())\
|
||||
.limit(8).all()
|
||||
# Get storage usage
|
||||
storage_used = sum(file.size for file in File.query.filter_by(user_id=current_user.id).all())
|
||||
|
||||
# Root folders for quick navigation
|
||||
root_folders = File.query.filter_by(user_id=current_user.id, is_folder=True, parent_id=None)\
|
||||
.order_by(File.name)\
|
||||
.limit(8).all()
|
||||
# Format size for display
|
||||
if storage_used < 1024:
|
||||
storage_used_formatted = f"{storage_used} bytes"
|
||||
elif storage_used < 1024 * 1024:
|
||||
storage_used_formatted = f"{storage_used / 1024:.2f} KB"
|
||||
elif storage_used < 1024 * 1024 * 1024:
|
||||
storage_used_formatted = f"{storage_used / (1024 * 1024):.2f} MB"
|
||||
else:
|
||||
storage_used_formatted = f"{storage_used / (1024 * 1024 * 1024):.2f} GB"
|
||||
|
||||
# Count active shares (if Share model exists)
|
||||
active_shares = 0
|
||||
recent_activities = 0
|
||||
# Get recent files
|
||||
recent_files = File.query.filter_by(user_id=current_user.id).order_by(File.created_at.desc()).limit(5).all()
|
||||
|
||||
# Check if Share and Download models exist/are imported
|
||||
try:
|
||||
# Count active shares
|
||||
active_shares = Share.query.filter_by(user_id=current_user.id).filter(
|
||||
(Share.expires_at > datetime.now()) | (Share.expires_at.is_(None))
|
||||
).count()
|
||||
# Create stats object that the template is expecting
|
||||
stats = {
|
||||
'file_count': file_count,
|
||||
'folder_count': folder_count,
|
||||
'storage_used': storage_used_formatted
|
||||
}
|
||||
|
||||
# Recent activities count (downloads, shares, etc.)
|
||||
recent_activities = Download.query.join(Share)\
|
||||
.filter(Share.user_id == current_user.id)\
|
||||
.filter(Download.timestamp > (datetime.now() - timedelta(days=7)))\
|
||||
.count()
|
||||
except:
|
||||
# Models not ready yet, using default values
|
||||
pass
|
||||
|
||||
return render_template('dashboard.html',
|
||||
title='Dashboard',
|
||||
total_files=total_files,
|
||||
total_folders=total_folders,
|
||||
recent_files=recent_files,
|
||||
root_folders=root_folders,
|
||||
active_shares=active_shares,
|
||||
recent_activities=recent_activities,
|
||||
now=datetime.now(),
|
||||
file_icon=get_file_icon,
|
||||
format_size=format_file_size)
|
||||
return render_template('dashboard/index.html',
|
||||
stats=stats, # Pass as stats object
|
||||
recent_files=recent_files)
|
||||
|
||||
def get_file_icon(mime_type, filename):
|
||||
"""Return Font Awesome icon class based on file type"""
|
||||
|
|
1168
app/routes/files.py
1168
app/routes/files.py
File diff suppressed because it is too large
Load diff
|
@ -1,3 +1,408 @@
|
|||
.browser-container {
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.browser-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.browser-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.browser-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.breadcrumbs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1.5rem;
|
||||
background: var(--bg-light);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.breadcrumb-separator {
|
||||
margin: 0 0.5rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.view-toggle {
|
||||
display: flex;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
overflow: hidden;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.view-btn {
|
||||
border: none;
|
||||
background: var(--card-bg);
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.view-btn.active {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
margin-bottom: 1rem;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-bar input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 1rem 0.5rem 2.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background: var(--bg-light);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
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;
|
||||
}
|
||||
|
||||
/* File Browser Styles */
|
||||
.browser-container {
|
||||
background: var(--card-bg);
|
||||
|
|
52
app/static/css/context-menu.css
Normal file
52
app/static/css/context-menu.css
Normal file
|
@ -0,0 +1,52 @@
|
|||
/* Context Menu Styles */
|
||||
.context-menu {
|
||||
position: absolute;
|
||||
display: none;
|
||||
background-color: var(--card-bg);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
|
||||
padding: 8px 0;
|
||||
min-width: 180px;
|
||||
z-index: 1000;
|
||||
animation: context-menu-appear 0.2s ease;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
@keyframes context-menu-appear {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.context-menu-item:hover {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.context-menu-item i {
|
||||
margin-right: 10px;
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.context-menu-divider {
|
||||
height: 1px;
|
||||
background-color: var(--border-color);
|
||||
margin: 5px 0;
|
||||
}
|
File diff suppressed because it is too large
Load diff
130
app/static/css/modal.css
Normal file
130
app/static/css/modal.css
Normal file
|
@ -0,0 +1,130 @@
|
|||
/* Beautiful modal styles */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(3px);
|
||||
z-index: 1000;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.modal.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: var(--card-bg);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
||||
width: 90%;
|
||||
max-width: 400px;
|
||||
position: relative;
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.modal.visible .modal-content {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
transition: color 0.2s ease;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: var(--danger-color);
|
||||
background-color: rgba(var(--danger-color-rgb), 0.1);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="password"],
|
||||
.form-group input[type="email"] {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-color-rgb), 0.2);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.form-actions .btn {
|
||||
padding: 0.6rem 1.25rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
transition: background 0.3s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.form-actions .btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.form-actions .btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
609
app/static/css/styles.css
Normal file
609
app/static/css/styles.css
Normal file
|
@ -0,0 +1,609 @@
|
|||
/* Add these styles to your existing styles.css file */
|
||||
|
||||
/* Theme Toggle */
|
||||
.theme-toggle-icon {
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
border-radius: 50%;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.theme-toggle-icon:hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .theme-toggle-icon:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Make sure theme transitions work */
|
||||
body,
|
||||
.card,
|
||||
.navbar,
|
||||
.sidebar,
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
button,
|
||||
.modal-content,
|
||||
.file-item,
|
||||
.folder-item {
|
||||
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
|
||||
}
|
||||
|
||||
/* Making sure the styles apply properly to light/dark themes */
|
||||
:root {
|
||||
--primary-color: #4a6bff;
|
||||
--primary-hover: #3a5bed;
|
||||
--secondary-color: #6c757d;
|
||||
--success-color: #28a745;
|
||||
--danger-color: #dc3545;
|
||||
--warning-color: #ffc107;
|
||||
--info-color: #17a2b8;
|
||||
--light-color: #f8f9fa;
|
||||
--dark-color: #343a40;
|
||||
--background-color: #1e2029;
|
||||
--card-bg: #282a36;
|
||||
--text-color: #f8f8f2;
|
||||
--text-muted: #bd93f9;
|
||||
--border-color: #44475a;
|
||||
--transition-speed: 0.3s;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
/* Ensure these are applied */
|
||||
--bg: #121418;
|
||||
--card-bg: #1e2029;
|
||||
--text: #f2f3f8;
|
||||
--primary-color-rgb: 109, 93, 252;
|
||||
}
|
||||
|
||||
/* Theme script fix */
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-color);
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
/* Global dropzone overlay for quick uploads */
|
||||
.global-dropzone {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(5px);
|
||||
z-index: 9999;
|
||||
display: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.global-dropzone.active {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dropzone-content {
|
||||
text-align: center;
|
||||
color: white;
|
||||
padding: 2rem;
|
||||
border-radius: 12px;
|
||||
background-color: rgba(var(--primary-color-rgb), 0.3);
|
||||
border: 3px dashed rgba(255, 255, 255, 0.5);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.dropzone-icon {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.dropzone-content h3 {
|
||||
font-size: 1.8rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.dropzone-content p {
|
||||
opacity: 0.8;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Upload toast notification */
|
||||
.upload-toast {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
width: 350px;
|
||||
background-color: var(--card-bg);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.2);
|
||||
z-index: 1080;
|
||||
overflow: hidden;
|
||||
display: none;
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.upload-toast.active {
|
||||
display: block;
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.upload-toast-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.8rem 1rem;
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.upload-toast-header i {
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
.upload-toast-close {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.upload-toast-close:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.upload-toast-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.upload-toast-progress-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.upload-toast-progress-bar-container {
|
||||
height: 8px;
|
||||
background-color: var(--border-color);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.upload-toast-progress-bar {
|
||||
height: 100%;
|
||||
width: 0;
|
||||
background-color: var(--primary-color);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* File and folder views */
|
||||
.files-container {
|
||||
padding: 20px;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
/* Grid view */
|
||||
.files-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.grid-view .folder-item,
|
||||
.grid-view .file-item {
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid var(--border-color);
|
||||
position: relative;
|
||||
transition: all 0.3s;
|
||||
text-decoration: none;
|
||||
color: var(--text-color);
|
||||
height: 150px;
|
||||
}
|
||||
|
||||
.grid-view .item-icon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 10px;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.grid-view .folder-item .item-icon {
|
||||
color: #f1c40f;
|
||||
}
|
||||
|
||||
.grid-view .item-info {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.grid-view .item-name {
|
||||
font-weight: 500;
|
||||
margin-bottom: 5px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.grid-view .item-details {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* File actions */
|
||||
.file-actions {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.folder-item:hover .file-actions,
|
||||
.file-item:hover .file-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border: none;
|
||||
color: white;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--primary-color);
|
||||
}
|
||||
|
||||
.action-btn.edit:hover {
|
||||
background: var(--info-color);
|
||||
}
|
||||
|
||||
.action-btn.delete:hover {
|
||||
background: var(--danger-color);
|
||||
}
|
||||
|
||||
/* Empty folder */
|
||||
.empty-folder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 300px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 3rem;
|
||||
color: var(--border-color);
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.empty-message h3 {
|
||||
margin-bottom: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.empty-message p {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.empty-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Main styles for the file management system */
|
||||
.browser-container {
|
||||
background-color: var(--card-bg);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.25);
|
||||
margin-bottom: 30px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.browser-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.browser-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.browser-title h2 {
|
||||
margin: 0 0 0 10px;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.browser-title i {
|
||||
font-size: 1.5rem;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.browser-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Breadcrumbs */
|
||||
.breadcrumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
.breadcrumb-item:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.breadcrumb-separator {
|
||||
margin: 0 8px;
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
/* Search bar */
|
||||
.search-container {
|
||||
position: relative;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.search-container input {
|
||||
padding: 8px 15px 8px 35px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
color: var(--text-color);
|
||||
width: 200px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.search-container input:focus {
|
||||
width: 250px;
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* View toggle */
|
||||
.view-toggle {
|
||||
display: flex;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.view-btn {
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-muted);
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.view-btn:first-child {
|
||||
border-radius: 4px 0 0 4px;
|
||||
}
|
||||
|
||||
.view-btn:last-child {
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
.view-btn.active {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 8px 15px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn.primary:hover {
|
||||
background-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.btn.secondary {
|
||||
background-color: transparent;
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn.secondary:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: var(--card-bg);
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.2);
|
||||
animation: modal-appear 0.3s forwards;
|
||||
}
|
||||
|
||||
@keyframes modal-appear {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-50px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 15px 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
246
app/static/js/browser.js
Normal file
246
app/static/js/browser.js
Normal file
|
@ -0,0 +1,246 @@
|
|||
/**
|
||||
* File browser functionality
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// View toggle functionality
|
||||
const filesContainer = document.getElementById('files-container');
|
||||
const gridViewBtn = document.getElementById('grid-view-btn');
|
||||
const listViewBtn = document.getElementById('list-view-btn');
|
||||
|
||||
if (filesContainer && gridViewBtn && listViewBtn) {
|
||||
// Set initial view based on saved preference
|
||||
const savedView = localStorage.getItem('view_preference') || 'grid';
|
||||
filesContainer.className = `files-container ${savedView}-view`;
|
||||
|
||||
// Highlight the correct button
|
||||
if (savedView === 'grid') {
|
||||
gridViewBtn.classList.add('active');
|
||||
listViewBtn.classList.remove('active');
|
||||
} else {
|
||||
listViewBtn.classList.add('active');
|
||||
gridViewBtn.classList.remove('active');
|
||||
}
|
||||
|
||||
// Add event listeners
|
||||
gridViewBtn.addEventListener('click', function () {
|
||||
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');
|
||||
});
|
||||
}
|
||||
|
||||
// Variables for tracking selected items
|
||||
let selectedItemId = null;
|
||||
let selectedItemType = null;
|
||||
|
||||
// Setup context menu functionality
|
||||
function setupContextMenu() {
|
||||
// Context menu already implemented via context-menu.js
|
||||
// We'll just need to ensure our item actions are properly set
|
||||
|
||||
// Add item click handler to set selected item
|
||||
document.querySelectorAll('.file-item, .folder-item').forEach(item => {
|
||||
item.addEventListener('click', function (e) {
|
||||
// If clicking on an action button, don't select the item
|
||||
if (e.target.closest('.item-actions') || e.target.closest('a')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set selected item
|
||||
selectedItemId = this.dataset.id;
|
||||
selectedItemType = this.classList.contains('folder-item') ? 'folder' : 'file';
|
||||
|
||||
// Highlight selected item
|
||||
document.querySelectorAll('.file-item, .folder-item').forEach(i => {
|
||||
i.classList.remove('selected');
|
||||
});
|
||||
this.classList.add('selected');
|
||||
});
|
||||
|
||||
// Right-click to open context menu
|
||||
item.addEventListener('contextmenu', function (e) {
|
||||
// Set selected item
|
||||
selectedItemId = this.dataset.id;
|
||||
selectedItemType = this.classList.contains('folder-item') ? 'folder' : 'file';
|
||||
|
||||
// Highlight selected item
|
||||
document.querySelectorAll('.file-item, .folder-item').forEach(i => {
|
||||
i.classList.remove('selected');
|
||||
});
|
||||
this.classList.add('selected');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Handle folder creation
|
||||
const newFolderForm = document.getElementById('new-folder-form');
|
||||
if (newFolderForm) {
|
||||
newFolderForm.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const folderName = document.getElementById('folder-name').value;
|
||||
const parentId = document.querySelector('input[name="parent_id"]').value;
|
||||
|
||||
// Create FormData
|
||||
const formData = new FormData();
|
||||
formData.append('name', folderName);
|
||||
if (parentId) formData.append('parent_id', parentId);
|
||||
|
||||
// Send request
|
||||
fetch('/files/create_folder', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
// Close modal
|
||||
closeModal('new-folder-modal');
|
||||
|
||||
// Show success message
|
||||
showAlert('Folder created successfully', 'success');
|
||||
|
||||
// Reload page to show new folder
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
} else {
|
||||
showAlert(data.error || 'Error creating folder', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showAlert('Error creating folder: ' + error, 'error');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Handle rename
|
||||
const renameForm = document.getElementById('rename-form');
|
||||
if (renameForm) {
|
||||
renameForm.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const newName = document.getElementById('new-name').value;
|
||||
|
||||
if (!selectedItemId) {
|
||||
showAlert('No item selected', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/files/rename', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
item_id: selectedItemId,
|
||||
new_name: newName
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
// Close modal
|
||||
closeModal('rename-modal');
|
||||
|
||||
// Show success message
|
||||
showAlert('Item renamed successfully', 'success');
|
||||
|
||||
// Update item name in the UI or reload
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
} else {
|
||||
showAlert(data.error || 'Failed to rename item', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showAlert('Error: ' + error, 'error');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Handle delete confirmation
|
||||
const confirmDeleteBtn = document.getElementById('confirm-delete-btn');
|
||||
if (confirmDeleteBtn) {
|
||||
confirmDeleteBtn.addEventListener('click', function () {
|
||||
if (!selectedItemId) {
|
||||
showAlert('No item selected', 'error');
|
||||
closeModal('delete-modal');
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/files/delete/${selectedItemId}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
// Close modal
|
||||
closeModal('delete-modal');
|
||||
|
||||
// Show success message
|
||||
showAlert(data.message || 'Item deleted successfully', 'success');
|
||||
|
||||
// Remove the item from the UI or reload
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
} else {
|
||||
showAlert(data.error || 'Failed to delete item', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showAlert('Error: ' + error, 'error');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize
|
||||
setupContextMenu();
|
||||
|
||||
// Buttons to open modals
|
||||
const deleteBtn = document.getElementById('delete-btn');
|
||||
if (deleteBtn) {
|
||||
deleteBtn.addEventListener('click', function () {
|
||||
if (!selectedItemId) {
|
||||
showAlert('Please select an item first', 'warning');
|
||||
return;
|
||||
}
|
||||
openModal('delete-modal');
|
||||
});
|
||||
}
|
||||
|
||||
const renameBtn = document.getElementById('rename-btn');
|
||||
if (renameBtn) {
|
||||
renameBtn.addEventListener('click', function () {
|
||||
if (!selectedItemId) {
|
||||
showAlert('Please select an item first', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current name
|
||||
const selectedItem = document.querySelector(`.file-item[data-id="${selectedItemId}"], .folder-item[data-id="${selectedItemId}"]`);
|
||||
const currentName = selectedItem ? selectedItem.querySelector('.item-name').textContent.trim() : '';
|
||||
|
||||
// Set current name in the input
|
||||
document.getElementById('new-name').value = currentName;
|
||||
|
||||
openModal('rename-modal');
|
||||
document.getElementById('new-name').focus();
|
||||
});
|
||||
}
|
||||
});
|
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';
|
||||
}
|
144
app/static/js/context-menu.js
Normal file
144
app/static/js/context-menu.js
Normal file
|
@ -0,0 +1,144 @@
|
|||
/**
|
||||
* Context Menu for Files/Folders
|
||||
*/
|
||||
|
||||
class ContextMenu {
|
||||
constructor() {
|
||||
this.menu = null;
|
||||
this.currentTarget = null;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Create context menu element
|
||||
this.menu = document.createElement('div');
|
||||
this.menu.className = 'context-menu';
|
||||
this.menu.style.display = 'none';
|
||||
document.body.appendChild(this.menu);
|
||||
|
||||
// Close menu on click outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (this.menu.style.display === 'block') {
|
||||
this.hideMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// Prevent default context menu
|
||||
document.addEventListener('contextmenu', (e) => {
|
||||
if (e.target.closest('.file-item, .folder-item')) {
|
||||
e.preventDefault();
|
||||
this.showMenu(e);
|
||||
}
|
||||
});
|
||||
|
||||
// Close on escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && this.menu.style.display === 'block') {
|
||||
this.hideMenu();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
showMenu(e) {
|
||||
// Get target item
|
||||
this.currentTarget = e.target.closest('.file-item, .folder-item');
|
||||
const itemId = this.currentTarget.dataset.id;
|
||||
const itemType = this.currentTarget.classList.contains('folder-item') ? 'folder' : 'file';
|
||||
const itemName = this.currentTarget.querySelector('.item-name').textContent;
|
||||
|
||||
// Create menu items based on item type
|
||||
this.menu.innerHTML = '';
|
||||
|
||||
if (itemType === 'folder') {
|
||||
// Folder actions
|
||||
this.addMenuItem('Open', 'fa-folder-open', () => {
|
||||
window.location.href = `/files/browse/${itemId}`;
|
||||
});
|
||||
|
||||
this.addMenuItem('Rename', 'fa-edit', () => {
|
||||
openModal('rename-modal');
|
||||
document.getElementById('new-name').value = itemName;
|
||||
window.selectedItemId = itemId;
|
||||
});
|
||||
|
||||
this.addMenuItem('Delete', 'fa-trash-alt', () => {
|
||||
openModal('delete-modal');
|
||||
window.selectedItemId = itemId;
|
||||
});
|
||||
} else {
|
||||
// File actions
|
||||
this.addMenuItem('Download', 'fa-download', () => {
|
||||
window.location.href = `/files/download/${itemId}`;
|
||||
});
|
||||
|
||||
this.addMenuItem('View', 'fa-eye', () => {
|
||||
window.location.href = `/files/view/${itemId}`;
|
||||
});
|
||||
|
||||
this.addMenuItem('Rename', 'fa-edit', () => {
|
||||
openModal('rename-modal');
|
||||
document.getElementById('new-name').value = itemName;
|
||||
window.selectedItemId = itemId;
|
||||
});
|
||||
|
||||
this.addMenuItem('Delete', 'fa-trash-alt', () => {
|
||||
openModal('delete-modal');
|
||||
window.selectedItemId = itemId;
|
||||
});
|
||||
}
|
||||
|
||||
// Position menu
|
||||
const x = e.clientX;
|
||||
const y = e.clientY;
|
||||
|
||||
// Set menu position
|
||||
this.menu.style.left = `${x}px`;
|
||||
this.menu.style.top = `${y}px`;
|
||||
|
||||
// Show menu with animation
|
||||
this.menu.style.display = 'block';
|
||||
|
||||
// Adjust position if menu goes off screen
|
||||
const menuRect = this.menu.getBoundingClientRect();
|
||||
const windowWidth = window.innerWidth;
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
if (menuRect.right > windowWidth) {
|
||||
this.menu.style.left = `${windowWidth - menuRect.width - 10}px`;
|
||||
}
|
||||
|
||||
if (menuRect.bottom > windowHeight) {
|
||||
this.menu.style.top = `${windowHeight - menuRect.height - 10}px`;
|
||||
}
|
||||
}
|
||||
|
||||
hideMenu() {
|
||||
this.menu.style.display = 'none';
|
||||
this.currentTarget = null;
|
||||
}
|
||||
|
||||
addMenuItem(label, icon, action) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'context-menu-item';
|
||||
item.innerHTML = `<i class="fas ${icon}"></i> ${label}`;
|
||||
|
||||
item.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.hideMenu();
|
||||
action();
|
||||
});
|
||||
|
||||
this.menu.appendChild(item);
|
||||
}
|
||||
|
||||
addDivider() {
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'context-menu-divider';
|
||||
this.menu.appendChild(divider);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize context menu
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
window.contextMenu = new ContextMenu();
|
||||
});
|
208
app/static/js/main.js
Normal file
208
app/static/js/main.js
Normal file
|
@ -0,0 +1,208 @@
|
|||
// Main JavaScript file for Flask Files
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Initialize components
|
||||
initializeViewToggle();
|
||||
initializeFolderNavigation();
|
||||
initializeModals();
|
||||
initializeContextMenu();
|
||||
initializeUploadFunctionality();
|
||||
|
||||
// Register service worker if supported
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/static/js/service-worker.js')
|
||||
.then(function (registration) {
|
||||
console.log('Service Worker registered with scope:', registration.scope);
|
||||
}).catch(function (error) {
|
||||
console.log('Service Worker registration failed:', error);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle between grid and list views
|
||||
function initializeViewToggle() {
|
||||
const gridViewBtn = document.getElementById('grid-view-btn');
|
||||
const listViewBtn = document.getElementById('list-view-btn');
|
||||
const filesContainer = document.getElementById('files-container');
|
||||
|
||||
if (gridViewBtn && listViewBtn && filesContainer) {
|
||||
gridViewBtn.addEventListener('click', function () {
|
||||
filesContainer.classList.add('grid-view');
|
||||
filesContainer.classList.remove('list-view');
|
||||
gridViewBtn.classList.add('active');
|
||||
listViewBtn.classList.remove('active');
|
||||
localStorage.setItem('fileViewPreference', 'grid');
|
||||
});
|
||||
|
||||
listViewBtn.addEventListener('click', function () {
|
||||
filesContainer.classList.add('list-view');
|
||||
filesContainer.classList.remove('grid-view');
|
||||
listViewBtn.classList.add('active');
|
||||
gridViewBtn.classList.remove('active');
|
||||
localStorage.setItem('fileViewPreference', 'list');
|
||||
});
|
||||
|
||||
// Load user preference from localStorage
|
||||
const viewPreference = localStorage.getItem('fileViewPreference') || 'grid';
|
||||
if (viewPreference === 'grid') {
|
||||
gridViewBtn.click();
|
||||
} else {
|
||||
listViewBtn.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add animations for folder navigation
|
||||
function initializeFolderNavigation() {
|
||||
// Add click event to folder items
|
||||
document.querySelectorAll('.folder-item').forEach(folder => {
|
||||
folder.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const href = this.getAttribute('href');
|
||||
const filesContainer = document.getElementById('files-container');
|
||||
|
||||
// Add transition class
|
||||
filesContainer.classList.add('changing');
|
||||
|
||||
// After a short delay, navigate to the folder
|
||||
setTimeout(() => {
|
||||
window.location.href = href;
|
||||
}, 200);
|
||||
});
|
||||
});
|
||||
|
||||
// Add the animation class when page loads
|
||||
const filesContainer = document.getElementById('files-container');
|
||||
if (filesContainer) {
|
||||
// Remove the class to trigger animation
|
||||
filesContainer.classList.add('folder-enter-active');
|
||||
}
|
||||
}
|
||||
|
||||
// Modal handling
|
||||
function initializeModals() {
|
||||
// New folder modal
|
||||
const newFolderBtn = document.getElementById('new-folder-btn');
|
||||
const newFolderModal = document.getElementById('new-folder-modal');
|
||||
const emptyNewFolderBtn = document.getElementById('empty-new-folder-btn');
|
||||
|
||||
if (newFolderBtn && newFolderModal) {
|
||||
newFolderBtn.addEventListener('click', function () {
|
||||
newFolderModal.style.display = 'flex';
|
||||
document.getElementById('folder-name').focus();
|
||||
});
|
||||
|
||||
if (emptyNewFolderBtn) {
|
||||
emptyNewFolderBtn.addEventListener('click', function () {
|
||||
newFolderModal.style.display = 'flex';
|
||||
document.getElementById('folder-name').focus();
|
||||
});
|
||||
}
|
||||
|
||||
// Close modal
|
||||
document.querySelectorAll('.modal-close, .modal-cancel').forEach(btn => {
|
||||
btn.addEventListener('click', function () {
|
||||
newFolderModal.style.display = 'none';
|
||||
});
|
||||
});
|
||||
|
||||
// Close on click outside
|
||||
window.addEventListener('click', function (event) {
|
||||
if (event.target === newFolderModal) {
|
||||
newFolderModal.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Context menu for right-click on files/folders
|
||||
function initializeContextMenu() {
|
||||
const contextMenu = document.getElementById('context-menu');
|
||||
|
||||
if (!contextMenu) return;
|
||||
|
||||
document.addEventListener('contextmenu', function (e) {
|
||||
const fileItem = e.target.closest('.file-item, .folder-item');
|
||||
|
||||
if (fileItem) {
|
||||
e.preventDefault();
|
||||
|
||||
const itemId = fileItem.getAttribute('data-id');
|
||||
const itemType = fileItem.classList.contains('file-item') ? 'file' : 'folder';
|
||||
|
||||
// Position menu
|
||||
contextMenu.style.left = `${e.pageX}px`;
|
||||
contextMenu.style.top = `${e.pageY}px`;
|
||||
|
||||
// Show menu
|
||||
contextMenu.style.display = 'block';
|
||||
contextMenu.setAttribute('data-item-id', itemId);
|
||||
contextMenu.setAttribute('data-item-type', itemType);
|
||||
|
||||
// Set up buttons for different item types
|
||||
setupContextMenuActions(contextMenu, itemId, itemType);
|
||||
}
|
||||
});
|
||||
|
||||
// Hide menu on click elsewhere
|
||||
document.addEventListener('click', function () {
|
||||
contextMenu.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function setupContextMenuActions(menu, itemId, itemType) {
|
||||
// Show/hide appropriate actions based on item type
|
||||
menu.querySelectorAll('[data-action]').forEach(action => {
|
||||
const forType = action.getAttribute('data-for');
|
||||
if (forType === 'all' || forType === itemType) {
|
||||
action.style.display = 'block';
|
||||
} else {
|
||||
action.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize file upload functionality
|
||||
function initializeUploadFunctionality() {
|
||||
const uploadBtn = document.querySelector('a[href*="upload"]');
|
||||
const fileInput = document.getElementById('file-upload');
|
||||
|
||||
if (uploadBtn && fileInput) {
|
||||
fileInput.addEventListener('change', function (e) {
|
||||
if (this.files.length) {
|
||||
const formData = new FormData();
|
||||
|
||||
for (let i = 0; i < this.files.length; i++) {
|
||||
formData.append('file', this.files[i]);
|
||||
}
|
||||
|
||||
// Get current folder ID from URL if available
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const folderId = urlParams.get('folder_id');
|
||||
|
||||
if (folderId) {
|
||||
formData.append('folder_id', folderId);
|
||||
}
|
||||
|
||||
fetch('/files/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
// Refresh page to show new file
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Upload failed: ' + data.error);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Upload failed. Please try again.');
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
183
app/static/js/quick-upload.js
Normal file
183
app/static/js/quick-upload.js
Normal file
|
@ -0,0 +1,183 @@
|
|||
/**
|
||||
* Quick upload functionality for instant file uploads
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Elements
|
||||
const globalDropzone = document.getElementById('global-dropzone');
|
||||
const uploadToast = document.getElementById('upload-toast');
|
||||
const uploadProgressBar = document.getElementById('upload-toast-progress-bar');
|
||||
const uploadPercentage = document.getElementById('upload-toast-percentage');
|
||||
const uploadFileName = document.getElementById('upload-toast-file');
|
||||
const uploadToastClose = document.getElementById('upload-toast-close');
|
||||
|
||||
// Get current folder ID from URL or data attribute
|
||||
function getCurrentFolderId() {
|
||||
// Check if we're on a folder page
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const folderIdFromUrl = urlParams.get('folder_id');
|
||||
|
||||
// Check for a data attribute on the page
|
||||
const folderElement = document.querySelector('[data-current-folder-id]');
|
||||
const folderIdFromData = folderElement ? folderElement.dataset.currentFolderId : null;
|
||||
|
||||
return folderIdFromUrl || folderIdFromData || null;
|
||||
}
|
||||
|
||||
// Show global dropzone when files are dragged over the window
|
||||
window.addEventListener('dragover', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Only show dropzone if user is on dashboard or files page
|
||||
const onRelevantPage = window.location.pathname.includes('/dashboard') ||
|
||||
window.location.pathname.includes('/files');
|
||||
|
||||
if (onRelevantPage) {
|
||||
globalDropzone.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Hide dropzone when dragging leaves window
|
||||
window.addEventListener('dragleave', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Only hide if leaving the window (not entering child elements)
|
||||
if (e.clientX <= 0 || e.clientY <= 0 ||
|
||||
e.clientX >= window.innerWidth || e.clientY >= window.innerHeight) {
|
||||
globalDropzone.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle drop event for quick upload
|
||||
window.addEventListener('drop', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Hide the dropzone
|
||||
globalDropzone.classList.remove('active');
|
||||
|
||||
// Make sure files were dropped
|
||||
if (!e.dataTransfer.files || e.dataTransfer.files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Show upload progress toast
|
||||
uploadToast.classList.add('active');
|
||||
|
||||
// Get the current folder ID (null for root)
|
||||
const currentFolderId = getCurrentFolderId();
|
||||
|
||||
// Upload the files
|
||||
uploadFiles(e.dataTransfer.files, currentFolderId);
|
||||
});
|
||||
|
||||
// Close upload toast
|
||||
if (uploadToastClose) {
|
||||
uploadToastClose.addEventListener('click', function () {
|
||||
uploadToast.classList.remove('active');
|
||||
});
|
||||
}
|
||||
|
||||
// Quick upload function
|
||||
function uploadFiles(files, folderId) {
|
||||
// Create FormData object
|
||||
const formData = new FormData();
|
||||
|
||||
// Add folder ID if provided
|
||||
if (folderId) {
|
||||
formData.append('parent_folder_id', folderId);
|
||||
}
|
||||
|
||||
// Add all files
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
formData.append('files[]', files[i]);
|
||||
}
|
||||
|
||||
// Create XHR request
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
// Update progress
|
||||
xhr.upload.addEventListener('progress', function (e) {
|
||||
if (e.lengthComputable) {
|
||||
const percent = Math.round((e.loaded / e.total) * 100);
|
||||
uploadProgressBar.style.width = percent + '%';
|
||||
uploadPercentage.textContent = percent + '%';
|
||||
|
||||
if (files.length === 1) {
|
||||
uploadFileName.textContent = files[0].name;
|
||||
} else {
|
||||
uploadFileName.textContent = `Uploading ${files.length} files...`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle completion
|
||||
xhr.addEventListener('load', function () {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
// Success - upload complete
|
||||
uploadProgressBar.style.width = '100%';
|
||||
uploadPercentage.textContent = '100%';
|
||||
uploadFileName.textContent = 'Upload Complete!';
|
||||
|
||||
// Show success alert
|
||||
if (typeof showAlert === 'function') {
|
||||
showAlert('Files uploaded successfully!', 'success');
|
||||
}
|
||||
|
||||
// Reload page after brief delay to show new files
|
||||
setTimeout(function () {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} else {
|
||||
// Error
|
||||
let errorMessage = 'Upload failed';
|
||||
|
||||
try {
|
||||
const response = JSON.parse(xhr.responseText);
|
||||
if (response.error) {
|
||||
errorMessage = response.error;
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parsing error
|
||||
}
|
||||
|
||||
uploadFileName.textContent = errorMessage;
|
||||
|
||||
if (typeof showAlert === 'function') {
|
||||
showAlert(errorMessage, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Hide toast after delay
|
||||
setTimeout(function () {
|
||||
uploadToast.classList.remove('active');
|
||||
|
||||
// Reset progress
|
||||
setTimeout(function () {
|
||||
uploadProgressBar.style.width = '0%';
|
||||
uploadPercentage.textContent = '0%';
|
||||
uploadFileName.textContent = 'Processing...';
|
||||
}, 300);
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
// Handle errors
|
||||
xhr.addEventListener('error', function () {
|
||||
uploadFileName.textContent = 'Network error occurred';
|
||||
|
||||
if (typeof showAlert === 'function') {
|
||||
showAlert('Network error occurred', 'error');
|
||||
}
|
||||
|
||||
// Hide toast after delay
|
||||
setTimeout(function () {
|
||||
uploadToast.classList.remove('active');
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
// Set up and send the request
|
||||
xhr.open('POST', '/files/upload_xhr', true);
|
||||
xhr.send(formData);
|
||||
}
|
||||
});
|
62
app/static/js/theme.js
Normal file
62
app/static/js/theme.js
Normal file
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* Theme handling functionality
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const themeToggle = document.querySelector('.theme-toggle-icon');
|
||||
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', function () {
|
||||
const currentTheme = document.documentElement.getAttribute('data-theme') || 'system';
|
||||
let newTheme;
|
||||
|
||||
if (currentTheme === 'dark') {
|
||||
newTheme = 'light';
|
||||
} else {
|
||||
newTheme = 'dark';
|
||||
}
|
||||
|
||||
// Update theme
|
||||
document.documentElement.setAttribute('data-theme', newTheme);
|
||||
|
||||
// Store preference
|
||||
localStorage.setItem('theme_preference', newTheme);
|
||||
|
||||
// Update icon
|
||||
updateThemeIcon(newTheme);
|
||||
});
|
||||
|
||||
// Initialize theme on page load
|
||||
initTheme();
|
||||
}
|
||||
|
||||
function initTheme() {
|
||||
// Get saved preference
|
||||
let theme = localStorage.getItem('theme_preference');
|
||||
|
||||
// If no preference, check system preference
|
||||
if (!theme) {
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
theme = 'dark';
|
||||
} else {
|
||||
theme = 'light';
|
||||
}
|
||||
}
|
||||
|
||||
// Apply theme
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
|
||||
// Update icon
|
||||
updateThemeIcon(theme);
|
||||
}
|
||||
|
||||
function updateThemeIcon(theme) {
|
||||
const icon = document.querySelector('.theme-toggle-icon i');
|
||||
if (icon) {
|
||||
if (theme === 'dark') {
|
||||
icon.className = 'fas fa-sun';
|
||||
} else {
|
||||
icon.className = 'fas fa-moon';
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
|
@ -10,6 +10,9 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||
const fileList = document.getElementById('file-list');
|
||||
const folderList = document.getElementById('folder-file-list');
|
||||
|
||||
// Fix to avoid darkModeToggle is null error
|
||||
const darkModeToggle = document.querySelector('.theme-toggle-icon');
|
||||
|
||||
// Progress elements
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
const progressPercentage = document.getElementById('progress-percentage');
|
||||
|
|
187
app/templates/admin/panel.html
Normal file
187
app/templates/admin/panel.html
Normal file
|
@ -0,0 +1,187 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Admin Panel - Flask Files{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="admin-panel">
|
||||
<div class="admin-header">
|
||||
<h2><i class="fas fa-cog"></i> Admin Panel</h2>
|
||||
</div>
|
||||
|
||||
<div class="admin-section">
|
||||
<h3>Database Management</h3>
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-header">
|
||||
<h4>Database Migrations</h4>
|
||||
</div>
|
||||
<div class="admin-card-body">
|
||||
<p>Run database migrations to update the schema if needed.</p>
|
||||
<button id="run-migrations-btn" class="btn primary">
|
||||
<i class="fas fa-database"></i> Run Migrations
|
||||
</button>
|
||||
<div id="migration-result" class="mt-3" style="display: none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-card mt-4">
|
||||
<div class="admin-card-header">
|
||||
<h4>Reset Database</h4>
|
||||
</div>
|
||||
<div class="admin-card-body">
|
||||
<p class="text-danger">
|
||||
<strong>WARNING:</strong> This will delete all data and recreate the database structure.
|
||||
All files, folders, and user accounts will be permanently deleted.
|
||||
</p>
|
||||
<button id="reset-db-btn" class="btn danger">
|
||||
<i class="fas fa-exclamation-triangle"></i> Reset Database
|
||||
</button>
|
||||
<div id="reset-result" class="mt-3" style="display: none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation Modal -->
|
||||
<div id="confirm-reset-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Confirm Database Reset</h3>
|
||||
<button class="modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert warning">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<p>
|
||||
<strong>WARNING:</strong> You are about to reset the entire database.
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
<p>All files, folders, users, and settings will be permanently deleted.</p>
|
||||
</div>
|
||||
<p>Type "RESET" in the box below to confirm:</p>
|
||||
<input type="text" id="reset-confirm-text" class="form-control mt-3" placeholder="Type RESET to confirm">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn" id="cancel-reset-btn">Cancel</button>
|
||||
<button class="btn danger" id="confirm-reset-btn" disabled>Reset Database</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const migrationsBtn = document.getElementById('run-migrations-btn');
|
||||
const resultDiv = document.getElementById('migration-result');
|
||||
const resetBtn = document.getElementById('reset-db-btn');
|
||||
const resetResultDiv = document.getElementById('reset-result');
|
||||
const resetConfirmText = document.getElementById('reset-confirm-text');
|
||||
const confirmResetBtn = document.getElementById('confirm-reset-btn');
|
||||
const cancelResetBtn = document.getElementById('cancel-reset-btn');
|
||||
|
||||
if (migrationsBtn) {
|
||||
migrationsBtn.addEventListener('click', function () {
|
||||
// Show loading state
|
||||
migrationsBtn.disabled = true;
|
||||
migrationsBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Running...';
|
||||
resultDiv.style.display = 'none';
|
||||
|
||||
// Call the migrations endpoint
|
||||
fetch('/admin/run-migrations', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Reset button
|
||||
migrationsBtn.disabled = false;
|
||||
migrationsBtn.innerHTML = '<i class="fas fa-database"></i> Run Migrations';
|
||||
|
||||
// Show result
|
||||
resultDiv.style.display = 'block';
|
||||
if (data.success) {
|
||||
resultDiv.innerHTML = '<div class="alert success"><i class="fas fa-check-circle"></i> ' + data.message + '</div>';
|
||||
} else {
|
||||
resultDiv.innerHTML = '<div class="alert error"><i class="fas fa-exclamation-circle"></i> ' + data.error + '</div>';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
// Error handling
|
||||
migrationsBtn.disabled = false;
|
||||
migrationsBtn.innerHTML = '<i class="fas fa-database"></i> Run Migrations';
|
||||
resultDiv.style.display = 'block';
|
||||
resultDiv.innerHTML = '<div class="alert error"><i class="fas fa-exclamation-circle"></i> Error: ' + error.message + '</div>';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (resetBtn) {
|
||||
resetBtn.addEventListener('click', function () {
|
||||
// Show confirmation modal
|
||||
openModal('confirm-reset-modal');
|
||||
});
|
||||
}
|
||||
|
||||
if (resetConfirmText) {
|
||||
resetConfirmText.addEventListener('input', function () {
|
||||
confirmResetBtn.disabled = this.value !== 'RESET';
|
||||
});
|
||||
}
|
||||
|
||||
if (cancelResetBtn) {
|
||||
cancelResetBtn.addEventListener('click', function () {
|
||||
closeModal('confirm-reset-modal');
|
||||
});
|
||||
}
|
||||
|
||||
if (confirmResetBtn) {
|
||||
confirmResetBtn.addEventListener('click', function () {
|
||||
// Close modal
|
||||
closeModal('confirm-reset-modal');
|
||||
|
||||
// Show loading state
|
||||
resetBtn.disabled = true;
|
||||
resetBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Resetting...';
|
||||
resetResultDiv.style.display = 'none';
|
||||
|
||||
// Call the reset endpoint
|
||||
fetch('/admin/reset-database', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Reset button
|
||||
resetBtn.disabled = false;
|
||||
resetBtn.innerHTML = '<i class="fas fa-exclamation-triangle"></i> Reset Database';
|
||||
|
||||
// Show result
|
||||
resetResultDiv.style.display = 'block';
|
||||
if (data.success) {
|
||||
resetResultDiv.innerHTML = '<div class="alert success"><i class="fas fa-check-circle"></i> ' + data.message + '</div>';
|
||||
// Redirect to login after a delay
|
||||
setTimeout(function () {
|
||||
window.location.href = '/auth/login';
|
||||
}, 3000);
|
||||
} else {
|
||||
resetResultDiv.innerHTML = '<div class="alert error"><i class="fas fa-exclamation-circle"></i> ' + data.error + '</div>';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
// Error handling
|
||||
resetBtn.disabled = false;
|
||||
resetBtn.innerHTML = '<i class="fas fa-exclamation-triangle"></i> Reset Database';
|
||||
resetResultDiv.style.display = 'block';
|
||||
resetResultDiv.innerHTML = '<div class="alert error"><i class="fas fa-exclamation-circle"></i> Error: ' + error.message + '</div>';
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
|
@ -1,238 +1,50 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Profile - Flask Files{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.profile-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.profile-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.profile-tab {
|
||||
padding: 0.75rem 1.5rem;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
font-weight: 500;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.profile-tab.active {
|
||||
color: var(--primary-color);
|
||||
border-bottom-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.setting-group {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.setting-group h3 {
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block title %}User Profile{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="profile-container">
|
||||
<h2>User Profile</h2>
|
||||
|
||||
<div class="profile-tabs">
|
||||
<button class="profile-tab active" data-tab="profile">Profile</button>
|
||||
<button class="profile-tab" data-tab="appearance">Appearance</button>
|
||||
<button class="profile-tab" data-tab="security">Security</button>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-8 offset-md-2">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>User Profile</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3 font-weight-bold">Username:</div>
|
||||
<div class="col-md-9">{{ user.username }}</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3 font-weight-bold">Email:</div>
|
||||
<div class="col-md-9">{{ user.email }}</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3 font-weight-bold">Member Since:</div>
|
||||
<div class="col-md-9">{{ user.created_at.strftime('%Y-%m-%d') }}</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3 font-weight-bold">Account Type:</div>
|
||||
<div class="col-md-9">{% if user.is_admin %}Administrator{% else %}Regular User{% endif %}</div>
|
||||
</div>
|
||||
|
||||
<!-- Profile Tab -->
|
||||
<div class="tab-content active" id="profile-tab">
|
||||
<div class="profile-card">
|
||||
<div class="profile-header">
|
||||
<div class="avatar">
|
||||
{{ current_user.username[0].upper() }}
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<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>
|
||||
<hr>
|
||||
|
||||
<div class="profile-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">{{ current_user.files.filter_by(is_folder=False).count() }}</span>
|
||||
<span class="stat-label">Files</span>
|
||||
<h4 class="mt-4">Storage Summary</h4>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3 font-weight-bold">Files:</div>
|
||||
<div class="col-md-9">{{ user.files.count() }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">{{ current_user.files.filter_by(is_folder=True).count() }}</span>
|
||||
<span class="stat-label">Folders</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">{{ current_user.shares.count() }}</span>
|
||||
<span class="stat-label">Shares</span>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3 font-weight-bold">Folders:</div>
|
||||
<div class="col-md-9">{{ user.folders.count() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-group">
|
||||
<h3>Edit Profile</h3>
|
||||
<form id="username-form" method="POST" action="{{ url_for('auth.update_profile') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" class="form-control"
|
||||
value="{{ current_user.username }}">
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn primary">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="card-footer">
|
||||
<a href="{{ url_for('dashboard.index') }}" class="btn btn-secondary">Back to Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Appearance Tab -->
|
||||
<div class="tab-content" id="appearance-tab">
|
||||
<div class="setting-group">
|
||||
<h3>Theme Settings</h3>
|
||||
<form id="theme-form" method="POST" action="{{ url_for('auth.update_preferences') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="form-group">
|
||||
<label>Default Theme</label>
|
||||
<div class="theme-options">
|
||||
<label class="radio-container">
|
||||
<input type="radio" name="theme_preference" value="light" {% if theme_preference=='light'
|
||||
%}checked{% endif %}>
|
||||
<span class="radio-label">Light</span>
|
||||
</label>
|
||||
<label class="radio-container">
|
||||
<input type="radio" name="theme_preference" value="dark" {% if theme_preference=='dark'
|
||||
%}checked{% endif %}>
|
||||
<span class="radio-label">Dark</span>
|
||||
</label>
|
||||
<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>
|
||||
|
||||
<!-- Security Tab -->
|
||||
<div class="tab-content" id="security-tab">
|
||||
<div class="setting-group">
|
||||
<h3>Change Password</h3>
|
||||
<form id="password-form" method="POST" action="{{ url_for('auth.change_password') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="form-group">
|
||||
<label for="current_password">Current Password</label>
|
||||
<input type="password" id="current_password" name="current_password" class="form-control" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new_password">New Password</label>
|
||||
<input type="password" id="new_password" name="new_password" class="form-control" required>
|
||||
<div class="password-strength" id="password-strength"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="confirm_password">Confirm New Password</label>
|
||||
<input type="password" id="confirm_password" name="confirm_password" class="form-control" required>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn primary">Change Password</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Tab switching
|
||||
const tabs = document.querySelectorAll('.profile-tab');
|
||||
const tabContents = document.querySelectorAll('.tab-content');
|
||||
|
||||
tabs.forEach(tab => {
|
||||
tab.addEventListener('click', function () {
|
||||
const tabId = this.getAttribute('data-tab');
|
||||
|
||||
// Update active tab
|
||||
tabs.forEach(t => t.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
|
||||
// Update active content
|
||||
tabContents.forEach(content => {
|
||||
content.classList.remove('active');
|
||||
if (content.id === `${tabId}-tab`) {
|
||||
content.classList.add('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Password strength meter
|
||||
const passwordInput = document.getElementById('new_password');
|
||||
const strengthIndicator = document.getElementById('password-strength');
|
||||
|
||||
if (passwordInput && strengthIndicator) {
|
||||
passwordInput.addEventListener('input', function () {
|
||||
const password = this.value;
|
||||
let strength = 0;
|
||||
|
||||
if (password.length >= 8) strength += 1;
|
||||
if (password.match(/[a-z]/) && password.match(/[A-Z]/)) strength += 1;
|
||||
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
|
||||
const confirmInput = document.getElementById('confirm_password');
|
||||
if (passwordInput && confirmInput) {
|
||||
confirmInput.addEventListener('input', function () {
|
||||
if (passwordInput.value !== this.value) {
|
||||
this.setCustomValidity('Passwords must match');
|
||||
} else {
|
||||
this.setCustomValidity('');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
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,16 +1,31 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<html lang="en" data-theme="{{ theme_preference or 'system' }}">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Flask Files{% endblock %}</title>
|
||||
|
||||
<!-- Bootstrap CSS -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.0/dist/css/bootstrap.min.css">
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/all.min.css">
|
||||
|
||||
<!-- Custom CSS -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/custom.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modal.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/context-menu.css') }}">
|
||||
|
||||
<!-- Classless CSS Framework -->
|
||||
<!-- <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/digitallytailored/classless@latest/classless.min.css"> -->
|
||||
|
||||
<!-- Custom CSS -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/custom.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modal.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/context-menu.css') }}">
|
||||
|
||||
<!-- Font Awesome Icons -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css">
|
||||
|
@ -18,83 +33,132 @@
|
|||
{% block extra_css %}{% endblock %}
|
||||
<!-- JavaScript -->
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Dark mode toggle button
|
||||
const darkModeToggle = document.getElementById('darkModeToggle');
|
||||
|
||||
function setColorScheme(scheme) {
|
||||
document.documentElement.setAttribute('color-scheme', scheme);
|
||||
localStorage.setItem('color-scheme', scheme);
|
||||
}
|
||||
|
||||
function getColorScheme() {
|
||||
let scheme = localStorage.getItem('color-scheme');
|
||||
if (scheme) {
|
||||
return scheme;
|
||||
}
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
setColorScheme(getColorScheme());
|
||||
|
||||
darkModeToggle.addEventListener('click', function () {
|
||||
const newScheme = getColorScheme() === 'dark' ? 'light' : 'dark';
|
||||
setColorScheme(newScheme);
|
||||
});
|
||||
|
||||
darkModeToggle.checked = getColorScheme() === 'dark';
|
||||
});
|
||||
</script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header id="main-header">
|
||||
<nav>
|
||||
<div class="logo">
|
||||
<h1><a href="{{ url_for('dashboard.index') }}">Flask Files</a></h1>
|
||||
</div>
|
||||
<ul>
|
||||
<li><a href="{{ url_for('dashboard.index') }}"><i class="fas fa-chart-pie"></i> Dashboard</a></li>
|
||||
<li><a href="{{ url_for('files.browser') }}"><i class="fas fa-folder"></i> Files</a></li>
|
||||
{% if current_user.is_authenticated %}
|
||||
<li><a href="{{ url_for('auth.profile') }}"><i class="fas fa-user"></i> {{ current_user.username }}</a>
|
||||
</li>
|
||||
<li><a href="{{ url_for('auth.logout') }}"><i class="fas fa-sign-out-alt"></i> Logout</a></li>
|
||||
{% else %}
|
||||
<li><a href="{{ url_for('auth.login') }}"><i class="fas fa-sign-in-alt"></i> Login</a></li>
|
||||
{% endif %}
|
||||
<li>
|
||||
<button id="darkModeToggle" class="toggle-button">
|
||||
<i class="fas fa-moon"></i>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
<!-- Global drop zone overlay - hidden by default -->
|
||||
<div id="global-dropzone" class="global-dropzone">
|
||||
<div class="dropzone-content">
|
||||
<div class="dropzone-icon">
|
||||
<i class="fas fa-cloud-upload-alt fa-3x"></i>
|
||||
</div>
|
||||
<h3>Drop Files to Upload</h3>
|
||||
<p>Files will be instantly uploaded to current folder</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<!-- Global upload progress toast -->
|
||||
<div id="upload-toast" class="upload-toast">
|
||||
<div class="upload-toast-header">
|
||||
<i class="fas fa-cloud-upload-alt"></i>
|
||||
<span>Uploading Files</span>
|
||||
<button id="upload-toast-close" class="upload-toast-close">×</button>
|
||||
</div>
|
||||
<div class="upload-toast-body">
|
||||
<div class="upload-toast-progress-info">
|
||||
<span id="upload-toast-file">Processing...</span>
|
||||
<span id="upload-toast-percentage">0%</span>
|
||||
</div>
|
||||
<div class="upload-toast-progress-bar-container">
|
||||
<div id="upload-toast-progress-bar" class="upload-toast-progress-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="navbar navbar-expand-md navbar-dark bg-dark fixed-top">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="{{ url_for('dashboard.index') }}">
|
||||
<i class="fas fa-folder-open mr-2"></i>
|
||||
Flask Files
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarContent">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<div class="collapse navbar-collapse" id="navbarContent">
|
||||
<ul class="navbar-nav ml-auto">
|
||||
{% if current_user.is_authenticated %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('dashboard.index') }}">
|
||||
<i class="fas fa-tachometer-alt mr-1"></i> Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('files.browser') }}">
|
||||
<i class="fas fa-folder mr-1"></i> My Files
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('files.upload') }}">
|
||||
<i class="fas fa-upload mr-1"></i> Upload
|
||||
</a>
|
||||
</li>
|
||||
{% if current_user.is_admin %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin.index') }}">
|
||||
<i class="fas fa-cog mr-1"></i> Admin
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="userDropdown" data-toggle="dropdown">
|
||||
<i class="fas fa-user-circle mr-1"></i> {{ current_user.username }}
|
||||
</a>
|
||||
<div class="dropdown-menu dropdown-menu-right">
|
||||
<a class="dropdown-item" href="{{ url_for('auth.profile') }}">
|
||||
<i class="fas fa-id-card mr-2"></i> Profile
|
||||
</a>
|
||||
<div class="dropdown-divider"></div>
|
||||
<a class="dropdown-item" href="{{ url_for('auth.logout') }}">
|
||||
<i class="fas fa-sign-out-alt mr-2"></i> Logout
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('auth.login') }}">
|
||||
<i class="fas fa-sign-in-alt mr-1"></i> Login
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('auth.register') }}">
|
||||
<i class="fas fa-user-plus mr-1"></i> Register
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
<div class="container mt-4">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<section class="alerts">
|
||||
{% for category, message in messages %}
|
||||
<div class="alert {{ category }}">
|
||||
<div class="alert alert-{{ category if category != 'message' else 'info' }} alert-dismissible fade show">
|
||||
{{ message }}
|
||||
<button class="close" aria-label="Close">×</button>
|
||||
<button type="button" class="close" data-dismiss="alert">×</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
<section class="content">
|
||||
<!-- Main Content -->
|
||||
<main role="main" class="container mt-3">
|
||||
{% block content %}{% endblock %}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>© {{ now.year }} Flask Files - A Simple File Manager</p>
|
||||
<!-- Footer -->
|
||||
<footer class="footer mt-auto py-3 bg-light">
|
||||
<div class="container text-center">
|
||||
<span class="text-muted">© 2023 Flask Files. All rights reserved.</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Add this right before the closing </body> tag -->
|
||||
|
@ -145,6 +209,24 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Common JS file with shared functions -->
|
||||
<script src="{{ url_for('static', filename='js/common.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/theme.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/context-menu.js') }}"></script>
|
||||
|
||||
{% if current_user.is_authenticated %}
|
||||
<script src="{{ url_for('static', filename='js/quick-upload.js') }}"></script>
|
||||
{% endif %}
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script src="https://code.jquery.com/jquery-3.5.1.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.6.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<!-- Custom JavaScript -->
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
|
||||
</html>
|
105
app/templates/dashboard/index.html
Normal file
105
app/templates/dashboard/index.html
Normal file
|
@ -0,0 +1,105 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<h1 class="mb-4">Dashboard</h1>
|
||||
|
||||
<!-- Storage Summary Cards -->
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="card border-primary">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-4 text-center">
|
||||
<i class="fas fa-file fa-3x text-primary"></i>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<h5 class="card-title">Files</h5>
|
||||
<h3>{{ file_count }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="card border-success">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-4 text-center">
|
||||
<i class="fas fa-folder fa-3x text-success"></i>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<h5 class="card-title">Folders</h5>
|
||||
<h3>{{ folder_count }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="card border-info">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-4 text-center">
|
||||
<i class="fas fa-hdd fa-3x text-info"></i>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<h5 class="card-title">Storage Used</h5>
|
||||
<h3>{{ storage_used_formatted }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Files -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header bg-light">
|
||||
<h4 class="mb-0">Recent Files</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if recent_files %}
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Size</th>
|
||||
<th>Uploaded</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for file in recent_files %}
|
||||
<tr>
|
||||
<td>
|
||||
<i class="fas {{ file.icon_class }} mr-2"></i>
|
||||
<a href="{{ url_for('files.download', file_id=file.id) }}">{{ file.name }}</a>
|
||||
</td>
|
||||
<td>{{ file.type }}</td>
|
||||
<td>{{ file.size|filesizeformat }}</td>
|
||||
<td>{{ file.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="alert alert-info">
|
||||
You haven't uploaded any files yet.
|
||||
<a href="{{ url_for('files.upload') }}" class="alert-link">Upload your first file</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<a href="{{ url_for('files.browser') }}" class="btn btn-primary">
|
||||
<i class="fas fa-folder-open mr-1"></i> Browse Files
|
||||
</a>
|
||||
<a href="{{ url_for('files.upload') }}" class="btn btn-success">
|
||||
<i class="fas fa-upload mr-1"></i> Upload Files
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -2,225 +2,120 @@
|
|||
|
||||
{% block title %}File Browser - Flask Files{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
/* Additional page-specific styles if needed */
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="file-browser">
|
||||
<div class="container">
|
||||
<div class="browser-container">
|
||||
<div class="browser-header">
|
||||
<h2>File Browser</h2>
|
||||
<!-- <div class="browser-actions"> -->
|
||||
<!-- <a href="{{ url_for('files.upload', folder=current_folder.id if current_folder else None) }}"
|
||||
<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">
|
||||
<div class="search-container">
|
||||
<form action="{{ url_for('files.browser') }}" method="get">
|
||||
<input type="text" name="q" placeholder="Search files..."
|
||||
value="{{ request.args.get('q', '') }}">
|
||||
<button type="submit" class="search-btn">
|
||||
<i class="fas fa-search"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<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>
|
||||
<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-cloud-upload-alt"></i> Upload
|
||||
</a> -->
|
||||
<!-- <button class="btn" id="new-folder-btn">
|
||||
<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> -->
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="path-nav">
|
||||
<a href="{{ url_for('files.browser') }}" class="path-item">
|
||||
<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="path-separator">/</span>
|
||||
<a href="{{ url_for('files.browser', folder=folder.id) }}" class="path-item">{{ folder.name }}</a>
|
||||
<span class="breadcrumb-separator">/</span>
|
||||
<a href="{{ url_for('files.browser', folder_id=folder.id) }}" class="breadcrumb-item">
|
||||
{{ folder.name }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if folders or files %}
|
||||
<div class="files-container">
|
||||
{% if folders %}
|
||||
<div class="folder-section">
|
||||
<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 id="files-container" class="files-container grid-view">
|
||||
{% include 'files/partials/folder_contents.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if files %}
|
||||
<div class="file-section">
|
||||
<h3>Files</h3>
|
||||
<div class="files-list">
|
||||
{% for file in files %}
|
||||
<div class="file-item">
|
||||
<div class="file-link">
|
||||
<div class="file-icon">
|
||||
{% if file.name.endswith('.pdf') %}<i class="fas fa-file-pdf fa-2x"></i>
|
||||
{% elif file.name.endswith(('.jpg', '.jpeg', '.png', '.gif')) %}<i
|
||||
class="fas fa-file-image fa-2x"></i>
|
||||
{% elif file.name.endswith(('.mp3', '.wav', '.flac')) %}<i
|
||||
class="fas fa-file-audio fa-2x"></i>
|
||||
{% 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>
|
||||
{% elif file.name.endswith(('.xls', '.xlsx')) %}<i class="fas fa-file-excel fa-2x"></i>
|
||||
{% elif file.name.endswith(('.ppt', '.pptx')) %}<i class="fas fa-file-powerpoint fa-2x"></i>
|
||||
{% elif file.name.endswith('.zip') %}<i class="fas fa-file-archive fa-2x"></i>
|
||||
{% else %}<i class="fas fa-file fa-2x"></i>{% endif %}
|
||||
<!-- 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="file-details">
|
||||
<div class="file-name">{{ file.name }}</div>
|
||||
<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>
|
||||
<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 placeholder="Enter folder name">
|
||||
<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 modal-cancel">Cancel</button>
|
||||
<button type="submit" class="btn primary">Create Folder</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-actions">
|
||||
<a href="#" class="action-btn download" data-id="{{ file.id }}" title="Download">
|
||||
<i class="fas fa-download"></i>
|
||||
</a>
|
||||
<a href="#" class="action-btn share" data-id="{{ file.id }}" title="Share">
|
||||
<i class="fas fa-share-alt"></i>
|
||||
</a>
|
||||
<button class="action-btn rename" data-id="{{ file.id }}" title="Rename">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button class="action-btn delete" data-id="{{ file.id }}" title="Delete">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
|
||||
<!-- Context Menu -->
|
||||
<div id="context-menu" class="context-menu">
|
||||
<div class="context-menu-item" data-action="open" data-for="folder">
|
||||
<i class="fas fa-folder-open"></i> Open
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="download" data-for="file">
|
||||
<i class="fas fa-download"></i> Download
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="share" data-for="file">
|
||||
<i class="fas fa-share-alt"></i> Share
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="rename" data-for="all">
|
||||
<i class="fas fa-pencil-alt"></i> Rename
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="delete" data-for="all">
|
||||
<i class="fas fa-trash"></i> Delete
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<i class="fas fa-folder-open fa-3x"></i>
|
||||
</div>
|
||||
<p>This folder is empty</p>
|
||||
<p>Upload files or create a new folder to get started</p>
|
||||
<div class="empty-actions">
|
||||
<a href="{{ url_for('files.upload', folder=current_folder.id if current_folder else None) }}"
|
||||
class="btn primary">
|
||||
<i class=" fas fa-cloud-upload-alt"></i> Upload
|
||||
</a>
|
||||
<button class="btn" id="empty-new-folder-btn">
|
||||
<i class="fas fa-folder-plus"></i> New Folder
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{{ url_for('static', filename='js/upload.js') }}"></script>
|
||||
<script>
|
||||
// Add folder entrance animation
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// New folder functionality
|
||||
const newFolderBtn = document.getElementById('new-folder-btn');
|
||||
const emptyNewFolderBtn = document.getElementById('empty-new-folder-btn');
|
||||
|
||||
function showNewFolderPrompt() {
|
||||
const folderName = prompt('Enter folder name:');
|
||||
if (folderName) {
|
||||
createNewFolder(folderName);
|
||||
const filesContainer = document.getElementById('files-container');
|
||||
if (filesContainer) {
|
||||
filesContainer.classList.add('folder-enter-active');
|
||||
}
|
||||
}
|
||||
|
||||
function createNewFolder(name) {
|
||||
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) {
|
||||
newFolderBtn.addEventListener('click', showNewFolderPrompt);
|
||||
}
|
||||
|
||||
if (emptyNewFolderBtn) {
|
||||
emptyNewFolderBtn.addEventListener('click', showNewFolderPrompt);
|
||||
}
|
||||
|
||||
// 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, "'");
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
|
@ -1,5 +1,5 @@
|
|||
{% if folders or files %}
|
||||
<div class="files-grid grid-view">
|
||||
<div class="files-grid">
|
||||
{% if folders %}
|
||||
{% for folder in folders %}
|
||||
<a href="{{ url_for('files.browser', folder_id=folder.id) }}" class="folder-item" data-id="{{ folder.id }}">
|
||||
|
@ -9,10 +9,19 @@
|
|||
<div class="item-info">
|
||||
<div class="item-name">{{ folder.name }}</div>
|
||||
<div class="item-details">
|
||||
<span class="item-count">{{ folder.files.count() }} items</span>
|
||||
<span class="item-date">{{ folder.created_at.strftime('%Y-%m-%d') }}</span>
|
||||
<span class="item-count">{{ folder.children.count() if folder.children is defined else
|
||||
folder.files.count() }} items</span>
|
||||
<span class="item-date">{{ folder.updated_at.strftime('%Y-%m-%d') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-actions">
|
||||
<button class="action-btn edit" title="Rename">
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
</button>
|
||||
<button class="action-btn delete" title="Delete">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
@ -21,15 +30,26 @@
|
|||
{% for file in files %}
|
||||
<a href="{{ url_for('files.download', file_id=file.id) }}" class="file-item" data-id="{{ file.id }}">
|
||||
<div class="item-icon">
|
||||
<i class="fas {{ file_icon(file.mime_type, file.name) }}"></i>
|
||||
<i class="fas {{ file.icon_class }}"></i>
|
||||
</div>
|
||||
<div class="item-info">
|
||||
<div class="item-name">{{ file.name }}</div>
|
||||
<div class="item-details">
|
||||
<span class="item-size">{{ format_size(file.size) }}</span>
|
||||
<span class="item-date">{{ file.created_at.strftime('%Y-%m-%d') }}</span>
|
||||
<span class="item-size">{{ file.size|filesizeformat }}</span>
|
||||
<span class="item-date">{{ file.updated_at.strftime('%Y-%m-%d') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-actions">
|
||||
<button class="action-btn download" title="Download">
|
||||
<i class="fas fa-download"></i>
|
||||
</button>
|
||||
<button class="action-btn share" title="Share">
|
||||
<i class="fas fa-share-alt"></i>
|
||||
</button>
|
||||
<button class="action-btn delete" title="Delete">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
@ -43,5 +63,14 @@
|
|||
<h3>This folder is empty</h3>
|
||||
<p>Upload files or create a folder to get started</p>
|
||||
</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>
|
||||
{% endif %}
|
|
@ -3,562 +3,222 @@
|
|||
{% block title %}Upload Files - Flask Files{% endblock %}
|
||||
|
||||
{% 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 %}
|
||||
|
||||
{% block content %}
|
||||
<section class="upload-container">
|
||||
<div class="container">
|
||||
<div class="upload-container">
|
||||
<div class="upload-header">
|
||||
<h2>Upload Files</h2>
|
||||
<div class="upload-location">
|
||||
<span>Uploading to:</span>
|
||||
{% if parent_folder %}
|
||||
<a href="{{ url_for('files.browser', folder_id=parent_folder.id) }}">{{ parent_folder.name }}</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('files.browser') }}">Root</a>
|
||||
{% endif %}
|
||||
<div>
|
||||
<a href="{{ url_for('files.browser', folder_id=parent_folder.id if parent_folder else None) }}"
|
||||
class="btn secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back to Files
|
||||
</a>
|
||||
</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 class="upload-tabs">
|
||||
<button class="upload-tab active" data-tab="file-upload-tab">
|
||||
<i class="fas fa-file-upload"></i> File Upload
|
||||
</button>
|
||||
<button class="upload-tab" data-tab="folder-upload-tab">
|
||||
<i class="fas fa-folder-plus"></i> Folder Upload
|
||||
</button>
|
||||
</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 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="parent_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 class="upload-progress">
|
||||
<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>
|
||||
|
||||
<div class="upload-stats">
|
||||
<div class="stat-item">
|
||||
<i class="fas fa-tachometer-alt"></i>
|
||||
<span id="upload-speed">0 KB/s</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<i class="fas fa-file-upload"></i>
|
||||
<span id="uploaded-size">0 KB / 0 KB</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<i class="fas fa-clock"></i>
|
||||
<span id="time-remaining">calculating...</span>
|
||||
</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 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 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 id="file-list" class="file-list"></div>
|
||||
</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="parent_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 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>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_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>
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/upload.js') }}"></script>
|
||||
{% endblock %}
|
59
app/utils/reset_db.py
Normal file
59
app/utils/reset_db.py
Normal file
|
@ -0,0 +1,59 @@
|
|||
"""
|
||||
Database reset utility - USE WITH CAUTION
|
||||
This will delete and recreate your entire database
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from flask import Flask
|
||||
from .. import create_app, db
|
||||
from ..models import User, Folder, File, Download
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def reset_database():
|
||||
"""Reset the entire database and recreate tables"""
|
||||
app = create_app()
|
||||
|
||||
with app.app_context():
|
||||
try:
|
||||
# Drop all tables
|
||||
logger.warning("Dropping all database tables...")
|
||||
db.drop_all()
|
||||
logger.info("All tables dropped successfully")
|
||||
|
||||
# Recreate tables based on models
|
||||
logger.info("Recreating database tables...")
|
||||
db.create_all()
|
||||
logger.info("Database tables created successfully")
|
||||
|
||||
# Create admin user if needed
|
||||
if User.query.filter_by(username='admin').first() is None:
|
||||
admin = User(username='admin', email='admin@example.com', is_admin=True)
|
||||
admin.password = 'adminpassword' # Set to a secure password in production
|
||||
db.session.add(admin)
|
||||
db.session.commit()
|
||||
logger.info("Created admin user")
|
||||
|
||||
# Clear uploads folder
|
||||
uploads_folder = app.config['UPLOAD_FOLDER']
|
||||
if os.path.exists(uploads_folder):
|
||||
logger.info(f"Clearing uploads folder: {uploads_folder}")
|
||||
for file in os.listdir(uploads_folder):
|
||||
file_path = os.path.join(uploads_folder, file)
|
||||
try:
|
||||
if os.path.isfile(file_path):
|
||||
os.unlink(file_path)
|
||||
elif os.path.isdir(file_path):
|
||||
import shutil
|
||||
shutil.rmtree(file_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing {file_path}: {e}")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error resetting database: {e}")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
reset_database()
|
161
reset_app.py
Normal file
161
reset_app.py
Normal file
|
@ -0,0 +1,161 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Complete application reset script
|
||||
Removes database, clears cache files, and reinitializes everything
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
import logging
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger("app-reset")
|
||||
|
||||
# Application paths
|
||||
DB_FILES = ["app.db", "instance/app.db"]
|
||||
UPLOADS_FOLDER = "uploads"
|
||||
PYCACHE_PATTERN = "**/__pycache__"
|
||||
|
||||
def confirm_reset():
|
||||
"""Get user confirmation before proceeding"""
|
||||
print("\n⚠️ WARNING: This will completely reset the application ⚠️")
|
||||
print("📢 All data will be lost, including:")
|
||||
print(" - Database and all its tables")
|
||||
print(" - All uploaded files")
|
||||
print(" - Cache files and temporary data")
|
||||
print("\nAre you absolutely sure you want to continue?")
|
||||
return input("Type 'yes' to confirm: ").lower() == 'yes'
|
||||
|
||||
def remove_database_files():
|
||||
"""Remove all database files"""
|
||||
success = True
|
||||
for db_file in DB_FILES:
|
||||
if os.path.exists(db_file):
|
||||
try:
|
||||
logger.info(f"Removing database file: {db_file}")
|
||||
os.remove(db_file)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to remove {db_file}: {e}")
|
||||
success = False
|
||||
return success
|
||||
|
||||
def clear_uploads_directory():
|
||||
"""Clear all uploaded files but keep the directory"""
|
||||
if not os.path.exists(UPLOADS_FOLDER):
|
||||
logger.info(f"Creating uploads folder: {UPLOADS_FOLDER}")
|
||||
os.makedirs(UPLOADS_FOLDER, exist_ok=True)
|
||||
return True
|
||||
|
||||
try:
|
||||
logger.info(f"Clearing uploads folder: {UPLOADS_FOLDER}")
|
||||
for item in os.listdir(UPLOADS_FOLDER):
|
||||
path = os.path.join(UPLOADS_FOLDER, item)
|
||||
if os.path.isfile(path):
|
||||
os.remove(path)
|
||||
elif os.path.isdir(path):
|
||||
shutil.rmtree(path)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to clear uploads directory: {e}")
|
||||
return False
|
||||
|
||||
def clear_pycache_files():
|
||||
"""Clear all __pycache__ directories"""
|
||||
try:
|
||||
logger.info("Clearing Python cache files...")
|
||||
count = 0
|
||||
for cache_dir in Path('.').glob(PYCACHE_PATTERN):
|
||||
if cache_dir.is_dir():
|
||||
shutil.rmtree(cache_dir)
|
||||
count += 1
|
||||
|
||||
logger.info(f"Removed {count} __pycache__ directories")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to clear cache files: {e}")
|
||||
return False
|
||||
|
||||
def initialize_application(create_admin=False):
|
||||
"""Initialize the application with fresh database"""
|
||||
try:
|
||||
logger.info("Initializing Flask application...")
|
||||
# Force reload the app module
|
||||
if 'app' in sys.modules:
|
||||
importlib.reload(sys.modules['app'])
|
||||
|
||||
# Import and create app
|
||||
from app import create_app, db
|
||||
app = create_app()
|
||||
|
||||
with app.app_context():
|
||||
logger.info("Creating database tables...")
|
||||
db.create_all()
|
||||
|
||||
# Create admin user if requested
|
||||
if create_admin:
|
||||
from app.models import User, Share
|
||||
if User.query.filter_by(username='admin').first() is None:
|
||||
admin = User(username='admin', email='admin@example.com', is_admin=True)
|
||||
admin.password = 'adminpassword'
|
||||
db.session.add(admin)
|
||||
db.session.commit()
|
||||
logger.info("Created admin user (username: admin, password: adminpassword)")
|
||||
else:
|
||||
logger.info("Admin user already exists")
|
||||
else:
|
||||
logger.info("Skipping admin user creation as requested")
|
||||
|
||||
logger.info("Application initialized successfully")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize application: {e}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
"""Main reset procedure"""
|
||||
print("\n🔄 Flask Files Application Reset Tool 🔄\n")
|
||||
|
||||
if not confirm_reset():
|
||||
print("\n❌ Reset cancelled by user")
|
||||
return False
|
||||
|
||||
create_admin = input("\nCreate admin user? (yes/no): ").lower() == 'yes'
|
||||
|
||||
print("\n🚀 Starting reset process...\n")
|
||||
|
||||
# Step 1: Remove database files
|
||||
if remove_database_files():
|
||||
logger.info("✅ Database files removed successfully")
|
||||
else:
|
||||
logger.error("❌ Failed to remove some database files")
|
||||
|
||||
# Step 2: Clear uploads directory
|
||||
if clear_uploads_directory():
|
||||
logger.info("✅ Uploads directory cleared successfully")
|
||||
else:
|
||||
logger.error("❌ Failed to clear uploads directory")
|
||||
|
||||
# Step 3: Clear cache files
|
||||
if clear_pycache_files():
|
||||
logger.info("✅ Python cache files cleared successfully")
|
||||
else:
|
||||
logger.error("❌ Failed to clear cache files")
|
||||
|
||||
# Step 4: Initialize application
|
||||
if initialize_application(create_admin):
|
||||
logger.info("✅ Application initialized successfully")
|
||||
else:
|
||||
logger.error("❌ Failed to initialize application")
|
||||
return False
|
||||
|
||||
print("\n✨ Reset completed successfully! ✨")
|
||||
print("\nYou can now start the application with 'python run.py'")
|
||||
if create_admin:
|
||||
print("Default admin credentials: username=admin, password=adminpassword")
|
||||
return True
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(0 if main() else 1)
|
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