wip
This commit is contained in:
parent
eb93961967
commit
ea3e92b8b7
10 changed files with 773 additions and 167 deletions
103
app/__init__.py
103
app/__init__.py
|
@ -1,16 +1,61 @@
|
|||
from flask import Flask
|
||||
from flask import Flask, current_app
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import LoginManager
|
||||
from config import Config
|
||||
from datetime import datetime
|
||||
import os
|
||||
from datetime import datetime
|
||||
import sqlite3
|
||||
|
||||
# Initialize extensions
|
||||
db = SQLAlchemy()
|
||||
login_manager = LoginManager()
|
||||
login_manager.login_view = 'auth.login'
|
||||
login_manager.login_message_category = 'info'
|
||||
|
||||
def initialize_database(app):
|
||||
"""Create database tables if they don't exist"""
|
||||
with app.app_context():
|
||||
try:
|
||||
# Create all tables
|
||||
db.create_all()
|
||||
app.logger.info("Database tables created successfully")
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error creating database tables: {str(e)}")
|
||||
|
||||
def run_migrations(app):
|
||||
"""Apply any necessary database migrations"""
|
||||
db_path = app.config['SQLALCHEMY_DATABASE_URI'].replace('sqlite:///', '')
|
||||
|
||||
if not os.path.exists(db_path):
|
||||
app.logger.info(f"Database file does not exist: {db_path}")
|
||||
return
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if storage_name column exists in file table
|
||||
cursor.execute("PRAGMA table_info(file)")
|
||||
columns = [column[1] for column in cursor.fetchall()]
|
||||
|
||||
if 'storage_name' not in columns:
|
||||
app.logger.info("Adding storage_name column to file table")
|
||||
cursor.execute("ALTER TABLE file ADD COLUMN storage_name TEXT")
|
||||
|
||||
# Update existing records to use filename as storage_name
|
||||
cursor.execute("UPDATE file SET storage_name = name WHERE storage_name IS NULL AND is_folder = 0")
|
||||
conn.commit()
|
||||
|
||||
conn.close()
|
||||
app.logger.info("Database migrations completed successfully")
|
||||
except sqlite3.OperationalError as e:
|
||||
if "no such table: file" in str(e):
|
||||
app.logger.info("File table doesn't exist yet, will be created with db.create_all()")
|
||||
else:
|
||||
app.logger.error(f"Error during migration: {str(e)}")
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error during migration: {str(e)}")
|
||||
|
||||
def create_app(config_class=Config):
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(config_class)
|
||||
|
@ -20,7 +65,7 @@ def create_app(config_class=Config):
|
|||
login_manager.init_app(app)
|
||||
|
||||
# Initialize the upload folder
|
||||
Config.init_app(app)
|
||||
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
||||
|
||||
# Auto initialize database if it doesn't exist
|
||||
with app.app_context():
|
||||
|
@ -28,7 +73,10 @@ def create_app(config_class=Config):
|
|||
run_migrations(app)
|
||||
|
||||
# Register blueprints
|
||||
from app.routes import auth_bp, files_bp, dashboard_bp
|
||||
from app.routes.auth import auth_bp
|
||||
from app.routes.files import files_bp
|
||||
from app.routes.dashboard import dashboard_bp
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(files_bp)
|
||||
app.register_blueprint(dashboard_bp)
|
||||
|
@ -40,51 +88,4 @@ def create_app(config_class=Config):
|
|||
|
||||
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
|
||||
|
|
|
@ -5,41 +5,61 @@ from app import db, login_manager
|
|||
import uuid
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
return User.query.get(int(user_id))
|
||||
def load_user(id):
|
||||
return User.query.get(int(id))
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
__tablename__ = 'user'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(64), unique=True, index=True)
|
||||
username = db.Column(db.String(64), index=True, unique=True)
|
||||
email = db.Column(db.String(120), index=True, unique=True)
|
||||
password_hash = db.Column(db.String(128))
|
||||
files = db.relationship('File', backref='owner', lazy='dynamic')
|
||||
shares = db.relationship('Share', backref='creator', lazy='dynamic')
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
last_login = db.Column(db.DateTime)
|
||||
|
||||
# Relationships
|
||||
files = db.relationship('File', backref='owner', lazy='dynamic',
|
||||
foreign_keys='File.user_id', cascade='all, delete-orphan')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<User {self.username}>'
|
||||
|
||||
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):
|
||||
__tablename__ = 'file'
|
||||
|
||||
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)
|
||||
name = db.Column(db.String(255), nullable=False)
|
||||
storage_name = db.Column(db.String(255)) # Used for storing files with unique names
|
||||
is_folder = db.Column(db.Boolean, default=False)
|
||||
mime_type = db.Column(db.String(128))
|
||||
size = db.Column(db.Integer, default=0) # Size in bytes
|
||||
parent_id = db.Column(db.Integer, db.ForeignKey('file.id', ondelete='CASCADE'), nullable=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
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')
|
||||
# Relationships
|
||||
children = db.relationship('File', backref=db.backref('parent', remote_side=[id]),
|
||||
lazy='dynamic', cascade='all, delete-orphan')
|
||||
|
||||
# Add relationship for shared files
|
||||
shares = db.relationship('Share', backref='file', lazy='dynamic')
|
||||
def __repr__(self):
|
||||
return f'<File {self.name} {"(Folder)" if self.is_folder else ""}>'
|
||||
|
||||
def generate_storage_name(self):
|
||||
"""Generate a unique name for file storage to prevent conflicts"""
|
||||
if self.is_folder:
|
||||
return None
|
||||
|
||||
# Generate a unique filename using UUID
|
||||
ext = self.name.rsplit('.', 1)[1].lower() if '.' in self.name else ''
|
||||
return f"{uuid.uuid4().hex}.{ext}" if ext else f"{uuid.uuid4().hex}"
|
||||
|
||||
class Share(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
from flask import render_template, redirect, url_for, flash, request
|
||||
from flask import render_template, redirect, url_for, flash, request, current_app, jsonify, session
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from urllib.parse import urlparse
|
||||
from app import db
|
||||
|
@ -7,6 +7,7 @@ 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
|
||||
from werkzeug.exceptions import BadRequest
|
||||
|
||||
# Login form
|
||||
class LoginForm(FlaskForm):
|
||||
|
@ -73,7 +74,74 @@ def register():
|
|||
|
||||
return render_template('auth/register.html', title='Register', form=form)
|
||||
|
||||
@auth_bp.route('/update_profile', methods=['POST'])
|
||||
@login_required
|
||||
def update_profile():
|
||||
"""Update user profile information"""
|
||||
username = request.form.get('username')
|
||||
|
||||
if not username or username.strip() == '':
|
||||
flash('Username cannot be empty', 'error')
|
||||
return redirect(url_for('auth.profile'))
|
||||
|
||||
# Check if username is already taken by another user
|
||||
existing_user = User.query.filter(User.username == username, User.id != current_user.id).first()
|
||||
if existing_user:
|
||||
flash('Username is already taken', 'error')
|
||||
return redirect(url_for('auth.profile'))
|
||||
|
||||
# Update username
|
||||
current_user.username = username
|
||||
db.session.commit()
|
||||
flash('Profile updated successfully', 'success')
|
||||
return redirect(url_for('auth.profile'))
|
||||
|
||||
@auth_bp.route('/change_password', methods=['POST'])
|
||||
@login_required
|
||||
def change_password():
|
||||
"""Change user password"""
|
||||
current_password = request.form.get('current_password')
|
||||
new_password = request.form.get('new_password')
|
||||
confirm_password = request.form.get('confirm_password')
|
||||
|
||||
# Validate input
|
||||
if not all([current_password, new_password, confirm_password]):
|
||||
flash('All fields are required', 'error')
|
||||
return redirect(url_for('auth.profile'))
|
||||
|
||||
if new_password != confirm_password:
|
||||
flash('New passwords do not match', 'error')
|
||||
return redirect(url_for('auth.profile'))
|
||||
|
||||
# Check current password
|
||||
if not current_user.check_password(current_password):
|
||||
flash('Current password is incorrect', 'error')
|
||||
return redirect(url_for('auth.profile'))
|
||||
|
||||
# Set new password
|
||||
current_user.set_password(new_password)
|
||||
db.session.commit()
|
||||
flash('Password changed successfully', 'success')
|
||||
return redirect(url_for('auth.profile'))
|
||||
|
||||
@auth_bp.route('/update_preferences', methods=['POST'])
|
||||
@login_required
|
||||
def update_preferences():
|
||||
"""Update user preferences like theme"""
|
||||
theme_preference = request.form.get('theme_preference', 'system')
|
||||
|
||||
# Store in session for now, but could be added to user model
|
||||
session['theme_preference'] = theme_preference
|
||||
|
||||
flash('Preferences updated successfully', 'success')
|
||||
return redirect(url_for('auth.profile'))
|
||||
|
||||
@auth_bp.route('/profile')
|
||||
@login_required
|
||||
def profile():
|
||||
return render_template('auth/profile.html', title='User Profile')
|
||||
# Get theme preference from session or default to system
|
||||
theme_preference = session.get('theme_preference', 'system')
|
||||
|
||||
return render_template('auth/profile.html',
|
||||
title='User Profile',
|
||||
theme_preference=theme_preference)
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
from flask import render_template
|
||||
from flask import Blueprint, 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
|
||||
from datetime import datetime, timedelta
|
||||
from app.models import File, Share, Download
|
||||
import os
|
||||
|
||||
dashboard_bp = Blueprint('dashboard', __name__)
|
||||
|
||||
@dashboard_bp.route('/')
|
||||
@login_required
|
||||
|
@ -10,15 +12,102 @@ 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()
|
||||
|
||||
# Recent files for quick access
|
||||
recent_files = File.query.filter_by(user_id=current_user.id, is_folder=False)\
|
||||
.order_by(File.updated_at.desc())\
|
||||
.limit(8).all()
|
||||
|
||||
# Root folders for quick navigation
|
||||
root_folders = File.query.filter_by(user_id=current_user.id, is_folder=True, parent_id=None)\
|
||||
.order_by(File.name)\
|
||||
.limit(8).all()
|
||||
|
||||
# Count active shares (if Share model exists)
|
||||
active_shares = 0
|
||||
recent_activities = 0
|
||||
|
||||
# Check if Share and Download models exist/are imported
|
||||
try:
|
||||
# Count active shares
|
||||
active_shares = Share.query.filter_by(user_id=current_user.id).filter(
|
||||
(Share.expires_at > datetime.now()) | (Share.expires_at.is_(None))
|
||||
).count()
|
||||
|
||||
# Recent activities count (downloads, shares, etc.)
|
||||
recent_activities = Download.query.join(Share)\
|
||||
.filter(Share.user_id == current_user.id)\
|
||||
.filter(Download.timestamp > (datetime.now() - timedelta(days=7)))\
|
||||
.count()
|
||||
except:
|
||||
# Models not ready yet, using default values
|
||||
pass
|
||||
|
||||
return render_template('dashboard.html',
|
||||
title='Dashboard',
|
||||
total_files=total_files,
|
||||
total_folders=total_folders,
|
||||
recent_files=recent_files,
|
||||
root_folders=root_folders,
|
||||
active_shares=active_shares,
|
||||
now=datetime.now())
|
||||
recent_activities=recent_activities,
|
||||
now=datetime.now(),
|
||||
file_icon=get_file_icon,
|
||||
format_size=format_file_size)
|
||||
|
||||
def get_file_icon(mime_type, filename):
|
||||
"""Return Font Awesome icon class based on file type"""
|
||||
if mime_type:
|
||||
if mime_type.startswith('image/'):
|
||||
return 'fa-file-image'
|
||||
elif mime_type.startswith('video/'):
|
||||
return 'fa-file-video'
|
||||
elif mime_type.startswith('audio/'):
|
||||
return 'fa-file-audio'
|
||||
elif mime_type.startswith('text/'):
|
||||
return 'fa-file-alt'
|
||||
elif mime_type.startswith('application/pdf'):
|
||||
return 'fa-file-pdf'
|
||||
elif 'spreadsheet' in mime_type or 'excel' in mime_type:
|
||||
return 'fa-file-excel'
|
||||
elif 'presentation' in mime_type or 'powerpoint' in mime_type:
|
||||
return 'fa-file-powerpoint'
|
||||
elif 'document' in mime_type or 'word' in mime_type:
|
||||
return 'fa-file-word'
|
||||
|
||||
# Check by extension
|
||||
ext = os.path.splitext(filename)[1].lower()[1:]
|
||||
if ext in ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp']:
|
||||
return 'fa-file-image'
|
||||
elif ext in ['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv']:
|
||||
return 'fa-file-video'
|
||||
elif ext in ['mp3', 'wav', 'ogg', 'flac', 'm4a']:
|
||||
return 'fa-file-audio'
|
||||
elif ext in ['doc', 'docx', 'odt']:
|
||||
return 'fa-file-word'
|
||||
elif ext in ['xls', 'xlsx', 'ods', 'csv']:
|
||||
return 'fa-file-excel'
|
||||
elif ext in ['ppt', 'pptx', 'odp']:
|
||||
return 'fa-file-powerpoint'
|
||||
elif ext == 'pdf':
|
||||
return 'fa-file-pdf'
|
||||
elif ext in ['zip', 'rar', '7z', 'tar', 'gz']:
|
||||
return 'fa-file-archive'
|
||||
elif ext in ['txt', 'rtf', 'md']:
|
||||
return 'fa-file-alt'
|
||||
elif ext in ['html', 'css', 'js', 'py', 'java', 'php', 'c', 'cpp', 'json', 'xml']:
|
||||
return 'fa-file-code'
|
||||
|
||||
return 'fa-file'
|
||||
|
||||
def format_file_size(size):
|
||||
"""Format file size in bytes to human-readable format"""
|
||||
if not size:
|
||||
return "0 B"
|
||||
|
||||
size_names = ("B", "KB", "MB", "GB", "TB")
|
||||
i = 0
|
||||
while size >= 1024 and i < len(size_names) - 1:
|
||||
size /= 1024
|
||||
i += 1
|
||||
return f"{size:.1f} {size_names[i]}"
|
|
@ -1,4 +1,4 @@
|
|||
from flask import render_template, redirect, url_for, flash, request, send_from_directory, abort, jsonify
|
||||
from flask import render_template, redirect, url_for, flash, request, send_from_directory, abort, jsonify, send_file
|
||||
from flask_login import login_required, current_user
|
||||
from werkzeug.utils import secure_filename
|
||||
from app import db
|
||||
|
@ -581,4 +581,68 @@ def upload_xhr():
|
|||
'errors': errors
|
||||
}
|
||||
|
||||
return jsonify(result)
|
||||
return jsonify(result)
|
||||
|
||||
@files_bp.route('/contents')
|
||||
@login_required
|
||||
def folder_contents():
|
||||
"""Returns the HTML for folder contents (used for AJAX loading)"""
|
||||
folder_id = request.args.get('folder_id', None, type=int)
|
||||
|
||||
if request.headers.get('X-Requested-With') != 'XMLHttpRequest':
|
||||
# If not an AJAX request, redirect to browser view
|
||||
return redirect(url_for('files.browser', folder_id=folder_id))
|
||||
|
||||
# Query 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()
|
||||
|
||||
# Get files and subfolders
|
||||
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/partials/folder_contents.html',
|
||||
folders=folders,
|
||||
files=files,
|
||||
parent=parent_folder)
|
||||
|
||||
@files_bp.route('/preview/<int:file_id>')
|
||||
@login_required
|
||||
def file_preview(file_id):
|
||||
"""Returns file preview data"""
|
||||
file = File.query.filter_by(id=file_id, user_id=current_user.id, is_folder=False).first_or_404()
|
||||
|
||||
result = {
|
||||
'success': True,
|
||||
'file': {
|
||||
'id': file.id,
|
||||
'name': file.name,
|
||||
'mime_type': file.mime_type,
|
||||
'size': file.size,
|
||||
'created_at': file.created_at.isoformat() if file.created_at else None,
|
||||
'updated_at': file.updated_at.isoformat() if file.updated_at else None
|
||||
},
|
||||
'download_url': url_for('files.download', file_id=file.id),
|
||||
'preview_url': url_for('files.raw', file_id=file.id)
|
||||
}
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
@files_bp.route('/raw/<int:file_id>')
|
||||
@login_required
|
||||
def raw(file_id):
|
||||
"""Serves raw file content for previews"""
|
||||
file = File.query.filter_by(id=file_id, user_id=current_user.id, is_folder=False).first_or_404()
|
||||
|
||||
# Check if file exists
|
||||
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], file.storage_name)
|
||||
if not os.path.exists(file_path):
|
||||
abort(404)
|
||||
|
||||
# Check file size for text files (only preview if under 2MB)
|
||||
if file.mime_type and file.mime_type.startswith('text/') and file.size > 2 * 1024 * 1024:
|
||||
return "File too large to preview", 413
|
||||
|
||||
return send_file(file_path, mimetype=file.mime_type)
|
|
@ -43,7 +43,7 @@
|
|||
--spacing-base: 1rem;
|
||||
--border-radius-sm: 0.25rem;
|
||||
--border-radius: 0.5rem;
|
||||
--border-radius-md: 0.75rem;
|
||||
--border-radius-md: 0.15rem;
|
||||
--border-radius-lg: 1rem;
|
||||
--border-radius-xl: 1.5rem;
|
||||
--border-radius-full: 9999px;
|
||||
|
@ -1105,7 +1105,7 @@ nav ul li a:hover {
|
|||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
border-radius: 90%;
|
||||
padding: 0;
|
||||
background: var(--body-bg);
|
||||
color: var(--body-color);
|
||||
|
|
|
@ -2,33 +2,237 @@
|
|||
|
||||
{% block title %}Profile - Flask Files{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.profile-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.profile-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.profile-tab {
|
||||
padding: 0.75rem 1.5rem;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
font-weight: 500;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.profile-tab.active {
|
||||
color: var(--primary-color);
|
||||
border-bottom-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.setting-group {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.setting-group h3 {
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block 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-tabs">
|
||||
<button class="profile-tab active" data-tab="profile">Profile</button>
|
||||
<button class="profile-tab" data-tab="appearance">Appearance</button>
|
||||
<button class="profile-tab" data-tab="security">Security</button>
|
||||
</div>
|
||||
|
||||
<div class="profile-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">{{ current_user.files.count() }}</span>
|
||||
<span class="stat-label">Files</span>
|
||||
<!-- Profile Tab -->
|
||||
<div class="tab-content active" id="profile-tab">
|
||||
<div class="profile-card">
|
||||
<div class="profile-header">
|
||||
<div class="avatar">
|
||||
{{ current_user.username[0].upper() }}
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<h3>{{ current_user.username }}</h3>
|
||||
<p>Member since {{ current_user.created_at.strftime('%B %Y') if current_user.created_at else
|
||||
'Unknown' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">{{ current_user.shares.count() }}</span>
|
||||
<span class="stat-label">Shares</span>
|
||||
|
||||
<div class="profile-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">{{ current_user.files.filter_by(is_folder=False).count() }}</span>
|
||||
<span class="stat-label">Files</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">{{ current_user.files.filter_by(is_folder=True).count() }}</span>
|
||||
<span class="stat-label">Folders</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">{{ current_user.shares.count() }}</span>
|
||||
<span class="stat-label">Shares</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-group">
|
||||
<h3>Edit Profile</h3>
|
||||
<form id="username-form" method="POST" action="{{ url_for('auth.update_profile') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" class="form-control"
|
||||
value="{{ current_user.username }}">
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn primary">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</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>
|
||||
<!-- Appearance Tab -->
|
||||
<div class="tab-content" id="appearance-tab">
|
||||
<div class="setting-group">
|
||||
<h3>Theme Settings</h3>
|
||||
<form id="theme-form" method="POST" action="{{ url_for('auth.update_preferences') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="form-group">
|
||||
<label>Default Theme</label>
|
||||
<div class="theme-options">
|
||||
<label class="radio-container">
|
||||
<input type="radio" name="theme_preference" value="light" {% if theme_preference=='light'
|
||||
%}checked{% endif %}>
|
||||
<span class="radio-label">Light</span>
|
||||
</label>
|
||||
<label class="radio-container">
|
||||
<input type="radio" name="theme_preference" value="dark" {% if theme_preference=='dark'
|
||||
%}checked{% endif %}>
|
||||
<span class="radio-label">Dark</span>
|
||||
</label>
|
||||
<label class="radio-container">
|
||||
<input type="radio" name="theme_preference" value="system" {% if theme_preference=='system'
|
||||
or not theme_preference %}checked{% endif %}>
|
||||
<span class="radio-label">Use System Preference</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn primary">Save Preferences</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Security Tab -->
|
||||
<div class="tab-content" id="security-tab">
|
||||
<div class="setting-group">
|
||||
<h3>Change Password</h3>
|
||||
<form id="password-form" method="POST" action="{{ url_for('auth.change_password') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="form-group">
|
||||
<label for="current_password">Current Password</label>
|
||||
<input type="password" id="current_password" name="current_password" class="form-control" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new_password">New Password</label>
|
||||
<input type="password" id="new_password" name="new_password" class="form-control" required>
|
||||
<div class="password-strength" id="password-strength"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="confirm_password">Confirm New Password</label>
|
||||
<input type="password" id="confirm_password" name="confirm_password" class="form-control" required>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn primary">Change Password</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Tab switching
|
||||
const tabs = document.querySelectorAll('.profile-tab');
|
||||
const tabContents = document.querySelectorAll('.tab-content');
|
||||
|
||||
tabs.forEach(tab => {
|
||||
tab.addEventListener('click', function () {
|
||||
const tabId = this.getAttribute('data-tab');
|
||||
|
||||
// Update active tab
|
||||
tabs.forEach(t => t.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
|
||||
// Update active content
|
||||
tabContents.forEach(content => {
|
||||
content.classList.remove('active');
|
||||
if (content.id === `${tabId}-tab`) {
|
||||
content.classList.add('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Password strength meter
|
||||
const passwordInput = document.getElementById('new_password');
|
||||
const strengthIndicator = document.getElementById('password-strength');
|
||||
|
||||
if (passwordInput && strengthIndicator) {
|
||||
passwordInput.addEventListener('input', function () {
|
||||
const password = this.value;
|
||||
let strength = 0;
|
||||
|
||||
if (password.length >= 8) strength += 1;
|
||||
if (password.match(/[a-z]/) && password.match(/[A-Z]/)) strength += 1;
|
||||
if (password.match(/\d/)) strength += 1;
|
||||
if (password.match(/[^a-zA-Z\d]/)) strength += 1;
|
||||
|
||||
// Update the strength indicator
|
||||
strengthIndicator.className = 'password-strength';
|
||||
if (password.length === 0) {
|
||||
strengthIndicator.textContent = '';
|
||||
} else if (strength < 2) {
|
||||
strengthIndicator.textContent = 'Weak';
|
||||
strengthIndicator.classList.add('weak');
|
||||
} else if (strength < 4) {
|
||||
strengthIndicator.textContent = 'Moderate';
|
||||
strengthIndicator.classList.add('moderate');
|
||||
} else {
|
||||
strengthIndicator.textContent = 'Strong';
|
||||
strengthIndicator.classList.add('strong');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Password confirmation matching
|
||||
const confirmInput = document.getElementById('confirm_password');
|
||||
if (passwordInput && confirmInput) {
|
||||
confirmInput.addEventListener('input', function () {
|
||||
if (passwordInput.value !== this.value) {
|
||||
this.setCustomValidity('Passwords must match');
|
||||
} else {
|
||||
this.setCustomValidity('');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
|
@ -2,13 +2,88 @@
|
|||
|
||||
{% block title %}Dashboard - Flask Files{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.dashboard {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.section-header .toggle-icon {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.section-header.collapsed .toggle-icon {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.collapsible-section {
|
||||
margin-bottom: 2rem;
|
||||
transition: max-height 0.5s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section-content {
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
|
||||
.section-content.collapsed {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.quick-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.quick-access {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.quick-action {
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.quick-action:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.quick-action i {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="dashboard">
|
||||
<h2>Dashboard</h2>
|
||||
|
||||
<div class="dashboard-stats">
|
||||
<div class="quick-stats">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">📁</div>
|
||||
<div class="stat-icon"><i class="fas fa-folder"></i></div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-value">{{ total_folders }}</span>
|
||||
<span class="stat-label">Folders</span>
|
||||
|
@ -16,7 +91,7 @@
|
|||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">📄</div>
|
||||
<div class="stat-icon"><i class="fas fa-file"></i></div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-value">{{ total_files }}</span>
|
||||
<span class="stat-label">Files</span>
|
||||
|
@ -24,51 +99,135 @@
|
|||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">🔗</div>
|
||||
<div class="stat-icon"><i class="fas fa-link"></i></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 class="stat-card">
|
||||
<div class="stat-icon"><i class="fas fa-clock"></i></div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-value">{{ recent_activities|default(0) }}</span>
|
||||
<span class="stat-label">Recent Activities</span>
|
||||
</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 class="quick-access">
|
||||
<a href="{{ url_for('files.upload') }}" class="quick-action">
|
||||
<i class="fas fa-cloud-upload-alt"></i>
|
||||
<div>Upload Files</div>
|
||||
</a>
|
||||
<a href="{{ url_for('files.browser') }}" class="quick-action">
|
||||
<i class="fas fa-folder-open"></i>
|
||||
<div>Browse Files</div>
|
||||
</a>
|
||||
<a href="#" class="quick-action">
|
||||
<i class="fas fa-share-alt"></i>
|
||||
<div>Manage Shares</div>
|
||||
</a>
|
||||
<a href="{{ url_for('auth.profile') }}" class="quick-action">
|
||||
<i class="fas fa-user-cog"></i>
|
||||
<div>Settings</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Recent Files Section (Collapsible) -->
|
||||
<div class="collapsible-section" id="recent-files-section">
|
||||
<div class="section-header" data-target="recent-files-content">
|
||||
<h3><i class="fas fa-clock"></i> Recent Files</h3>
|
||||
<span class="toggle-icon"><i class="fas fa-chevron-down"></i></span>
|
||||
</div>
|
||||
<div class="section-content" id="recent-files-content">
|
||||
{% if recent_files %}
|
||||
<div class="files-grid grid-view">
|
||||
{% for file in recent_files %}
|
||||
<a href="{{ url_for('files.download', file_id=file.id) }}" class="file-item" data-id="{{ file.id }}">
|
||||
<div class="item-icon">
|
||||
<i class="fas {{ file_icon(file.mime_type, file.name) }}"></i>
|
||||
</div>
|
||||
<div class="item-info">
|
||||
<div class="item-name">{{ file.name }}</div>
|
||||
<div class="item-details">
|
||||
<span class="item-size">{{ format_size(file.size) }}</span>
|
||||
<span class="item-date">{{ file.updated_at.strftime('%b %d, %Y') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>No files uploaded yet. <a href="{{ url_for('files.upload') }}">Upload your first file</a>.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Folder View (Collapsible) -->
|
||||
<div class="collapsible-section" id="folders-section">
|
||||
<div class="section-header" data-target="folders-content">
|
||||
<h3><i class="fas fa-folder"></i> My Folders</h3>
|
||||
<span class="toggle-icon"><i class="fas fa-chevron-down"></i></span>
|
||||
</div>
|
||||
<div class="section-content" id="folders-content">
|
||||
{% if root_folders %}
|
||||
<div class="files-grid grid-view">
|
||||
{% for folder in root_folders %}
|
||||
<a href="{{ url_for('files.browser', folder_id=folder.id) }}" class="folder-item"
|
||||
data-id="{{ folder.id }}">
|
||||
<div class="item-icon">
|
||||
<i class="fas fa-folder"></i>
|
||||
</div>
|
||||
<div class="item-info">
|
||||
<div class="item-name">{{ folder.name }}</div>
|
||||
<div class="item-details">
|
||||
<span class="item-count">{{ folder.children.count() }} items</span>
|
||||
<span class="item-date">{{ folder.created_at.strftime('%b %d, %Y') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>No folders created yet. <a href="{{ url_for('files.browser') }}">Create your first folder</a>.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Set up collapsible sections
|
||||
const sectionHeaders = document.querySelectorAll('.section-header');
|
||||
|
||||
sectionHeaders.forEach(header => {
|
||||
header.addEventListener('click', function () {
|
||||
const targetId = this.getAttribute('data-target');
|
||||
const content = document.getElementById(targetId);
|
||||
|
||||
this.classList.toggle('collapsed');
|
||||
content.classList.toggle('collapsed');
|
||||
|
||||
// Store preference in localStorage
|
||||
localStorage.setItem(`section_${targetId}`, content.classList.contains('collapsed') ? 'closed' : 'open');
|
||||
});
|
||||
|
||||
// Check localStorage for saved preferences
|
||||
const targetId = header.getAttribute('data-target');
|
||||
const savedState = localStorage.getItem(`section_${targetId}`);
|
||||
|
||||
if (savedState === 'closed') {
|
||||
header.classList.add('collapsed');
|
||||
document.getElementById(targetId).classList.add('collapsed');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
|
@ -6,15 +6,15 @@
|
|||
<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) }}"
|
||||
<!-- <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">
|
||||
</a> -->
|
||||
<!-- <button class="btn" id="new-folder-btn">
|
||||
<i class="fas fa-folder-plus"></i> New Folder
|
||||
</button>
|
||||
</div>
|
||||
</button> -->
|
||||
<!-- </div> -->
|
||||
</div>
|
||||
|
||||
<div class="path-nav">
|
||||
|
@ -112,10 +112,10 @@
|
|||
<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>
|
||||
<a href="{{ url_for('files.upload', folder=current_folder.id if current_folder else None) }}"
|
||||
class="btn primary">
|
||||
<i class=" fas fa-cloud-upload-alt"></i> Upload
|
||||
</a>
|
||||
<button class="btn" id="empty-new-folder-btn">
|
||||
<i class="fas fa-folder-plus"></i> New Folder
|
||||
</button>
|
||||
|
|
|
@ -116,6 +116,7 @@
|
|||
const MAX_CONCURRENT_UPLOADS = 3;
|
||||
const folderId = {{ parent_folder.id if parent_folder else 'null' }
|
||||
};
|
||||
});
|
||||
|
||||
// Setup event listeners
|
||||
dropzone.addEventListener('dragover', function (e) {
|
||||
|
@ -231,21 +232,21 @@
|
|||
const fileIcon = getFileIcon(file.name);
|
||||
|
||||
fileItem.innerHTML = `
|
||||
<div class="file-icon">
|
||||
<i class="fas ${fileIcon}"></i>
|
||||
</div>
|
||||
<div class="file-info">
|
||||
<div class="file-name">${file.name}</div>
|
||||
<div class="file-path">${file.relativePath || 'No path'}</div>
|
||||
<div class="file-size">${formatSize(file.size)}</div>
|
||||
<div class="file-progress">
|
||||
<div class="progress-bar-small" style="width: 0%"></div>
|
||||
<div class="file-icon">
|
||||
<i class="fas ${fileIcon}"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-status">
|
||||
<span class="status-indicator queued">Queued</span>
|
||||
</div>
|
||||
`;
|
||||
<div class="file-info">
|
||||
<div class="file-name">${file.name}</div>
|
||||
<div class="file-path">${file.relativePath || 'No path'}</div>
|
||||
<div class="file-size">${formatSize(file.size)}</div>
|
||||
<div class="file-progress">
|
||||
<div class="progress-bar-small" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-status">
|
||||
<span class="status-indicator queued">Queued</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
uploadList.appendChild(fileItem);
|
||||
}
|
||||
|
@ -466,9 +467,9 @@
|
|||
const alert = document.createElement('div');
|
||||
alert.className = `alert ${type || 'info'}`;
|
||||
alert.innerHTML = `
|
||||
<div class="alert-content">${message}</div>
|
||||
<button class="close" aria-label="Close">×</button>
|
||||
`;
|
||||
<div class="alert-content">${message}</div>
|
||||
<button class="close" aria-label="Close">×</button>
|
||||
`;
|
||||
|
||||
// Add to container
|
||||
alertsContainer.appendChild(alert);
|
||||
|
@ -558,6 +559,6 @@
|
|||
|
||||
return 'fa-file';
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
Loading…
Add table
Add a link
Reference in a new issue