kinda working safe point

This commit is contained in:
pika 2025-03-23 03:29:05 +01:00
parent b9a82af12f
commit 6dda02141e
31 changed files with 4302 additions and 2937 deletions

View file

@ -0,0 +1,9 @@
---
description:
globs:
---
# Your rule content
- You can @ files here
- You can use markdown but dont have to

View file

@ -1,4 +1,4 @@
from flask import Flask, current_app from flask import Flask, current_app, render_template
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager from flask_login import LoginManager
from config import Config from config import Config
@ -7,6 +7,13 @@ from datetime import datetime
import sqlite3 import sqlite3
import logging import logging
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Initialize extensions # Initialize extensions
db = SQLAlchemy() db = SQLAlchemy()
login_manager = LoginManager() login_manager = LoginManager()
@ -18,37 +25,57 @@ def initialize_database(app):
with app.app_context(): with app.app_context():
app.logger.info("Initializing database...") app.logger.info("Initializing database...")
try: try:
# Create all tables (this is safe to call even if tables exist) # Check if tables exist before creating them
db.create_all() from sqlalchemy import inspect
inspector = inspect(db.engine)
existing_tables = inspector.get_table_names()
# Check if we need to add the Share and Download models # Only create tables that don't exist
inspector = db.inspect(db.engine) if not existing_tables:
if not inspector.has_table('share'): app.logger.info("Creating database tables...")
app.logger.info("Creating Share table...") db.create_all()
# Import models to ensure they're registered with SQLAlchemy
from app.models import Share # Check if we need to add the Share and Download models
inspector = db.inspect(db.engine)
if not inspector.has_table('share'): if not inspector.has_table('share'):
# Create the Share table app.logger.info("Creating Share table...")
Share.__table__.create(db.engine) # 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'): if not inspector.has_table('download'):
# Create the Download table app.logger.info("Creating Download table...")
Download.__table__.create(db.engine) 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 # Check for existing users - create admin if none
from app.models import User from app.models import User
if User.query.count() == 0: if User.query.count() == 0:
app.logger.info("No users found, creating default admin user...") app.logger.info("No users found, creating default admin user...")
admin = User(username='admin', email='admin@example.com') admin = User(username='admin', email='admin@example.com')
admin.set_password('adminpassword') admin.set_password('adminpassword')
db.session.add(admin) db.session.add(admin)
db.session.commit() db.session.commit()
app.logger.info("Default admin user created") 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()
app.logger.info("Database initialization complete")
except Exception as e: except Exception as e:
app.logger.error(f"Error initializing database: {str(e)}") app.logger.error(f"Error initializing database: {str(e)}")
# Don't raise the exception to prevent app startup failure # Don't raise the exception to prevent app startup failure
@ -160,7 +187,17 @@ def format_file_size(size):
def create_app(config_class=Config): def create_app(config_class=Config):
app = Flask(__name__) app = Flask(__name__)
app.config.from_object(config_class)
# 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 # Configure logging
if not app.debug: if not app.debug:
@ -198,13 +235,15 @@ def create_app(config_class=Config):
run_migrations(app) run_migrations(app)
# Register blueprints # Register blueprints
from app.routes.auth import auth_bp from app.routes.auth import bp as auth_bp
from app.routes.files import files_bp from app.routes.files import bp as files_bp
from app.routes.dashboard import dashboard_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(auth_bp, url_prefix='/auth')
app.register_blueprint(files_bp) app.register_blueprint(files_bp, url_prefix='/files')
app.register_blueprint(dashboard_bp) app.register_blueprint(dashboard_bp)
app.register_blueprint(admin_bp, url_prefix='/admin')
# Add context processor for template variables # Add context processor for template variables
@app.context_processor @app.context_processor
@ -227,8 +266,8 @@ def create_app(config_class=Config):
db.session.rollback() # Rollback any failed database transactions db.session.rollback() # Rollback any failed database transactions
return render_template('errors/500.html'), 500 return render_template('errors/500.html'), 500
logger.info("Flask Files startup")
return app return app
# Import must come after create_app to avoid circular imports # Import must come after create_app to avoid circular imports
from flask import render_template # For error handlers
from app import models from app import models

View 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.")

View 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()

View 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

View file

@ -1,109 +1,181 @@
from datetime import datetime from datetime import datetime
import logging
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
from flask_login import UserMixin from flask_login import UserMixin
from app import db, login_manager from app import db, login_manager
import uuid import uuid
import os
import mimetypes
# Add debug logging
logger = logging.getLogger(__name__)
logger.info("Loading models.py module")
@login_manager.user_loader @login_manager.user_loader
def load_user(id): def load_user(id):
return User.query.get(int(id)) return User.query.get(int(id))
class User(UserMixin, db.Model): class User(UserMixin, db.Model):
__tablename__ = 'user' __tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), index=True, unique=True) username = db.Column(db.String(64), unique=True, index=True)
email = db.Column(db.String(120), index=True, unique=True) email = db.Column(db.String(120), unique=True, index=True)
password_hash = db.Column(db.String(128)) password_hash = db.Column(db.String(128))
created_at = db.Column(db.DateTime, default=datetime.utcnow) 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 # Define relationships without circular backrefs
files = db.relationship('File', backref='owner', lazy='dynamic', folders = db.relationship('Folder', foreign_keys='Folder.user_id',
foreign_keys='File.user_id', cascade='all, delete-orphan') backref=db.backref('owner', lazy='joined'),
shares = db.relationship('Share', backref='owner', lazy='dynamic', lazy='dynamic', cascade="all, delete-orphan")
foreign_keys='Share.user_id', cascade='all, delete-orphan')
downloads = db.relationship('Download', backref='user', lazy='dynamic', files = db.relationship('File', foreign_keys='File.user_id',
foreign_keys='Download.user_id', cascade='all, delete-orphan') 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): def __repr__(self):
return f'<User {self.username}>' return f'<User {self.username}>'
def set_password(self, password): # Log that the User class is defined
self.password_hash = generate_password_hash(password) logger.info("User class defined with set_password method")
def check_password(self, password): class Folder(db.Model):
return check_password_hash(self.password_hash, password) __tablename__ = 'folders'
class File(db.Model):
__tablename__ = 'file'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), nullable=False) name = db.Column(db.String(255), nullable=False)
storage_name = db.Column(db.String(255)) # Used for storing files with unique names user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
is_folder = db.Column(db.Boolean, default=False) parent_id = db.Column(db.Integer, db.ForeignKey('folders.id'))
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)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships # Define relationship to parent folder with proper backref
children = db.relationship('File', backref=db.backref('parent', remote_side=[id]), parent = db.relationship('Folder', remote_side=[id],
lazy='dynamic', cascade='all, delete-orphan') backref=db.backref('children', lazy='dynamic'))
shares = db.relationship('Share', backref='file', lazy='dynamic', cascade='all, delete-orphan')
downloads = db.relationship('Download', backref='file', lazy='dynamic', cascade='all, delete-orphan') # 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): def __repr__(self):
return f'<File {self.name} {"(Folder)" if self.is_folder else ""}>' return f'<Folder {self.name}>'
def generate_storage_name(self): class File(db.Model):
"""Generate a unique name for file storage to prevent conflicts""" __tablename__ = 'files'
if self.is_folder:
return None
# Generate a unique filename using UUID id = db.Column(db.Integer, primary_key=True)
ext = self.name.rsplit('.', 1)[1].lower() if '.' in self.name else '' name = db.Column(db.String(255), nullable=False)
return f"{uuid.uuid4().hex}.{ext}" if ext else f"{uuid.uuid4().hex}" 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): class Share(db.Model):
__tablename__ = 'share' __tablename__ = 'shares'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
file_id = db.Column(db.Integer, db.ForeignKey('file.id', ondelete='CASCADE'), nullable=False) file_id = db.Column(db.Integer, db.ForeignKey('files.id'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
share_token = db.Column(db.String(64), unique=True) access_key = db.Column(db.String(64), unique=True, index=True, nullable=False)
is_public = db.Column(db.Boolean, default=False) expires_at = db.Column(db.DateTime, nullable=True)
is_password_protected = db.Column(db.Boolean, default=False)
password_hash = db.Column(db.String(128))
expiry_date = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
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): def __repr__(self):
return f'<Share {self.share_token}>' return f'<Share {self.access_key}>'
def set_password(self, password):
self.password_hash = generate_password_hash(password)
self.is_password_protected = True
def check_password(self, password):
return check_password_hash(self.password_hash, password)
def generate_token(self):
"""Generate a unique token for sharing"""
return uuid.uuid4().hex
class Download(db.Model): class Download(db.Model):
__tablename__ = 'download' __tablename__ = 'downloads'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
file_id = db.Column(db.Integer, db.ForeignKey('file.id', ondelete='CASCADE'), nullable=False) file_id = db.Column(db.Integer, db.ForeignKey('files.id'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True) # Nullable for anonymous downloads user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
ip_address = db.Column(db.String(64)) download_date = db.Column(db.DateTime, default=datetime.utcnow)
user_agent = db.Column(db.String(255))
share_id = db.Column(db.Integer, db.ForeignKey('share.id', ondelete='SET NULL'), nullable=True) # Define relationship to file
downloaded_at = db.Column(db.DateTime, default=datetime.utcnow) file = db.relationship('File', foreign_keys=[file_id],
backref=db.backref('file_downloads', lazy='dynamic'))
def __repr__(self): def __repr__(self):
return f'<Download of File {self.file_id} at {self.downloaded_at}>' return f'<Download {self.id} by {self.user_id}>'

47
app/routes/admin.py Normal file
View 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

View file

@ -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 flask_login import login_user, logout_user, login_required, current_user
from urllib.parse import urlparse from urllib.parse import urlparse
from app import db from app import db
@ -8,6 +8,13 @@ from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Length, EqualTo, ValidationError from wtforms.validators import DataRequired, Length, EqualTo, ValidationError
from werkzeug.exceptions import BadRequest 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 # Login form
class LoginForm(FlaskForm): class LoginForm(FlaskForm):
@ -28,51 +35,77 @@ class RegistrationForm(FlaskForm):
if user is not None: if user is not None:
raise ValidationError('Please use a different username.') raise ValidationError('Please use a different username.')
@auth_bp.route('/login', methods=['GET', 'POST']) @bp.route('/login', methods=['GET', 'POST'])
def login(): def login():
"""User login page"""
if current_user.is_authenticated: if current_user.is_authenticated:
return redirect(url_for('dashboard.index')) return redirect(url_for('dashboard.index'))
form = LoginForm() form = LoginForm()
if form.validate_on_submit(): if form.validate_on_submit():
user = User.query.filter_by(username=form.username.data).first() user = User.query.filter_by(username=form.username.data).first()
if user is None or not user.verify_password(form.password.data):
if user is None or not user.check_password(form.password.data):
flash('Invalid username or password', 'error') flash('Invalid username or password', 'error')
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))
login_user(user, remember=form.remember_me.data) login_user(user, remember=form.remember_me.data)
# Redirect to requested page or dashboard
next_page = request.args.get('next') next_page = request.args.get('next')
if not next_page or urlparse(next_page).netloc != '': if not next_page or urlparse(next_page).netloc != '':
next_page = url_for('dashboard.index') next_page = url_for('dashboard.index')
flash('Login successful!', 'success')
return redirect(next_page) 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 @login_required
def logout(): def logout():
"""User logout"""
logout_user() logout_user()
flash('You have been logged out', 'info') flash('You have been logged out.', 'info')
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))
@auth_bp.route('/register', methods=['GET', 'POST']) @bp.route('/register', methods=['GET', 'POST'])
def register(): def register():
"""User registration page"""
if current_user.is_authenticated: if current_user.is_authenticated:
return redirect(url_for('dashboard.index')) return redirect(url_for('dashboard.index'))
form = RegistrationForm() form = RegistrationForm()
if form.validate_on_submit(): if form.validate_on_submit():
user = User(username=form.username.data) # Create new user
user.set_password(form.password.data) 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.add(user)
db.session.commit() db.session.commit()
logger.info(f"User {user.username} registered successfully")
flash('Registration successful! You can now log in.', 'success') flash('Registration successful! You can now log in.', 'success')
return redirect(url_for('auth.login')) 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']) @auth_bp.route('/update_profile', methods=['POST'])
@login_required @login_required
@ -136,12 +169,8 @@ def update_preferences():
flash('Preferences updated successfully', 'success') flash('Preferences updated successfully', 'success')
return redirect(url_for('auth.profile')) return redirect(url_for('auth.profile'))
@auth_bp.route('/profile') @bp.route('/profile')
@login_required @login_required
def profile(): def profile():
# Get theme preference from session or default to system """User profile page"""
theme_preference = session.get('theme_preference', 'system') return render_template('auth/profile.html', user=current_user)
return render_template('auth/profile.html',
title='User Profile',
theme_preference=theme_preference)

View file

@ -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 flask_login import login_required, current_user
from datetime import datetime, timedelta from ..models import File, Folder
from app.models import File, Share, Download
import os 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 @login_required
def index(): def index():
# Get some stats for the dashboard """Dashboard index page"""
total_files = File.query.filter_by(user_id=current_user.id, is_folder=False).count() # Count user's files and folders
total_folders = File.query.filter_by(user_id=current_user.id, is_folder=True).count() 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 # Get storage usage
recent_files = File.query.filter_by(user_id=current_user.id, is_folder=False)\ storage_used = sum(file.size for file in File.query.filter_by(user_id=current_user.id).all())
.order_by(File.updated_at.desc())\
.limit(8).all()
# Root folders for quick navigation # Format size for display
root_folders = File.query.filter_by(user_id=current_user.id, is_folder=True, parent_id=None)\ if storage_used < 1024:
.order_by(File.name)\ storage_used_formatted = f"{storage_used} bytes"
.limit(8).all() 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) # Get recent files
active_shares = 0 recent_files = File.query.filter_by(user_id=current_user.id).order_by(File.created_at.desc()).limit(5).all()
recent_activities = 0
# Check if Share and Download models exist/are imported # Create stats object that the template is expecting
try: stats = {
# Count active shares 'file_count': file_count,
active_shares = Share.query.filter_by(user_id=current_user.id).filter( 'folder_count': folder_count,
(Share.expires_at > datetime.now()) | (Share.expires_at.is_(None)) 'storage_used': storage_used_formatted
).count() }
# Recent activities count (downloads, shares, etc.) return render_template('dashboard/index.html',
recent_activities = Download.query.join(Share)\ stats=stats, # Pass as stats object
.filter(Share.user_id == current_user.id)\ recent_files=recent_files)
.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)
def get_file_icon(mime_type, filename): def get_file_icon(mime_type, filename):
"""Return Font Awesome icon class based on file type""" """Return Font Awesome icon class based on file type"""

View file

@ -2,7 +2,7 @@ from flask import Blueprint, render_template, redirect, url_for, flash, request,
from flask_login import login_required, current_user from flask_login import login_required, current_user
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from app import db from app import db
from app.models import File, Share from app.models import File, Share, Folder
from config import Config from config import Config
import os import os
from datetime import datetime, timedelta from datetime import datetime, timedelta
@ -10,12 +10,13 @@ import uuid
import mimetypes import mimetypes
import shutil import shutil
import json import json
import time
files_bp = Blueprint('files', __name__, url_prefix='/files') bp = Blueprint('files', __name__, url_prefix='/files')
@files_bp.route('/') @bp.route('/')
@files_bp.route('/browser') @bp.route('/browser')
@files_bp.route('/browser/<int:folder_id>') @bp.route('/browser/<int:folder_id>')
@login_required @login_required
def browser(folder_id=None): def browser(folder_id=None):
"""Display file browser interface""" """Display file browser interface"""
@ -23,7 +24,7 @@ def browser(folder_id=None):
breadcrumbs = [] breadcrumbs = []
if folder_id: if folder_id:
current_folder = File.query.filter_by(id=folder_id, user_id=current_user.id, is_folder=True).first_or_404() current_folder = Folder.query.filter_by(id=folder_id, user_id=current_user.id).first_or_404()
# Generate breadcrumbs # Generate breadcrumbs
breadcrumbs = [] breadcrumbs = []
@ -36,11 +37,18 @@ def browser(folder_id=None):
# For initial load - only get folders and files if it's not an AJAX request # For initial load - only get folders and files if it's not an AJAX request
if not request.headers.get('X-Requested-With') == 'XMLHttpRequest': if not request.headers.get('X-Requested-With') == 'XMLHttpRequest':
if current_folder: if current_folder:
folders = File.query.filter_by(parent_id=current_folder.id, user_id=current_user.id, is_folder=True).all() folders = Folder.query.filter_by(parent_id=current_folder.id, user_id=current_user.id).all()
files = File.query.filter_by(parent_id=current_folder.id, user_id=current_user.id, is_folder=False).all() files = File.query.filter_by(folder_id=current_folder.id, user_id=current_user.id).all()
else: else:
folders = File.query.filter_by(parent_id=None, user_id=current_user.id, is_folder=True).all() folders = Folder.query.filter_by(parent_id=None, user_id=current_user.id).all()
files = File.query.filter_by(parent_id=None, user_id=current_user.id, is_folder=False).all() files = File.query.filter_by(folder_id=None, user_id=current_user.id).all()
# Get the search query if provided
query = request.args.get('q', '')
if query:
# Filter folders and files by name containing the query
folders = [f for f in folders if query.lower() in f.name.lower()]
files = [f for f in files if query.lower() in f.name.lower()]
return render_template('files/browser.html', return render_template('files/browser.html',
current_folder=current_folder, current_folder=current_folder,
@ -48,10 +56,11 @@ def browser(folder_id=None):
folders=folders, folders=folders,
files=files) files=files)
else: else:
# If it's an AJAX request, return JSON # For AJAX request, return just the folder contents
return jsonify({'error': 'Use the /contents endpoint for AJAX requests'}) # Implement this if needed
pass
@files_bp.route('/contents') @bp.route('/contents')
@login_required @login_required
def folder_contents(): def folder_contents():
"""Returns the HTML for folder contents (used for AJAX loading)""" """Returns the HTML for folder contents (used for AJAX loading)"""
@ -63,31 +72,31 @@ def folder_contents():
# Get the current folder if a folder_id is provided # Get the current folder if a folder_id is provided
current_folder = None current_folder = None
if folder_id: if folder_id:
current_folder = File.query.filter_by(id=folder_id, user_id=current_user.id, is_folder=True).first_or_404() current_folder = Folder.query.filter_by(id=folder_id, user_id=current_user.id).first_or_404()
# Base query for folders and files # Base query for folders and files
folders_query = File.query.filter_by(user_id=current_user.id, is_folder=True) folders_query = Folder.query.filter_by(user_id=current_user.id)
files_query = File.query.filter_by(user_id=current_user.id, is_folder=False) files_query = File.query.filter_by(user_id=current_user.id)
# Filter by parent folder # Filter by parent folder
if current_folder: if current_folder:
folders_query = folders_query.filter_by(parent_id=current_folder.id) folders_query = folders_query.filter_by(parent_id=current_folder.id)
files_query = files_query.filter_by(parent_id=current_folder.id) files_query = files_query.filter_by(folder_id=current_folder.id)
else: else:
folders_query = folders_query.filter_by(parent_id=None) folders_query = folders_query.filter_by(parent_id=None)
files_query = files_query.filter_by(parent_id=None) files_query = files_query.filter_by(folder_id=None)
# Apply search if provided # Apply search if provided
if search_query: if search_query:
folders_query = folders_query.filter(File.name.ilike(f'%{search_query}%')) folders_query = folders_query.filter(Folder.name.ilike(f'%{search_query}%'))
files_query = files_query.filter(File.name.ilike(f'%{search_query}%')) files_query = files_query.filter(File.name.ilike(f'%{search_query}%'))
# Apply sorting # Apply sorting
if sort_by == 'name': if sort_by == 'name':
folders_query = folders_query.order_by(File.name.asc() if sort_order == 'asc' else File.name.desc()) folders_query = folders_query.order_by(Folder.name.asc() if sort_order == 'asc' else Folder.name.desc())
files_query = files_query.order_by(File.name.asc() if sort_order == 'asc' else File.name.desc()) files_query = files_query.order_by(File.name.asc() if sort_order == 'asc' else File.name.desc())
elif sort_by == 'date': elif sort_by == 'date':
folders_query = folders_query.order_by(File.updated_at.desc() if sort_order == 'asc' else File.updated_at.asc()) folders_query = folders_query.order_by(Folder.updated_at.desc() if sort_order == 'asc' else Folder.updated_at.asc())
files_query = files_query.order_by(File.updated_at.desc() if sort_order == 'asc' else File.updated_at.asc()) files_query = files_query.order_by(File.updated_at.desc() if sort_order == 'asc' else File.updated_at.asc())
elif sort_by == 'size': elif sort_by == 'size':
# Folders always come first, then sort files by size # Folders always come first, then sort files by size
@ -107,7 +116,7 @@ def folder_contents():
'name': folder.name, 'name': folder.name,
'updated_at': folder.updated_at.isoformat(), 'updated_at': folder.updated_at.isoformat(),
'is_folder': True, 'is_folder': True,
'item_count': folder.children.count(), 'item_count': folder.files.count(),
'url': url_for('files.browser', folder_id=folder.id) 'url': url_for('files.browser', folder_id=folder.id)
} for folder in folders] } for folder in folders]
@ -116,10 +125,10 @@ def folder_contents():
'name': file.name, 'name': file.name,
'size': file.size, 'size': file.size,
'formatted_size': format_file_size(file.size), 'formatted_size': format_file_size(file.size),
'mime_type': file.mime_type, 'type': file.type,
'updated_at': file.updated_at.isoformat(), 'updated_at': file.updated_at.isoformat(),
'is_folder': False, 'is_folder': False,
'icon': get_file_icon(file.mime_type, file.name), 'icon': file.icon_class,
'url': url_for('files.download', file_id=file.id) 'url': url_for('files.download', file_id=file.id)
} for file in files] } for file in files]
@ -141,34 +150,53 @@ def folder_contents():
else: else:
return redirect(url_for('files.browser')) return redirect(url_for('files.browser'))
@files_bp.route('/upload', methods=['GET', 'POST']) @bp.route('/upload', methods=['GET', 'POST'])
@files_bp.route('/upload/<int:folder_id>', methods=['GET', 'POST']) @bp.route('/upload/<int:folder_id>', methods=['GET', 'POST'])
@login_required @login_required
def upload(folder_id=None): def upload(folder_id=None):
"""Page for uploading files""" """Page for uploading files"""
parent_folder = None
if folder_id:
parent_folder = File.query.filter_by(id=folder_id, user_id=current_user.id, is_folder=True).first_or_404()
if request.method == 'POST': if request.method == 'POST':
# Handle XHR upload # Handle file upload
if request.headers.get('X-Requested-With') == 'XMLHttpRequest': if 'file' not in request.files:
# Check if file was included if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
if 'file' not in request.files:
return jsonify({'error': 'No file part'}), 400 return jsonify({'error': 'No file part'}), 400
flash('No file part', 'error')
return redirect(request.url)
file = request.files['file'] file = request.files['file']
# Check if the file was actually selected # Validate filename
if file.filename == '': if file.filename == '':
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'error': 'No selected file'}), 400 return jsonify({'error': 'No selected file'}), 400
flash('No selected file', 'error')
return redirect(request.url)
# Validate and save file # Get the parent folder
parent_folder = None
if folder_id:
parent_folder = Folder.query.get_or_404(folder_id)
# Check if user has permission
if parent_folder.user_id != current_user.id:
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'error': 'You do not have permission to upload to this folder'}), 403
flash('You do not have permission to upload to this folder', 'error')
return redirect(url_for('files.browser'))
# Process the file
if file and allowed_file(file.filename):
filename = secure_filename(file.filename) filename = secure_filename(file.filename)
# Generate UUID for storage # Generate a unique filename
file_uuid = str(uuid.uuid4()) file_uuid = str(uuid.uuid4())
storage_path = os.path.join(Config.UPLOAD_FOLDER, file_uuid) _, file_extension = os.path.splitext(filename)
storage_name = f"{file_uuid}{file_extension}"
# Create storage path
upload_folder = current_app.config['UPLOAD_FOLDER']
user_folder = os.path.join(upload_folder, str(current_user.id))
os.makedirs(user_folder, exist_ok=True)
storage_path = os.path.join(user_folder, storage_name)
try: try:
# Save file to storage location # Save file to storage location
@ -183,12 +211,12 @@ def upload(folder_id=None):
# Create file record # Create file record
db_file = File( db_file = File(
name=filename, name=filename,
storage_name=file_uuid, original_name=filename,
mime_type=mime_type, path=storage_path,
size=file_size, size=file_size,
type=mime_type,
user_id=current_user.id, user_id=current_user.id,
parent_id=parent_folder.id if parent_folder else None, folder_id=parent_folder.id if parent_folder else None
is_folder=False
) )
db.session.add(db_file) db.session.add(db_file)
db.session.commit() db.session.commit()
@ -201,8 +229,8 @@ def upload(folder_id=None):
'name': db_file.name, 'name': db_file.name,
'size': db_file.size, 'size': db_file.size,
'formatted_size': format_file_size(db_file.size), 'formatted_size': format_file_size(db_file.size),
'mime_type': db_file.mime_type, 'type': db_file.type,
'icon': get_file_icon(db_file.mime_type, db_file.name) 'icon': db_file.icon_class
} }
}) })
@ -215,19 +243,15 @@ def upload(folder_id=None):
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
else: else:
# Regular form POST (non-XHR) - redirect to browser if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
flash('Please use the browser interface to upload files', 'info') return jsonify({'error': 'File type not allowed'}), 400
if parent_folder: flash('File type not allowed', 'error')
return redirect(url_for('files.browser', folder_id=parent_folder.id)) return redirect(request.url)
else:
return redirect(url_for('files.browser'))
# GET request - show upload page # GET request - show upload form
return render_template('files/upload.html', return render_template('files/upload.html', folder_id=folder_id)
parent_folder=parent_folder,
title="Upload Files")
@files_bp.route('/upload_folder', methods=['POST']) @bp.route('/upload_folder', methods=['POST'])
@login_required @login_required
def upload_folder(): def upload_folder():
"""Handle folder upload - this processes ZIP files uploaded as folders""" """Handle folder upload - this processes ZIP files uploaded as folders"""
@ -235,7 +259,7 @@ def upload_folder():
parent_folder = None parent_folder = None
if folder_id: if folder_id:
parent_folder = File.query.filter_by(id=folder_id, user_id=current_user.id, is_folder=True).first_or_404() parent_folder = Folder.query.filter_by(id=folder_id, user_id=current_user.id).first_or_404()
# Check if folder data was provided # Check if folder data was provided
if 'folder_data' not in request.form: if 'folder_data' not in request.form:
@ -246,13 +270,10 @@ def upload_folder():
folder_name = secure_filename(folder_data.get('name', 'Unnamed Folder')) folder_name = secure_filename(folder_data.get('name', 'Unnamed Folder'))
# Create folder record # Create folder record
folder = File( folder = Folder(
name=folder_name, name=folder_name,
is_folder=True,
user_id=current_user.id, user_id=current_user.id,
parent_id=parent_folder.id if parent_folder else None, parent_id=parent_folder.id if parent_folder else None
size=0,
mime_type=None
) )
db.session.add(folder) db.session.add(folder)
db.session.flush() # Get folder.id without committing db.session.flush() # Get folder.id without committing
@ -286,22 +307,18 @@ def upload_folder():
continue continue
# Check if folder already exists # Check if folder already exists
subfolder = File.query.filter_by( subfolder = Folder.query.filter_by(
name=part, name=part,
parent_id=current_parent_id, parent_id=current_parent_id,
user_id=current_user.id, user_id=current_user.id
is_folder=True
).first() ).first()
if not subfolder: if not subfolder:
# Create new subfolder # Create new subfolder
subfolder = File( subfolder = Folder(
name=part, name=part,
is_folder=True,
user_id=current_user.id, user_id=current_user.id,
parent_id=current_parent_id, parent_id=current_parent_id
size=0,
mime_type=None
) )
db.session.add(subfolder) db.session.add(subfolder)
db.session.flush() db.session.flush()
@ -330,12 +347,12 @@ def upload_folder():
# Create file record # Create file record
db_file = File( db_file = File(
name=filename, name=filename,
storage_name=file_uuid, original_name=filename,
mime_type=mime_type, path=storage_path,
size=file_size, size=file_size,
type=mime_type,
user_id=current_user.id, user_id=current_user.id,
parent_id=current_parent_id, folder_id=current_parent_id
is_folder=False
) )
db.session.add(db_file) db.session.add(db_file)
file_records.append(db_file) file_records.append(db_file)
@ -368,23 +385,23 @@ def upload_folder():
current_app.logger.error(f"Folder upload parsing error: {str(e)}") current_app.logger.error(f"Folder upload parsing error: {str(e)}")
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
@files_bp.route('/download/<int:file_id>') @bp.route('/download/<int:file_id>')
@login_required @login_required
def download(file_id): def download(file_id):
"""Download a file""" """Download a file"""
file = File.query.filter_by(id=file_id, user_id=current_user.id, is_folder=False).first_or_404() file = File.query.filter_by(id=file_id, user_id=current_user.id).first_or_404()
# Can't download folders directly # Can't download folders directly
if file.is_folder: if file.folder:
flash('Cannot download folders directly. Please use the ZIP option.', 'warning') flash('Cannot download folders directly. Please use the ZIP option.', 'warning')
return redirect(url_for('files.browser', folder_id=file.id)) return redirect(url_for('files.browser', folder_id=file.folder.id))
# Check if file exists in storage # Check if file exists in storage
storage_path = os.path.join(Config.UPLOAD_FOLDER, file.storage_name) storage_path = file.path
if not os.path.exists(storage_path): if not os.path.exists(storage_path):
flash('File not found in storage', 'error') flash('File not found in storage', 'error')
return redirect(url_for('files.browser', folder_id=file.parent_id)) return redirect(url_for('files.browser', folder_id=file.folder_id))
# Return the file # Return the file
return send_file( return send_file(
@ -393,131 +410,336 @@ def download(file_id):
as_attachment=True as_attachment=True
) )
@files_bp.route('/create_folder', methods=['POST']) @bp.route('/create_folder', methods=['POST'])
@login_required @login_required
def create_folder(): def create_folder():
"""Create a new folder""" """Create a new folder"""
parent_id = request.form.get('parent_id', type=int) name = request.form.get('name')
folder_name = request.form.get('name', '').strip() parent_id = request.form.get('parent_id')
if not folder_name: if not name:
if request.headers.get('X-Requested-With') == 'XMLHttpRequest': flash('Folder name is required', 'danger')
return jsonify({'error': 'Folder name is required'}), 400 return redirect(url_for('files.browser'))
else:
flash('Folder name is required', 'error')
return redirect(url_for('files.browser', folder_id=parent_id))
# Sanitize folder name # Create folder
folder_name = secure_filename(folder_name) folder = Folder(
name=name,
# Check if folder already exists
parent = None
if parent_id:
parent = File.query.filter_by(id=parent_id, user_id=current_user.id, is_folder=True).first_or_404()
existing = File.query.filter_by(
name=folder_name,
parent_id=parent_id,
user_id=current_user.id,
is_folder=True
).first()
else:
existing = File.query.filter_by(
name=folder_name,
parent_id=None,
user_id=current_user.id,
is_folder=True
).first()
if existing:
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'error': 'A folder with this name already exists'}), 400
else:
flash('A folder with this name already exists', 'error')
return redirect(url_for('files.browser', folder_id=parent_id))
# Create new folder
new_folder = File(
name=folder_name,
is_folder=True,
user_id=current_user.id, user_id=current_user.id,
parent_id=parent_id parent_id=parent_id if parent_id else None
) )
db.session.add(new_folder)
db.session.commit()
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({
'success': True,
'folder': {
'id': new_folder.id,
'name': new_folder.name,
'url': url_for('files.browser', folder_id=new_folder.id)
}
})
else:
flash('Folder created successfully', 'success')
return redirect(url_for('files.browser', folder_id=parent_id))
@files_bp.route('/rename/<int:item_id>', methods=['POST'])
@login_required
def rename(item_id):
"""Rename a file or folder"""
item = File.query.filter_by(id=item_id, user_id=current_user.id).first_or_404()
new_name = request.form.get('name', '').strip()
if not new_name:
return jsonify({'error': 'Name is required'}), 400
# Sanitize name
new_name = secure_filename(new_name)
# Check if a file/folder with this name already exists in the same location
existing = File.query.filter(
File.name == new_name,
File.parent_id == item.parent_id,
File.user_id == current_user.id,
File.is_folder == item.is_folder,
File.id != item.id
).first()
if existing:
return jsonify({'error': 'An item with this name already exists'}), 400
# Update name
item.name = new_name
db.session.commit()
return jsonify({
'success': True,
'item': {
'id': item.id,
'name': item.name
}
})
@files_bp.route('/delete/<int:item_id>', methods=['POST'])
@login_required
def delete(item_id):
"""Delete a file or folder"""
item = File.query.filter_by(id=item_id, user_id=current_user.id).first_or_404()
try: try:
# If it's a file, delete the actual file from storage db.session.add(folder)
if not item.is_folder and item.storage_name:
storage_path = os.path.join(Config.UPLOAD_FOLDER, item.storage_name)
if os.path.exists(storage_path):
os.remove(storage_path)
# Delete the database record (this will cascade delete any children due to the model relationship)
db.session.delete(item)
db.session.commit() db.session.commit()
flash(f'Folder "{name}" created successfully', 'success')
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Error creating folder: {str(e)}")
flash('Error creating folder', 'danger')
return jsonify({'success': True}) # Redirect to appropriate location
if parent_id:
return redirect(url_for('files.browser', folder_id=parent_id))
return redirect(url_for('files.browser'))
@bp.route('/rename', methods=['POST'])
@login_required
def rename_item():
"""Rename a file or folder"""
try:
# Get JSON data
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
item_id = data.get('item_id')
new_name = data.get('new_name')
# Validation
if not item_id or not new_name or new_name.strip() == '':
return jsonify({'error': 'Item ID and new name are required'}), 400
# Determine if it's a file or folder
file = File.query.filter_by(id=item_id, user_id=current_user.id).first()
folder = Folder.query.filter_by(id=item_id, user_id=current_user.id).first()
if file:
# For files, we need to handle the file system and database
old_path = file.path
file_dir = os.path.dirname(old_path)
# Create safe name
safe_name = secure_filename(new_name)
# Check for duplicates
existing_file = File.query.filter_by(
name=safe_name,
folder_id=file.folder_id,
user_id=current_user.id
).filter(File.id != file.id).first()
if existing_file:
return jsonify({'error': 'A file with this name already exists'}), 400
# Update file path
new_path = os.path.join(file_dir, safe_name)
# Rename file on disk
try:
if os.path.exists(old_path):
os.rename(old_path, new_path)
except OSError as e:
return jsonify({'error': f'Error renaming file: {str(e)}'}), 500
# Update database
file.name = safe_name
file.path = new_path
db.session.commit()
return jsonify({
'success': True,
'message': 'File renamed successfully',
'new_name': safe_name
})
elif folder:
# For folders, we just update the database
# Check for duplicates
existing_folder = Folder.query.filter_by(
name=new_name,
parent_id=folder.parent_id,
user_id=current_user.id
).filter(Folder.id != folder.id).first()
if existing_folder:
return jsonify({'error': 'A folder with this name already exists'}), 400
# Update folder name
folder.name = new_name
db.session.commit()
return jsonify({
'success': True,
'message': 'Folder renamed successfully',
'new_name': new_name
})
else:
return jsonify({'error': 'Item not found'}), 404
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
current_app.logger.error(f"Delete error: {str(e)}") current_app.logger.error(f"Error renaming item: {str(e)}")
return jsonify({'error': str(e)}), 500
@bp.route('/delete/<item_id>', methods=['POST'])
@login_required
def delete_item(item_id):
"""Delete a file or folder"""
try:
# Check if item exists
if not item_id or item_id == 'null':
return jsonify({'error': 'Invalid item ID'}), 400
# Determine if it's a file or folder
file = File.query.filter_by(id=item_id, user_id=current_user.id).first()
folder = Folder.query.filter_by(id=item_id, user_id=current_user.id).first()
if file:
# Delete file from storage
try:
if os.path.exists(file.path):
os.remove(file.path)
except OSError as e:
current_app.logger.error(f"Error deleting file from disk: {str(e)}")
# Delete from database
db.session.delete(file)
db.session.commit()
return jsonify({'success': True, 'message': 'File deleted successfully'})
elif folder:
# Check if folder has contents
has_files = File.query.filter_by(folder_id=folder.id).first() is not None
has_subfolders = Folder.query.filter_by(parent_id=folder.id).first() is not None
if has_files or has_subfolders:
# Delete recursively
delete_folder_recursive(folder.id)
return jsonify({'success': True, 'message': 'Folder and contents deleted successfully'})
else:
# Empty folder, simple delete
db.session.delete(folder)
db.session.commit()
return jsonify({'success': True, 'message': 'Folder deleted successfully'})
else:
return jsonify({'error': 'Item not found'}), 404
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Error deleting item: {str(e)}")
return jsonify({'error': str(e)}), 500
def delete_folder_recursive(folder_id):
"""Recursively delete a folder and its contents"""
# Delete all files in the folder
files = File.query.filter_by(folder_id=folder_id).all()
for file in files:
try:
if os.path.exists(file.path):
os.remove(file.path)
except OSError:
pass
db.session.delete(file)
# Recursively delete subfolders
subfolders = Folder.query.filter_by(parent_id=folder_id).all()
for subfolder in subfolders:
delete_folder_recursive(subfolder.id)
# Delete the folder itself
folder = Folder.query.get(folder_id)
if folder:
db.session.delete(folder)
@bp.route('/upload_xhr', methods=['POST'])
@login_required
def upload_xhr():
"""Handle XHR file uploads with improved error handling"""
try:
# Get parent folder ID if provided
parent_folder_id = request.form.get('parent_folder_id')
parent_folder = None
if parent_folder_id:
parent_folder = Folder.query.get(parent_folder_id)
if not parent_folder or parent_folder.user_id != current_user.id:
return jsonify({'error': 'Invalid parent folder'}), 400
# Check if files were uploaded
if 'files[]' not in request.files:
return jsonify({'error': 'No files in request'}), 400
files = request.files.getlist('files[]')
if not files or len(files) == 0 or files[0].filename == '':
return jsonify({'error': 'No files selected'}), 400
# Process files
uploaded_files = []
for file in files:
# Handle folder uploads by parsing the path
path_parts = []
if '/' in file.filename:
# This is a file in a folder structure
path_parts = file.filename.split('/')
filename = path_parts[-1] # Last part is the actual filename
# Create folder structure
current_parent = parent_folder
for i, folder_name in enumerate(path_parts[:-1]):
if not folder_name: # Skip empty folder names
continue
# Check if folder already exists
existing_folder = Folder.query.filter_by(
name=folder_name,
parent_id=current_parent.id if current_parent else None,
user_id=current_user.id
).first()
if existing_folder:
current_parent = existing_folder
else:
# Create new folder
new_folder = Folder(
name=folder_name,
parent_id=current_parent.id if current_parent else None,
user_id=current_user.id
)
db.session.add(new_folder)
db.session.flush() # Get ID without committing
current_parent = new_folder
else:
# Regular file upload
filename = file.filename
current_parent = parent_folder
# Save the file
if not filename:
continue # Skip files with empty names
secure_name = secure_filename(filename)
# Check for duplicates
existing_file = File.query.filter_by(
name=secure_name,
folder_id=current_parent.id if current_parent else None,
user_id=current_user.id
).first()
if existing_file:
# Append timestamp to avoid overwrite
name_parts = secure_name.rsplit('.', 1)
if len(name_parts) > 1:
secure_name = f"{name_parts[0]}_{int(time.time())}.{name_parts[1]}"
else:
secure_name = f"{secure_name}_{int(time.time())}"
# Create file path
file_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], str(current_user.id))
# Ensure directory exists
os.makedirs(file_dir, exist_ok=True)
file_path = os.path.join(file_dir, secure_name)
# Save file to disk
file.save(file_path)
# Create file record in database
file_size = os.path.getsize(file_path)
file_type = file.content_type or 'application/octet-stream'
new_file = File(
name=secure_name,
original_name=filename,
path=file_path,
size=file_size,
type=file_type,
folder_id=current_parent.id if current_parent else None,
user_id=current_user.id
)
db.session.add(new_file)
uploaded_files.append({
'id': None, # Will be set after commit
'name': secure_name,
'size': file_size,
'type': file_type
})
# Commit all changes
db.session.commit()
# Update file IDs for response
for i, file_data in enumerate(uploaded_files):
if i < len(db.session.new):
file_data['id'] = db.session.new[i].id
return jsonify({
'success': True,
'message': f'Successfully uploaded {len(uploaded_files)} files',
'files': uploaded_files
})
except Exception as e:
# Log the error for debugging
current_app.logger.error(f"Upload error: {str(e)}")
db.session.rollback()
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
# Import the helper functions from __init__.py # Import the helper functions from __init__.py

View file

@ -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 */ /* File Browser Styles */
.browser-container { .browser-container {
background: var(--card-bg); background: var(--card-bg);

View 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
View 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
View 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
View 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();
});
}
});

View 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
View 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.');
});
}
});
}
}

View 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
View 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';
}
}
}
});

View file

@ -10,6 +10,9 @@ document.addEventListener('DOMContentLoaded', function () {
const fileList = document.getElementById('file-list'); const fileList = document.getElementById('file-list');
const folderList = document.getElementById('folder-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 // Progress elements
const progressBar = document.getElementById('progress-bar'); const progressBar = document.getElementById('progress-bar');
const progressPercentage = document.getElementById('progress-percentage'); const progressPercentage = document.getElementById('progress-percentage');

View 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">&times;</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 %}

View file

@ -1,666 +1,50 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}User Profile - Flask Files{% endblock %} {% block title %}User Profile{% 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);
}
/* Modal styles */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1000;
overflow: auto;
justify-content: center;
align-items: center;
}
.modal.visible {
display: flex;
}
.modal-content {
background: var(--card-bg);
border-radius: var(--border-radius);
width: 100%;
max-width: 500px;
box-shadow: var(--shadow-lg);
margin: 2rem;
}
/* Danger zone */
.dangerous-zone {
background-color: rgba(var(--danger-color-rgb), 0.1);
border-radius: var(--border-radius);
padding: 1.5rem;
margin-top: 2rem;
}
.danger-actions {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-top: 1rem;
}
.btn.dangerous {
background-color: var(--danger-color);
color: #fff;
}
.btn.dangerous:hover {
background-color: var(--danger-hover-color);
}
/* Theme toggle */
.theme-options {
display: flex;
gap: 0.5rem;
}
.theme-btn {
padding: 0.5rem 1rem;
border-radius: var(--border-radius);
background: var(--secondary-bg);
border: 1px solid var(--border-color);
cursor: pointer;
}
.theme-btn.active {
background: var(--primary-color);
color: white;
border-color: var(--primary-color);
}
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="container"> <div class="container">
<div class="profile-card"> <div class="row">
<div class="profile-header"> <div class="col-md-8 offset-md-2">
<h2>User Profile</h2> <div class="card">
<div class="theme-toggle"> <div class="card-header">
<span>Theme:</span> <h3>User Profile</h3>
<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> <div class="card-body">
</div> <div class="row mb-3">
<div class="col-md-3 font-weight-bold">Username:</div>
<!-- Tab navigation --> <div class="col-md-9">{{ user.username }}</div>
<div class="profile-tabs"> </div>
<button class="profile-tab active" data-tab="account">Account</button> <div class="row mb-3">
<button class="profile-tab" data-tab="settings">Settings</button> <div class="col-md-3 font-weight-bold">Email:</div>
</div> <div class="col-md-9">{{ user.email }}</div>
</div>
<!-- Account Tab --> <div class="row mb-3">
<div class="tab-content active" id="account-tab"> <div class="col-md-3 font-weight-bold">Member Since:</div>
<div class="profile-content"> <div class="col-md-9">{{ user.created_at.strftime('%Y-%m-%d') }}</div>
<div class="profile-stats"> </div>
<div class="stat-card"> <div class="row mb-3">
<div class="stat-icon"> <div class="col-md-3 font-weight-bold">Account Type:</div>
<i class="fas fa-file"></i> <div class="col-md-9">{% if user.is_admin %}Administrator{% else %}Regular User{% endif %}</div>
</div>
<div class="stat-info">
<h3 class="stat-title">Files</h3>
<span class="stat-value">{{ current_user.files.filter_by(is_folder=False).count() }}</span>
</div>
</div> </div>
<div class="stat-card"> <hr>
<div class="stat-icon">
<i class="fas fa-folder"></i>
</div>
<div class="stat-info">
<h3 class="stat-title">Folders</h3>
<span class="stat-value">{{ current_user.files.filter_by(is_folder=True).count() }}</span>
</div>
</div>
<div class="stat-card"> <h4 class="mt-4">Storage Summary</h4>
<div class="stat-icon"> <div class="row mb-3">
<i class="fas fa-share-alt"></i> <div class="col-md-3 font-weight-bold">Files:</div>
</div> <div class="col-md-9">{{ user.files.count() }}</div>
<div class="stat-info">
<h3 class="stat-title">Shares</h3>
<span class="stat-value">{{ current_user.shares.count() if hasattr(current_user, 'shares')
else 0 }}</span>
</div>
</div> </div>
<div class="row mb-3">
<div class="stat-card"> <div class="col-md-3 font-weight-bold">Folders:</div>
<div class="stat-icon"> <div class="col-md-9">{{ user.folders.count() }}</div>
<i class="fas fa-calendar-alt"></i>
</div>
<div class="stat-info">
<h3 class="stat-title">Member Since</h3>
<span class="stat-value">{{ current_user.created_at.strftime('%b %d, %Y') }}</span>
</div>
</div> </div>
</div> </div>
<div class="card-footer">
<div class="profile-form"> <a href="{{ url_for('dashboard.index') }}" class="btn btn-secondary">Back to Dashboard</a>
<h3>Account Information</h3>
<form action="{{ url_for('auth.update_profile') }}" method="post">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" value="{{ current_user.username }}"
required>
</div>
<div class="form-group">
<label for="email">Email Address</label>
<input type="email" id="email" name="email" value="{{ current_user.email }}" required>
</div>
<div class="form-actions">
<button type="submit" class="btn primary">Save Changes</button>
</div>
</form>
<h3>Change Password</h3>
<form action="{{ url_for('auth.change_password') }}" method="post">
<div class="form-group">
<label for="current_password">Current Password</label>
<input type="password" id="current_password" name="current_password" required>
</div>
<div class="form-group">
<label for="new_password">New Password</label>
<input type="password" id="new_password" name="new_password" required>
</div>
<div class="form-group">
<label for="confirm_password">Confirm New Password</label>
<input type="password" id="confirm_password" name="confirm_password" required>
</div>
<div class="form-actions">
<button type="submit" class="btn primary">Change Password</button>
</div>
</form>
</div> </div>
</div> </div>
</div> </div>
<!-- Settings Tab -->
<div class="tab-content" id="settings-tab">
<div class="settings-content">
<div class="settings-section">
<h3>Appearance</h3>
<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 class="settings-section">
<h3>Notifications</h3>
<div class="setting-group">
<div class="setting-label">Email Notifications</div>
<div class="setting-controls">
<label class="toggle">
<input type="checkbox" id="email-notifications" {% if notifications and
notifications.email %}checked{% endif %}>
<span class="toggle-slider"></span>
</label>
</div>
<div class="setting-description">
Receive email notifications about file shares and new comments
</div>
</div>
</div>
<div class="settings-section">
<h3>Privacy</h3>
<div class="setting-group">
<div class="setting-label">Public Profile</div>
<div class="setting-controls">
<label class="toggle">
<input type="checkbox" id="public-profile" {% if privacy and privacy.public_profile
%}checked{% endif %}>
<span class="toggle-slider"></span>
</label>
</div>
<div class="setting-description">
Allow others to see your profile and shared files
</div>
</div>
<div class="setting-group">
<div class="setting-label">Share Statistics</div>
<div class="setting-controls">
<label class="toggle">
<input type="checkbox" id="share-statistics" {% if privacy and privacy.share_statistics
%}checked{% endif %}>
<span class="toggle-slider"></span>
</label>
</div>
<div class="setting-description">
Collect anonymous usage statistics to improve the service
</div>
</div>
</div>
<div class="settings-section dangerous-zone">
<h3>Danger Zone</h3>
<p class="warning-text">
These actions are permanent and cannot be undone
</p>
<div class="danger-actions">
<button id="delete-files-btn" class="btn dangerous">
<i class="fas fa-trash-alt"></i> Delete All Files
</button>
<button id="delete-account-btn" class="btn dangerous">
<i class="fas fa-user-times"></i> Delete Account
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 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">&times;</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">&times;</button>
</div>
<div class="modal-body">
<p class="warning-text">
Are you sure you want to delete your account? All your files will be permanently deleted.
This action cannot be undone.
</p>
<div class="form-group">
<label for="delete-account-confirm">Type your 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>
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function () {
// Make sure modals are hidden on load
document.querySelectorAll('.modal').forEach(modal => {
modal.style.display = 'none';
});
// Tab switching
const tabs = document.querySelectorAll('.profile-tab');
const tabContents = document.querySelectorAll('.tab-content');
tabs.forEach(tab => {
tab.addEventListener('click', function () {
const tabId = this.dataset.tab;
// Hide all tab contents
tabContents.forEach(content => {
content.classList.remove('active');
});
// Show selected tab content
document.getElementById(`${tabId}-tab`).classList.add('active');
// Update active tab
tabs.forEach(t => t.classList.remove('active'));
this.classList.add('active');
});
});
// Theme switching functionality
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('/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 preference
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
fetch('/auth/set_view_preference', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({ view: view })
});
});
});
// Toggle settings
const toggles = document.querySelectorAll('.toggle input[type="checkbox"]');
toggles.forEach(toggle => {
toggle.addEventListener('change', function () {
const setting = this.id;
const value = this.checked;
fetch('/auth/update_setting', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({
setting: setting,
value: value
})
});
});
});
// Password validation
const passwordForm = document.querySelector('form[action*="change_password"]');
passwordForm.addEventListener('submit', function (e) {
const newPassword = document.getElementById('new_password').value;
const confirmPassword = document.getElementById('confirm_password').value;
if (newPassword !== confirmPassword) {
e.preventDefault();
showAlert('New password and confirmation do not match', 'error');
}
});
// Delete files functionality
const deleteFilesBtn = document.getElementById('delete-files-btn');
const deleteFilesModal = document.getElementById('delete-files-modal');
const deleteFilesConfirmInput = document.getElementById('delete-files-confirm');
const confirmDeleteFilesBtn = document.getElementById('confirm-delete-files');
deleteFilesBtn.addEventListener('click', function () {
openModal(deleteFilesModal);
deleteFilesConfirmInput.value = '';
confirmDeleteFilesBtn.disabled = true;
deleteFilesConfirmInput.focus();
});
deleteFilesConfirmInput.addEventListener('input', function () {
confirmDeleteFilesBtn.disabled = this.value !== 'DELETE';
});
confirmDeleteFilesBtn.addEventListener('click', function () {
if (deleteFilesConfirmInput.value === 'DELETE') {
fetch('/auth/delete_all_files', {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
closeModal(deleteFilesModal);
if (data.success) {
showAlert('All files have been deleted', 'success');
// Update file count
document.querySelectorAll('.stat-value')[0].textContent = '0';
document.querySelectorAll('.stat-value')[1].textContent = '0';
} else {
showAlert(data.error || 'Failed to delete files', 'error');
}
})
.catch(error => {
showAlert('An error occurred: ' + error, 'error');
});
}
});
// Delete account functionality
const deleteAccountBtn = document.getElementById('delete-account-btn');
const deleteAccountModal = document.getElementById('delete-account-modal');
const deleteAccountConfirmInput = document.getElementById('delete-account-confirm');
const confirmDeleteAccountBtn = document.getElementById('confirm-delete-account');
const usernameToConfirm = "{{ current_user.username }}";
deleteAccountBtn.addEventListener('click', function () {
openModal(deleteAccountModal);
deleteAccountConfirmInput.value = '';
confirmDeleteAccountBtn.disabled = true;
deleteAccountConfirmInput.focus();
});
deleteAccountConfirmInput.addEventListener('input', function () {
confirmDeleteAccountBtn.disabled = this.value !== usernameToConfirm;
});
confirmDeleteAccountBtn.addEventListener('click', function () {
if (deleteAccountConfirmInput.value === usernameToConfirm) {
fetch('/auth/delete_account', {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
closeModal(deleteAccountModal);
if (data.success) {
showAlert('Your account has been deleted', 'success');
setTimeout(() => {
window.location.href = '/auth/logout';
}, 1500);
} else {
showAlert(data.error || 'Failed to delete account', 'error');
}
})
.catch(error => {
showAlert('An error occurred: ' + error, 'error');
});
}
});
// Modal utilities
function openModal(modal) {
modal.style.display = 'flex';
document.body.classList.add('modal-open');
}
function closeModal(modal) {
modal.style.display = 'none';
document.body.classList.remove('modal-open');
}
// Close modals when clicking outside or on close button
document.addEventListener('click', function (e) {
if (e.target.classList.contains('modal')) {
closeModal(e.target);
} else if (e.target.classList.contains('modal-close') || e.target.classList.contains('modal-cancel')) {
const modal = e.target.closest('.modal');
closeModal(modal);
}
});
// Escape key to close modals
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
document.querySelectorAll('.modal').forEach(modal => {
closeModal(modal);
});
}
});
// Helper function to show alerts
function showAlert(message, type = 'info') {
// Create alerts container if it doesn't exist
let alertsContainer = document.querySelector('.alerts');
if (!alertsContainer) {
alertsContainer = document.createElement('div');
alertsContainer.className = 'alerts';
document.body.appendChild(alertsContainer);
}
// Create alert
const alert = document.createElement('div');
alert.className = `alert ${type}`;
alert.innerHTML = `
<div class="alert-content">${message}</div>
<button class="close" aria-label="Close">&times;</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 %}

View file

@ -6,11 +6,26 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Flask Files{% endblock %}</title> <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 --> <!-- Classless CSS Framework -->
<!-- <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/digitallytailored/classless@latest/classless.min.css"> --> <!-- <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/digitallytailored/classless@latest/classless.min.css"> -->
<!-- Custom CSS --> <!-- Custom CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='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 --> <!-- Font Awesome Icons -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css">
@ -18,95 +33,131 @@
{% block extra_css %}{% endblock %} {% block extra_css %}{% endblock %}
<!-- JavaScript --> <!-- 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 %} {% block extra_js %}{% endblock %}
</head> </head>
<body> <body>
<header class="navbar"> {% if current_user.is_authenticated %}
<div class="navbar-brand"> <!-- Global drop zone overlay - hidden by default -->
<a href="{{ url_for('dashboard.index') }}">Flask Files</a> <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>
<nav class="navbar-menu"> </div>
{% if current_user.is_authenticated %}
<a href="{{ url_for('dashboard.index') }}" class="nav-item">
<i class="fas fa-tachometer-alt"></i> Dashboard
</a>
<a href="{{ url_for('files.browser') }}" class="nav-item">
<i class="fas fa-folder"></i> Files
</a>
<a href="{{ url_for('auth.profile') }}" class="nav-item">
<i class="fas fa-user"></i> {{ current_user.username }}
</a>
<a href="{{ url_for('auth.logout') }}" class="nav-item">
<i class="fas fa-sign-out-alt"></i> Logout
</a>
<div class="theme-toggle-icon">
<i class="fas fa-moon"></i>
</div>
{% else %}
<a href="{{ url_for('auth.login') }}" class="nav-item">
<i class="fas fa-sign-in-alt"></i> Login
</a>
<a href="{{ url_for('auth.register') }}" class="nav-item">
<i class="fas fa-user-plus"></i> Register
</a>
<div class="theme-toggle-icon">
<i class="fas fa-moon"></i>
</div>
{% endif %}
</nav>
</header>
<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">&times;</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) %} {% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %} {% if messages %}
<section class="alerts"> {% for category, message in messages %}
{% for category, message in messages %} <div class="alert alert-{{ category if category != 'message' else 'info' }} alert-dismissible fade show">
<div class="alert {{ category }}"> {{ message }}
{{ message }} <button type="button" class="close" data-dismiss="alert">&times;</button>
<button class="close" aria-label="Close">&times;</button> </div>
</div> {% endfor %}
{% endfor %}
</section>
{% endif %} {% endif %}
{% endwith %} {% endwith %}
</div>
<section class="content"> <!-- Main Content -->
{% block content %}{% endblock %} <main role="main" class="container mt-3">
</section> {% block content %}{% endblock %}
</main> </main>
<footer> <!-- Footer -->
<div class="container"> <footer class="footer mt-auto py-3 bg-light">
<p>&copy; {{ now.year }} Flask Files. All rights reserved.</p> <div class="container text-center">
<span class="text-muted">&copy; 2023 Flask Files. All rights reserved.</span>
</div> </div>
</footer> </footer>
@ -161,6 +212,19 @@
<!-- Common JS file with shared functions --> <!-- Common JS file with shared functions -->
<script src="{{ url_for('static', filename='js/common.js') }}"></script> <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 %} {% block scripts %}{% endblock %}
</body> </body>

View 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 %}

View file

@ -4,410 +4,7 @@
{% block extra_css %} {% block extra_css %}
<style> <style>
.browser-container { /* Additional page-specific styles if needed */
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;
}
</style> </style>
{% endblock %} {% endblock %}
@ -420,17 +17,15 @@
<h2>{% if current_folder %}{{ current_folder.name }}{% else %}My Files{% endif %}</h2> <h2>{% if current_folder %}{{ current_folder.name }}{% else %}My Files{% endif %}</h2>
</div> </div>
<div class="browser-actions"> <div class="browser-actions">
<input type="text" id="search-input" class="search-input" placeholder="Search files..."> <div class="search-container">
<button id="search-btn" class="btn"> <form action="{{ url_for('files.browser') }}" method="get">
<i class="fas fa-search"></i> <input type="text" name="q" placeholder="Search files..."
</button> value="{{ request.args.get('q', '') }}">
<a href="{% if current_folder %}{{ url_for('files.upload', folder_id=current_folder.id) }}{% else %}{{ url_for('files.upload') }}{% endif %}" <button type="submit" class="search-btn">
class="btn primary"> <i class="fas fa-search"></i>
<i class="fas fa-upload"></i> Upload </button>
</a> </form>
<button id="new-folder-btn" class="btn secondary"> </div>
<i class="fas fa-folder-plus"></i> New Folder
</button>
<div class="view-toggle"> <div class="view-toggle">
<button id="grid-view-btn" class="view-btn active" title="Grid View"> <button id="grid-view-btn" class="view-btn active" title="Grid View">
<i class="fas fa-th"></i> <i class="fas fa-th"></i>
@ -439,6 +34,13 @@
<i class="fas fa-list"></i> <i class="fas fa-list"></i>
</button> </button>
</div> </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-upload"></i> Upload
</a>
<button id="new-folder-btn" class="btn secondary">
<i class="fas fa-folder-plus"></i> New Folder
</button>
</div> </div>
</div> </div>
@ -473,12 +75,12 @@
<form id="new-folder-form" action="{{ url_for('files.create_folder') }}" method="post"> <form id="new-folder-form" action="{{ url_for('files.create_folder') }}" method="post">
<div class="form-group"> <div class="form-group">
<label for="folder-name">Folder Name</label> <label for="folder-name">Folder Name</label>
<input type="text" id="folder-name" name="name" required> <input type="text" id="folder-name" name="name" required placeholder="Enter folder name">
<input type="hidden" id="parent-folder-id" name="parent_id" <input type="hidden" id="parent-folder-id" name="parent_id"
value="{% if current_folder %}{{ current_folder.id }}{% endif %}"> value="{% if current_folder %}{{ current_folder.id }}{% endif %}">
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button type="button" class="btn secondary modal-cancel">Cancel</button> <button type="button" class="btn modal-cancel">Cancel</button>
<button type="submit" class="btn primary">Create Folder</button> <button type="submit" class="btn primary">Create Folder</button>
</div> </div>
</form> </form>
@ -486,133 +88,34 @@
</div> </div>
</div> </div>
<!-- File Actions Modal --> <!-- Context Menu -->
<div id="file-actions-modal" class="modal"> <div id="context-menu" class="context-menu">
<div class="modal-content"> <div class="context-menu-item" data-action="open" data-for="folder">
<div class="modal-header"> <i class="fas fa-folder-open"></i> Open
<h3 id="file-name-header">File Actions</h3> </div>
<button class="modal-close">&times;</button> <div class="context-menu-item" data-action="download" data-for="file">
</div> <i class="fas fa-download"></i> Download
<div class="modal-body"> </div>
<div class="file-actions-list"> <div class="context-menu-item" data-action="share" data-for="file">
<a id="download-action" href="#" class="file-action"> <i class="fas fa-share-alt"></i> Share
<i class="fas fa-download"></i> Download </div>
</a> <div class="context-menu-item" data-action="rename" data-for="all">
<a id="share-action" href="#" class="file-action"> <i class="fas fa-pencil-alt"></i> Rename
<i class="fas fa-share-alt"></i> Share </div>
</a> <div class="context-menu-item" data-action="delete" data-for="all">
<button id="rename-action" class="file-action"> <i class="fas fa-trash"></i> Delete
<i class="fas fa-edit"></i> Rename
</button>
<button id="delete-action" class="file-action dangerous">
<i class="fas fa-trash-alt"></i> Delete
</button>
</div>
</div>
</div> </div>
</div> </div>
<!-- Rename Modal --> {% block extra_js %}
<div id="rename-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>Rename Item</h3>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
<form id="rename-form">
<div class="form-group">
<label for="new-name">New Name</label>
<input type="text" id="new-name" name="name" required>
<input type="hidden" id="rename-item-id" name="item_id">
</div>
<div class="form-actions">
<button type="button" class="btn secondary modal-cancel">Cancel</button>
<button type="submit" class="btn primary">Rename</button>
</div>
</form>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div id="delete-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>Confirm Deletion</h3>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
<p id="delete-confirmation-message">Are you sure you want to delete this item? This action cannot be undone.
</p>
<div class="form-actions">
<button class="btn secondary modal-cancel">Cancel</button>
<button id="confirm-delete" class="btn dangerous">Delete</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script> <script>
// Add folder entrance animation
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
// Setup modals on page load
setupModals();
// Initialize variables
const filesContainer = document.getElementById('files-container'); const filesContainer = document.getElementById('files-container');
const gridViewBtn = document.getElementById('grid-view-btn'); if (filesContainer) {
const listViewBtn = document.getElementById('list-view-btn'); filesContainer.classList.add('folder-enter-active');
const newFolderBtn = document.getElementById('new-folder-btn');
let selectedItemId = null;
// Button event listeners
if (gridViewBtn && listViewBtn) {
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');
});
} }
// Apply saved view preference
const savedView = localStorage.getItem('view_preference');
if (savedView === 'list') {
filesContainer.className = 'files-container list-view';
if (listViewBtn && gridViewBtn) {
listViewBtn.classList.add('active');
gridViewBtn.classList.remove('active');
}
}
// New folder button
if (newFolderBtn) {
newFolderBtn.addEventListener('click', function () {
openModal('new-folder-modal');
document.getElementById('folder-name').focus();
});
}
// Setup file item event listeners
function setupFileListeners() {
// ... your existing file listeners ...
}
// Initial setup
setupFileListeners();
}); });
</script> </script>
{% endblock %} {% endblock %}
{% endblock %}

View file

@ -9,10 +9,19 @@
<div class="item-info"> <div class="item-info">
<div class="item-name">{{ folder.name }}</div> <div class="item-name">{{ folder.name }}</div>
<div class="item-details"> <div class="item-details">
<span class="item-count">{{ folder.children.count() }} items</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> <span class="item-date">{{ folder.updated_at.strftime('%Y-%m-%d') }}</span>
</div> </div>
</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> </a>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
@ -21,15 +30,26 @@
{% for file in files %} {% for file in files %}
<a href="{{ url_for('files.download', file_id=file.id) }}" class="file-item" data-id="{{ file.id }}"> <a href="{{ url_for('files.download', file_id=file.id) }}" class="file-item" data-id="{{ file.id }}">
<div class="item-icon"> <div class="item-icon">
<i class="fas {{ file_icon(file.mime_type, file.name) }}"></i> <i class="fas {{ file.icon_class }}"></i>
</div> </div>
<div class="item-info"> <div class="item-info">
<div class="item-name">{{ file.name }}</div> <div class="item-name">{{ file.name }}</div>
<div class="item-details"> <div class="item-details">
<span class="item-size">{{ format_size(file.size) }}</span> <span class="item-size">{{ file.size|filesizeformat }}</span>
<span class="item-date">{{ file.updated_at.strftime('%Y-%m-%d') }}</span> <span class="item-date">{{ file.updated_at.strftime('%Y-%m-%d') }}</span>
</div> </div>
</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> </a>
{% endfor %} {% endfor %}
{% endif %} {% endif %}

View file

@ -145,7 +145,7 @@
<i class="fas fa-file-upload"></i> File Upload <i class="fas fa-file-upload"></i> File Upload
</button> </button>
<button class="upload-tab" data-tab="folder-upload-tab"> <button class="upload-tab" data-tab="folder-upload-tab">
<i class="fas fa-folder-upload"></i> Folder Upload <i class="fas fa-folder-plus"></i> Folder Upload
</button> </button>
</div> </div>
@ -158,7 +158,7 @@
<p>Or click to browse your device</p> <p>Or click to browse your device</p>
<button id="file-select-btn" class="btn primary">Select Files</button> <button id="file-select-btn" class="btn primary">Select Files</button>
<form id="file-upload-form" method="post" enctype="multipart/form-data"> <form id="file-upload-form" method="post" enctype="multipart/form-data">
<input type="hidden" name="folder_id" <input type="hidden" name="parent_folder_id"
value="{% if parent_folder %}{{ parent_folder.id }}{% endif %}"> value="{% if parent_folder %}{{ parent_folder.id }}{% endif %}">
<input type="file" id="file-input" name="files[]" multiple style="display: none;"> <input type="file" id="file-input" name="files[]" multiple style="display: none;">
</form> </form>
@ -191,7 +191,7 @@
<p>Or click to browse your device</p> <p>Or click to browse your device</p>
<button id="folder-select-btn" class="btn primary">Select Folder</button> <button id="folder-select-btn" class="btn primary">Select Folder</button>
<form id="folder-upload-form" method="post" enctype="multipart/form-data"> <form id="folder-upload-form" method="post" enctype="multipart/form-data">
<input type="hidden" name="folder_id" <input type="hidden" name="parent_folder_id"
value="{% if parent_folder %}{{ parent_folder.id }}{% endif %}"> value="{% if parent_folder %}{{ parent_folder.id }}{% endif %}">
<input type="file" id="folder-input" name="files[]" webkitdirectory directory multiple <input type="file" id="folder-input" name="files[]" webkitdirectory directory multiple
style="display: none;"> style="display: none;">

59
app/utils/reset_db.py Normal file
View 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
View 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)