From acb3c7642abded46bae2c480e6ea16d9f08d3d26 Mon Sep 17 00:00:00 2001 From: pika Date: Sat, 22 Mar 2025 12:30:45 +0100 Subject: [PATCH] batman --- .gitignore | 73 ++ Readme.md | 22 + app/__init__.py | 90 ++ app/models.py | 58 ++ app/routes/__init__.py | 8 + app/routes/auth.py | 79 ++ app/routes/dashboard.py | 24 + app/routes/files.py | 584 ++++++++++++ app/static/css/custom.css | 1455 ++++++++++++++++++++++++++++++ app/static/css/upload.css | 302 +++++++ app/static/js/upload.js | 470 ++++++++++ app/templates/auth/login.html | 42 + app/templates/auth/profile.html | 34 + app/templates/auth/register.html | 45 + app/templates/base.html | 157 ++++ app/templates/dashboard.html | 74 ++ app/templates/files/browser.html | 181 ++++ app/templates/files/upload.html | 140 +++ config.py | 19 + init_db.py | 18 + migrations/add_storage_name.py | 52 ++ requirements.txt | 7 + run.py | 6 + 23 files changed, 3940 insertions(+) create mode 100644 .gitignore create mode 100644 Readme.md create mode 100644 app/__init__.py create mode 100644 app/models.py create mode 100644 app/routes/__init__.py create mode 100644 app/routes/auth.py create mode 100644 app/routes/dashboard.py create mode 100644 app/routes/files.py create mode 100644 app/static/css/custom.css create mode 100644 app/static/css/upload.css create mode 100644 app/static/js/upload.js create mode 100644 app/templates/auth/login.html create mode 100644 app/templates/auth/profile.html create mode 100644 app/templates/auth/register.html create mode 100644 app/templates/base.html create mode 100644 app/templates/dashboard.html create mode 100644 app/templates/files/browser.html create mode 100644 app/templates/files/upload.html create mode 100644 config.py create mode 100644 init_db.py create mode 100644 migrations/add_storage_name.py create mode 100644 requirements.txt create mode 100644 run.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2a176af --- /dev/null +++ b/.gitignore @@ -0,0 +1,73 @@ +# Python bytecode +__pycache__/ +*.py[cod] +*$py.class + +# Distribution / packaging +dist/ +build/ +*.egg-info/ + +# Flask +instance/ +.webassets-cache + +# Environment variables +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Database files +*.db +*.sqlite +*.sqlite3 + +# Upload folder +uploads/ + +# Logs +*.log + +# OS specific files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# IDE specific files +.idea/ +.vscode/ +*.swp +*.swo +*.sublime-workspace +*.sublime-project + +# Coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml + +# Local development configurations +local_settings.py \ No newline at end of file diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..e1c4350 --- /dev/null +++ b/Readme.md @@ -0,0 +1,22 @@ +# Flask Files + +Im tired of all the file managers, which arent fast and dont have a good UI. + +So i decided to make my own. + +## Features + +- login with username and password +- beautiful UI with dark and light mode +- dashboard with overview of new files, shared files, server status, etc. +- upload files +- download files +- delete files +- create folders +- rename files +- search for files +- create share links +- share files with a generated link + password + expiration date +- see who downloaded the file +- set folder description via readme.md file + diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..bd17b26 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,90 @@ +from flask import Flask +from flask_sqlalchemy import SQLAlchemy +from flask_login import LoginManager +from config import Config +from datetime import datetime +import os +import sqlite3 + +db = SQLAlchemy() +login_manager = LoginManager() +login_manager.login_view = 'auth.login' +login_manager.login_message_category = 'info' + +def create_app(config_class=Config): + app = Flask(__name__) + app.config.from_object(config_class) + + # Initialize extensions + db.init_app(app) + login_manager.init_app(app) + + # Initialize the upload folder + Config.init_app(app) + + # Auto initialize database if it doesn't exist + with app.app_context(): + initialize_database(app) + run_migrations(app) + + # Register blueprints + from app.routes import auth_bp, files_bp, dashboard_bp + app.register_blueprint(auth_bp) + app.register_blueprint(files_bp) + app.register_blueprint(dashboard_bp) + + # Add context processor for template variables + @app.context_processor + def inject_now(): + return {'now': datetime.now()} + + return app + +def initialize_database(app): + """Create database tables if they don't exist.""" + db_path = app.config['SQLALCHEMY_DATABASE_URI'].replace('sqlite:///', '') + + # Check if database file exists + if not os.path.exists(db_path): + print("Database does not exist. Creating tables...") + db.create_all() + + # Import models here to avoid circular imports + from app.models import User + + # Create admin user if it doesn't exist + admin = User.query.filter_by(username='admin').first() + if not admin: + admin = User(username='admin') + admin.set_password('admin') # Change this in production + db.session.add(admin) + db.session.commit() + print("Admin user created.") + +def run_migrations(app): + """Run any needed database migrations.""" + db_path = app.config['SQLALCHEMY_DATABASE_URI'].replace('sqlite:///', '') + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + try: + # Check for missing columns in File table + cursor.execute("PRAGMA table_info(file)") + columns = [column[1] for column in cursor.fetchall()] + + # Add storage_name column if missing + if 'storage_name' not in columns: + print("Running migration: Adding storage_name column to file table...") + cursor.execute("ALTER TABLE file ADD COLUMN storage_name TEXT") + + # Update existing files to use name as storage_name + cursor.execute("UPDATE file SET storage_name = name WHERE is_folder = 0") + + conn.commit() + print("Migration completed successfully!") + except Exception as e: + print(f"Migration error: {e}") + finally: + conn.close() + +from app import models diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..3df8837 --- /dev/null +++ b/app/models.py @@ -0,0 +1,58 @@ +from datetime import datetime +from werkzeug.security import generate_password_hash, check_password_hash +from flask_login import UserMixin +from app import db, login_manager +import uuid + +@login_manager.user_loader +def load_user(user_id): + return User.query.get(int(user_id)) + +class User(UserMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(64), unique=True, index=True) + password_hash = db.Column(db.String(128)) + files = db.relationship('File', backref='owner', lazy='dynamic') + shares = db.relationship('Share', backref='creator', lazy='dynamic') + + def set_password(self, password): + self.password_hash = generate_password_hash(password) + + def check_password(self, password): + return check_password_hash(self.password_hash, password) + +class File(db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(255)) + storage_name = db.Column(db.String(255)) # Added field for UUID-based storage + mime_type = db.Column(db.String(128)) + size = db.Column(db.Integer, default=0) + is_folder = db.Column(db.Boolean, default=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + user_id = db.Column(db.Integer, db.ForeignKey('user.id')) + parent_id = db.Column(db.Integer, db.ForeignKey('file.id')) + + # Add relationship to represent folder structure + children = db.relationship('File', + backref=db.backref('parent', remote_side=[id]), + lazy='dynamic') + + # Add relationship for shared files + shares = db.relationship('Share', backref='file', lazy='dynamic') + +class Share(db.Model): + id = db.Column(db.Integer, primary_key=True) + file_id = db.Column(db.Integer, db.ForeignKey('file.id')) + user_id = db.Column(db.Integer, db.ForeignKey('user.id')) + token = db.Column(db.String(64), unique=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + expires_at = db.Column(db.DateTime, nullable=True) + downloads = db.relationship('Download', backref='share', lazy='dynamic') + +class Download(db.Model): + id = db.Column(db.Integer, primary_key=True) + file_id = db.Column(db.Integer, db.ForeignKey('file.id')) + share_id = db.Column(db.Integer, db.ForeignKey('share.id')) + ip_address = db.Column(db.String(45)) + timestamp = db.Column(db.DateTime, default=datetime.utcnow) diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..c1be57e --- /dev/null +++ b/app/routes/__init__.py @@ -0,0 +1,8 @@ +from flask import Blueprint + +auth_bp = Blueprint('auth', __name__, url_prefix='/auth') +files_bp = Blueprint('files', __name__, url_prefix='/files') +dashboard_bp = Blueprint('dashboard', __name__, url_prefix='') + +# Import the route handlers AFTER creating the blueprints +from app.routes import auth, files, dashboard diff --git a/app/routes/auth.py b/app/routes/auth.py new file mode 100644 index 0000000..9b7c633 --- /dev/null +++ b/app/routes/auth.py @@ -0,0 +1,79 @@ +from flask import render_template, redirect, url_for, flash, request +from flask_login import login_user, logout_user, login_required, current_user +from urllib.parse import urlparse +from app import db +from app.models import User +from app.routes import auth_bp +from flask_wtf import FlaskForm +from wtforms import StringField, PasswordField, BooleanField, SubmitField +from wtforms.validators import DataRequired, Length, EqualTo, ValidationError + +# Login form +class LoginForm(FlaskForm): + username = StringField('Username', validators=[DataRequired()]) + password = PasswordField('Password', validators=[DataRequired()]) + remember_me = BooleanField('Remember Me') + submit = SubmitField('Sign In') + +# Registration form +class RegistrationForm(FlaskForm): + username = StringField('Username', validators=[DataRequired(), Length(min=3, max=64)]) + password = PasswordField('Password', validators=[DataRequired(), Length(min=8)]) + password2 = PasswordField('Confirm Password', validators=[DataRequired(), EqualTo('password')]) + submit = SubmitField('Register') + + def validate_username(self, username): + user = User.query.filter_by(username=username.data).first() + if user is not None: + raise ValidationError('Please use a different username.') + +@auth_bp.route('/login', methods=['GET', 'POST']) +def login(): + if current_user.is_authenticated: + return redirect(url_for('dashboard.index')) + + form = LoginForm() + if form.validate_on_submit(): + user = User.query.filter_by(username=form.username.data).first() + + if user is None or not user.check_password(form.password.data): + flash('Invalid username or password', 'error') + return redirect(url_for('auth.login')) + + login_user(user, remember=form.remember_me.data) + + next_page = request.args.get('next') + if not next_page or urlparse(next_page).netloc != '': + next_page = url_for('dashboard.index') + + return redirect(next_page) + + return render_template('auth/login.html', title='Sign In', form=form) + +@auth_bp.route('/logout') +@login_required +def logout(): + logout_user() + flash('You have been logged out', 'info') + return redirect(url_for('auth.login')) + +@auth_bp.route('/register', methods=['GET', 'POST']) +def register(): + if current_user.is_authenticated: + return redirect(url_for('dashboard.index')) + + form = RegistrationForm() + if form.validate_on_submit(): + user = User(username=form.username.data) + user.set_password(form.password.data) + db.session.add(user) + db.session.commit() + flash('Registration successful! You can now log in.', 'success') + return redirect(url_for('auth.login')) + + return render_template('auth/register.html', title='Register', form=form) + +@auth_bp.route('/profile') +@login_required +def profile(): + return render_template('auth/profile.html', title='User Profile') diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py new file mode 100644 index 0000000..e062f23 --- /dev/null +++ b/app/routes/dashboard.py @@ -0,0 +1,24 @@ +from flask import render_template +from flask_login import login_required, current_user +from app.routes import dashboard_bp +from app.models import File, Share +from datetime import datetime + +@dashboard_bp.route('/') +@login_required +def index(): + # Get some stats for the dashboard + total_files = File.query.filter_by(user_id=current_user.id, is_folder=False).count() + total_folders = File.query.filter_by(user_id=current_user.id, is_folder=True).count() + recent_files = File.query.filter_by(user_id=current_user.id, is_folder=False).order_by(File.updated_at.desc()).limit(5).all() + active_shares = Share.query.filter_by(user_id=current_user.id).filter( + (Share.expires_at > datetime.now()) | (Share.expires_at.is_(None)) + ).count() + + return render_template('dashboard.html', + title='Dashboard', + total_files=total_files, + total_folders=total_folders, + recent_files=recent_files, + active_shares=active_shares, + now=datetime.now()) \ No newline at end of file diff --git a/app/routes/files.py b/app/routes/files.py new file mode 100644 index 0000000..dd2ad07 --- /dev/null +++ b/app/routes/files.py @@ -0,0 +1,584 @@ +from flask import render_template, redirect, url_for, flash, request, send_from_directory, abort, jsonify +from flask_login import login_required, current_user +from werkzeug.utils import secure_filename +from app import db +from app.models import File, Share +from app.routes import files_bp +import os +from datetime import datetime, timedelta +import uuid +import mimetypes +from config import Config +import shutil + +@files_bp.route('/browser') +@files_bp.route('/browser/') +@login_required +def browser(folder_id=None): + # Get current folder + current_folder = None + if folder_id: + current_folder = File.query.filter_by(id=folder_id, user_id=current_user.id).first_or_404() + if not current_folder.is_folder: + abort(400) + + # Generate breadcrumb navigation + breadcrumbs = [] + if current_folder: + temp_folder = current_folder + while temp_folder: + breadcrumbs.append(temp_folder) + temp_folder = temp_folder.parent + breadcrumbs.reverse() + + # Get files and folders + query = File.query.filter_by(user_id=current_user.id, parent_id=folder_id) + folders = query.filter_by(is_folder=True).order_by(File.name).all() + files = query.filter_by(is_folder=False).order_by(File.name).all() + + return render_template('files/browser.html', + title='File Browser', + current_folder=current_folder, + breadcrumbs=breadcrumbs, + folders=folders, + files=files) + +@files_bp.route('/upload') +@login_required +def upload(): + folder_id = request.args.get('folder', None, type=int) + + # Get parent folder + 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() + + return render_template('files/upload.html', + title='Upload Files', + parent_folder=parent_folder) + +@files_bp.route('/upload_folder', methods=['POST']) +@login_required +def upload_folder(): + folder_id = request.form.get('folder_id', None, type=int) + + # Get parent folder + 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() + + # Create temporary directory for uploaded files + temp_dir = os.path.join(Config.UPLOAD_FOLDER, 'temp', str(uuid.uuid4())) + os.makedirs(temp_dir, exist_ok=True) + + try: + # Process uploaded files (with relative paths) + files = request.files.getlist('folder[]') + if not files or all(file.filename == '' for file in files): + flash('No folder selected for upload', 'error') + shutil.rmtree(temp_dir, ignore_errors=True) + return redirect(url_for('files.browser', folder_id=folder_id)) + + # Save files to temp directory with their relative paths + for file in files: + if file.filename == '': + continue + + # Get the relative path within the folder + rel_path = file.filename + if '\\' in rel_path: # Windows paths + rel_path = rel_path.replace('\\', '/') + + # Create directories for the path + dir_path = os.path.join(temp_dir, os.path.dirname(rel_path)) + os.makedirs(dir_path, exist_ok=True) + + # Save the file + file.save(os.path.join(temp_dir, rel_path)) + + # Process the folder structure + base_folder_name = os.path.basename(os.path.normpath(temp_dir)) + for root, dirs, files in os.walk(temp_dir): + rel_root = os.path.relpath(root, temp_dir) + + # Skip the root directory itself + if rel_root == '.': + rel_root = '' + + # Find or create parent folder + current_parent_id = folder_id + if rel_root: + path_parts = rel_root.split(os.path.sep) + for part in path_parts: + existing_folder = File.query.filter_by( + name=part, + parent_id=current_parent_id, + user_id=current_user.id, + is_folder=True + ).first() + + if existing_folder: + current_parent_id = existing_folder.id + else: + new_folder = File( + name=part, + user_id=current_user.id, + parent_id=current_parent_id, + is_folder=True + ) + db.session.add(new_folder) + db.session.flush() # To get the ID + current_parent_id = new_folder.id + + # Create file records for files in current directory + for filename in files: + full_path = os.path.join(root, filename) + secure_name = secure_filename(filename) + + # Generate UUID for storage + file_uuid = str(uuid.uuid4()) + storage_path = os.path.join(Config.UPLOAD_FOLDER, file_uuid) + + # Copy file to storage location + shutil.copy2(full_path, storage_path) + + # Get file info + file_size = os.path.getsize(storage_path) + mime_type, _ = mimetypes.guess_type(filename) + if not mime_type: + mime_type = 'application/octet-stream' + + # Create file record + db_file = File( + name=secure_name, + storage_name=file_uuid, + mime_type=mime_type, + size=file_size, + user_id=current_user.id, + parent_id=current_parent_id, + is_folder=False + ) + db.session.add(db_file) + + db.session.commit() + flash('Folder uploaded successfully', 'success') + except Exception as e: + db.session.rollback() + flash(f'Error uploading folder: {str(e)}', 'error') + finally: + # Clean up temp directory + shutil.rmtree(temp_dir, ignore_errors=True) + + # Redirect back to the folder + if parent_folder: + return redirect(url_for('files.browser', folder_id=parent_folder.id)) + else: + return redirect(url_for('files.browser')) + +@files_bp.route('/create_folder', methods=['GET', 'POST']) +@login_required +def create_folder(): + parent_id = request.args.get('folder', None, type=int) or request.form.get('parent_id', None, type=int) + + # Verify parent folder if specified + if parent_id: + parent = File.query.filter_by(id=parent_id, user_id=current_user.id, is_folder=True).first_or_404() + + if request.method == 'POST': + folder_name = request.form.get('folder_name', '').strip() + + if not folder_name: + flash('Folder name cannot be empty', 'error') + return redirect(request.url) + + # Secure the folder name + folder_name = secure_filename(folder_name) + + # Check if folder already exists + existing_folder = File.query.filter_by( + name=folder_name, + parent_id=parent_id, + user_id=current_user.id, + is_folder=True + ).first() + + if existing_folder: + flash(f'A folder named "{folder_name}" already exists', 'error') + return redirect(request.url) + + # Create new folder + new_folder = File( + name=folder_name, + user_id=current_user.id, + parent_id=parent_id, + is_folder=True + ) + + db.session.add(new_folder) + db.session.commit() + + flash(f'Folder "{folder_name}" created successfully', 'success') + + # Handle AJAX requests + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': True, 'folder_id': new_folder.id}) + + # Redirect to the new folder + return redirect(url_for('files.browser', folder_id=new_folder.id)) + + # For GET, show the create folder form + return render_template('files/create_folder.html', + title='Create Folder', + parent_id=parent_id) + +@files_bp.route('/download/') +@login_required +def download(file_id): + file = File.query.filter_by(id=file_id, user_id=current_user.id, is_folder=False).first_or_404() + + # Record the download + download = Download( + file_id=file.id, + user_id=current_user.id, + ip_address=request.remote_addr + ) + db.session.add(download) + db.session.commit() + + return send_from_directory( + Config.UPLOAD_FOLDER, + file.storage_name, + as_attachment=True, + attachment_filename=file.name + ) + +@files_bp.route('/delete/', methods=['POST']) +@login_required +def delete(file_id): + file = File.query.filter_by(id=file_id, user_id=current_user.id).first_or_404() + parent_id = file.parent_id + + # If it's a folder, delete all children recursively + if file.is_folder: + delete_folder_recursive(file) + else: + # Delete the actual file + try: + os.remove(os.path.join(Config.UPLOAD_FOLDER, file.storage_name)) + except (FileNotFoundError, OSError): + pass # File already gone, continue + + # Delete the database record + db.session.delete(file) + + db.session.commit() + flash(f'{"Folder" if file.is_folder else "File"} deleted successfully', 'success') + + # Handle AJAX requests + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': True}) + + return redirect(url_for('files.browser', folder_id=parent_id)) + +def delete_folder_recursive(folder): + """Recursively delete a folder and all its contents""" + # First get all child items + children = File.query.filter_by(parent_id=folder.id).all() + + for child in children: + if child.is_folder: + delete_folder_recursive(child) + else: + # Delete the actual file + try: + os.remove(os.path.join(Config.UPLOAD_FOLDER, child.storage_name)) + except (FileNotFoundError, OSError): + pass # File already gone, continue + + # Delete the database record + db.session.delete(child) + + # Finally delete the folder itself + db.session.delete(folder) + +@files_bp.route('/rename/', methods=['POST']) +@login_required +def rename(file_id): + file = File.query.filter_by(id=file_id, user_id=current_user.id).first_or_404() + new_name = request.form.get('new_name', '').strip() + + if not new_name: + flash('Name cannot be empty', 'error') + return redirect(url_for('files.browser', folder_id=file.parent_id)) + + # Secure the new name + new_name = secure_filename(new_name) + + # Check if a file/folder with this name already exists + existing = File.query.filter_by( + name=new_name, + parent_id=file.parent_id, + user_id=current_user.id, + is_folder=file.is_folder + ).first() + + if existing and existing.id != file.id: + flash(f'A {"folder" if file.is_folder else "file"} with this name already exists', 'error') + return redirect(url_for('files.browser', folder_id=file.parent_id)) + + # Update the name + file.name = new_name + db.session.commit() + + flash(f'{"Folder" if file.is_folder else "File"} renamed successfully', 'success') + + # Handle AJAX requests + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': True}) + + return redirect(url_for('files.browser', folder_id=file.parent_id)) + +@files_bp.route('/share/', methods=['GET', 'POST']) +@login_required +def share(file_id): + file = File.query.filter_by(id=file_id, user_id=current_user.id).first_or_404() + + if request.method == 'POST': + # Generate share link + expires_days = request.form.get('expires', type=int) + + expires_at = None + if expires_days: + expires_at = datetime.utcnow() + timedelta(days=expires_days) + + # Create unique share token + share_token = str(uuid.uuid4()) + + # Save share in database + share = Share( + file_id=file.id, + user_id=current_user.id, + token=share_token, + expires_at=expires_at + ) + + db.session.add(share) + db.session.commit() + + # Generate the share URL + share_url = url_for('files.public_share', token=share_token, _external=True) + + flash('Share link created successfully', 'success') + return render_template('files/share_success.html', + title='Share Link', + file=file, + share=share, + share_url=share_url) + + return render_template('files/share.html', + title='Share File', + file=file) + +@files_bp.route('/public/') +def public_share(token): + # Find the share by token + share = Share.query.filter_by(token=token).first_or_404() + + # Check if share has expired + if share.expires_at and share.expires_at < datetime.utcnow(): + return render_template('files/share_expired.html', + title='Share Expired') + + # Get the file details + file = File.query.get_or_404(share.file_id) + + # Record the download + download = Download( + file_id=file.id, + share_id=share.id, + ip_address=request.remote_addr + ) + db.session.add(download) + db.session.commit() + + # If it's a viewable file type, show a preview + if file.mime_type and ( + file.mime_type.startswith('image/') or + file.mime_type == 'application/pdf' or + file.mime_type.startswith('text/') or + file.mime_type in ['application/javascript', 'application/json'] + ): + return render_template('files/preview.html', + title=file.name, + file=file, + share=share, + download_url=url_for('files.public_download', token=token)) + + # Otherwise, redirect to download + return redirect(url_for('files.public_download', token=token)) + +@files_bp.route('/public/download/') +def public_download(token): + # Find the share by token + share = Share.query.filter_by(token=token).first_or_404() + + # Check if share has expired + if share.expires_at and share.expires_at < datetime.utcnow(): + return render_template('files/share_expired.html', + title='Share Expired') + + # Get the file details + file = File.query.get_or_404(share.file_id) + + # Send the file + return send_from_directory( + Config.UPLOAD_FOLDER, + file.storage_name, + as_attachment=True, + attachment_filename=file.name + ) + +@files_bp.route('/upload_xhr', methods=['POST']) +@login_required +def upload_xhr(): + """Handle AJAX file uploads with progress tracking""" + if 'files[]' not in request.files: + return jsonify({'success': False, 'error': 'No files found in the request'}) + + files = request.files.getlist('files[]') + folder_id = request.form.get('folder_id', None, type=int) + is_folder = request.form.get('is_folder') == '1' + paths = request.form.getlist('paths[]') + + # Check if any files were selected + if not files or all(f.filename == '' for f in files): + return jsonify({'success': False, 'error': 'No files selected for upload'}) + + # Check folder exists if folder_id is provided + parent_folder = None + if folder_id: + parent_folder = File.query.filter_by(id=folder_id, user_id=current_user.id, is_folder=True).first() + if not parent_folder: + return jsonify({'success': False, 'error': 'Parent folder not found'}) + + # Process uploads + successful = 0 + failed = 0 + errors = [] + + # If this is a folder upload, we need to create the folder structure + folder_map = {} # Maps path to folder ID + + for i, file in enumerate(files): + try: + if file.filename == '': + continue + + # Get the relative path for folder uploads + relative_path = paths[i] if is_folder and i < len(paths) else None + + # Handle folder structure if needed + current_parent_id = folder_id + if is_folder and relative_path: + # Split path into directory components + path_parts = os.path.dirname(relative_path).split('/') + if path_parts and path_parts[0]: # Skip empty path (files at root) + # Create each folder in the path if needed + current_path = "" + for part in path_parts: + if not part: # Skip empty parts + continue + + current_path = os.path.join(current_path, part) if current_path else part + + # Check if we've already created this folder + if current_path in folder_map: + current_parent_id = folder_map[current_path] + continue + + # Check if folder already exists + folder_name = secure_filename(part) + existing_folder = File.query.filter_by( + name=folder_name, + parent_id=current_parent_id, + user_id=current_user.id, + is_folder=True + ).first() + + if existing_folder: + current_parent_id = existing_folder.id + folder_map[current_path] = existing_folder.id + else: + # Create new folder + new_folder = File( + name=folder_name, + parent_id=current_parent_id, + user_id=current_user.id, + is_folder=True + ) + db.session.add(new_folder) + db.session.flush() # Get the ID without committing + + current_parent_id = new_folder.id + folder_map[current_path] = new_folder.id + + # Now handle the actual file + filename = os.path.basename(relative_path) if relative_path else file.filename + filename = secure_filename(filename) + + # Check if file already exists + existing_file = File.query.filter_by( + name=filename, + parent_id=current_parent_id, + user_id=current_user.id, + is_folder=False + ).first() + + if existing_file: + # Create a unique name by adding timestamp + name_parts = os.path.splitext(filename) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"{name_parts[0]}_{timestamp}{name_parts[1]}" + + # Generate a unique storage name + storage_name = f"{str(uuid.uuid4())}{os.path.splitext(filename)[1]}" + + # Save the file + file_path = os.path.join(Config.UPLOAD_FOLDER, storage_name) + file.save(file_path) + + # Get file size and mime type + file_size = os.path.getsize(file_path) + mime_type = mimetypes.guess_type(filename)[0] or 'application/octet-stream' + + # Create file entry in database + db_file = File( + name=filename, + storage_name=storage_name, + mime_type=mime_type, + size=file_size, + user_id=current_user.id, + parent_id=current_parent_id, + is_folder=False + ) + + db.session.add(db_file) + successful += 1 + + except Exception as e: + failed += 1 + errors.append(f"{file.filename}: {str(e)}") + + # Commit all database changes + if successful > 0: + db.session.commit() + + result = { + 'success': True if successful > 0 else False, + 'message': f"Successfully uploaded {successful} files, {failed} failed.", + 'successful': successful, + 'failed': failed, + 'errors': errors + } + + return jsonify(result) \ No newline at end of file diff --git a/app/static/css/custom.css b/app/static/css/custom.css new file mode 100644 index 0000000..8eadb2d --- /dev/null +++ b/app/static/css/custom.css @@ -0,0 +1,1455 @@ +:root { + --primary: #364153; + --primary-light: #202732; + --primary-dark: #364153; + --secondary: #64748b; + --secondary-light: #94a3b8; + --success: #10b981; + --success-light: #34d399; + --danger: #ef4444; + --danger-light: #f87171; + --warning: #f59e0b; + --warning-light: #fbbf24; + --info: #0ea5e9; + --info-light: #38bdf8; + --light: #f8fafc; + --dark: #1e293b; + --body-bg: #ffffff; + --body-color: #334155; + --heading-color: #1e293b; + --link-color: #3b82f6; + --link-hover-color: #0b65ff; + --border-color: #e2e8f0; + --shadow-color: rgba(0, 0, 0, 0.1); + --card-bg: #ffffff; + --gradient-primary: linear-gradient(135deg, var(--primary) 0%, var(--primary-light) 100%); + --gradient-link: linear-gradient(135deg, var(--link-color) 0%, var(--link-hover-color) 100%); + --gradient-secondary: linear-gradient(135deg, var(--secondary) 0%, var(--secondary-light) 100%); + --gradient-success: linear-gradient(135deg, var(--success) 0%, var(--success-light) 100%); + --gradient-danger: linear-gradient(135deg, var(--danger) 0%, var(--danger-light) 100%); + --gradient-warning: linear-gradient(135deg, var(--warning) 0%, var(--warning-light) 100%); + --gradient-info: linear-gradient(135deg, var(--info) 0%, var(--info-light) 100%); + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.1); + --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + --shadow-md: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + --shadow-lg: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + --font-family-sans: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --font-family-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + --font-size-base: 1rem; + --line-height-base: 1.6; + --font-weight-normal: 400; + --font-weight-medium: 500; + --font-weight-bold: 700; + --spacing-base: 1rem; + --border-radius-sm: 0.25rem; + --border-radius: 0.5rem; + --border-radius-md: 0.75rem; + --border-radius-lg: 1rem; + --border-radius-xl: 1.5rem; + --border-radius-full: 9999px; + --container-max-width: 1200px; + --transition-base: all 0.2s ease-in-out; + --transition-slow: all 0.3s ease-in-out +} + +:root { + color-scheme: light; + --color-mode: 'light' +} + +@media (prefers-color-scheme:dark) { + :root { + --color-mode: 'dark' + } +} + +:root[color-scheme=dark] { + --primary: #13181f; + --primary-light: #13181f; + --primary-dark: #cbe0ff; + --body-bg: #0f172a; + --body-color: #e2e8f0; + --heading-color: #f8fafc; + --border-color: #334155; + --shadow-color: rgba(0, 0, 0, 0.3); + --card-bg: #1e293b; + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.2); + --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2); + --shadow-md: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -2px rgba(0, 0, 0, 0.2); + --shadow-lg: 0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 10px 10px -5px rgba(0, 0, 0, 0.2) +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box +} + +html { + font-size: 16px; + scroll-behavior: smooth +} + +body { + font-family: var(--font-family-sans); + font-size: var(--font-size-base); + line-height: var(--line-height-base); + color: var(--body-color); + background-color: var(--body-bg); + padding: var(--spacing-base); + max-width: var(--container-max-width); + margin: 0 auto; + transition: var(--transition-slow) +} + +h1, +h2, +h3, +h4, +h5, +h6 { + color: var(--heading-color); + margin-bottom: var(--spacing-base); + font-weight: var(--font-weight-bold); + line-height: 1.2; + letter-spacing: -.025em +} + +h1 { + font-size: 2.75rem; + margin-top: 2rem; + background: var(--heading-color); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + text-fill-color: transparent +} + +h2 { + font-size: 2.25rem; + margin-top: 1.75rem; + position: relative +} + +h2::after { + content: ""; + position: absolute; + bottom: -.5rem; + left: 0; + width: 3rem; + height: .15rem; + background: var(--gradient-primary); + border-radius: var(--border-radius-full) +} + +h3 { + font-size: 1.75rem; + margin-top: 1.5rem +} + +h4 { + font-size: 1.5rem; + margin-top: 1.25rem +} + +h5 { + font-size: 1.25rem; + margin-top: 1rem +} + +h6 { + font-size: 1rem; + margin-top: .75rem; + text-transform: uppercase; + letter-spacing: .05em +} + +p { + margin-bottom: var(--spacing-base) +} + +a { + color: var(--link-color); + text-decoration: none; + transition: var(--transition-base); + position: relative +} + +a:hover { + color: var(--link-hover-color) +} + +a:not(nav a):before { + content: ''; + position: absolute; + width: 100%; + transform: scaleX(0); + height: 2px; + bottom: -2px; + left: 0; + background: var(--gradient-link); + transform-origin: bottom right; + transition: transform .3s ease-out +} + +a:not(nav a):hover:before { + transform: scaleX(1); + transform-origin: bottom left +} + +a[target="_blank"]:after { + content: "[+]"; + display: inline-block; + margin-left: .25em; + font-size: .6em; + font-weight: 700; + text-decoration: none; + vertical-align: super +} + +small { + font-size: .875rem; + opacity: .85 +} + +code, +pre { + font-family: var(--font-family-mono); + border-radius: var(--border-radius) +} + +code { + padding: .2em .4em; + font-size: .875em; + background-color: rgba(0, 0, 0, .05) +} + +:root[color-scheme=dark] code { + background-color: rgba(255, 255, 255, .1) +} + +pre { + padding: var(--spacing-base); + margin-bottom: var(--spacing-base); + overflow-x: auto; + background-color: rgba(0, 0, 0, .03); + border-radius: var(--border-radius-md); + box-shadow: var(--shadow-sm) +} + +:root[color-scheme=dark] pre { + background-color: rgba(255, 255, 255, .05) +} + +pre code { + display: block; + background-color: transparent +} + +blockquote { + border-left: 4px solid var(--primary); + padding: var(--spacing-base); + margin-bottom: var(--spacing-base); + font-style: italic; + background-color: rgba(0, 0, 0, .02); + border-radius: 0 var(--border-radius) var(--border-radius) 0; + box-shadow: var(--shadow-sm) +} + +:root[color-scheme=dark] blockquote { + background-color: rgba(255, 255, 255, .03) +} + +hr { + border: 0; + height: 1px; + background: linear-gradient(to right, transparent, var(--border-color), transparent); + margin: calc(var(--spacing-base) * 2) 0 +} + +ol, +ul { + margin-bottom: var(--spacing-base); + padding-left: 1.5rem +} + +li { + margin-bottom: calc(var(--spacing-base) * .5) +} + +table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + margin-bottom: var(--spacing-base); + border-radius: var(--border-radius); + overflow: hidden; + box-shadow: var(--shadow) +} + +td, +th { + padding: calc(var(--spacing-base) * .75); + border-bottom: 1px solid var(--border-color); + text-align: left +} + +thead { + background: var(--gradient-secondary) +} + +th { + font-weight: var(--font-weight-bold); + color: #fff; + text-transform: uppercase; + font-size: .875rem; + letter-spacing: .05em +} + +tr:last-child td { + border-bottom: none +} + +tr:hover td { + background-color: rgba(0, 0, 0, .02) +} + +:root[color-scheme=dark] tr:hover td { + background-color: rgba(255, 255, 255, .03) +} + +button, +input, +select, +textarea { + font-family: inherit; + font-size: inherit; + line-height: inherit +} + +input[type=date], +input[type=datetime-local], +input[type=email], +input[type=month], +input[type=number], +input[type=password], +input[type=search], +input[type=tel], +input[type=text], +input[type=time], +input[type=url], +input[type=week], +select, +textarea { + display: block; + width: 100%; + padding: calc(var(--spacing-base) * .75); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-md); + background-color: var(--body-bg); + color: var(--body-color); + margin-bottom: var(--spacing-base); + transition: var(--transition-base); + box-shadow: var(--shadow-sm) +} + +input:focus, +select:focus, +textarea:focus { + outline: 0; + border-color: var(--border-color); + box-shadow: 0 0 0 3px rgba(79, 70, 229, .25) +} + +select { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + padding-right: 2.5rem; + background-image: url("data:image/svg+xml;utf8,"); + background-repeat: no-repeat; + background-position: right 1rem center; + background-size: 2rem +} + +:root[color-scheme=dark] select { + background-image: url("data:image/svg+xml;utf8,") +} + +textarea { + min-height: 9rem; + resize: vertical +} + +label { + display: block; + margin-bottom: calc(var(--spacing-base) * .5); + font-weight: var(--font-weight-medium) +} + +button, +input[type=button], +input[type=reset], +input[type=submit] { + min-width: 100px; + display: inline-block; + padding: calc(var(--spacing-base) * .75) var(--spacing-base); + background: var(--gradient-primary); + color: #fff; + border: none; + border-radius: var(--border-radius-md); + cursor: pointer; + font-weight: var(--font-weight-medium); + text-align: center; + transition: var(--transition-base); + position: relative; + overflow: hidden +} + +button::after, +input[type=button]::after, +input[type=reset]::after, +input[type=submit]::after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(255, 255, 255, 0); + transition: var(--transition-base) +} + +button:hover::after, +input[type=button]:hover::after, +input[type=reset]:hover::after, +input[type=submit]:hover::after { + background-color: rgba(255, 255, 255, .1) +} + +button:hover, +input[type=button]:hover, +input[type=reset]:hover, +input[type=submit]:hover { + box-shadow: var(--shadow-md) +} + +button:active, +input[type=button]:active, +input[type=reset]:active, +input[type=submit]:active { + transform: translateY(2px); + box-shadow: var(--shadow-sm) +} + +button:disabled, +input[type=button]:disabled, +input[type=reset]:disabled, +input[type=submit]:disabled { + background: var(--gradient-secondary); + cursor: not-allowed; + opacity: .7; + transform: none +} + +button.success, +input[type=button].success, +input[type=submit].success { + background: var(--gradient-success) +} + +button.danger, +input[type=button].danger, +input[type=submit].danger { + background: var(--gradient-danger) +} + +button.warning, +input[type=button].warning, +input[type=submit].warning { + background: var(--gradient-warning) +} + +button.info, +input[type=button].info, +input[type=submit].info { + background: var(--gradient-info) +} + +button.outline, +input[type=button].outline, +input[type=submit].outline { + background: 0 0; + border: 1px solid var(--primary-dark); + color: var(--primary-dark) +} + +button.outline:hover, +input[type=button].outline:hover, +input[type=submit].outline:hover { + background-color: var(--primary); + color: #fff +} + +img { + max-width: 100%; + height: auto; + border-radius: var(--border-radius-md); + transition: var(--transition-base); + box-shadow: var(--shadow) +} + +img:hover { + transform: scale(1.01); + box-shadow: var(--shadow-md) +} + +figure { + text-align: center; + margin-bottom: var(--spacing-base) +} + +figcaption { + font-size: .875rem; + color: var(--secondary); + text-align: center; + margin-top: calc(var(--spacing-base) * .5); + font-style: italic +} + +details summary { + cursor: pointer; + position: relative; + padding-bottom: calc(var(--spacing-base) * .5) +} + +details summary::after { + content: ''; + position: absolute; + left: 0; + bottom: 0; + width: 100%; + height: 1px; + background: linear-gradient(to right, var(--border-color), transparent) +} + +details>:not(summary) { + cursor: auto; + margin-top: calc(var(--spacing-base) * .5) +} + +article, +aside, +section { + margin-bottom: calc(var(--spacing-base) * 2); + padding: var(--spacing-base); + background-color: var(--card-bg); + border-radius: var(--border-radius-lg); + box-shadow: var(--shadow); + transition: var(--transition-base); + border: 1px solid var(--border-color) +} + +article:hover, +aside:hover, +section:hover { + box-shadow: var(--shadow-md) +} + +:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px +} + +::-webkit-scrollbar { + width: 10px; + height: 10px +} + +::-webkit-scrollbar-track { + background: rgba(0, 0, 0, .05); + border-radius: var(--border-radius-full) +} + +::-webkit-scrollbar-thumb { + background: var(--gradient-secondary); + border-radius: var(--border-radius-full) +} + +::-webkit-scrollbar-thumb:hover { + background: var(--gradient-primary) +} + +.glass { + background: rgba(255, 255, 255, .5); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, .2); + color: var(--body-color); + box-shadow: 0 4px 6px rgba(0, 0, 0, .1) +} + +:root[color-scheme=dark] .glass { + background: rgba(0, 0, 0, .3); + border: 1px solid rgba(255, 255, 255, .1) +} + +:root:not([color-scheme=dark]) .glass { + text-shadow: 0 1px 1px rgba(0, 0, 0, .1) +} + +@media (max-width:768px) { + html { + font-size: 14px + } + + body { + padding: calc(var(--spacing-base) * .75) + } + + table { + width: 100%; + display: block; + overflow-x: auto + } + + h1 { + font-size: 2.25rem + } + + h2 { + font-size: 1.75rem + } +} + +@media print { + body { + background-color: #fff; + color: #000 + } + + a { + text-decoration: underline; + color: #000 + } + + a[href]:after { + content: " (" attr(href) ")" + } + + blockquote, + pre { + border: 1px solid #999; + page-break-inside: avoid + } + + thead { + background: var(--gradient-secondary); + display: table-header-group + } + + img, + tr { + page-break-inside: avoid + } + + h2, + h3, + p { + orphans: 3; + widows: 3 + } + + h2, + h3 { + page-break-after: avoid + } + + button, + h1, + h2::after, + input[type=button], + input[type=reset], + input[type=submit] { + background: 0 0 !important; + -webkit-text-fill-color: #000; + text-fill-color: #000; + box-shadow: none !important + } + + article, + aside, + blockquote, + img, + pre, + section, + table { + box-shadow: none !important + } +} + +a, +article, +aside, +blockquote, +body, +button, +code, +input, +pre, +section, +select, +td, +textarea, +th { + transition: background-color .3s ease, color .3s ease, border-color .3s ease, box-shadow .3s ease +} + +@keyframes colorSchemeTransition { + 0% { + opacity: 0 + } + + 100% { + opacity: 1 + } +} + +body { + animation: colorSchemeTransition .5s ease-out +} + +/* Auth Forms */ +.auth-container { + max-width: 400px; + margin: 2rem auto; + padding: 2rem; + background: var(--card-bg); + border-radius: var(--border-radius-md); + box-shadow: var(--shadow-md); +} + +.auth-form .form-group { + margin-bottom: 1.5rem; +} + +.auth-links { + margin-top: 1.5rem; + text-align: center; +} + +/* File Browser */ +.file-browser { + background: var(--card-bg); + border-radius: var(--border-radius-md); + box-shadow: var(--shadow); + padding: 1.5rem; +} + +.browser-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} + +.browser-actions { + display: flex; + gap: 0.5rem; +} + +.path-nav { + background: var(--body-bg); + padding: 0.75rem; + border-radius: var(--border-radius); + margin-bottom: 1.5rem; + font-family: var(--font-family-mono); + font-size: 0.9rem; + overflow-x: auto; + white-space: nowrap; +} + +.path-item { + color: var(--link-color); + text-decoration: none; +} + +.path-separator { + margin: 0 0.5rem; + color: var(--secondary); +} + +.files-container { + display: flex; + flex-direction: column; + gap: 2rem; +} + +.folder-section, +.file-section { + width: 100%; +} + +.files-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 1rem; + margin-top: 1rem; +} + +.file-item { + position: relative; + background: var(--body-bg); + border-radius: var(--border-radius); + overflow: hidden; + padding: 1rem; + box-shadow: var(--shadow-sm); + transition: transform 0.2s, box-shadow 0.2s; + display: flex; + flex-direction: column; + height: 150px; +} + +.file-item:hover { + transform: translateY(-3px); + box-shadow: var(--shadow-md); +} + +.file-item.folder { + background: rgba(59, 130, 246, 0.1); + border: 1px solid rgba(59, 130, 246, 0.2); +} + +.file-item .file-link { + display: flex; + flex-direction: column; + align-items: center; + text-decoration: none; + color: var(--body-color); + flex: 1; +} + +.file-icon, +.file-preview { + font-size: 2.5rem; + margin-bottom: 0.5rem; + text-align: center; +} + +.file-name { + font-size: 0.9rem; + font-weight: var(--font-weight-medium); + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + width: 100%; +} + +.file-details { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.file-meta { + display: flex; + justify-content: center; + gap: 1rem; + font-size: 0.75rem; + color: var(--secondary); + margin-top: 0.5rem; +} + +.file-actions { + position: absolute; + top: 0.5rem; + right: 0.5rem; + display: none; + background: var(--card-bg); + border-radius: var(--border-radius); + padding: 0.25rem; + box-shadow: var(--shadow); +} + +.file-item:hover .file-actions { + display: flex; + gap: 0.5rem; +} + +.action-btn { + background: none; + border: none; + cursor: pointer; + font-size: 1rem; + padding: 0.25rem; + border-radius: var(--border-radius-sm); + transition: background-color 0.2s; +} + +.action-btn:hover { + background: rgba(0, 0, 0, 0.05); +} + +.empty-state { + text-align: center; + padding: 3rem 1rem; + color: var(--secondary); +} + +.empty-icon { + font-size: 4rem; + margin-bottom: 1rem; + opacity: 0.5; +} + +/* Dashboard */ +.dashboard { + display: flex; + flex-direction: column; + gap: 2rem; +} + +.dashboard-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1.5rem; + margin-top: 1rem; +} + +.stat-card { + display: flex; + align-items: center; + background: var(--card-bg); + padding: 1.5rem; + border-radius: var(--border-radius-md); + box-shadow: var(--shadow); +} + +.stat-icon { + font-size: 2.5rem; + margin-right: 1.5rem; +} + +.stat-info { + display: flex; + flex-direction: column; +} + +.stat-value { + font-size: 1.75rem; + font-weight: var(--font-weight-bold); + margin-bottom: 0.25rem; +} + +.stat-label { + color: var(--secondary); + font-size: 0.9rem; +} + +.dashboard-recent { + background: var(--card-bg); + border-radius: var(--border-radius-md); + padding: 1.5rem; + box-shadow: var(--shadow); +} + +.recent-files-list { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-top: 1rem; +} + +.recent-files-list .file-item { + height: auto; + flex-direction: row; + align-items: center; + padding: 0.75rem; +} + +.recent-files-list .file-icon { + margin-bottom: 0; + margin-right: 1rem; + font-size: 1.75rem; +} + +.recent-files-list .file-details { + align-items: flex-start; +} + +.recent-files-list .file-name { + text-align: left; +} + +.recent-files-list .file-meta { + justify-content: flex-start; +} + +.dashboard-actions { + display: flex; + gap: 1rem; + margin-top: 1rem; +} + +/* Profile */ +.profile-container { + max-width: 600px; + margin: 2rem auto; +} + +.profile-card { + background: var(--card-bg); + border-radius: var(--border-radius-md); + box-shadow: var(--shadow); + padding: 2rem; +} + +.profile-header { + display: flex; + align-items: center; + margin-bottom: 2rem; +} + +.avatar { + width: 80px; + height: 80px; + border-radius: 50%; + background: var(--gradient-primary); + color: white; + display: flex; + align-items: center; + justify-content: center; + font-size: 2rem; + font-weight: var(--font-weight-bold); + margin-right: 1.5rem; +} + +.profile-stats { + display: flex; + gap: 2rem; + margin-bottom: 2rem; +} + +.profile-actions { + display: flex; + gap: 1rem; +} + +/* Improved Navigation Bar */ +header { + position: sticky; + top: 0; + z-index: 100; + background: var(--card-bg); + box-shadow: var(--shadow); + padding: 0; + transition: transform 0.3s ease; +} + +header.hidden { + transform: translateY(-100%); +} + +nav { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 2rem; + max-width: var(--container-max-width); + margin: 0 auto; +} + +nav .logo h1 { + margin: 0; + font-size: 1.5rem; +} + +nav .logo a { + text-decoration: none; + color: var(--heading-color); + background: var(--gradient-primary); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +nav ul { + display: flex; + list-style: none; + gap: 1.5rem; + align-items: center; + margin: 0; + padding: 0; +} + +nav ul li a { + text-decoration: none; + color: var(--body-color); + font-weight: var(--font-weight-medium); + transition: color 0.2s; +} + +nav ul li a:hover { + color: var(--link-color); +} + +.toggle-button { + background: none; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 50%; + padding: 0; + background: var(--body-bg); + color: var(--body-color); + font-size: 1rem; +} + +/* Improved Flash Messages */ +.alerts { + position: fixed; + top: 1rem; + right: 1rem; + z-index: 1000; + display: flex; + flex-direction: column; + gap: 0.5rem; + max-width: 300px; +} + +.alert { + background: var(--card-bg); + border-radius: var(--border-radius); + padding: 1rem; + box-shadow: var(--shadow-md); + color: var(--body-color); + position: relative; + animation: alertIn 0.3s ease forwards, alertOut 0.3s ease 5s forwards; + display: flex; + align-items: center; + justify-content: space-between; +} + +.alert.success { + border-left: 4px solid var(--success); +} + +.alert.error { + border-left: 4px solid var(--danger); +} + +.alert.info { + border-left: 4px solid var(--info); +} + +.alert.warning { + border-left: 4px solid var(--warning); +} + +.alert .close { + background: none; + border: none; + cursor: pointer; + font-size: 1.25rem; + margin-left: 0.5rem; + opacity: 0; + transition: opacity 0.2s; +} + +.alert:hover .close { + opacity: 1; +} + +@keyframes alertIn { + from { + transform: translateX(100%); + opacity: 0; + } + + to { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes alertOut { + from { + transform: translateX(0); + opacity: 1; + } + + to { + transform: translateX(100%); + opacity: 0; + } +} + +/* For mobile responsiveness */ +@media (max-width: 768px) { + nav { + flex-direction: column; + padding: 1rem; + } + + nav ul { + flex-direction: column; + width: 100%; + margin-top: 1rem; + } + + .stat-card { + padding: 1rem; + } + + .stat-icon { + font-size: 2rem; + margin-right: 1rem; + } + + .files-list { + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + } + + .file-item { + height: 120px; + } + + .avatar { + margin-right: 0; + margin-bottom: 1rem; + } +} + +/* Upload Overlay */ +.upload-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + z-index: 1050; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + visibility: hidden; + transition: opacity 0.3s ease, visibility 0.3s ease; +} + +.upload-overlay.active { + opacity: 1; + visibility: visible; +} + +.upload-modal { + width: 90%; + max-width: 800px; + max-height: 90vh; + background-color: var(--card-bg); + border-radius: var(--border-radius-md); + box-shadow: var(--shadow-lg); + display: flex; + flex-direction: column; + overflow: hidden; + transform: translateY(20px); + transition: transform 0.3s ease; +} + +.upload-overlay.active .upload-modal { + transform: translateY(0); +} + +.upload-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--border-color); +} + +.upload-header h3 { + margin: 0; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.close-upload { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: var(--body-color); + opacity: 0.7; + transition: opacity 0.2s; +} + +.close-upload:hover { + opacity: 1; +} + +.upload-body { + padding: 1.5rem; + overflow-y: auto; + flex: 1; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.upload-dropzone { + border: 2px dashed var(--border-color); + border-radius: var(--border-radius); + padding: 2rem; + text-align: center; + transition: border-color 0.2s, background-color 0.2s; + cursor: pointer; +} + +.upload-dropzone.drag-over { + border-color: var(--primary-color); + background-color: rgba(59, 130, 246, 0.05); +} + +.upload-icon { + font-size: 3rem; + color: var(--secondary); + margin-bottom: 1rem; +} + +.upload-dropzone p { + margin: 0.5rem 0; + color: var(--body-color); +} + +.upload-buttons { + display: flex; + justify-content: center; + gap: 1rem; + margin-top: 1rem; +} + +.upload-list { + background-color: var(--body-bg); + border-radius: var(--border-radius); + padding: 1rem; +} + +.upload-list h4 { + margin: 0 0 1rem 0; + font-size: 1rem; + color: var(--body-color); +} + +.upload-items { + display: flex; + flex-direction: column; + gap: 0.75rem; + max-height: 200px; + overflow-y: auto; +} + +.upload-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem; + background-color: var(--card-bg); + border-radius: var(--border-radius); + box-shadow: var(--shadow-sm); +} + +.upload-item-icon { + font-size: 1.25rem; + color: var(--secondary); +} + +.upload-item-details { + flex: 1; +} + +.upload-item-name { + font-size: 0.9rem; + font-weight: var(--font-weight-medium); + color: var(--body-color); + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.upload-item-size { + font-size: 0.75rem; + color: var(--secondary); +} + +.upload-item-progress { + width: 100%; + height: 4px; + background-color: var(--border-color); + border-radius: var(--border-radius-full); + margin-top: 0.5rem; + overflow: hidden; +} + +.upload-item-progress-bar { + height: 100%; + background-color: var(--primary-color); + width: 0%; + transition: width 0.2s; +} + +.upload-progress-overall { + margin-top: auto; +} + +.progress-label { + display: flex; + justify-content: space-between; + margin-bottom: 0.5rem; + font-size: 0.875rem; + color: var(--body-color); +} + +.progress-bar-container { + height: 8px; + background-color: var(--border-color); + border-radius: var(--border-radius-full); + overflow: hidden; +} + +.progress-bar { + height: 100%; + background-color: var(--primary-color); + width: 0%; + transition: width 0.3s; +} + +.upload-footer { + display: flex; + justify-content: flex-end; + gap: 1rem; + padding: 1rem 1.5rem; + border-top: 1px solid var(--border-color); +} + +/* Upload Status Indicators */ +.upload-item.uploading .upload-item-icon { + color: var(--info); +} + +.upload-item.success .upload-item-icon { + color: var(--success); +} + +.upload-item.error .upload-item-icon { + color: var(--danger); +} \ No newline at end of file diff --git a/app/static/css/upload.css b/app/static/css/upload.css new file mode 100644 index 0000000..acc4563 --- /dev/null +++ b/app/static/css/upload.css @@ -0,0 +1,302 @@ +/* Upload specific styles */ +.upload-container { + max-width: 800px; + margin: 0 auto; + background: var(--card-bg); + border-radius: var(--border-radius-md); + box-shadow: var(--shadow-md); + padding: 1.5rem; +} + +.upload-tabs { + display: flex; + border-bottom: 1px solid var(--border-color); + margin-bottom: 1.5rem; +} + +.tab-btn { + background: none; + border: none; + padding: 0.75rem 1.5rem; + font-size: 1rem; + font-weight: var(--font-weight-medium); + color: var(--body-color); + cursor: pointer; + border-bottom: 2px solid transparent; + transition: var(--transition-base); +} + +.tab-btn.active { + color: var(--link-color); + border-bottom-color: var(--link-color); +} + +.tab-content { + display: none; +} + +.tab-content.active { + display: block; +} + +.upload-location { + padding: 0.75rem; + background-color: var(--body-bg); + border-radius: var(--border-radius); + margin-bottom: 1.5rem; +} + +.upload-dropzone { + border: 2px dashed var(--border-color); + border-radius: var(--border-radius); + padding: 2rem; + text-align: center; + margin-bottom: 1.5rem; + transition: all 0.2s; + cursor: pointer; +} + +.upload-dropzone.highlight { + border-color: var(--link-color); + background-color: rgba(59, 130, 246, 0.05); +} + +.upload-icon { + font-size: 3rem; + color: var(--secondary); + margin-bottom: 1rem; +} + +.upload-progress-container { + background-color: var(--body-bg); + border-radius: var(--border-radius); + padding: 1rem; + margin-bottom: 1.5rem; +} + +.upload-progress-container h4 { + margin: 0 0 1rem 0; + font-size: 1rem; + color: var(--body-color); +} + +.progress-overall { + margin-bottom: 1rem; +} + +.progress-label { + display: flex; + justify-content: space-between; + margin-bottom: 0.5rem; + font-size: 0.875rem; + color: var(--body-color); +} + +.progress-bar-container { + height: 8px; + background-color: var(--border-color); + border-radius: var(--border-radius-full); + overflow: hidden; +} + +.progress-bar { + height: 100%; + background-color: var(--primary-color); + width: 0%; + transition: width 0.3s; +} + +.upload-stats { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem; + margin-top: 0.75rem; + font-size: 0.75rem; +} + +.stat { + display: flex; + flex-direction: column; + min-width: 80px; +} + +.stat-label { + color: var(--secondary); + margin-bottom: 0.25rem; +} + +.stat-value { + font-weight: var(--font-weight-medium); +} + +.selected-files { + background-color: var(--body-bg); + border-radius: var(--border-radius); + padding: 1rem; + margin-bottom: 1.5rem; +} + +.selected-files h4 { + margin: 0 0 1rem 0; + font-size: 1rem; + color: var(--body-color); +} + +.file-list { + max-height: 300px; + overflow-y: auto; +} + +.file-item { + display: flex; + align-items: center; + padding: 0.75rem; + margin-bottom: 0.5rem; + background-color: var(--card-bg); + border-radius: var(--border-radius); + box-shadow: var(--shadow-sm); + transition: all 0.2s; +} + +.file-item.success { + background-color: rgba(16, 185, 129, 0.1); +} + +.file-item.error { + background-color: rgba(239, 68, 68, 0.1); +} + +.file-item-icon { + margin-right: 1rem; + font-size: 1.25rem; + color: var(--secondary); +} + +.file-item.success .file-item-icon { + color: var(--success); +} + +.file-item.error .file-item-icon { + color: var(--danger); +} + +.file-item-details { + flex: 1; +} + +.file-item-name { + font-size: 0.9rem; + font-weight: var(--font-weight-medium); + color: var(--body-color); + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.file-item-size { + font-size: 0.75rem; + color: var(--secondary); + margin: 0; +} + +.file-item-status { + margin-left: 1rem; +} + +.status-indicator { + width: 12px; + height: 12px; + border-radius: 50%; +} + +.status-indicator.waiting { + background-color: var(--secondary); + animation: pulse 1.5s infinite; +} + +.status-indicator.uploading { + background-color: var(--info); + animation: pulse 1.5s infinite; +} + +.status-indicator.success { + background-color: var(--success); +} + +.status-indicator.error { + background-color: var(--danger); +} + +@keyframes pulse { + 0% { + opacity: 0.3; + } + + 50% { + opacity: 1; + } + + 100% { + opacity: 0.3; + } +} + +.empty-message { + color: var(--secondary); + text-align: center; + padding: 1rem; +} + +.form-actions { + display: flex; + justify-content: flex-end; + gap: 1rem; +} + +.alerts { + position: fixed; + top: 1rem; + right: 1rem; + z-index: 1000; + max-width: 400px; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.alert { + padding: 1rem; + border-radius: var(--border-radius); + background-color: var(--card-bg); + box-shadow: var(--shadow-md); + display: flex; + justify-content: space-between; + align-items: flex-start; + animation: alertIn 0.5s forwards; +} + +@keyframes alertIn { + from { + transform: translateX(100%); + opacity: 0; + } + + to { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes alertOut { + from { + transform: translateX(0); + opacity: 1; + } + + to { + transform: translateX(100%); + opacity: 0; + } +} \ No newline at end of file diff --git a/app/static/js/upload.js b/app/static/js/upload.js new file mode 100644 index 0000000..4f82858 --- /dev/null +++ b/app/static/js/upload.js @@ -0,0 +1,470 @@ +document.addEventListener('DOMContentLoaded', function () { + // File Upload JavaScript + + const fileForm = document.getElementById('file-upload-form'); + const folderForm = document.getElementById('folder-upload-form'); + const fileInput = document.getElementById('file-input'); + const folderInput = document.getElementById('folder-input'); + const fileDropzone = document.getElementById('file-dropzone'); + const folderDropzone = document.getElementById('folder-dropzone'); + const fileList = document.getElementById('file-list'); + const folderList = document.getElementById('folder-file-list'); + + // Progress elements + const progressBar = document.getElementById('progress-bar'); + const progressPercentage = document.getElementById('progress-percentage'); + const folderProgressBar = document.getElementById('folder-progress-bar'); + const folderProgressPercentage = document.getElementById('folder-progress-percentage'); + const uploadSpeed = document.getElementById('upload-speed'); + const folderUploadSpeed = document.getElementById('folder-upload-speed'); + const uploadedSize = document.getElementById('uploaded-size'); + const folderUploadedSize = document.getElementById('folder-uploaded-size'); + const timeRemaining = document.getElementById('time-remaining'); + const folderTimeRemaining = document.getElementById('folder-time-remaining'); + + // Variables for tracking upload progress + let uploadStartTime = 0; + let lastUploadedBytes = 0; + let totalBytes = 0; + let uploadedBytes = 0; + let uploadIntervalId = null; + + // Initialize upload forms + if (fileInput) { + fileInput.addEventListener('change', function () { + if (this.files.length > 0) { + prepareAndUploadFiles(this.files, false); + } + }); + } + + if (folderInput) { + folderInput.addEventListener('change', function () { + if (this.files.length > 0) { + prepareAndUploadFiles(this.files, true); + } + }); + } + + // Drag and drop setup + if (fileDropzone) { + ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(event => { + fileDropzone.addEventListener(event, preventDefaults, false); + }); + + ['dragenter', 'dragover'].forEach(event => { + fileDropzone.addEventListener(event, function () { + this.classList.add('highlight'); + }, false); + }); + + ['dragleave', 'drop'].forEach(event => { + fileDropzone.addEventListener(event, function () { + this.classList.remove('highlight'); + }, false); + }); + + fileDropzone.addEventListener('drop', function (e) { + if (e.dataTransfer.files.length > 0) { + prepareAndUploadFiles(e.dataTransfer.files, false); + } + }, false); + } + + if (folderDropzone) { + ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(event => { + folderDropzone.addEventListener(event, preventDefaults, false); + }); + + ['dragenter', 'dragover'].forEach(event => { + folderDropzone.addEventListener(event, function () { + this.classList.add('highlight'); + }, false); + }); + + ['dragleave', 'drop'].forEach(event => { + folderDropzone.addEventListener(event, function () { + this.classList.remove('highlight'); + }, false); + }); + + folderDropzone.addEventListener('drop', function (e) { + // Check if items contains directories + let hasFolder = false; + if (e.dataTransfer.items) { + for (let i = 0; i < e.dataTransfer.items.length; i++) { + const item = e.dataTransfer.items[i].webkitGetAsEntry && + e.dataTransfer.items[i].webkitGetAsEntry(); + if (item && item.isDirectory) { + hasFolder = true; + break; + } + } + } + + if (hasFolder) { + showMessage('Folder detected, but browser API limitations prevent direct processing. Please use the Select Folder button.', 'info'); + } else if (e.dataTransfer.files.length > 0) { + showMessage('These appear to be files, not a folder. Using the Files tab instead.', 'info'); + // Switch to files tab and upload there + document.querySelector('[data-tab="file-tab"]').click(); + setTimeout(() => { + prepareAndUploadFiles(e.dataTransfer.files, false); + }, 300); + } + }, false); + } + + // Tab switching + const tabBtns = document.querySelectorAll('.tab-btn'); + const tabContents = document.querySelectorAll('.tab-content'); + + tabBtns.forEach(btn => { + btn.addEventListener('click', function () { + const tabId = this.dataset.tab; + + // Remove active class from all tabs and contents + tabBtns.forEach(b => b.classList.remove('active')); + tabContents.forEach(c => c.classList.remove('active')); + + // Add active class to current tab and content + this.classList.add('active'); + document.getElementById(tabId).classList.add('active'); + }); + }); + + // Helper functions + function preventDefaults(e) { + e.preventDefault(); + e.stopPropagation(); + } + + function prepareAndUploadFiles(files, isFolder) { + // Reset upload tracking + uploadStartTime = Date.now(); + lastUploadedBytes = 0; + totalBytes = 0; + uploadedBytes = 0; + + // Calculate total size + for (let i = 0; i < files.length; i++) { + totalBytes += files[i].size; + } + + // Display files + const targetList = isFolder ? folderList : fileList; + displayFiles(files, targetList); + + // Start upload + uploadFiles(files, isFolder); + + // Start progress tracking + if (uploadIntervalId) { + clearInterval(uploadIntervalId); + } + uploadIntervalId = setInterval(updateProgress, 1000); + } + + function displayFiles(files, targetList) { + targetList.innerHTML = ''; + + if (files.length === 0) { + targetList.innerHTML = '

No files selected

'; + return; + } + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + const item = document.createElement('div'); + item.className = 'file-item'; + item.id = `file-item-${i}`; + + // Get relative path for folder uploads + let displayName = file.name; + if (file.webkitRelativePath && file.webkitRelativePath !== '') { + displayName = file.webkitRelativePath; + } + + item.innerHTML = ` +
+ +
+
+

${displayName}

+

${formatSize(file.size)}

+
+
+
+
+ `; + + targetList.appendChild(item); + } + } + + function uploadFiles(files, isFolder) { + const formData = new FormData(); + const folderId = isFolder ? + document.querySelector('#folder-upload-form input[name="folder_id"]').value : + document.querySelector('#file-upload-form input[name="folder_id"]').value; + + formData.append('folder_id', folderId); + formData.append('is_folder', isFolder ? '1' : '0'); + + // Add files to form data + for (let i = 0; i < files.length; i++) { + formData.append('files[]', files[i]); + + // If it's a folder upload, also include the path + if (isFolder && files[i].webkitRelativePath) { + formData.append('paths[]', files[i].webkitRelativePath); + } else { + formData.append('paths[]', ''); + } + + // Update file status to uploading + const statusIndicator = document.getElementById(`status-${i}`); + if (statusIndicator) { + statusIndicator.className = 'status-indicator uploading'; + } + } + + // Create and configure XHR request + const xhr = new XMLHttpRequest(); + xhr.open('POST', '/files/upload_xhr', true); + + // Set up progress event + xhr.upload.onprogress = function (e) { + if (e.lengthComputable) { + uploadedBytes = e.loaded; + const percent = Math.round((e.loaded / e.total) * 100); + + if (isFolder) { + folderProgressBar.style.width = `${percent}%`; + folderProgressPercentage.textContent = `${percent}%`; + } else { + progressBar.style.width = `${percent}%`; + progressPercentage.textContent = `${percent}%`; + } + } + }; + + // Set up completion and error handlers + xhr.onload = function () { + if (xhr.status === 200) { + try { + const response = JSON.parse(xhr.responseText); + + if (response.success) { + showMessage(`Successfully uploaded ${response.successful} files.`, 'success'); + + // Update all file statuses to success + for (let i = 0; i < files.length; i++) { + const statusIndicator = document.getElementById(`status-${i}`); + if (statusIndicator) { + statusIndicator.className = 'status-indicator success'; + } + } + + // Mark specific failures if any + if (response.errors && response.errors.length > 0) { + for (let i = 0; i < response.errors.length; i++) { + // Try to find the file by name + const errorFileName = response.errors[i].split(':')[0]; + for (let j = 0; j < files.length; j++) { + if (files[j].name === errorFileName) { + const statusIndicator = document.getElementById(`status-${j}`); + if (statusIndicator) { + statusIndicator.className = 'status-indicator error'; + } + break; + } + } + } + + // Show error messages + showMessage(`Failed to upload some files. See errors for details.`, 'warning'); + response.errors.forEach(err => showMessage(err, 'error')); + } + } else { + showMessage(response.error || 'Upload failed', 'error'); + + // Update all file statuses to error + for (let i = 0; i < files.length; i++) { + const statusIndicator = document.getElementById(`status-${i}`); + if (statusIndicator) { + statusIndicator.className = 'status-indicator error'; + } + } + } + } catch (e) { + showMessage('Error parsing server response', 'error'); + } + } else { + showMessage(`Upload failed with status ${xhr.status}`, 'error'); + + // Update all file statuses to error + for (let i = 0; i < files.length; i++) { + const statusIndicator = document.getElementById(`status-${i}`); + if (statusIndicator) { + statusIndicator.className = 'status-indicator error'; + } + } + } + + // Stop progress updates + if (uploadIntervalId) { + clearInterval(uploadIntervalId); + uploadIntervalId = null; + } + }; + + xhr.onerror = function () { + showMessage('Network error during upload', 'error'); + + // Update all file statuses to error + for (let i = 0; i < files.length; i++) { + const statusIndicator = document.getElementById(`status-${i}`); + if (statusIndicator) { + statusIndicator.className = 'status-indicator error'; + } + } + + // Stop progress updates + if (uploadIntervalId) { + clearInterval(uploadIntervalId); + uploadIntervalId = null; + } + }; + + // Send the request + xhr.send(formData); + } + + function updateProgress() { + const currentTime = Date.now(); + const elapsedSeconds = (currentTime - uploadStartTime) / 1000; + + // Calculate upload speed (bytes per second) + const bytesPerSecond = elapsedSeconds > 0 ? uploadedBytes / elapsedSeconds : 0; + + // Calculate remaining time + const remainingBytes = totalBytes - uploadedBytes; + let remainingTime = 'calculating...'; + + if (bytesPerSecond > 0 && remainingBytes > 0) { + const remainingSeconds = remainingBytes / bytesPerSecond; + + if (remainingSeconds < 60) { + remainingTime = `${Math.round(remainingSeconds)} seconds`; + } else if (remainingSeconds < 3600) { + remainingTime = `${Math.round(remainingSeconds / 60)} minutes`; + } else { + remainingTime = `${Math.round(remainingSeconds / 3600)} hours`; + } + } + + // Update DOM elements + const speed = formatSize(bytesPerSecond) + '/s'; + const progress = `${formatSize(uploadedBytes)} / ${formatSize(totalBytes)}`; + + // Update regular upload view + if (uploadSpeed) uploadSpeed.textContent = speed; + if (uploadedSize) uploadedSize.textContent = progress; + if (timeRemaining) timeRemaining.textContent = remainingTime; + + // Update folder upload view + if (folderUploadSpeed) folderUploadSpeed.textContent = speed; + if (folderUploadedSize) folderUploadedSize.textContent = progress; + if (folderTimeRemaining) folderTimeRemaining.textContent = remainingTime; + } + + // Helper Functions + function formatSize(bytes) { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } + + function getFileIcon(fileName) { + if (!fileName) return 'fa-file'; + + const extension = fileName.split('.').pop().toLowerCase(); + + if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp'].includes(extension)) { + return 'fa-file-image'; + } else if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv'].includes(extension)) { + return 'fa-file-video'; + } else if (['mp3', 'wav', 'ogg', 'flac', 'm4a'].includes(extension)) { + return 'fa-file-audio'; + } else if (['doc', 'docx'].includes(extension)) { + return 'fa-file-word'; + } else if (['xls', 'xlsx'].includes(extension)) { + return 'fa-file-excel'; + } else if (['ppt', 'pptx'].includes(extension)) { + return 'fa-file-powerpoint'; + } else if (['pdf'].includes(extension)) { + return 'fa-file-pdf'; + } else if (['zip', 'rar', '7z', 'tar', 'gz'].includes(extension)) { + return 'fa-file-archive'; + } else if (['txt', 'rtf', 'md'].includes(extension)) { + return 'fa-file-alt'; + } else if (['html', 'css', 'js', 'php', 'py', 'java', 'c', 'cpp', 'h', 'json', 'xml'].includes(extension)) { + return 'fa-file-code'; + } + + return 'fa-file'; + } + + function showMessage(message, type) { + // Create alerts container if it doesn't exist + let alertsContainer = document.querySelector('.alerts'); + if (!alertsContainer) { + alertsContainer = document.createElement('div'); + alertsContainer.className = 'alerts'; + document.body.appendChild(alertsContainer); + } + + // Create alert + const alert = document.createElement('div'); + alert.className = `alert ${type}`; + alert.innerHTML = `${message} `; + + // Add to container + alertsContainer.appendChild(alert); + + // Set up close button + const closeBtn = alert.querySelector('.close'); + closeBtn.addEventListener('click', function () { + alert.style.animation = 'alertOut 0.5s forwards'; + setTimeout(() => alert.remove(), 500); + }); + + // Auto close after 5 seconds + setTimeout(function () { + if (alert.parentNode) { + alert.style.animation = 'alertOut 0.5s forwards'; + setTimeout(() => alert.remove(), 500); + } + }, 5000); + } + + function createAlertSection() { + const alertSection = document.createElement('div'); + alertSection.className = 'alerts'; + document.body.appendChild(alertSection); + return alertSection; + } + + function setupAlertDismiss(alert) { + const closeBtn = alert.querySelector('.close'); + if (closeBtn) { + closeBtn.addEventListener('click', function () { + alert.style.animation = 'alertOut 0.5s forwards'; + setTimeout(() => alert.remove(), 500); + }); + } + } +}); \ No newline at end of file diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html new file mode 100644 index 0000000..b0d6b5c --- /dev/null +++ b/app/templates/auth/login.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} + +{% block title %}Sign In - Flask Files{% endblock %} + +{% block content %} +
+

Sign In

+ +
+ {{ form.hidden_tag() }} + +
+ {{ form.username.label }} + {{ form.username(size=32, class="form-control") }} + {% for error in form.username.errors %} + {{ error }} + {% endfor %} +
+ +
+ {{ form.password.label }} + {{ form.password(size=32, class="form-control") }} + {% for error in form.password.errors %} + {{ error }} + {% endfor %} +
+ +
+ {{ form.remember_me() }} + {{ form.remember_me.label }} +
+ +
+ {{ form.submit(class="btn primary") }} +
+
+ + +
+{% endblock %} \ No newline at end of file diff --git a/app/templates/auth/profile.html b/app/templates/auth/profile.html new file mode 100644 index 0000000..7b5d981 --- /dev/null +++ b/app/templates/auth/profile.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} + +{% block title %}Profile - Flask Files{% endblock %} + +{% block content %} +
+

User Profile

+ +
+
+
+ {{ current_user.username[0].upper() }} +
+

{{ current_user.username }}

+
+ +
+
+ {{ current_user.files.count() }} + Files +
+
+ {{ current_user.shares.count() }} + Shares +
+
+ + +
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/auth/register.html b/app/templates/auth/register.html new file mode 100644 index 0000000..51364f1 --- /dev/null +++ b/app/templates/auth/register.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} + +{% block title %}Register - Flask Files{% endblock %} + +{% block content %} +
+

Create an Account

+ +
+ {{ form.hidden_tag() }} + +
+ {{ form.username.label }} + {{ form.username(size=32, class="form-control") }} + {% for error in form.username.errors %} + {{ error }} + {% endfor %} +
+ +
+ {{ form.password.label }} + {{ form.password(size=32, class="form-control") }} + {% for error in form.password.errors %} + {{ error }} + {% endfor %} +
+ +
+ {{ form.password2.label }} + {{ form.password2(size=32, class="form-control") }} + {% for error in form.password2.errors %} + {{ error }} + {% endfor %} +
+ +
+ {{ form.submit(class="btn primary") }} +
+
+ + +
+{% endblock %} \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..08bde10 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,157 @@ + + + + + + + {% block title %}Flask Files{% endblock %} + + + + + + + + + + + {% block extra_css %}{% endblock %} + + + {% block extra_js %}{% endblock %} + + + +
+ +
+ +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
+ {{ message }} + +
+ {% endfor %} +
+ {% endif %} + {% endwith %} + +
+ {% block content %}{% endblock %} +
+
+ +
+

© {{ now.year }} Flask Files - A Simple File Manager

+
+ + +
+
+
+

Upload Files

+ +
+ +
+
+ +

Drag & drop files or folders here

+

or

+
+ + +
+
+ +
+

Upload Queue

+
+
+ +
+
+ Overall Progress + 0% +
+
+
+
+
+
+ + +
+
+ + + \ No newline at end of file diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html new file mode 100644 index 0000000..a3e51cf --- /dev/null +++ b/app/templates/dashboard.html @@ -0,0 +1,74 @@ +{% extends "base.html" %} + +{% block title %}Dashboard - Flask Files{% endblock %} + +{% block content %} +
+

Dashboard

+ +
+
+
📁
+
+ {{ total_folders }} + Folders +
+
+ +
+
📄
+
+ {{ total_files }} + Files +
+
+ +
+
🔗
+
+ {{ active_shares }} + Active Shares +
+
+
+ +
+

Recent Files

+ + {% if recent_files %} +
+ {% for file in recent_files %} +
+
+ {% if file.name.endswith('.pdf') %}📕 + {% elif file.name.endswith(('.jpg', '.jpeg', '.png', '.gif')) %}🖼️ + {% elif file.name.endswith(('.mp3', '.wav', '.flac')) %}🎵 + {% elif file.name.endswith(('.mp4', '.mov', '.avi')) %}🎬 + {% elif file.name.endswith(('.doc', '.docx')) %}📘 + {% elif file.name.endswith(('.xls', '.xlsx')) %}📊 + {% elif file.name.endswith(('.ppt', '.pptx')) %}📙 + {% elif file.name.endswith('.zip') %}📦 + {% else %}📄{% endif %} +
+
+
{{ file.name }}
+
+ {{ (file.size / 1024)|round(1) }} KB + {{ file.updated_at.strftime('%b %d, %Y') }} +
+
+
+ {% endfor %} +
+ {% else %} +

No files uploaded yet. Upload your first + file.

+ {% endif %} +
+ + +
+{% endblock %} \ No newline at end of file diff --git a/app/templates/files/browser.html b/app/templates/files/browser.html new file mode 100644 index 0000000..6413689 --- /dev/null +++ b/app/templates/files/browser.html @@ -0,0 +1,181 @@ +{% extends "base.html" %} + +{% block title %}File Browser - Flask Files{% endblock %} + +{% block content %} +
+
+

File Browser

+
+ + Upload + + +
+
+ +
+ + Home + + {% for folder in breadcrumbs %} + / + {{ folder.name }} + {% endfor %} +
+ + {% if folders or files %} +
+ {% if folders %} +
+

Folders

+
+ {% for folder in folders %} +
+ +
+ +
+
{{ folder.name }}
+
+
+ + +
+
+ {% endfor %} +
+
+ {% endif %} + + {% if files %} +
+

Files

+
+ {% for file in files %} +
+ +
+ + + + + + +
+
+ {% endfor %} +
+
+ {% endif %} +
+ {% else %} +
+
+ +
+

This folder is empty

+

Upload files or create a new folder to get started

+
+ + +
+
+ {% endif %} +
+{% endblock %} + +{% block extra_js %} + + +{% endblock %} \ No newline at end of file diff --git a/app/templates/files/upload.html b/app/templates/files/upload.html new file mode 100644 index 0000000..e410377 --- /dev/null +++ b/app/templates/files/upload.html @@ -0,0 +1,140 @@ +{% extends "base.html" %} + +{% block title %}Upload Files - Flask Files{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+

Upload Files

+ +
+ + +
+ +
+

+ Uploading to: + {% if parent_folder %} + {{ parent_folder.name }} + {% else %} + Root + {% endif %} +

+
+ +
+
+ + +
+ +

Drag & drop files here to start uploading

+

or

+ +
+ +
+

Upload Progress

+
+
+ Overall Progress + 0% +
+
+
+
+
+
+
+ Speed: + 0 KB/s +
+
+ Uploaded: + 0 KB / 0 KB +
+
+ Remaining: + calculating... +
+
+
+ +
+

Files

+
+
+ + +
+
+ +
+
+ + +
+ +

Select a folder to upload

+

(Some browsers may not fully support folder drag & drop)

+ +
+ +
+

Upload Progress

+
+
+ Overall Progress + 0% +
+
+
+
+
+
+
+ Speed: + 0 KB/s +
+
+ Uploaded: + 0 KB / 0 KB +
+
+ Remaining: + calculating... +
+
+
+ +
+

Folder Contents

+
+
+ + +
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..579077f --- /dev/null +++ b/config.py @@ -0,0 +1,19 @@ +import os +from datetime import timedelta + +class Config: + SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-key-change-in-production' + SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or 'sqlite:///flask_files.db' + SQLALCHEMY_TRACK_MODIFICATIONS = False + + # File storage settings + UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER') or os.path.join(os.getcwd(), 'uploads') + MAX_CONTENT_LENGTH = 8000 * 1024 * 1024 # 8GB limit + + # Session settings + PERMANENT_SESSION_LIFETIME = timedelta(days=7) + + # Make sure upload folder exists + @staticmethod + def init_app(app): + os.makedirs(Config.UPLOAD_FOLDER, exist_ok=True) diff --git a/init_db.py b/init_db.py new file mode 100644 index 0000000..30274ae --- /dev/null +++ b/init_db.py @@ -0,0 +1,18 @@ +from app import create_app, db +from app.models import User, File, Share, Download + +app = create_app() + +with app.app_context(): + # Create all tables + db.create_all() + + # Check if we need to create an admin user + if not User.query.filter_by(username='admin').first(): + admin = User(username='admin') + admin.set_password('password') # Change this in production! + db.session.add(admin) + db.session.commit() + print('Admin user created!') + + print('Database initialized!') \ No newline at end of file diff --git a/migrations/add_storage_name.py b/migrations/add_storage_name.py new file mode 100644 index 0000000..b70fd42 --- /dev/null +++ b/migrations/add_storage_name.py @@ -0,0 +1,52 @@ +""" +Script to add storage_name column to the file table. +Run this once from the command line: +python migrations/add_storage_name.py +""" + +import os +import sys +import sqlite3 + +# Add the parent directory to the path so we can import the app +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from config import Config +from app import create_app + +def add_storage_name_column(): + """Add storage_name column to file table""" + # Get database path from config + app = create_app() + with app.app_context(): + db_path = app.config['SQLALCHEMY_DATABASE_URI'].replace('sqlite:///', '') + + print(f"Database path: {db_path}") + + # Connect to the database + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + try: + # Check if column already exists + cursor.execute("PRAGMA table_info(file)") + columns = [column[1] for column in cursor.fetchall()] + + if 'storage_name' not in columns: + print("Adding storage_name column to file table...") + cursor.execute("ALTER TABLE file ADD COLUMN storage_name TEXT") + + # Update existing files to use name as storage_name + cursor.execute("UPDATE file SET storage_name = name WHERE is_folder = 0") + + conn.commit() + print("Column added successfully!") + else: + print("Column already exists.") + except Exception as e: + print(f"Error: {e}") + finally: + conn.close() + +if __name__ == "__main__": + add_storage_name_column() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..610089d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +Flask==2.3.3 +Flask-SQLAlchemy==3.1.1 +Flask-Login==0.6.2 +Flask-WTF==1.2.1 +Werkzeug==2.3.7 +python-dotenv==1.0.0 +markdown==3.5 \ No newline at end of file diff --git a/run.py b/run.py new file mode 100644 index 0000000..523d51a --- /dev/null +++ b/run.py @@ -0,0 +1,6 @@ +from app import create_app + +app = create_app() + +if __name__ == '__main__': + app.run(debug=True) \ No newline at end of file