batman
This commit is contained in:
commit
acb3c7642a
23 changed files with 3940 additions and 0 deletions
73
.gitignore
vendored
Normal file
73
.gitignore
vendored
Normal file
|
@ -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
|
22
Readme.md
Normal file
22
Readme.md
Normal file
|
@ -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
|
||||||
|
|
90
app/__init__.py
Normal file
90
app/__init__.py
Normal file
|
@ -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
|
58
app/models.py
Normal file
58
app/models.py
Normal file
|
@ -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)
|
8
app/routes/__init__.py
Normal file
8
app/routes/__init__.py
Normal file
|
@ -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
|
79
app/routes/auth.py
Normal file
79
app/routes/auth.py
Normal file
|
@ -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')
|
24
app/routes/dashboard.py
Normal file
24
app/routes/dashboard.py
Normal file
|
@ -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())
|
584
app/routes/files.py
Normal file
584
app/routes/files.py
Normal file
|
@ -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/<int:folder_id>')
|
||||||
|
@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/<int:file_id>')
|
||||||
|
@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/<int:file_id>', 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/<int:file_id>', 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/<int:file_id>', 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/<string:token>')
|
||||||
|
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/<string:token>')
|
||||||
|
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)
|
1455
app/static/css/custom.css
Normal file
1455
app/static/css/custom.css
Normal file
File diff suppressed because it is too large
Load diff
302
app/static/css/upload.css
Normal file
302
app/static/css/upload.css
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
470
app/static/js/upload.js
Normal file
470
app/static/js/upload.js
Normal file
|
@ -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 = '<p class="empty-message">No files selected</p>';
|
||||||
|
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 = `
|
||||||
|
<div class="file-item-icon">
|
||||||
|
<i class="fas ${getFileIcon(file.name)}"></i>
|
||||||
|
</div>
|
||||||
|
<div class="file-item-details">
|
||||||
|
<p class="file-item-name" title="${displayName}">${displayName}</p>
|
||||||
|
<p class="file-item-size">${formatSize(file.size)}</p>
|
||||||
|
</div>
|
||||||
|
<div class="file-item-status">
|
||||||
|
<div class="status-indicator waiting" id="status-${i}"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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} <button class="close">×</button>`;
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
42
app/templates/auth/login.html
Normal file
42
app/templates/auth/login.html
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Sign In - Flask Files{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="auth-container">
|
||||||
|
<h2>Sign In</h2>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('auth.login') }}" class="auth-form">
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.username.label }}
|
||||||
|
{{ form.username(size=32, class="form-control") }}
|
||||||
|
{% for error in form.username.errors %}
|
||||||
|
<span class="error">{{ error }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.password.label }}
|
||||||
|
{{ form.password(size=32, class="form-control") }}
|
||||||
|
{% for error in form.password.errors %}
|
||||||
|
<span class="error">{{ error }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group checkbox">
|
||||||
|
{{ form.remember_me() }}
|
||||||
|
{{ form.remember_me.label }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
{{ form.submit(class="btn primary") }}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="auth-links">
|
||||||
|
<p>Don't have an account? <a href="{{ url_for('auth.register') }}">Register here</a></p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
34
app/templates/auth/profile.html
Normal file
34
app/templates/auth/profile.html
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Profile - Flask Files{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="profile-container">
|
||||||
|
<h2>User Profile</h2>
|
||||||
|
|
||||||
|
<div class="profile-card">
|
||||||
|
<div class="profile-header">
|
||||||
|
<div class="avatar">
|
||||||
|
{{ current_user.username[0].upper() }}
|
||||||
|
</div>
|
||||||
|
<h3>{{ current_user.username }}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="profile-stats">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-value">{{ current_user.files.count() }}</span>
|
||||||
|
<span class="stat-label">Files</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-value">{{ current_user.shares.count() }}</span>
|
||||||
|
<span class="stat-label">Shares</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="profile-actions">
|
||||||
|
<a href="#" class="btn">Change Password</a>
|
||||||
|
<a href="{{ url_for('files.browser') }}" class="btn primary">Manage Files</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
45
app/templates/auth/register.html
Normal file
45
app/templates/auth/register.html
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Register - Flask Files{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="auth-container">
|
||||||
|
<h2>Create an Account</h2>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('auth.register') }}" class="auth-form">
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.username.label }}
|
||||||
|
{{ form.username(size=32, class="form-control") }}
|
||||||
|
{% for error in form.username.errors %}
|
||||||
|
<span class="error">{{ error }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.password.label }}
|
||||||
|
{{ form.password(size=32, class="form-control") }}
|
||||||
|
{% for error in form.password.errors %}
|
||||||
|
<span class="error">{{ error }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.password2.label }}
|
||||||
|
{{ form.password2(size=32, class="form-control") }}
|
||||||
|
{% for error in form.password2.errors %}
|
||||||
|
<span class="error">{{ error }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
{{ form.submit(class="btn primary") }}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="auth-links">
|
||||||
|
<p>Already have an account? <a href="{{ url_for('auth.login') }}">Sign in</a></p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
157
app/templates/base.html
Normal file
157
app/templates/base.html
Normal file
|
@ -0,0 +1,157 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="light">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}Flask Files{% endblock %}</title>
|
||||||
|
|
||||||
|
<!-- Classless CSS Framework -->
|
||||||
|
<!-- <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/digitallytailored/classless@latest/classless.min.css"> -->
|
||||||
|
|
||||||
|
<!-- Custom CSS -->
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/custom.css') }}">
|
||||||
|
|
||||||
|
<!-- Font Awesome Icons -->
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css">
|
||||||
|
|
||||||
|
{% block extra_css %}{% endblock %}
|
||||||
|
<!-- JavaScript -->
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
// Close alert buttons
|
||||||
|
document.querySelectorAll('.alert .close').forEach(function (alert) {
|
||||||
|
alert.addEventListener('click', function () {
|
||||||
|
this.parentElement.style.display = 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dark mode toggle
|
||||||
|
const darkModeToggle = document.getElementById('darkModeToggle');
|
||||||
|
if (darkModeToggle) {
|
||||||
|
function setColorScheme(scheme) {
|
||||||
|
document.documentElement.setAttribute('data-theme', scheme);
|
||||||
|
localStorage.setItem('color-scheme', scheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getColorScheme() {
|
||||||
|
let scheme = localStorage.getItem('color-scheme');
|
||||||
|
if (scheme) {
|
||||||
|
return scheme;
|
||||||
|
}
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
setColorScheme(getColorScheme());
|
||||||
|
|
||||||
|
darkModeToggle.addEventListener('click', function () {
|
||||||
|
const newScheme = getColorScheme() === 'dark' ? 'light' : 'dark';
|
||||||
|
setColorScheme(newScheme);
|
||||||
|
});
|
||||||
|
|
||||||
|
darkModeToggle.checked = getColorScheme() === 'dark';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% block extra_js %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<header id="main-header">
|
||||||
|
<nav>
|
||||||
|
<div class="logo">
|
||||||
|
<h1><a href="{{ url_for('dashboard.index') }}">Flask Files</a></h1>
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
<li><a href="{{ url_for('dashboard.index') }}"><i class="fas fa-chart-pie"></i> Dashboard</a></li>
|
||||||
|
<li><a href="{{ url_for('files.browser') }}"><i class="fas fa-folder"></i> Files</a></li>
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<li><a href="{{ url_for('auth.profile') }}"><i class="fas fa-user"></i> {{ current_user.username }}</a>
|
||||||
|
</li>
|
||||||
|
<li><a href="{{ url_for('auth.logout') }}"><i class="fas fa-sign-out-alt"></i> Logout</a></li>
|
||||||
|
{% else %}
|
||||||
|
<li><a href="{{ url_for('auth.login') }}"><i class="fas fa-sign-in-alt"></i> Login</a></li>
|
||||||
|
{% endif %}
|
||||||
|
<li>
|
||||||
|
<button id="darkModeToggle" class="toggle-button" aria-label="Toggle dark mode">
|
||||||
|
<i class="fas fa-moon"></i>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
<section class="alerts">
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert {{ category }}">
|
||||||
|
{{ message }}
|
||||||
|
<button class="close" aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<section class="content">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>© {{ now.year }} Flask Files - A Simple File Manager</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<!-- Add this right before the closing </body> tag -->
|
||||||
|
<div id="upload-overlay" class="upload-overlay">
|
||||||
|
<div class="upload-modal">
|
||||||
|
<div class="upload-header">
|
||||||
|
<h3><i class="fas fa-cloud-upload-alt"></i> Upload Files</h3>
|
||||||
|
<button class="close-upload" id="close-upload-btn">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="upload-body">
|
||||||
|
<div class="upload-dropzone" id="dropzone">
|
||||||
|
<i class="fas fa-cloud-upload-alt upload-icon"></i>
|
||||||
|
<p>Drag & drop files or folders here</p>
|
||||||
|
<p>or</p>
|
||||||
|
<div class="upload-buttons">
|
||||||
|
<label class="btn primary">
|
||||||
|
<i class="fas fa-file"></i> Select Files
|
||||||
|
<input type="file" id="file-upload" multiple style="display: none">
|
||||||
|
</label>
|
||||||
|
<label class="btn">
|
||||||
|
<i class="fas fa-folder"></i> Select Folder
|
||||||
|
<input type="file" id="folder-upload" webkitdirectory directory multiple
|
||||||
|
style="display: none">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="upload-list" id="upload-list">
|
||||||
|
<h4>Upload Queue</h4>
|
||||||
|
<div class="upload-items" id="upload-items"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="upload-progress-overall">
|
||||||
|
<div class="progress-label">
|
||||||
|
<span>Overall Progress</span>
|
||||||
|
<span id="upload-percentage">0%</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar-container">
|
||||||
|
<div class="progress-bar" id="total-progress-bar" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="upload-footer">
|
||||||
|
<button class="btn" id="cancel-upload-btn">Cancel</button>
|
||||||
|
<button class="btn primary" id="start-upload-btn">Start Upload</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
74
app/templates/dashboard.html
Normal file
74
app/templates/dashboard.html
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Dashboard - Flask Files{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="dashboard">
|
||||||
|
<h2>Dashboard</h2>
|
||||||
|
|
||||||
|
<div class="dashboard-stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">📁</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<span class="stat-value">{{ total_folders }}</span>
|
||||||
|
<span class="stat-label">Folders</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">📄</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<span class="stat-value">{{ total_files }}</span>
|
||||||
|
<span class="stat-label">Files</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">🔗</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<span class="stat-value">{{ active_shares }}</span>
|
||||||
|
<span class="stat-label">Active Shares</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dashboard-recent">
|
||||||
|
<h3>Recent Files</h3>
|
||||||
|
|
||||||
|
{% if recent_files %}
|
||||||
|
<div class="recent-files-list">
|
||||||
|
{% for file in recent_files %}
|
||||||
|
<div class="file-item">
|
||||||
|
<div class="file-icon">
|
||||||
|
{% 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 %}
|
||||||
|
</div>
|
||||||
|
<div class="file-details">
|
||||||
|
<div class="file-name">{{ file.name }}</div>
|
||||||
|
<div class="file-meta">
|
||||||
|
<span class="file-size">{{ (file.size / 1024)|round(1) }} KB</span>
|
||||||
|
<span class="file-date">{{ file.updated_at.strftime('%b %d, %Y') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="empty-state">No files uploaded yet. <a href="{{ url_for('files.browser') }}">Upload your first
|
||||||
|
file</a>.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dashboard-actions">
|
||||||
|
<a href="{{ url_for('files.browser') }}" class="btn primary">Browse Files</a>
|
||||||
|
<a href="{{ url_for('files.upload') }}" class="btn">Upload Files</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
181
app/templates/files/browser.html
Normal file
181
app/templates/files/browser.html
Normal file
|
@ -0,0 +1,181 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}File Browser - Flask Files{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="file-browser">
|
||||||
|
<div class="browser-header">
|
||||||
|
<h2>File Browser</h2>
|
||||||
|
<div class="browser-actions">
|
||||||
|
<a href="{{ url_for('files.upload', folder=current_folder.id if current_folder else None) }}"
|
||||||
|
class="btn primary">
|
||||||
|
<i class="fas fa-cloud-upload-alt"></i> Upload
|
||||||
|
</a>
|
||||||
|
<button class="btn" id="new-folder-btn">
|
||||||
|
<i class="fas fa-folder-plus"></i> New Folder
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="path-nav">
|
||||||
|
<a href="{{ url_for('files.browser') }}" class="path-item">
|
||||||
|
<i class="fas fa-home"></i> Home
|
||||||
|
</a>
|
||||||
|
{% for folder in breadcrumbs %}
|
||||||
|
<span class="path-separator">/</span>
|
||||||
|
<a href="{{ url_for('files.browser', folder=folder.id) }}" class="path-item">{{ folder.name }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if folders or files %}
|
||||||
|
<div class="files-container">
|
||||||
|
{% if folders %}
|
||||||
|
<div class="folder-section">
|
||||||
|
<h3>Folders</h3>
|
||||||
|
<div class="files-list">
|
||||||
|
{% for folder in folders %}
|
||||||
|
<div class="file-item folder">
|
||||||
|
<a href="{{ url_for('files.browser', folder=folder.id) }}" class="file-link">
|
||||||
|
<div class="file-icon">
|
||||||
|
<i class="fas fa-folder fa-2x"></i>
|
||||||
|
</div>
|
||||||
|
<div class="file-name">{{ folder.name }}</div>
|
||||||
|
</a>
|
||||||
|
<div class="file-actions">
|
||||||
|
<button class="action-btn rename" data-id="{{ folder.id }}" title="Rename">
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
</button>
|
||||||
|
<button class="action-btn delete" data-id="{{ folder.id }}" title="Delete">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if files %}
|
||||||
|
<div class="file-section">
|
||||||
|
<h3>Files</h3>
|
||||||
|
<div class="files-list">
|
||||||
|
{% for file in files %}
|
||||||
|
<div class="file-item">
|
||||||
|
<div class="file-link">
|
||||||
|
<div class="file-icon">
|
||||||
|
{% if file.name.endswith('.pdf') %}<i class="fas fa-file-pdf fa-2x"></i>
|
||||||
|
{% elif file.name.endswith(('.jpg', '.jpeg', '.png', '.gif')) %}<i
|
||||||
|
class="fas fa-file-image fa-2x"></i>
|
||||||
|
{% elif file.name.endswith(('.mp3', '.wav', '.flac')) %}<i
|
||||||
|
class="fas fa-file-audio fa-2x"></i>
|
||||||
|
{% elif file.name.endswith(('.mp4', '.mov', '.avi')) %}<i
|
||||||
|
class="fas fa-file-video fa-2x"></i>
|
||||||
|
{% elif file.name.endswith(('.doc', '.docx')) %}<i class="fas fa-file-word fa-2x"></i>
|
||||||
|
{% elif file.name.endswith(('.xls', '.xlsx')) %}<i class="fas fa-file-excel fa-2x"></i>
|
||||||
|
{% elif file.name.endswith(('.ppt', '.pptx')) %}<i class="fas fa-file-powerpoint fa-2x"></i>
|
||||||
|
{% elif file.name.endswith('.zip') %}<i class="fas fa-file-archive fa-2x"></i>
|
||||||
|
{% else %}<i class="fas fa-file fa-2x"></i>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="file-details">
|
||||||
|
<div class="file-name">{{ file.name }}</div>
|
||||||
|
<div class="file-meta">
|
||||||
|
<span class="file-size">{{ (file.size / 1024)|round(1) }} KB</span>
|
||||||
|
<span class="file-date">{{ file.updated_at.strftime('%b %d, %Y') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="file-actions">
|
||||||
|
<a href="#" class="action-btn download" data-id="{{ file.id }}" title="Download">
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
</a>
|
||||||
|
<a href="#" class="action-btn share" data-id="{{ file.id }}" title="Share">
|
||||||
|
<i class="fas fa-share-alt"></i>
|
||||||
|
</a>
|
||||||
|
<button class="action-btn rename" data-id="{{ file.id }}" title="Rename">
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
</button>
|
||||||
|
<button class="action-btn delete" data-id="{{ file.id }}" title="Delete">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">
|
||||||
|
<i class="fas fa-folder-open fa-3x"></i>
|
||||||
|
</div>
|
||||||
|
<p>This folder is empty</p>
|
||||||
|
<p>Upload files or create a new folder to get started</p>
|
||||||
|
<div class="empty-actions">
|
||||||
|
<button class="btn primary" data-action="upload"
|
||||||
|
data-folder-id="{{ current_folder.id if current_folder else None }}">
|
||||||
|
<i class="fas fa-cloud-upload-alt"></i> Upload Files
|
||||||
|
</button>
|
||||||
|
<button class="btn" id="empty-new-folder-btn">
|
||||||
|
<i class="fas fa-folder-plus"></i> New Folder
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script src="{{ url_for('static', filename='js/upload.js') }}"></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
// New folder functionality
|
||||||
|
const newFolderBtn = document.getElementById('new-folder-btn');
|
||||||
|
const emptyNewFolderBtn = document.getElementById('empty-new-folder-btn');
|
||||||
|
|
||||||
|
function showNewFolderPrompt() {
|
||||||
|
const folderName = prompt('Enter folder name:');
|
||||||
|
if (folderName) {
|
||||||
|
createNewFolder(folderName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNewFolder(name) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('name', name);
|
||||||
|
|
||||||
|
{% if current_folder %}
|
||||||
|
formData.append('parent_id', '{{ current_folder.id }}');
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
fetch('/files/create_folder', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
alert(data.error || 'Failed to create folder');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('An error occurred while creating the folder');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newFolderBtn) {
|
||||||
|
newFolderBtn.addEventListener('click', showNewFolderPrompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emptyNewFolderBtn) {
|
||||||
|
emptyNewFolderBtn.addEventListener('click', showNewFolderPrompt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
140
app/templates/files/upload.html
Normal file
140
app/templates/files/upload.html
Normal file
|
@ -0,0 +1,140 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Upload Files - Flask Files{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/upload.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="upload-container">
|
||||||
|
<h2>Upload Files</h2>
|
||||||
|
|
||||||
|
<div class="upload-tabs">
|
||||||
|
<button class="tab-btn active" data-tab="file-tab">Files</button>
|
||||||
|
<button class="tab-btn" data-tab="folder-tab">Folder</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="upload-location">
|
||||||
|
<p>
|
||||||
|
Uploading to:
|
||||||
|
{% if parent_folder %}
|
||||||
|
<a href="{{ url_for('files.browser', folder_id=parent_folder.id) }}">{{ parent_folder.name }}</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url_for('files.browser') }}">Root</a>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-content active" id="file-tab">
|
||||||
|
<form id="file-upload-form" enctype="multipart/form-data" class="upload-form">
|
||||||
|
<input type="hidden" name="folder_id" value="{{ parent_folder.id if parent_folder else '' }}">
|
||||||
|
|
||||||
|
<div class="upload-dropzone" id="file-dropzone">
|
||||||
|
<i class="fas fa-cloud-upload-alt upload-icon"></i>
|
||||||
|
<p>Drag & drop files here to start uploading</p>
|
||||||
|
<p>or</p>
|
||||||
|
<label class="btn primary">
|
||||||
|
<i class="fas fa-file"></i> Select Files
|
||||||
|
<input type="file" name="files[]" multiple id="file-input" style="display: none">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="upload-progress-container">
|
||||||
|
<h4>Upload Progress</h4>
|
||||||
|
<div class="progress-overall">
|
||||||
|
<div class="progress-label">
|
||||||
|
<span>Overall Progress</span>
|
||||||
|
<span id="progress-percentage">0%</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar-container">
|
||||||
|
<div class="progress-bar" id="progress-bar"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="upload-stats">
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-label">Speed:</span>
|
||||||
|
<span class="stat-value" id="upload-speed">0 KB/s</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-label">Uploaded:</span>
|
||||||
|
<span class="stat-value" id="uploaded-size">0 KB / 0 KB</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-label">Remaining:</span>
|
||||||
|
<span class="stat-value" id="time-remaining">calculating...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="selected-files">
|
||||||
|
<h4>Files</h4>
|
||||||
|
<div class="file-list" id="file-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<a href="{{ url_for('files.browser', folder_id=parent_folder.id if parent_folder else None) }}"
|
||||||
|
class="btn">Back to Browser</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-content" id="folder-tab">
|
||||||
|
<form id="folder-upload-form" enctype="multipart/form-data" class="upload-form">
|
||||||
|
<input type="hidden" name="folder_id" value="{{ parent_folder.id if parent_folder else '' }}">
|
||||||
|
|
||||||
|
<div class="upload-dropzone folder-dropzone" id="folder-dropzone">
|
||||||
|
<i class="fas fa-folder-open upload-icon"></i>
|
||||||
|
<p>Select a folder to upload</p>
|
||||||
|
<p>(Some browsers may not fully support folder drag & drop)</p>
|
||||||
|
<label class="btn primary">
|
||||||
|
<i class="fas fa-folder"></i> Select Folder
|
||||||
|
<input type="file" name="files[]" webkitdirectory directory multiple id="folder-input"
|
||||||
|
style="display: none">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="upload-progress-container">
|
||||||
|
<h4>Upload Progress</h4>
|
||||||
|
<div class="progress-overall">
|
||||||
|
<div class="progress-label">
|
||||||
|
<span>Overall Progress</span>
|
||||||
|
<span id="folder-progress-percentage">0%</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar-container">
|
||||||
|
<div class="progress-bar" id="folder-progress-bar"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="upload-stats">
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-label">Speed:</span>
|
||||||
|
<span class="stat-value" id="folder-upload-speed">0 KB/s</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-label">Uploaded:</span>
|
||||||
|
<span class="stat-value" id="folder-uploaded-size">0 KB / 0 KB</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-label">Remaining:</span>
|
||||||
|
<span class="stat-value" id="folder-time-remaining">calculating...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="selected-files">
|
||||||
|
<h4>Folder Contents</h4>
|
||||||
|
<div class="file-list" id="folder-file-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<a href="{{ url_for('files.browser', folder_id=parent_folder.id if parent_folder else None) }}"
|
||||||
|
class="btn">Back to Browser</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script src="{{ url_for('static', filename='js/upload.js') }}"></script>
|
||||||
|
{% endblock %}
|
19
config.py
Normal file
19
config.py
Normal file
|
@ -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)
|
18
init_db.py
Normal file
18
init_db.py
Normal file
|
@ -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!')
|
52
migrations/add_storage_name.py
Normal file
52
migrations/add_storage_name.py
Normal file
|
@ -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()
|
7
requirements.txt
Normal file
7
requirements.txt
Normal file
|
@ -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
|
6
run.py
Normal file
6
run.py
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
from app import create_app
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app.run(debug=True)
|
Loading…
Add table
Add a link
Reference in a new issue