Initial commit - LEGO Instructions Manager v1.5.0
This commit is contained in:
78
app/__init__.py
Normal file
78
app/__init__.py
Normal file
@@ -0,0 +1,78 @@
|
||||
import os
|
||||
from flask import Flask
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_migrate import Migrate
|
||||
from flask_login import LoginManager
|
||||
from flask_bcrypt import Bcrypt
|
||||
from app.config import config
|
||||
|
||||
# Initialize extensions
|
||||
db = SQLAlchemy()
|
||||
migrate = Migrate()
|
||||
login_manager = LoginManager()
|
||||
bcrypt = Bcrypt()
|
||||
|
||||
|
||||
def create_app(config_name='default'):
|
||||
"""Application factory pattern."""
|
||||
app = Flask(__name__)
|
||||
|
||||
# Load configuration
|
||||
app.config.from_object(config[config_name])
|
||||
|
||||
# Initialize extensions
|
||||
db.init_app(app)
|
||||
migrate.init_app(app, db)
|
||||
bcrypt.init_app(app)
|
||||
|
||||
# Configure Flask-Login
|
||||
login_manager.init_app(app)
|
||||
login_manager.login_view = 'auth.login'
|
||||
login_manager.login_message = 'Please log in to access this page.'
|
||||
login_manager.login_message_category = 'info'
|
||||
|
||||
# User loader for Flask-Login
|
||||
from app.models.user import User
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
return User.query.get(int(user_id))
|
||||
|
||||
# Register blueprints
|
||||
from app.routes.auth import auth_bp
|
||||
from app.routes.main import main_bp
|
||||
from app.routes.sets import sets_bp
|
||||
from app.routes.instructions import instructions_bp
|
||||
from app.routes.admin import admin_bp
|
||||
from app.routes.extra_files import extra_files_bp
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(main_bp)
|
||||
app.register_blueprint(sets_bp)
|
||||
app.register_blueprint(instructions_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
app.register_blueprint(extra_files_bp)
|
||||
|
||||
# Import models to ensure they're registered with SQLAlchemy
|
||||
from app.models.user import User
|
||||
from app.models.set import Set
|
||||
from app.models.instruction import Instruction
|
||||
from app.models.extra_file import ExtraFile
|
||||
|
||||
# Create upload directories
|
||||
os.makedirs(os.path.join(app.config['UPLOAD_FOLDER'], 'pdfs'), exist_ok=True)
|
||||
os.makedirs(os.path.join(app.config['UPLOAD_FOLDER'], 'images'), exist_ok=True)
|
||||
os.makedirs(os.path.join(app.config['UPLOAD_FOLDER'], 'covers'), exist_ok=True)
|
||||
os.makedirs(os.path.join(app.config['UPLOAD_FOLDER'], 'thumbnails'), exist_ok=True)
|
||||
os.makedirs(os.path.join(app.config['UPLOAD_FOLDER'], 'extra_files'), exist_ok=True)
|
||||
|
||||
# Context processor for global template variables
|
||||
@app.context_processor
|
||||
def inject_global_vars():
|
||||
from app.services.brickset_api import BricksetAPI
|
||||
return {
|
||||
'app_name': 'LEGO Instructions Manager',
|
||||
'brickset_available': BricksetAPI.is_configured()
|
||||
}
|
||||
|
||||
return app
|
||||
59
app/config.py
Normal file
59
app/config.py
Normal file
@@ -0,0 +1,59 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
basedir = os.path.abspath(os.path.dirname(__file__))
|
||||
load_dotenv(os.path.join(basedir, '.env'))
|
||||
|
||||
|
||||
class Config:
|
||||
"""Base configuration."""
|
||||
|
||||
# Flask
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
|
||||
|
||||
# Database
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \
|
||||
'sqlite:///' + os.path.join(basedir, 'lego_instructions.db')
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
|
||||
# File Upload
|
||||
# basedir is E:\LIM\app, so we just need 'static/uploads' to get E:\LIM\app\static\uploads
|
||||
UPLOAD_FOLDER = os.path.join(basedir, 'static', 'uploads')
|
||||
MAX_CONTENT_LENGTH = int(os.environ.get('MAX_CONTENT_LENGTH', 52428800)) # 50MB
|
||||
ALLOWED_EXTENSIONS = {'pdf', 'png', 'jpg', 'jpeg', 'gif', 'webp'}
|
||||
|
||||
# Brickset API
|
||||
BRICKSET_API_KEY = os.environ.get('BRICKSET_API_KEY')
|
||||
BRICKSET_USERNAME = os.environ.get('BRICKSET_USERNAME')
|
||||
BRICKSET_PASSWORD = os.environ.get('BRICKSET_PASSWORD')
|
||||
BRICKSET_API_URL = 'https://brickset.com/api/v3.asmx'
|
||||
|
||||
# Pagination
|
||||
SETS_PER_PAGE = int(os.environ.get('SETS_PER_PAGE', 20))
|
||||
|
||||
|
||||
class DevelopmentConfig(Config):
|
||||
"""Development configuration."""
|
||||
DEBUG = True
|
||||
TESTING = False
|
||||
|
||||
|
||||
class ProductionConfig(Config):
|
||||
"""Production configuration."""
|
||||
DEBUG = False
|
||||
TESTING = False
|
||||
|
||||
|
||||
class TestingConfig(Config):
|
||||
"""Testing configuration."""
|
||||
TESTING = True
|
||||
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
|
||||
WTF_CSRF_ENABLED = False
|
||||
|
||||
|
||||
config = {
|
||||
'development': DevelopmentConfig,
|
||||
'production': ProductionConfig,
|
||||
'testing': TestingConfig,
|
||||
'default': DevelopmentConfig
|
||||
}
|
||||
5
app/models/__init__.py
Normal file
5
app/models/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from app.models.user import User
|
||||
from app.models.set import Set
|
||||
from app.models.instruction import Instruction
|
||||
|
||||
__all__ = ['User', 'Set', 'Instruction']
|
||||
123
app/models/extra_file.py
Normal file
123
app/models/extra_file.py
Normal file
@@ -0,0 +1,123 @@
|
||||
from datetime import datetime
|
||||
from app import db
|
||||
|
||||
|
||||
class ExtraFile(db.Model):
|
||||
"""Model for extra files attached to sets (BrickLink XML, Stud.io, box art, etc)."""
|
||||
|
||||
__tablename__ = 'extra_files'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
set_id = db.Column(db.Integer, db.ForeignKey('sets.id', ondelete='CASCADE'), nullable=False)
|
||||
|
||||
# File information
|
||||
file_name = db.Column(db.String(255), nullable=False)
|
||||
original_filename = db.Column(db.String(255), nullable=False) # Original name before hashing
|
||||
file_path = db.Column(db.String(500), nullable=False)
|
||||
file_type = db.Column(db.String(50), nullable=False) # Extension
|
||||
file_size = db.Column(db.Integer, nullable=False) # Size in bytes
|
||||
|
||||
# Metadata
|
||||
description = db.Column(db.Text)
|
||||
category = db.Column(db.String(50)) # 'bricklink', 'studio', 'box_art', 'document', 'photo', 'other'
|
||||
|
||||
# Tracking
|
||||
uploaded_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
uploaded_by = db.Column(db.Integer, db.ForeignKey('users.id'))
|
||||
|
||||
# Relationships
|
||||
lego_set = db.relationship('Set', back_populates='extra_files')
|
||||
uploader = db.relationship('User', backref='uploaded_files')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<ExtraFile {self.file_name} for Set {self.set_id}>'
|
||||
|
||||
@property
|
||||
def file_size_formatted(self):
|
||||
"""Return human-readable file size."""
|
||||
size = self.file_size
|
||||
for unit in ['B', 'KB', 'MB', 'GB']:
|
||||
if size < 1024.0:
|
||||
return f"{size:.1f} {unit}"
|
||||
size /= 1024.0
|
||||
return f"{size:.1f} TB"
|
||||
|
||||
@property
|
||||
def file_icon(self):
|
||||
"""Return Bootstrap icon class based on file type."""
|
||||
icon_map = {
|
||||
# Images
|
||||
'jpg': 'file-image',
|
||||
'jpeg': 'file-image',
|
||||
'png': 'file-image',
|
||||
'gif': 'file-image',
|
||||
'webp': 'file-image',
|
||||
'bmp': 'file-image',
|
||||
|
||||
# Documents
|
||||
'pdf': 'file-pdf',
|
||||
'doc': 'file-word',
|
||||
'docx': 'file-word',
|
||||
'txt': 'file-text',
|
||||
'rtf': 'file-text',
|
||||
|
||||
# Spreadsheets
|
||||
'xls': 'file-excel',
|
||||
'xlsx': 'file-excel',
|
||||
'csv': 'file-spreadsheet',
|
||||
|
||||
# Data files
|
||||
'xml': 'file-code',
|
||||
'json': 'file-code',
|
||||
|
||||
# 3D/CAD files
|
||||
'ldr': 'box-seam', # LDraw
|
||||
'mpd': 'box-seam', # LDraw
|
||||
'io': 'box-seam', # Stud.io
|
||||
'lxf': 'box-seam', # LEGO Digital Designer
|
||||
'lxfml': 'box-seam',
|
||||
|
||||
# Archives
|
||||
'zip': 'file-zip',
|
||||
'rar': 'file-zip',
|
||||
'7z': 'file-zip',
|
||||
'tar': 'file-zip',
|
||||
'gz': 'file-zip',
|
||||
|
||||
# Other
|
||||
'default': 'file-earmark'
|
||||
}
|
||||
|
||||
return icon_map.get(self.file_type.lower(), icon_map['default'])
|
||||
|
||||
@property
|
||||
def is_image(self):
|
||||
"""Check if file is an image."""
|
||||
image_types = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp']
|
||||
return self.file_type.lower() in image_types
|
||||
|
||||
@property
|
||||
def can_preview(self):
|
||||
"""Check if file can be previewed in browser."""
|
||||
preview_types = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf', 'txt']
|
||||
return self.file_type.lower() in preview_types
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary for JSON responses."""
|
||||
return {
|
||||
'id': self.id,
|
||||
'set_id': self.set_id,
|
||||
'file_name': self.file_name,
|
||||
'original_filename': self.original_filename,
|
||||
'file_path': self.file_path.replace('\\', '/'),
|
||||
'file_type': self.file_type,
|
||||
'file_size': self.file_size,
|
||||
'file_size_formatted': self.file_size_formatted,
|
||||
'description': self.description,
|
||||
'category': self.category,
|
||||
'uploaded_at': self.uploaded_at.isoformat(),
|
||||
'is_image': self.is_image,
|
||||
'can_preview': self.can_preview,
|
||||
'file_icon': self.file_icon,
|
||||
'download_url': f'/extra-files/download/{self.id}'
|
||||
}
|
||||
60
app/models/instruction.py
Normal file
60
app/models/instruction.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from datetime import datetime
|
||||
from app import db
|
||||
|
||||
|
||||
class Instruction(db.Model):
|
||||
"""Model for instruction files (PDFs or images)."""
|
||||
|
||||
__tablename__ = 'instructions'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
set_id = db.Column(db.Integer, db.ForeignKey('sets.id'), nullable=False, index=True)
|
||||
|
||||
# File information
|
||||
file_type = db.Column(db.String(10), nullable=False) # 'PDF' or 'IMAGE'
|
||||
file_path = db.Column(db.String(500), nullable=False)
|
||||
file_name = db.Column(db.String(200), nullable=False)
|
||||
file_size = db.Column(db.Integer) # Size in bytes
|
||||
thumbnail_path = db.Column(db.String(500)) # Thumbnail preview (especially for PDFs)
|
||||
|
||||
# For image sequences
|
||||
page_number = db.Column(db.Integer, default=1)
|
||||
|
||||
# Metadata
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
uploaded_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Instruction {self.file_name} for Set {self.set_id}>'
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert instruction to dictionary."""
|
||||
# Ensure forward slashes for web URLs
|
||||
clean_path = self.file_path.replace('\\', '/')
|
||||
thumbnail_clean = self.thumbnail_path.replace('\\', '/') if self.thumbnail_path else None
|
||||
|
||||
return {
|
||||
'id': self.id,
|
||||
'set_id': self.set_id,
|
||||
'file_type': self.file_type,
|
||||
'file_name': self.file_name,
|
||||
'file_size': self.file_size,
|
||||
'page_number': self.page_number,
|
||||
'file_url': f'/static/uploads/{clean_path}',
|
||||
'thumbnail_url': f'/static/uploads/{thumbnail_clean}' if thumbnail_clean else None,
|
||||
'uploaded_at': self.uploaded_at.isoformat()
|
||||
}
|
||||
|
||||
@property
|
||||
def file_size_mb(self):
|
||||
"""Return file size in MB."""
|
||||
if self.file_size:
|
||||
return round(self.file_size / (1024 * 1024), 2)
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def allowed_file(filename):
|
||||
"""Check if file extension is allowed."""
|
||||
from flask import current_app
|
||||
return '.' in filename and \
|
||||
filename.rsplit('.', 1)[1].lower() in current_app.config['ALLOWED_EXTENSIONS']
|
||||
99
app/models/set.py
Normal file
99
app/models/set.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from datetime import datetime
|
||||
from app import db
|
||||
|
||||
|
||||
class Set(db.Model):
|
||||
"""Model for LEGO sets."""
|
||||
|
||||
__tablename__ = 'sets'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
set_number = db.Column(db.String(20), unique=True, nullable=False, index=True)
|
||||
set_name = db.Column(db.String(200), nullable=False)
|
||||
theme = db.Column(db.String(100), nullable=False, index=True)
|
||||
year_released = db.Column(db.Integer, nullable=False, index=True)
|
||||
piece_count = db.Column(db.Integer)
|
||||
|
||||
# MOC (My Own Creation) support
|
||||
is_moc = db.Column(db.Boolean, default=False, nullable=False, index=True)
|
||||
moc_designer = db.Column(db.String(100), nullable=True) # Designer/creator name
|
||||
moc_description = db.Column(db.Text, nullable=True) # Detailed description
|
||||
|
||||
# Brickset integration
|
||||
brickset_id = db.Column(db.Integer, unique=True, nullable=True)
|
||||
image_url = db.Column(db.String(500)) # External URL (Brickset, etc.)
|
||||
cover_image = db.Column(db.String(500)) # Uploaded cover image path
|
||||
|
||||
# Metadata
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow,
|
||||
onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
instructions = db.relationship('Instruction', backref='set', lazy='dynamic',
|
||||
cascade='all, delete-orphan')
|
||||
extra_files = db.relationship('ExtraFile', back_populates='lego_set', lazy='dynamic',
|
||||
cascade='all, delete-orphan', order_by='ExtraFile.uploaded_at.desc()')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Set {self.set_number}: {self.set_name}>'
|
||||
|
||||
def get_image(self):
|
||||
"""Get the best available image (uploaded cover takes priority)."""
|
||||
if self.cover_image:
|
||||
# Ensure forward slashes for web URLs
|
||||
clean_path = self.cover_image.replace('\\', '/')
|
||||
return f'/static/uploads/{clean_path}'
|
||||
return self.image_url
|
||||
|
||||
@property
|
||||
def has_cover_image(self):
|
||||
"""Check if set has an uploaded cover image."""
|
||||
return bool(self.cover_image)
|
||||
|
||||
@property
|
||||
def cover_image_url(self):
|
||||
"""Get the uploaded cover image URL."""
|
||||
if self.cover_image:
|
||||
clean_path = self.cover_image.replace('\\', '/')
|
||||
return f'/static/uploads/{clean_path}'
|
||||
return None
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert set to dictionary."""
|
||||
return {
|
||||
'id': self.id,
|
||||
'set_number': self.set_number,
|
||||
'set_name': self.set_name,
|
||||
'theme': self.theme,
|
||||
'year_released': self.year_released,
|
||||
'piece_count': self.piece_count,
|
||||
'image_url': self.image_url,
|
||||
'is_moc': self.is_moc,
|
||||
'moc_designer': self.moc_designer,
|
||||
'moc_description': self.moc_description,
|
||||
'instruction_count': self.instructions.count(),
|
||||
'created_at': self.created_at.isoformat(),
|
||||
'updated_at': self.updated_at.isoformat()
|
||||
}
|
||||
|
||||
@property
|
||||
def instruction_files(self):
|
||||
"""Get all instruction files for this set."""
|
||||
return self.instructions.order_by(Instruction.page_number).all()
|
||||
|
||||
@property
|
||||
def pdf_instructions(self):
|
||||
"""Get only PDF instructions."""
|
||||
return self.instructions.filter_by(file_type='PDF').all()
|
||||
|
||||
@property
|
||||
def image_instructions(self):
|
||||
"""Get only image instructions."""
|
||||
return self.instructions.filter_by(file_type='IMAGE').order_by(
|
||||
Instruction.page_number).all()
|
||||
|
||||
|
||||
# Import here to avoid circular imports
|
||||
from app.models.instruction import Instruction
|
||||
42
app/models/user.py
Normal file
42
app/models/user.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from datetime import datetime
|
||||
from flask_login import UserMixin
|
||||
from app import db, bcrypt
|
||||
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
"""User model for authentication."""
|
||||
|
||||
__tablename__ = 'users'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(80), unique=True, nullable=False, index=True)
|
||||
email = db.Column(db.String(120), unique=True, nullable=False, index=True)
|
||||
password_hash = db.Column(db.String(128), nullable=False)
|
||||
is_admin = db.Column(db.Boolean, default=False, nullable=False, index=True)
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
sets = db.relationship('Set', backref='added_by', lazy='dynamic',
|
||||
foreign_keys='Set.user_id')
|
||||
instructions = db.relationship('Instruction', backref='uploaded_by',
|
||||
lazy='dynamic')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<User {self.username}>'
|
||||
|
||||
def set_password(self, password):
|
||||
"""Hash and set the user's password."""
|
||||
self.password_hash = bcrypt.generate_password_hash(password).decode('utf-8')
|
||||
|
||||
def check_password(self, password):
|
||||
"""Check if the provided password matches the hash."""
|
||||
return bcrypt.check_password_hash(self.password_hash, password)
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert user to dictionary."""
|
||||
return {
|
||||
'id': self.id,
|
||||
'username': self.username,
|
||||
'email': self.email,
|
||||
'created_at': self.created_at.isoformat()
|
||||
}
|
||||
6
app/routes/__init__.py
Normal file
6
app/routes/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from app.routes.auth import auth_bp
|
||||
from app.routes.main import main_bp
|
||||
from app.routes.sets import sets_bp
|
||||
from app.routes.instructions import instructions_bp
|
||||
|
||||
__all__ = ['auth_bp', 'main_bp', 'sets_bp', 'instructions_bp']
|
||||
409
app/routes/admin.py
Normal file
409
app/routes/admin.py
Normal file
@@ -0,0 +1,409 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
from functools import wraps
|
||||
from app import db
|
||||
from app.models.user import User
|
||||
from app.models.set import Set
|
||||
from app.models.instruction import Instruction
|
||||
from sqlalchemy import func
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||
|
||||
|
||||
def admin_required(f):
|
||||
"""Decorator to require admin access."""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not current_user.is_authenticated:
|
||||
flash('Please log in to access this page.', 'warning')
|
||||
return redirect(url_for('auth.login'))
|
||||
if not current_user.is_admin:
|
||||
flash('You do not have permission to access this page.', 'danger')
|
||||
return redirect(url_for('main.index'))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
|
||||
@admin_bp.route('/')
|
||||
@login_required
|
||||
@admin_required
|
||||
def dashboard():
|
||||
"""Admin dashboard with site statistics."""
|
||||
# Get statistics
|
||||
total_users = User.query.count()
|
||||
total_sets = Set.query.count()
|
||||
total_instructions = Instruction.query.count()
|
||||
total_mocs = Set.query.filter_by(is_moc=True).count()
|
||||
|
||||
# Recent activity
|
||||
recent_users = User.query.order_by(User.created_at.desc()).limit(5).all()
|
||||
recent_sets = Set.query.order_by(Set.created_at.desc()).limit(10).all()
|
||||
|
||||
# Storage statistics
|
||||
total_storage = db.session.query(
|
||||
func.sum(Instruction.file_size)
|
||||
).scalar() or 0
|
||||
total_storage_mb = round(total_storage / (1024 * 1024), 2)
|
||||
|
||||
# Get users with most sets
|
||||
top_contributors = db.session.query(
|
||||
User, func.count(Set.id).label('set_count')
|
||||
).join(Set).group_by(User.id).order_by(
|
||||
func.count(Set.id).desc()
|
||||
).limit(5).all()
|
||||
|
||||
# Theme statistics
|
||||
theme_stats = db.session.query(
|
||||
Set.theme, func.count(Set.id).label('count')
|
||||
).group_by(Set.theme).order_by(
|
||||
func.count(Set.id).desc()
|
||||
).limit(10).all()
|
||||
|
||||
return render_template('admin/dashboard.html',
|
||||
total_users=total_users,
|
||||
total_sets=total_sets,
|
||||
total_instructions=total_instructions,
|
||||
total_mocs=total_mocs,
|
||||
total_storage_mb=total_storage_mb,
|
||||
recent_users=recent_users,
|
||||
recent_sets=recent_sets,
|
||||
top_contributors=top_contributors,
|
||||
theme_stats=theme_stats)
|
||||
|
||||
|
||||
@admin_bp.route('/users')
|
||||
@login_required
|
||||
@admin_required
|
||||
def users():
|
||||
"""User management page."""
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = 20
|
||||
|
||||
# Search functionality
|
||||
search = request.args.get('search', '')
|
||||
query = User.query
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
(User.username.ilike(f'%{search}%')) |
|
||||
(User.email.ilike(f'%{search}%'))
|
||||
)
|
||||
|
||||
pagination = query.order_by(User.created_at.desc()).paginate(
|
||||
page=page, per_page=per_page, error_out=False
|
||||
)
|
||||
|
||||
# Get set counts for each user
|
||||
user_stats = {}
|
||||
for user in pagination.items:
|
||||
user_stats[user.id] = {
|
||||
'sets': user.sets.count(),
|
||||
'instructions': user.instructions.count()
|
||||
}
|
||||
|
||||
return render_template('admin/users.html',
|
||||
users=pagination.items,
|
||||
pagination=pagination,
|
||||
user_stats=user_stats,
|
||||
search=search)
|
||||
|
||||
|
||||
@admin_bp.route('/users/<int:user_id>/toggle-admin', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def toggle_admin(user_id):
|
||||
"""Toggle admin status for a user."""
|
||||
if user_id == current_user.id:
|
||||
return jsonify({'error': 'Cannot change your own admin status'}), 400
|
||||
|
||||
user = User.query.get_or_404(user_id)
|
||||
user.is_admin = not user.is_admin
|
||||
db.session.commit()
|
||||
|
||||
status = 'granted' if user.is_admin else 'revoked'
|
||||
flash(f'Admin access {status} for {user.username}', 'success')
|
||||
|
||||
return jsonify({'success': True, 'is_admin': user.is_admin})
|
||||
|
||||
|
||||
@admin_bp.route('/users/<int:user_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def delete_user(user_id):
|
||||
"""Delete a user and optionally their data."""
|
||||
if user_id == current_user.id:
|
||||
flash('Cannot delete your own account from admin panel', 'danger')
|
||||
return redirect(url_for('admin.users'))
|
||||
|
||||
user = User.query.get_or_404(user_id)
|
||||
username = user.username
|
||||
|
||||
# Check if should delete user's data
|
||||
delete_data = request.form.get('delete_data') == 'on'
|
||||
|
||||
if delete_data:
|
||||
# Delete user's sets and instructions
|
||||
Set.query.filter_by(user_id=user_id).delete()
|
||||
Instruction.query.filter_by(user_id=user_id).delete()
|
||||
else:
|
||||
# Reassign to admin (current user)
|
||||
Set.query.filter_by(user_id=user_id).update({'user_id': current_user.id})
|
||||
Instruction.query.filter_by(user_id=user_id).update({'user_id': current_user.id})
|
||||
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
|
||||
flash(f'User {username} has been deleted', 'success')
|
||||
return redirect(url_for('admin.users'))
|
||||
|
||||
|
||||
@admin_bp.route('/sets')
|
||||
@login_required
|
||||
@admin_required
|
||||
def sets():
|
||||
"""View all sets in the system."""
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = 20
|
||||
|
||||
# Filter options
|
||||
filter_type = request.args.get('type', 'all')
|
||||
search = request.args.get('search', '')
|
||||
|
||||
query = Set.query
|
||||
|
||||
# Apply filters
|
||||
if filter_type == 'mocs':
|
||||
query = query.filter_by(is_moc=True)
|
||||
elif filter_type == 'official':
|
||||
query = query.filter_by(is_moc=False)
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
(Set.set_number.ilike(f'%{search}%')) |
|
||||
(Set.set_name.ilike(f'%{search}%')) |
|
||||
(Set.theme.ilike(f'%{search}%'))
|
||||
)
|
||||
|
||||
pagination = query.order_by(Set.created_at.desc()).paginate(
|
||||
page=page, per_page=per_page, error_out=False
|
||||
)
|
||||
|
||||
return render_template('admin/sets.html',
|
||||
sets=pagination.items,
|
||||
pagination=pagination,
|
||||
filter_type=filter_type,
|
||||
search=search)
|
||||
|
||||
|
||||
@admin_bp.route('/sets/<int:set_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def delete_set(set_id):
|
||||
"""Delete a set and its instructions."""
|
||||
lego_set = Set.query.get_or_404(set_id)
|
||||
set_number = lego_set.set_number
|
||||
|
||||
# Delete instructions (cascade should handle this)
|
||||
db.session.delete(lego_set)
|
||||
db.session.commit()
|
||||
|
||||
flash(f'Set {set_number} has been deleted', 'success')
|
||||
return redirect(url_for('admin.sets'))
|
||||
|
||||
|
||||
@admin_bp.route('/site-settings', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def site_settings():
|
||||
"""Site-wide settings configuration."""
|
||||
if request.method == 'POST':
|
||||
# This is a placeholder for future site settings
|
||||
# You could store settings in a database table or config file
|
||||
flash('Settings updated successfully', 'success')
|
||||
return redirect(url_for('admin.site_settings'))
|
||||
|
||||
# Get current stats for display
|
||||
stats = {
|
||||
'total_users': User.query.count(),
|
||||
'total_sets': Set.query.count(),
|
||||
'total_instructions': Instruction.query.count(),
|
||||
'total_storage': db.session.query(func.sum(Instruction.file_size)).scalar() or 0
|
||||
}
|
||||
|
||||
return render_template('admin/settings.html', stats=stats)
|
||||
|
||||
|
||||
@admin_bp.route('/api/stats')
|
||||
@login_required
|
||||
@admin_required
|
||||
def api_stats():
|
||||
"""API endpoint for real-time statistics."""
|
||||
# Get stats for the last 30 days
|
||||
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
|
||||
|
||||
new_users = User.query.filter(User.created_at >= thirty_days_ago).count()
|
||||
new_sets = Set.query.filter(Set.created_at >= thirty_days_ago).count()
|
||||
|
||||
return jsonify({
|
||||
'total_users': User.query.count(),
|
||||
'total_sets': Set.query.count(),
|
||||
'total_instructions': Instruction.query.count(),
|
||||
'new_users_30d': new_users,
|
||||
'new_sets_30d': new_sets
|
||||
})
|
||||
|
||||
|
||||
@admin_bp.route('/bulk-import', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def bulk_import():
|
||||
"""Bulk import sets from Brickset."""
|
||||
from app.services.brickset_api import BricksetAPI
|
||||
import time
|
||||
|
||||
if request.method == 'POST':
|
||||
# Get form data
|
||||
set_numbers_text = request.form.get('set_numbers', '')
|
||||
user_id = request.form.get('user_id')
|
||||
throttle_delay = float(request.form.get('throttle_delay', '0.5')) # Default 0.5 seconds
|
||||
|
||||
# Parse set numbers (split by newlines, commas, or spaces)
|
||||
import re
|
||||
set_numbers = re.split(r'[,\s\n]+', set_numbers_text.strip())
|
||||
set_numbers = [s.strip() for s in set_numbers if s.strip()]
|
||||
|
||||
if not set_numbers:
|
||||
flash('Please enter at least one set number.', 'warning')
|
||||
return redirect(url_for('admin.bulk_import'))
|
||||
|
||||
if not user_id:
|
||||
flash('Please select a user.', 'warning')
|
||||
return redirect(url_for('admin.bulk_import'))
|
||||
|
||||
# Warn if trying to import too many at once
|
||||
if len(set_numbers) > 50:
|
||||
flash('Warning: Importing more than 50 sets may take a while. Consider splitting into smaller batches.', 'warning')
|
||||
|
||||
# Check if Brickset is configured
|
||||
if not BricksetAPI.is_configured():
|
||||
flash('Brickset API is not configured. Please add credentials to .env file.', 'danger')
|
||||
return redirect(url_for('admin.bulk_import'))
|
||||
|
||||
# Import sets with throttling
|
||||
api = BricksetAPI()
|
||||
results = {
|
||||
'success': [],
|
||||
'failed': [],
|
||||
'already_exists': [],
|
||||
'rate_limited': []
|
||||
}
|
||||
|
||||
for index, set_number in enumerate(set_numbers, 1):
|
||||
try:
|
||||
# Check if set already exists
|
||||
existing_set = Set.query.filter_by(set_number=set_number).first()
|
||||
if existing_set:
|
||||
results['already_exists'].append({
|
||||
'set_number': set_number,
|
||||
'name': existing_set.set_name
|
||||
})
|
||||
continue
|
||||
|
||||
# Add delay between requests to respect rate limits
|
||||
if index > 1: # Don't delay on first request
|
||||
time.sleep(throttle_delay)
|
||||
|
||||
# Fetch from Brickset
|
||||
set_data = api.get_set_by_number(set_number)
|
||||
|
||||
if not set_data:
|
||||
results['failed'].append({
|
||||
'set_number': set_number,
|
||||
'reason': 'Not found in Brickset'
|
||||
})
|
||||
continue
|
||||
|
||||
# Create set in database
|
||||
new_set = Set(
|
||||
set_number=set_number,
|
||||
set_name=set_data.get('name', 'Unknown'),
|
||||
theme=set_data.get('theme', 'Unknown'),
|
||||
year_released=set_data.get('year', datetime.now().year),
|
||||
piece_count=set_data.get('pieces', 0),
|
||||
image_url=set_data.get('image', {}).get('imageURL'),
|
||||
user_id=user_id,
|
||||
is_moc=False
|
||||
)
|
||||
|
||||
db.session.add(new_set)
|
||||
results['success'].append({
|
||||
'set_number': set_number,
|
||||
'name': new_set.set_name,
|
||||
'theme': new_set.theme
|
||||
})
|
||||
|
||||
current_app.logger.info(f"Imported set {index}/{len(set_numbers)}: {set_number}")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
if 'API limit exceeded' in error_msg or 'rate limit' in error_msg.lower():
|
||||
results['rate_limited'].append({
|
||||
'set_number': set_number,
|
||||
'reason': 'API rate limit exceeded'
|
||||
})
|
||||
current_app.logger.warning(f"Rate limit hit on set {set_number}, stopping import")
|
||||
# Stop processing remaining sets
|
||||
break
|
||||
else:
|
||||
results['failed'].append({
|
||||
'set_number': set_number,
|
||||
'reason': error_msg
|
||||
})
|
||||
current_app.logger.error(f"Failed to import {set_number}: {error_msg}")
|
||||
|
||||
# Commit all successful imports
|
||||
if results['success']:
|
||||
db.session.commit()
|
||||
flash(f"Successfully imported {len(results['success'])} set(s)!", 'success')
|
||||
|
||||
if results['rate_limited']:
|
||||
remaining = len(set_numbers) - len(results['success']) - len(results['failed']) - len(results['already_exists']) - len(results['rate_limited'])
|
||||
flash(f"API rate limit reached after {len(results['success'])} imports. "
|
||||
f"{len(results['rate_limited'])} set(s) not processed due to rate limit. "
|
||||
f"Try again in a few minutes or increase the throttle delay.", 'warning')
|
||||
|
||||
if results['failed']:
|
||||
flash(f"Failed to import {len(results['failed'])} set(s).", 'warning')
|
||||
|
||||
if results['already_exists']:
|
||||
flash(f"{len(results['already_exists'])} set(s) already exist in database.", 'info')
|
||||
|
||||
# Store results in session for display
|
||||
from flask import session
|
||||
session['import_results'] = results
|
||||
|
||||
return redirect(url_for('admin.bulk_import_results'))
|
||||
|
||||
# GET request - show form
|
||||
users = User.query.order_by(User.username).all()
|
||||
brickset_configured = BricksetAPI.is_configured()
|
||||
|
||||
return render_template('admin/bulk_import.html',
|
||||
users=users,
|
||||
brickset_configured=brickset_configured)
|
||||
|
||||
|
||||
@admin_bp.route('/bulk-import/results')
|
||||
@login_required
|
||||
@admin_required
|
||||
def bulk_import_results():
|
||||
"""Display bulk import results."""
|
||||
from flask import session
|
||||
results = session.pop('import_results', None)
|
||||
|
||||
if not results:
|
||||
flash('No import results to display.', 'info')
|
||||
return redirect(url_for('admin.bulk_import'))
|
||||
|
||||
return render_template('admin/bulk_import_results.html', results=results)
|
||||
102
app/routes/auth.py
Normal file
102
app/routes/auth.py
Normal file
@@ -0,0 +1,102 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from app import db
|
||||
from app.models.user import User
|
||||
|
||||
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
|
||||
|
||||
|
||||
@auth_bp.route('/register', methods=['GET', 'POST'])
|
||||
def register():
|
||||
"""User registration."""
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.index'))
|
||||
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username')
|
||||
email = request.form.get('email')
|
||||
password = request.form.get('password')
|
||||
confirm_password = request.form.get('confirm_password')
|
||||
|
||||
# Validation
|
||||
if not all([username, email, password, confirm_password]):
|
||||
flash('All fields are required.', 'danger')
|
||||
return render_template('auth/register.html')
|
||||
|
||||
if password != confirm_password:
|
||||
flash('Passwords do not match.', 'danger')
|
||||
return render_template('auth/register.html')
|
||||
|
||||
if len(password) < 6:
|
||||
flash('Password must be at least 6 characters long.', 'danger')
|
||||
return render_template('auth/register.html')
|
||||
|
||||
# Check if user already exists
|
||||
if User.query.filter_by(username=username).first():
|
||||
flash('Username already exists.', 'danger')
|
||||
return render_template('auth/register.html')
|
||||
|
||||
if User.query.filter_by(email=email).first():
|
||||
flash('Email already registered.', 'danger')
|
||||
return render_template('auth/register.html')
|
||||
|
||||
# Create new user
|
||||
user = User(username=username, email=email)
|
||||
user.set_password(password)
|
||||
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
flash('Registration successful! Please log in.', 'success')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
return render_template('auth/register.html')
|
||||
|
||||
|
||||
@auth_bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
"""User login."""
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.index'))
|
||||
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username')
|
||||
password = request.form.get('password')
|
||||
remember = request.form.get('remember', False)
|
||||
|
||||
if not username or not password:
|
||||
flash('Please provide both username and password.', 'danger')
|
||||
return render_template('auth/login.html')
|
||||
|
||||
user = User.query.filter_by(username=username).first()
|
||||
|
||||
if user and user.check_password(password):
|
||||
login_user(user, remember=bool(remember))
|
||||
next_page = request.args.get('next')
|
||||
flash(f'Welcome back, {user.username}!', 'success')
|
||||
return redirect(next_page) if next_page else redirect(url_for('main.index'))
|
||||
else:
|
||||
flash('Invalid username or password.', 'danger')
|
||||
|
||||
return render_template('auth/login.html')
|
||||
|
||||
|
||||
@auth_bp.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
"""User logout."""
|
||||
logout_user()
|
||||
flash('You have been logged out.', 'info')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
|
||||
@auth_bp.route('/profile')
|
||||
@login_required
|
||||
def profile():
|
||||
"""User profile page."""
|
||||
set_count = current_user.sets.count()
|
||||
instruction_count = current_user.instructions.count()
|
||||
|
||||
return render_template('auth/profile.html',
|
||||
set_count=set_count,
|
||||
instruction_count=instruction_count)
|
||||
273
app/routes/extra_files.py
Normal file
273
app/routes/extra_files.py
Normal file
@@ -0,0 +1,273 @@
|
||||
import os
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, send_file, current_app, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
from werkzeug.utils import secure_filename
|
||||
from app import db
|
||||
from app.models.set import Set
|
||||
from app.models.extra_file import ExtraFile
|
||||
import uuid
|
||||
|
||||
extra_files_bp = Blueprint('extra_files', __name__)
|
||||
|
||||
|
||||
ALLOWED_EXTENSIONS = {
|
||||
# Images
|
||||
'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg',
|
||||
# Documents
|
||||
'pdf', 'doc', 'docx', 'txt', 'rtf', 'odt',
|
||||
# Spreadsheets
|
||||
'xls', 'xlsx', 'csv', 'ods',
|
||||
# Data files
|
||||
'xml', 'json', 'yaml', 'yml',
|
||||
# 3D/CAD files
|
||||
'ldr', 'mpd', 'io', 'lxf', 'lxfml', 'stl', 'obj',
|
||||
# Archives
|
||||
'zip', 'rar', '7z', 'tar', 'gz',
|
||||
# Other
|
||||
'md', 'html', 'css', 'js'
|
||||
}
|
||||
|
||||
# File categories
|
||||
FILE_CATEGORIES = {
|
||||
'bricklink': ['xml'],
|
||||
'studio': ['io'],
|
||||
'ldraw': ['ldr', 'mpd'],
|
||||
'ldd': ['lxf', 'lxfml'],
|
||||
'box_art': ['jpg', 'jpeg', 'png', 'gif', 'webp'],
|
||||
'document': ['pdf', 'doc', 'docx', 'txt', 'rtf'],
|
||||
'photo': ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'],
|
||||
'data': ['xml', 'json', 'csv', 'xlsx', 'xls'],
|
||||
'archive': ['zip', 'rar', '7z', 'tar', 'gz'],
|
||||
'other': []
|
||||
}
|
||||
|
||||
|
||||
def allowed_file(filename):
|
||||
"""Check if file extension is allowed."""
|
||||
return '.' in filename and \
|
||||
filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||
|
||||
|
||||
def get_file_category(file_extension):
|
||||
"""Determine file category based on extension."""
|
||||
ext = file_extension.lower()
|
||||
|
||||
# Check each category
|
||||
for category, extensions in FILE_CATEGORIES.items():
|
||||
if ext in extensions:
|
||||
return category
|
||||
|
||||
return 'other'
|
||||
|
||||
|
||||
@extra_files_bp.route('/upload/<int:set_id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def upload(set_id):
|
||||
"""Upload extra files for a set."""
|
||||
lego_set = Set.query.get_or_404(set_id)
|
||||
|
||||
# Check permission
|
||||
if lego_set.user_id != current_user.id and not current_user.is_admin:
|
||||
flash('You do not have permission to upload files to this set.', 'danger')
|
||||
return redirect(url_for('sets.view_set', set_id=set_id))
|
||||
|
||||
if request.method == 'POST':
|
||||
# Check if files were uploaded
|
||||
if 'files' not in request.files:
|
||||
flash('No files selected.', 'warning')
|
||||
return redirect(request.url)
|
||||
|
||||
files = request.files.getlist('files')
|
||||
description = request.form.get('description', '').strip()
|
||||
category = request.form.get('category', 'other')
|
||||
|
||||
if not files or all(file.filename == '' for file in files):
|
||||
flash('No files selected.', 'warning')
|
||||
return redirect(request.url)
|
||||
|
||||
uploaded_count = 0
|
||||
failed_files = []
|
||||
|
||||
for file in files:
|
||||
if file and file.filename:
|
||||
if not allowed_file(file.filename):
|
||||
failed_files.append(f"{file.filename} (unsupported file type)")
|
||||
continue
|
||||
|
||||
try:
|
||||
# Secure the filename
|
||||
original_filename = secure_filename(file.filename)
|
||||
file_extension = original_filename.rsplit('.', 1)[1].lower()
|
||||
|
||||
# Generate unique filename
|
||||
unique_filename = f"{uuid.uuid4().hex}.{file_extension}"
|
||||
|
||||
# Create directory for extra files
|
||||
extra_files_dir = os.path.join(current_app.config['UPLOAD_FOLDER'],
|
||||
'extra_files',
|
||||
str(lego_set.set_number))
|
||||
os.makedirs(extra_files_dir, exist_ok=True)
|
||||
|
||||
# Save file
|
||||
file_path = os.path.join(extra_files_dir, unique_filename)
|
||||
file.save(file_path)
|
||||
|
||||
# Get file size
|
||||
file_size = os.path.getsize(file_path)
|
||||
|
||||
# Determine category if auto
|
||||
if category == 'auto':
|
||||
category = get_file_category(file_extension)
|
||||
|
||||
# Create database record
|
||||
relative_path = os.path.join('extra_files',
|
||||
str(lego_set.set_number),
|
||||
unique_filename)
|
||||
|
||||
extra_file = ExtraFile(
|
||||
set_id=lego_set.id,
|
||||
file_name=unique_filename,
|
||||
original_filename=original_filename,
|
||||
file_path=relative_path,
|
||||
file_type=file_extension,
|
||||
file_size=file_size,
|
||||
description=description if description else None,
|
||||
category=category,
|
||||
uploaded_by=current_user.id
|
||||
)
|
||||
|
||||
db.session.add(extra_file)
|
||||
uploaded_count += 1
|
||||
|
||||
except Exception as e:
|
||||
failed_files.append(f"{file.filename} ({str(e)})")
|
||||
current_app.logger.error(f"Error uploading file {file.filename}: {str(e)}")
|
||||
|
||||
# Commit all successful uploads
|
||||
if uploaded_count > 0:
|
||||
db.session.commit()
|
||||
flash(f'Successfully uploaded {uploaded_count} file(s)!', 'success')
|
||||
|
||||
if failed_files:
|
||||
flash(f"Failed to upload {len(failed_files)} file(s): {', '.join(failed_files)}", 'warning')
|
||||
|
||||
return redirect(url_for('sets.view_set', set_id=set_id))
|
||||
|
||||
# GET request - show upload form
|
||||
return render_template('extra_files/upload.html',
|
||||
lego_set=lego_set,
|
||||
file_categories=FILE_CATEGORIES)
|
||||
|
||||
|
||||
@extra_files_bp.route('/download/<int:file_id>')
|
||||
@login_required
|
||||
def download(file_id):
|
||||
"""Download an extra file."""
|
||||
extra_file = ExtraFile.query.get_or_404(file_id)
|
||||
|
||||
# Check permission
|
||||
if extra_file.lego_set.user_id != current_user.id and not current_user.is_admin:
|
||||
flash('You do not have permission to download this file.', 'danger')
|
||||
return redirect(url_for('main.index'))
|
||||
|
||||
# Get full file path
|
||||
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], extra_file.file_path)
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
flash('File not found.', 'danger')
|
||||
return redirect(url_for('sets.view_set', set_id=extra_file.set_id))
|
||||
|
||||
return send_file(
|
||||
file_path,
|
||||
as_attachment=True,
|
||||
download_name=extra_file.original_filename
|
||||
)
|
||||
|
||||
|
||||
@extra_files_bp.route('/preview/<int:file_id>')
|
||||
@login_required
|
||||
def preview(file_id):
|
||||
"""Preview a file (for images and PDFs)."""
|
||||
extra_file = ExtraFile.query.get_or_404(file_id)
|
||||
|
||||
# Check permission
|
||||
if extra_file.lego_set.user_id != current_user.id and not current_user.is_admin:
|
||||
flash('You do not have permission to view this file.', 'danger')
|
||||
return redirect(url_for('main.index'))
|
||||
|
||||
if not extra_file.can_preview:
|
||||
flash('This file type cannot be previewed.', 'info')
|
||||
return redirect(url_for('extra_files.download', file_id=file_id))
|
||||
|
||||
# Get full file path
|
||||
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], extra_file.file_path)
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
flash('File not found.', 'danger')
|
||||
return redirect(url_for('sets.view_set', set_id=extra_file.set_id))
|
||||
|
||||
return send_file(file_path)
|
||||
|
||||
|
||||
@extra_files_bp.route('/delete/<int:file_id>', methods=['POST'])
|
||||
@login_required
|
||||
def delete(file_id):
|
||||
"""Delete an extra file."""
|
||||
extra_file = ExtraFile.query.get_or_404(file_id)
|
||||
set_id = extra_file.set_id
|
||||
|
||||
# Check permission
|
||||
if extra_file.lego_set.user_id != current_user.id and not current_user.is_admin:
|
||||
flash('You do not have permission to delete this file.', 'danger')
|
||||
return redirect(url_for('sets.view_set', set_id=set_id))
|
||||
|
||||
try:
|
||||
# Delete physical file
|
||||
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], extra_file.file_path)
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
|
||||
# Delete database record
|
||||
db.session.delete(extra_file)
|
||||
db.session.commit()
|
||||
|
||||
flash('File deleted successfully!', 'success')
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error deleting file: {str(e)}', 'danger')
|
||||
current_app.logger.error(f"Error deleting extra file {file_id}: {str(e)}")
|
||||
|
||||
return redirect(url_for('sets.view_set', set_id=set_id))
|
||||
|
||||
|
||||
@extra_files_bp.route('/edit/<int:file_id>', methods=['POST'])
|
||||
@login_required
|
||||
def edit(file_id):
|
||||
"""Edit file description and category."""
|
||||
extra_file = ExtraFile.query.get_or_404(file_id)
|
||||
|
||||
# Check permission
|
||||
if extra_file.lego_set.user_id != current_user.id and not current_user.is_admin:
|
||||
return jsonify({'success': False, 'error': 'Permission denied'}), 403
|
||||
|
||||
try:
|
||||
extra_file.description = request.form.get('description', '').strip() or None
|
||||
extra_file.category = request.form.get('category', 'other')
|
||||
|
||||
db.session.commit()
|
||||
|
||||
if request.is_json or request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return jsonify({'success': True, 'message': 'File updated successfully'})
|
||||
else:
|
||||
flash('File updated successfully!', 'success')
|
||||
return redirect(url_for('sets.view_set', set_id=extra_file.set_id))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f"Error updating extra file {file_id}: {str(e)}")
|
||||
|
||||
if request.is_json or request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
else:
|
||||
flash(f'Error updating file: {str(e)}', 'danger')
|
||||
return redirect(url_for('sets.view_set', set_id=extra_file.set_id))
|
||||
305
app/routes/instructions.py
Normal file
305
app/routes/instructions.py
Normal file
@@ -0,0 +1,305 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, send_file
|
||||
from flask_login import login_required, current_user
|
||||
from werkzeug.utils import secure_filename
|
||||
from app import db
|
||||
from app.models.set import Set
|
||||
from app.models.instruction import Instruction
|
||||
from app.services.file_handler import FileHandler
|
||||
import os
|
||||
|
||||
instructions_bp = Blueprint('instructions', __name__, url_prefix='/instructions')
|
||||
|
||||
|
||||
@instructions_bp.route('/upload/<int:set_id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def upload(set_id):
|
||||
"""Upload instruction files for a specific set."""
|
||||
lego_set = Set.query.get_or_404(set_id)
|
||||
|
||||
if request.method == 'POST':
|
||||
# Check if files were uploaded
|
||||
if 'files[]' not in request.files:
|
||||
flash('No files selected.', 'danger')
|
||||
return redirect(request.url)
|
||||
|
||||
files = request.files.getlist('files[]')
|
||||
uploaded_count = 0
|
||||
|
||||
for file in files:
|
||||
if file and file.filename and FileHandler.allowed_file(file.filename):
|
||||
try:
|
||||
# Determine file type
|
||||
file_type = FileHandler.get_file_type(file.filename)
|
||||
|
||||
# Save file and generate thumbnail
|
||||
file_path, file_size, thumbnail_path = FileHandler.save_file(
|
||||
file,
|
||||
lego_set.set_number,
|
||||
file_type
|
||||
)
|
||||
|
||||
# Determine page number for images
|
||||
page_number = 1
|
||||
if file_type == 'IMAGE':
|
||||
# Get the highest page number for this set
|
||||
max_page = db.session.query(
|
||||
db.func.max(Instruction.page_number)
|
||||
).filter_by(
|
||||
set_id=set_id,
|
||||
file_type='IMAGE'
|
||||
).scalar()
|
||||
page_number = (max_page or 0) + 1
|
||||
|
||||
# Create instruction record
|
||||
instruction = Instruction(
|
||||
set_id=set_id,
|
||||
file_type=file_type,
|
||||
file_path=file_path,
|
||||
file_name=secure_filename(file.filename),
|
||||
file_size=file_size,
|
||||
page_number=page_number,
|
||||
thumbnail_path=thumbnail_path,
|
||||
user_id=current_user.id
|
||||
)
|
||||
|
||||
db.session.add(instruction)
|
||||
uploaded_count += 1
|
||||
|
||||
except Exception as e:
|
||||
flash(f'Error uploading {file.filename}: {str(e)}', 'danger')
|
||||
continue
|
||||
|
||||
if uploaded_count > 0:
|
||||
db.session.commit()
|
||||
flash(f'Successfully uploaded {uploaded_count} file(s)!', 'success')
|
||||
else:
|
||||
flash('No files were uploaded.', 'warning')
|
||||
|
||||
return redirect(url_for('sets.view_set', set_id=set_id))
|
||||
|
||||
return render_template('instructions/upload.html', set=lego_set)
|
||||
|
||||
|
||||
@instructions_bp.route('/<int:instruction_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete(instruction_id):
|
||||
"""Delete an instruction file."""
|
||||
instruction = Instruction.query.get_or_404(instruction_id)
|
||||
set_id = instruction.set_id
|
||||
|
||||
# Delete the physical file
|
||||
FileHandler.delete_file(instruction.file_path)
|
||||
|
||||
# Delete thumbnail if exists
|
||||
if instruction.thumbnail_path:
|
||||
FileHandler.delete_file(instruction.thumbnail_path)
|
||||
|
||||
# Delete the database record
|
||||
db.session.delete(instruction)
|
||||
db.session.commit()
|
||||
|
||||
flash('Instruction file deleted successfully.', 'success')
|
||||
return redirect(url_for('sets.view_set', set_id=set_id))
|
||||
|
||||
|
||||
@instructions_bp.route('/delete-all-images/<int:set_id>', methods=['POST'])
|
||||
@login_required
|
||||
def delete_all_images(set_id):
|
||||
"""Delete all image instructions for a set."""
|
||||
lego_set = Set.query.get_or_404(set_id)
|
||||
|
||||
# Get all image instructions
|
||||
image_instructions = Instruction.query.filter_by(
|
||||
set_id=set_id,
|
||||
file_type='IMAGE'
|
||||
).all()
|
||||
|
||||
count = len(image_instructions)
|
||||
|
||||
# Delete each one
|
||||
for instruction in image_instructions:
|
||||
# Delete physical file
|
||||
FileHandler.delete_file(instruction.file_path)
|
||||
|
||||
# Delete thumbnail if exists
|
||||
if instruction.thumbnail_path:
|
||||
FileHandler.delete_file(instruction.thumbnail_path)
|
||||
|
||||
# Delete database record
|
||||
db.session.delete(instruction)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
flash(f'Successfully deleted {count} image instruction(s).', 'success')
|
||||
return redirect(url_for('sets.view_set', set_id=set_id))
|
||||
|
||||
|
||||
@instructions_bp.route('/<int:instruction_id>/view')
|
||||
@login_required
|
||||
def view(instruction_id):
|
||||
"""View a specific instruction file."""
|
||||
instruction = Instruction.query.get_or_404(instruction_id)
|
||||
|
||||
from flask import current_app
|
||||
file_path = os.path.join(
|
||||
current_app.config['UPLOAD_FOLDER'],
|
||||
instruction.file_path
|
||||
)
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
flash('File not found.', 'danger')
|
||||
return redirect(url_for('sets.view_set', set_id=instruction.set_id))
|
||||
|
||||
return send_file(file_path)
|
||||
|
||||
|
||||
@instructions_bp.route('/<int:set_id>/reorder', methods=['POST'])
|
||||
@login_required
|
||||
def reorder(set_id):
|
||||
"""Reorder image instructions for a set."""
|
||||
lego_set = Set.query.get_or_404(set_id)
|
||||
|
||||
# Get new order from request
|
||||
new_order = request.json.get('order', [])
|
||||
|
||||
if not new_order:
|
||||
return jsonify({'error': 'No order provided'}), 400
|
||||
|
||||
try:
|
||||
# Update page numbers
|
||||
for index, instruction_id in enumerate(new_order, start=1):
|
||||
instruction = Instruction.query.get(instruction_id)
|
||||
if instruction and instruction.set_id == set_id:
|
||||
instruction.page_number = index
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({'success': True})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@instructions_bp.route('/bulk-upload/<int:set_id>', methods=['POST'])
|
||||
@login_required
|
||||
def bulk_upload(set_id):
|
||||
"""Handle bulk upload via AJAX."""
|
||||
lego_set = Set.query.get_or_404(set_id)
|
||||
|
||||
if 'file' not in request.files:
|
||||
return jsonify({'error': 'No file provided'}), 400
|
||||
|
||||
file = request.files['file']
|
||||
|
||||
if not file or not file.filename:
|
||||
return jsonify({'error': 'Invalid file'}), 400
|
||||
|
||||
if not FileHandler.allowed_file(file.filename):
|
||||
return jsonify({'error': 'File type not allowed'}), 400
|
||||
|
||||
try:
|
||||
# Determine file type
|
||||
file_type = FileHandler.get_file_type(file.filename)
|
||||
|
||||
# Save file
|
||||
file_path, file_size = FileHandler.save_file(
|
||||
file,
|
||||
lego_set.set_number,
|
||||
file_type
|
||||
)
|
||||
|
||||
# Determine page number for images
|
||||
page_number = 1
|
||||
if file_type == 'IMAGE':
|
||||
max_page = db.session.query(
|
||||
db.func.max(Instruction.page_number)
|
||||
).filter_by(
|
||||
set_id=set_id,
|
||||
file_type='IMAGE'
|
||||
).scalar()
|
||||
page_number = (max_page or 0) + 1
|
||||
|
||||
# Create instruction record
|
||||
instruction = Instruction(
|
||||
set_id=set_id,
|
||||
file_type=file_type,
|
||||
file_path=file_path,
|
||||
file_name=secure_filename(file.filename),
|
||||
file_size=file_size,
|
||||
page_number=page_number,
|
||||
user_id=current_user.id
|
||||
)
|
||||
|
||||
db.session.add(instruction)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'instruction': instruction.to_dict()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@instructions_bp.route('/viewer/<int:set_id>')
|
||||
@login_required
|
||||
def image_viewer(set_id):
|
||||
"""View image instructions in a scrollable PDF-like viewer."""
|
||||
lego_set = Set.query.get_or_404(set_id)
|
||||
|
||||
# Get all image instructions sorted by page number
|
||||
image_instructions = lego_set.image_instructions
|
||||
|
||||
if not image_instructions:
|
||||
flash('No image instructions available for this set.', 'info')
|
||||
return redirect(url_for('sets.view_set', set_id=set_id))
|
||||
|
||||
return render_template('instructions/viewer.html',
|
||||
set=lego_set,
|
||||
images=image_instructions)
|
||||
|
||||
|
||||
@instructions_bp.route('/debug/<int:set_id>')
|
||||
@login_required
|
||||
def debug_paths(set_id):
|
||||
"""Debug endpoint to check instruction paths."""
|
||||
from flask import current_app
|
||||
lego_set = Set.query.get_or_404(set_id)
|
||||
|
||||
debug_info = {
|
||||
'set_number': lego_set.set_number,
|
||||
'set_name': lego_set.set_name,
|
||||
'upload_folder': current_app.config['UPLOAD_FOLDER'],
|
||||
'instructions': []
|
||||
}
|
||||
|
||||
for instruction in lego_set.instructions:
|
||||
file_path = instruction.file_path.replace('\\', '/')
|
||||
full_path = os.path.join(current_app.config['UPLOAD_FOLDER'], instruction.file_path)
|
||||
|
||||
info = {
|
||||
'id': instruction.id,
|
||||
'file_name': instruction.file_name,
|
||||
'file_type': instruction.file_type,
|
||||
'page_number': instruction.page_number,
|
||||
'db_path': instruction.file_path,
|
||||
'clean_path': file_path,
|
||||
'full_disk_path': full_path,
|
||||
'file_exists': os.path.exists(full_path),
|
||||
'web_url': f'/static/uploads/{file_path}'
|
||||
}
|
||||
|
||||
if instruction.thumbnail_path:
|
||||
thumb_clean = instruction.thumbnail_path.replace('\\', '/')
|
||||
thumb_full = os.path.join(current_app.config['UPLOAD_FOLDER'], instruction.thumbnail_path)
|
||||
info['thumbnail_db'] = instruction.thumbnail_path
|
||||
info['thumbnail_clean'] = thumb_clean
|
||||
info['thumbnail_full'] = thumb_full
|
||||
info['thumbnail_exists'] = os.path.exists(thumb_full)
|
||||
info['thumbnail_url'] = f'/static/uploads/{thumb_clean}'
|
||||
|
||||
debug_info['instructions'].append(info)
|
||||
|
||||
return jsonify(debug_info)
|
||||
48
app/routes/main.py
Normal file
48
app/routes/main.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for
|
||||
from flask_login import login_required
|
||||
from app.models.set import Set
|
||||
from app.models.instruction import Instruction
|
||||
from sqlalchemy import func
|
||||
|
||||
main_bp = Blueprint('main', __name__)
|
||||
|
||||
|
||||
@main_bp.route('/')
|
||||
def index():
|
||||
"""Homepage."""
|
||||
return render_template('index.html')
|
||||
|
||||
|
||||
@main_bp.route('/dashboard')
|
||||
@login_required
|
||||
def dashboard():
|
||||
"""User dashboard with statistics and recent sets."""
|
||||
# Get statistics
|
||||
total_sets = Set.query.count()
|
||||
total_instructions = Instruction.query.count()
|
||||
|
||||
# Get theme statistics
|
||||
theme_stats = db.session.query(
|
||||
Set.theme,
|
||||
func.count(Set.id).label('count')
|
||||
).group_by(Set.theme).order_by(func.count(Set.id).desc()).limit(5).all()
|
||||
|
||||
# Get recent sets
|
||||
recent_sets = Set.query.order_by(Set.created_at.desc()).limit(6).all()
|
||||
|
||||
# Get sets by year
|
||||
year_stats = db.session.query(
|
||||
Set.year_released,
|
||||
func.count(Set.id).label('count')
|
||||
).group_by(Set.year_released).order_by(Set.year_released.desc()).limit(10).all()
|
||||
|
||||
return render_template('dashboard.html',
|
||||
total_sets=total_sets,
|
||||
total_instructions=total_instructions,
|
||||
theme_stats=theme_stats,
|
||||
year_stats=year_stats,
|
||||
recent_sets=recent_sets)
|
||||
|
||||
|
||||
# Import here to avoid circular imports at module level
|
||||
from app import db
|
||||
274
app/routes/sets.py
Normal file
274
app/routes/sets.py
Normal file
@@ -0,0 +1,274 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, current_app
|
||||
from flask_login import login_required, current_user
|
||||
import os
|
||||
from app import db
|
||||
from app.models.set import Set
|
||||
from app.models.instruction import Instruction
|
||||
from app.services.brickset_api import BricksetAPI
|
||||
from app.services.file_handler import FileHandler
|
||||
|
||||
sets_bp = Blueprint('sets', __name__, url_prefix='/sets')
|
||||
|
||||
|
||||
@sets_bp.route('/debug/<int:set_id>')
|
||||
@login_required
|
||||
def debug_set_image(set_id):
|
||||
"""Debug route to check image paths."""
|
||||
lego_set = Set.query.get_or_404(set_id)
|
||||
|
||||
debug_info = {
|
||||
'set_number': lego_set.set_number,
|
||||
'set_name': lego_set.set_name,
|
||||
'cover_image_db': lego_set.cover_image,
|
||||
'image_url_db': lego_set.image_url,
|
||||
'get_image_result': lego_set.get_image(),
|
||||
'cover_image_exists': False,
|
||||
'cover_image_full_path': None
|
||||
}
|
||||
|
||||
# Check if file actually exists
|
||||
if lego_set.cover_image:
|
||||
from flask import current_app
|
||||
full_path = os.path.join(current_app.config['UPLOAD_FOLDER'], lego_set.cover_image)
|
||||
debug_info['cover_image_full_path'] = full_path
|
||||
debug_info['cover_image_exists'] = os.path.exists(full_path)
|
||||
|
||||
return jsonify(debug_info)
|
||||
|
||||
|
||||
@sets_bp.route('/')
|
||||
@login_required
|
||||
def list_sets():
|
||||
"""List all LEGO sets with sorting and filtering."""
|
||||
page = request.args.get('page', 1, type=int)
|
||||
sort_by = request.args.get('sort', 'set_number')
|
||||
theme_filter = request.args.get('theme', '')
|
||||
year_filter = request.args.get('year', type=int)
|
||||
search_query = request.args.get('q', '')
|
||||
|
||||
# Build query
|
||||
query = Set.query
|
||||
|
||||
# Apply filters
|
||||
if theme_filter:
|
||||
query = query.filter(Set.theme == theme_filter)
|
||||
if year_filter:
|
||||
query = query.filter(Set.year_released == year_filter)
|
||||
if search_query:
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
Set.set_name.ilike(f'%{search_query}%'),
|
||||
Set.set_number.ilike(f'%{search_query}%')
|
||||
)
|
||||
)
|
||||
|
||||
# Apply sorting
|
||||
if sort_by == 'set_number':
|
||||
query = query.order_by(Set.set_number)
|
||||
elif sort_by == 'name':
|
||||
query = query.order_by(Set.set_name)
|
||||
elif sort_by == 'theme':
|
||||
query = query.order_by(Set.theme, Set.set_number)
|
||||
elif sort_by == 'year':
|
||||
query = query.order_by(Set.year_released.desc(), Set.set_number)
|
||||
elif sort_by == 'newest':
|
||||
query = query.order_by(Set.created_at.desc())
|
||||
|
||||
# Paginate
|
||||
from flask import current_app
|
||||
per_page = current_app.config.get('SETS_PER_PAGE', 20)
|
||||
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
|
||||
|
||||
# Get unique themes and years for filters
|
||||
themes = db.session.query(Set.theme).distinct().order_by(Set.theme).all()
|
||||
themes = [t[0] for t in themes]
|
||||
|
||||
years = db.session.query(Set.year_released).distinct().order_by(Set.year_released.desc()).all()
|
||||
years = [y[0] for y in years]
|
||||
|
||||
return render_template('sets/list.html',
|
||||
sets=pagination.items,
|
||||
pagination=pagination,
|
||||
themes=themes,
|
||||
years=years,
|
||||
current_theme=theme_filter,
|
||||
current_year=year_filter,
|
||||
current_sort=sort_by,
|
||||
search_query=search_query)
|
||||
|
||||
|
||||
@sets_bp.route('/<int:set_id>')
|
||||
@login_required
|
||||
def view_set(set_id):
|
||||
"""View detailed information about a specific set."""
|
||||
lego_set = Set.query.get_or_404(set_id)
|
||||
|
||||
# Get instructions grouped by type
|
||||
pdf_instructions = lego_set.pdf_instructions
|
||||
image_instructions = lego_set.image_instructions
|
||||
|
||||
return render_template('sets/detail.html',
|
||||
set=lego_set,
|
||||
pdf_instructions=pdf_instructions,
|
||||
image_instructions=image_instructions)
|
||||
|
||||
|
||||
@sets_bp.route('/add', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def add_set():
|
||||
"""Add a new LEGO set or MOC."""
|
||||
if request.method == 'POST':
|
||||
set_number = request.form.get('set_number', '').strip()
|
||||
set_name = request.form.get('set_name', '').strip()
|
||||
theme = request.form.get('theme', '').strip()
|
||||
year_released = request.form.get('year_released', type=int)
|
||||
piece_count = request.form.get('piece_count', type=int)
|
||||
image_url = request.form.get('image_url', '').strip()
|
||||
|
||||
# MOC fields
|
||||
is_moc = request.form.get('is_moc') == 'on'
|
||||
moc_designer = request.form.get('moc_designer', '').strip() if is_moc else None
|
||||
moc_description = request.form.get('moc_description', '').strip() if is_moc else None
|
||||
|
||||
# Validation
|
||||
if not all([set_number, set_name, theme, year_released]):
|
||||
flash('Set number, name, theme, and year are required.', 'danger')
|
||||
return render_template('sets/add.html')
|
||||
|
||||
# Check if set already exists
|
||||
if Set.query.filter_by(set_number=set_number).first():
|
||||
flash(f'Set {set_number} already exists in the database.', 'warning')
|
||||
return redirect(url_for('sets.list_sets'))
|
||||
|
||||
# Handle cover image upload
|
||||
cover_image_path = None
|
||||
if 'cover_image' in request.files:
|
||||
file = request.files['cover_image']
|
||||
if file and file.filename and FileHandler.allowed_file(file.filename):
|
||||
try:
|
||||
cover_image_path, _ = FileHandler.save_cover_image(file, set_number)
|
||||
except Exception as e:
|
||||
flash(f'Error uploading cover image: {str(e)}', 'warning')
|
||||
|
||||
# Create new set
|
||||
new_set = Set(
|
||||
set_number=set_number,
|
||||
set_name=set_name,
|
||||
theme=theme,
|
||||
year_released=year_released,
|
||||
piece_count=piece_count,
|
||||
image_url=image_url,
|
||||
cover_image=cover_image_path,
|
||||
is_moc=is_moc,
|
||||
moc_designer=moc_designer,
|
||||
moc_description=moc_description,
|
||||
user_id=current_user.id
|
||||
)
|
||||
|
||||
db.session.add(new_set)
|
||||
db.session.commit()
|
||||
|
||||
set_type = "MOC" if is_moc else "Set"
|
||||
flash(f'{set_type} {set_number}: {set_name} added successfully!', 'success')
|
||||
return redirect(url_for('sets.view_set', set_id=new_set.id))
|
||||
|
||||
return render_template('sets/add.html')
|
||||
|
||||
|
||||
@sets_bp.route('/<int:set_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_set(set_id):
|
||||
"""Edit an existing LEGO set or MOC."""
|
||||
lego_set = Set.query.get_or_404(set_id)
|
||||
|
||||
if request.method == 'POST':
|
||||
lego_set.set_name = request.form.get('set_name', '').strip()
|
||||
lego_set.theme = request.form.get('theme', '').strip()
|
||||
lego_set.year_released = request.form.get('year_released', type=int)
|
||||
lego_set.piece_count = request.form.get('piece_count', type=int)
|
||||
lego_set.image_url = request.form.get('image_url', '').strip()
|
||||
|
||||
# Update MOC fields
|
||||
lego_set.is_moc = request.form.get('is_moc') == 'on'
|
||||
lego_set.moc_designer = request.form.get('moc_designer', '').strip() if lego_set.is_moc else None
|
||||
lego_set.moc_description = request.form.get('moc_description', '').strip() if lego_set.is_moc else None
|
||||
|
||||
# Handle cover image upload
|
||||
if 'cover_image' in request.files:
|
||||
file = request.files['cover_image']
|
||||
if file and file.filename and FileHandler.allowed_file(file.filename):
|
||||
try:
|
||||
# Delete old cover image if exists
|
||||
if lego_set.cover_image:
|
||||
old_path = os.path.join(current_app.config['UPLOAD_FOLDER'], lego_set.cover_image)
|
||||
if os.path.exists(old_path):
|
||||
os.remove(old_path)
|
||||
|
||||
# Save new cover image
|
||||
cover_image_path, _ = FileHandler.save_cover_image(file, lego_set.set_number)
|
||||
lego_set.cover_image = cover_image_path
|
||||
except Exception as e:
|
||||
flash(f'Error uploading cover image: {str(e)}', 'warning')
|
||||
|
||||
# Option to remove cover image
|
||||
if request.form.get('remove_cover_image') == 'on':
|
||||
if lego_set.cover_image:
|
||||
old_path = os.path.join(current_app.config['UPLOAD_FOLDER'], lego_set.cover_image)
|
||||
if os.path.exists(old_path):
|
||||
os.remove(old_path)
|
||||
lego_set.cover_image = None
|
||||
|
||||
db.session.commit()
|
||||
flash('Set updated successfully!', 'success')
|
||||
return redirect(url_for('sets.view_set', set_id=set_id))
|
||||
|
||||
return render_template('sets/edit.html', set=lego_set)
|
||||
|
||||
|
||||
@sets_bp.route('/<int:set_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_set(set_id):
|
||||
"""Delete a LEGO set and all its instructions."""
|
||||
lego_set = Set.query.get_or_404(set_id)
|
||||
|
||||
# Delete associated files
|
||||
from app.services.file_handler import FileHandler
|
||||
for instruction in lego_set.instructions:
|
||||
FileHandler.delete_file(instruction.file_path)
|
||||
|
||||
db.session.delete(lego_set)
|
||||
db.session.commit()
|
||||
|
||||
flash(f'Set {lego_set.set_number} deleted successfully.', 'success')
|
||||
return redirect(url_for('sets.list_sets'))
|
||||
|
||||
|
||||
@sets_bp.route('/search-brickset')
|
||||
@login_required
|
||||
def search_brickset():
|
||||
"""Search for sets using Brickset API."""
|
||||
query = request.args.get('q', '').strip()
|
||||
|
||||
if not query:
|
||||
return jsonify({'error': 'Search query is required'}), 400
|
||||
|
||||
api = BricksetAPI()
|
||||
|
||||
# Try to search by set number first, then by name
|
||||
results = api.search_sets(set_number=query)
|
||||
if not results:
|
||||
results = api.search_sets(query=query)
|
||||
|
||||
# Format results for JSON response
|
||||
formatted_results = []
|
||||
for result in results[:10]: # Limit to 10 results
|
||||
formatted_results.append({
|
||||
'setNumber': result.get('number'),
|
||||
'name': result.get('name'),
|
||||
'theme': result.get('theme'),
|
||||
'year': result.get('year'),
|
||||
'pieces': result.get('pieces'),
|
||||
'imageUrl': result.get('image', {}).get('imageURL') if result.get('image') else None
|
||||
})
|
||||
|
||||
return jsonify(formatted_results)
|
||||
4
app/services/__init__.py
Normal file
4
app/services/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from app.services.brickset_api import BricksetAPI
|
||||
from app.services.file_handler import FileHandler
|
||||
|
||||
__all__ = ['BricksetAPI', 'FileHandler']
|
||||
198
app/services/brickset_api.py
Normal file
198
app/services/brickset_api.py
Normal file
@@ -0,0 +1,198 @@
|
||||
import requests
|
||||
from flask import current_app
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
|
||||
class BricksetAPI:
|
||||
"""Service for interacting with the Brickset API v3."""
|
||||
|
||||
BASE_URL = 'https://brickset.com/api/v3.asmx'
|
||||
|
||||
def __init__(self):
|
||||
self.api_key = current_app.config.get('BRICKSET_API_KEY')
|
||||
self.username = current_app.config.get('BRICKSET_USERNAME')
|
||||
self.password = current_app.config.get('BRICKSET_PASSWORD')
|
||||
self._user_hash = None
|
||||
|
||||
@staticmethod
|
||||
def is_configured() -> bool:
|
||||
"""Check if Brickset API is properly configured."""
|
||||
return bool(
|
||||
current_app.config.get('BRICKSET_API_KEY') and
|
||||
current_app.config.get('BRICKSET_USERNAME') and
|
||||
current_app.config.get('BRICKSET_PASSWORD')
|
||||
)
|
||||
|
||||
def _get_user_hash(self) -> Optional[str]:
|
||||
"""Authenticate and get user hash token."""
|
||||
if self._user_hash:
|
||||
return self._user_hash
|
||||
|
||||
if not all([self.api_key, self.username, self.password]):
|
||||
current_app.logger.warning('Brickset API credentials not configured')
|
||||
return None
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
f'{self.BASE_URL}/login',
|
||||
params={
|
||||
'apiKey': self.api_key,
|
||||
'username': self.username,
|
||||
'password': self.password
|
||||
},
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if data.get('status') == 'success':
|
||||
self._user_hash = data.get('hash')
|
||||
return self._user_hash
|
||||
else:
|
||||
current_app.logger.error(f"Brickset login failed: {data.get('message')}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Brickset API authentication error: {str(e)}')
|
||||
return None
|
||||
|
||||
def search_sets(self,
|
||||
set_number: Optional[str] = None,
|
||||
theme: Optional[str] = None,
|
||||
year: Optional[int] = None,
|
||||
query: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search for LEGO sets using various parameters.
|
||||
|
||||
Args:
|
||||
set_number: Specific set number to search for
|
||||
theme: Theme name to filter by
|
||||
year: Year released
|
||||
query: General search query
|
||||
|
||||
Returns:
|
||||
List of set dictionaries
|
||||
"""
|
||||
user_hash = self._get_user_hash()
|
||||
if not user_hash:
|
||||
return []
|
||||
|
||||
params = {
|
||||
'apiKey': self.api_key,
|
||||
'userHash': user_hash,
|
||||
'params': '{}' # JSON params object
|
||||
}
|
||||
|
||||
# Build search parameters
|
||||
search_params = {}
|
||||
if set_number:
|
||||
search_params['setNumber'] = set_number
|
||||
if theme:
|
||||
search_params['theme'] = theme
|
||||
if year:
|
||||
search_params['year'] = year
|
||||
if query:
|
||||
search_params['query'] = query
|
||||
|
||||
params['params'] = str(search_params)
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
f'{self.BASE_URL}/getSets',
|
||||
params=params,
|
||||
timeout=15
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if data.get('status') == 'success':
|
||||
return data.get('sets', [])
|
||||
else:
|
||||
current_app.logger.error(f"Brickset search failed: {data.get('message')}")
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Brickset API search error: {str(e)}')
|
||||
return []
|
||||
|
||||
def get_set_by_number(self, set_number: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get detailed information for a specific set by its number.
|
||||
|
||||
Args:
|
||||
set_number: The LEGO set number (e.g., "10497")
|
||||
|
||||
Returns:
|
||||
Dictionary with set information or None
|
||||
"""
|
||||
results = self.search_sets(set_number=set_number)
|
||||
return results[0] if results else None
|
||||
|
||||
def get_themes(self) -> List[str]:
|
||||
"""
|
||||
Get list of all available LEGO themes.
|
||||
|
||||
Returns:
|
||||
List of theme names
|
||||
"""
|
||||
user_hash = self._get_user_hash()
|
||||
if not user_hash:
|
||||
return []
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
f'{self.BASE_URL}/getThemes',
|
||||
params={
|
||||
'apiKey': self.api_key,
|
||||
'userHash': user_hash
|
||||
},
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if data.get('status') == 'success':
|
||||
themes = data.get('themes', [])
|
||||
return [theme.get('theme') for theme in themes if theme.get('theme')]
|
||||
else:
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Brickset API themes error: {str(e)}')
|
||||
return []
|
||||
|
||||
def get_instructions(self, set_number: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get instruction information for a specific set.
|
||||
|
||||
Args:
|
||||
set_number: The LEGO set number
|
||||
|
||||
Returns:
|
||||
List of instruction dictionaries
|
||||
"""
|
||||
user_hash = self._get_user_hash()
|
||||
if not user_hash:
|
||||
return []
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
f'{self.BASE_URL}/getInstructions',
|
||||
params={
|
||||
'apiKey': self.api_key,
|
||||
'userHash': user_hash,
|
||||
'setNumber': set_number
|
||||
},
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if data.get('status') == 'success':
|
||||
return data.get('instructions', [])
|
||||
else:
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Brickset API instructions error: {str(e)}')
|
||||
return []
|
||||
317
app/services/file_handler.py
Normal file
317
app/services/file_handler.py
Normal file
@@ -0,0 +1,317 @@
|
||||
import os
|
||||
import uuid
|
||||
from werkzeug.utils import secure_filename
|
||||
from flask import current_app
|
||||
from PIL import Image
|
||||
from typing import Tuple, Optional
|
||||
|
||||
|
||||
class FileHandler:
|
||||
"""Service for handling file uploads and storage."""
|
||||
|
||||
@staticmethod
|
||||
def allowed_file(filename: str) -> bool:
|
||||
"""Check if file extension is allowed."""
|
||||
return '.' in filename and \
|
||||
filename.rsplit('.', 1)[1].lower() in current_app.config['ALLOWED_EXTENSIONS']
|
||||
|
||||
@staticmethod
|
||||
def get_file_type(filename: str) -> str:
|
||||
"""Determine file type based on extension."""
|
||||
ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
|
||||
return 'PDF' if ext == 'pdf' else 'IMAGE'
|
||||
|
||||
@staticmethod
|
||||
def generate_unique_filename(original_filename: str) -> str:
|
||||
"""Generate a unique filename while preserving the extension."""
|
||||
ext = secure_filename(original_filename).rsplit('.', 1)[1].lower()
|
||||
unique_name = f"{uuid.uuid4().hex}.{ext}"
|
||||
return unique_name
|
||||
|
||||
@staticmethod
|
||||
def save_cover_image(file, set_number: str) -> Tuple[str, int]:
|
||||
"""
|
||||
Save cover image for a set.
|
||||
|
||||
Args:
|
||||
file: FileStorage object from request
|
||||
set_number: Set number for organizing files
|
||||
|
||||
Returns:
|
||||
Tuple of (relative_path, file_size_bytes)
|
||||
"""
|
||||
# Create covers directory if it doesn't exist
|
||||
covers_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'covers')
|
||||
os.makedirs(covers_dir, exist_ok=True)
|
||||
|
||||
# Generate unique filename
|
||||
filename = FileHandler.generate_unique_filename(file.filename)
|
||||
|
||||
# Create set-specific subdirectory
|
||||
set_dir = os.path.join(covers_dir, secure_filename(set_number))
|
||||
os.makedirs(set_dir, exist_ok=True)
|
||||
|
||||
# Save file
|
||||
file_path = os.path.join(set_dir, filename)
|
||||
file.save(file_path)
|
||||
|
||||
# Get file size
|
||||
file_size = os.path.getsize(file_path)
|
||||
|
||||
# Create thumbnail and optimize
|
||||
try:
|
||||
img = Image.open(file_path)
|
||||
|
||||
# Convert RGBA to RGB if necessary
|
||||
if img.mode == 'RGBA':
|
||||
background = Image.new('RGB', img.size, (255, 255, 255))
|
||||
background.paste(img, mask=img.split()[3])
|
||||
img = background
|
||||
|
||||
# Resize if too large (max 800px on longest side)
|
||||
max_size = 800
|
||||
if max(img.size) > max_size:
|
||||
ratio = max_size / max(img.size)
|
||||
new_size = tuple(int(dim * ratio) for dim in img.size)
|
||||
img = img.resize(new_size, Image.Resampling.LANCZOS)
|
||||
img.save(file_path, optimize=True, quality=85)
|
||||
file_size = os.path.getsize(file_path)
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error processing cover image: {e}")
|
||||
|
||||
# Return relative path from uploads folder
|
||||
relative_path = os.path.join('covers', secure_filename(set_number), filename)
|
||||
return relative_path.replace('\\', '/'), file_size
|
||||
|
||||
@staticmethod
|
||||
def save_file(file, set_number: str, file_type: str) -> Tuple[str, int, Optional[str]]:
|
||||
"""
|
||||
Save uploaded file to appropriate directory.
|
||||
|
||||
Args:
|
||||
file: FileStorage object from request
|
||||
set_number: LEGO set number for organizing files
|
||||
file_type: 'PDF' or 'IMAGE'
|
||||
|
||||
Returns:
|
||||
Tuple of (relative_path, file_size, thumbnail_path)
|
||||
"""
|
||||
# Generate unique filename
|
||||
unique_filename = FileHandler.generate_unique_filename(file.filename)
|
||||
|
||||
# Determine subdirectory
|
||||
subdir = 'pdfs' if file_type == 'PDF' else 'images'
|
||||
|
||||
# Create set-specific directory
|
||||
set_dir = os.path.join(
|
||||
current_app.config['UPLOAD_FOLDER'],
|
||||
subdir,
|
||||
secure_filename(set_number)
|
||||
)
|
||||
os.makedirs(set_dir, exist_ok=True)
|
||||
|
||||
# Full file path
|
||||
file_path = os.path.join(set_dir, unique_filename)
|
||||
|
||||
# Save the file
|
||||
file.save(file_path)
|
||||
|
||||
# Get file size
|
||||
file_size = os.path.getsize(file_path)
|
||||
|
||||
# Generate thumbnail
|
||||
thumbnail_rel_path = None
|
||||
if file_type == 'IMAGE':
|
||||
thumb_path = FileHandler.create_thumbnail(file_path)
|
||||
if thumb_path:
|
||||
# Get relative path for thumbnail
|
||||
thumbnail_rel_path = os.path.join(subdir, secure_filename(set_number),
|
||||
os.path.basename(thumb_path))
|
||||
thumbnail_rel_path = thumbnail_rel_path.replace('\\', '/')
|
||||
elif file_type == 'PDF':
|
||||
thumb_path = FileHandler.create_pdf_thumbnail(file_path, set_number)
|
||||
if thumb_path:
|
||||
thumbnail_rel_path = thumb_path.replace('\\', '/')
|
||||
|
||||
# Return relative path for database storage
|
||||
relative_path = os.path.join(subdir, secure_filename(set_number), unique_filename)
|
||||
|
||||
return relative_path.replace('\\', '/'), file_size, thumbnail_rel_path
|
||||
|
||||
@staticmethod
|
||||
def create_thumbnail(image_path: str, size: Tuple[int, int] = (300, 300)) -> Optional[str]:
|
||||
"""
|
||||
Create a thumbnail for an image.
|
||||
|
||||
Args:
|
||||
image_path: Path to the original image
|
||||
size: Thumbnail size (width, height)
|
||||
|
||||
Returns:
|
||||
Path to thumbnail or None if failed
|
||||
"""
|
||||
try:
|
||||
img = Image.open(image_path)
|
||||
img.thumbnail(size, Image.Resampling.LANCZOS)
|
||||
|
||||
# Generate thumbnail filename
|
||||
base, ext = os.path.splitext(image_path)
|
||||
thumb_path = f"{base}_thumb{ext}"
|
||||
|
||||
img.save(thumb_path, optimize=True)
|
||||
return thumb_path
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Thumbnail creation failed: {str(e)}')
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def create_pdf_thumbnail(pdf_path: str, set_number: str, size: Tuple[int, int] = (300, 400)) -> Optional[str]:
|
||||
"""
|
||||
Create a thumbnail from the first page of a PDF.
|
||||
|
||||
Args:
|
||||
pdf_path: Path to the PDF file
|
||||
set_number: Set number for organizing thumbnails
|
||||
size: Thumbnail size (width, height)
|
||||
|
||||
Returns:
|
||||
Relative path to thumbnail or None if failed
|
||||
"""
|
||||
try:
|
||||
# Try using PyMuPDF (fitz) first
|
||||
try:
|
||||
import fitz # PyMuPDF
|
||||
|
||||
# Open PDF
|
||||
doc = fitz.open(pdf_path)
|
||||
|
||||
# Get first page
|
||||
page = doc[0]
|
||||
|
||||
# Render page to pixmap (image)
|
||||
# Use matrix for scaling to desired size
|
||||
zoom = 2 # Higher zoom for better quality
|
||||
mat = fitz.Matrix(zoom, zoom)
|
||||
pix = page.get_pixmap(matrix=mat)
|
||||
|
||||
# Create thumbnails directory
|
||||
thumbnails_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'thumbnails', secure_filename(set_number))
|
||||
os.makedirs(thumbnails_dir, exist_ok=True)
|
||||
|
||||
# Generate thumbnail filename
|
||||
pdf_filename = os.path.basename(pdf_path)
|
||||
thumb_filename = os.path.splitext(pdf_filename)[0] + '_thumb.png'
|
||||
thumb_path = os.path.join(thumbnails_dir, thumb_filename)
|
||||
|
||||
# Save as PNG
|
||||
pix.save(thumb_path)
|
||||
|
||||
# Close document
|
||||
doc.close()
|
||||
|
||||
# Resize to target size using PIL
|
||||
img = Image.open(thumb_path)
|
||||
img.thumbnail(size, Image.Resampling.LANCZOS)
|
||||
img.save(thumb_path, optimize=True)
|
||||
|
||||
# Return relative path
|
||||
rel_path = os.path.join('thumbnails', secure_filename(set_number), thumb_filename)
|
||||
return rel_path
|
||||
|
||||
except ImportError:
|
||||
# Try pdf2image as fallback
|
||||
try:
|
||||
from pdf2image import convert_from_path
|
||||
|
||||
# Convert first page only
|
||||
images = convert_from_path(pdf_path, first_page=1, last_page=1, dpi=150)
|
||||
|
||||
if images:
|
||||
# Create thumbnails directory
|
||||
thumbnails_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'thumbnails', secure_filename(set_number))
|
||||
os.makedirs(thumbnails_dir, exist_ok=True)
|
||||
|
||||
# Generate thumbnail filename
|
||||
pdf_filename = os.path.basename(pdf_path)
|
||||
thumb_filename = os.path.splitext(pdf_filename)[0] + '_thumb.png'
|
||||
thumb_path = os.path.join(thumbnails_dir, thumb_filename)
|
||||
|
||||
# Resize and save
|
||||
img = images[0]
|
||||
img.thumbnail(size, Image.Resampling.LANCZOS)
|
||||
img.save(thumb_path, 'PNG', optimize=True)
|
||||
|
||||
# Return relative path
|
||||
rel_path = os.path.join('thumbnails', secure_filename(set_number), thumb_filename)
|
||||
return rel_path
|
||||
|
||||
except ImportError:
|
||||
current_app.logger.warning('Neither PyMuPDF nor pdf2image available for PDF thumbnails')
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'PDF thumbnail creation failed: {str(e)}')
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def delete_file(file_path: str) -> bool:
|
||||
"""
|
||||
Delete a file from storage.
|
||||
|
||||
Args:
|
||||
file_path: Relative path to the file
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
full_path = os.path.join(current_app.config['UPLOAD_FOLDER'], file_path)
|
||||
|
||||
if os.path.exists(full_path):
|
||||
os.remove(full_path)
|
||||
|
||||
# Also delete thumbnail if it exists
|
||||
base, ext = os.path.splitext(full_path)
|
||||
thumb_path = f"{base}_thumb{ext}"
|
||||
if os.path.exists(thumb_path):
|
||||
os.remove(thumb_path)
|
||||
|
||||
return True
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'File deletion failed: {str(e)}')
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_directory_size(set_number: str) -> Tuple[int, int]:
|
||||
"""
|
||||
Get total size of all files for a specific set.
|
||||
|
||||
Args:
|
||||
set_number: LEGO set number
|
||||
|
||||
Returns:
|
||||
Tuple of (total_size_bytes, file_count)
|
||||
"""
|
||||
total_size = 0
|
||||
file_count = 0
|
||||
|
||||
for subdir in ['pdfs', 'images']:
|
||||
set_dir = os.path.join(
|
||||
current_app.config['UPLOAD_FOLDER'],
|
||||
subdir,
|
||||
secure_filename(set_number)
|
||||
)
|
||||
|
||||
if os.path.exists(set_dir):
|
||||
for filename in os.listdir(set_dir):
|
||||
if not filename.endswith('_thumb'):
|
||||
filepath = os.path.join(set_dir, filename)
|
||||
if os.path.isfile(filepath):
|
||||
total_size += os.path.getsize(filepath)
|
||||
file_count += 1
|
||||
|
||||
return total_size, file_count
|
||||
146
app/static/css/style.css
Normal file
146
app/static/css/style.css
Normal file
@@ -0,0 +1,146 @@
|
||||
/* Custom styles for LEGO Instructions Manager */
|
||||
|
||||
:root {
|
||||
--lego-red: #d11013;
|
||||
--lego-yellow: #ffd700;
|
||||
--lego-blue: #0055bf;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Navbar customization */
|
||||
.navbar-brand {
|
||||
font-weight: bold;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
/* Card hover effects */
|
||||
.card {
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
|
||||
/* Set card image */
|
||||
.set-image {
|
||||
height: 200px;
|
||||
object-fit: contain;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
/* Dashboard clickable thumbnails */
|
||||
.dashboard a .set-image,
|
||||
.dashboard a .card-img-top {
|
||||
transition: opacity 0.3s, transform 0.3s;
|
||||
}
|
||||
|
||||
.dashboard a:hover .set-image,
|
||||
.dashboard a:hover .card-img-top {
|
||||
opacity: 0.85;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.dashboard a:hover img {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
/* Instruction thumbnails */
|
||||
.instruction-thumbnail {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
object-fit: cover;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.instruction-thumbnail:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* File upload area */
|
||||
.upload-area {
|
||||
border: 2px dashed #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
padding: 3rem;
|
||||
text-align: center;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.upload-area:hover {
|
||||
border-color: var(--lego-red);
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.upload-area.dragover {
|
||||
border-color: var(--lego-red);
|
||||
background-color: #fff3cd;
|
||||
}
|
||||
|
||||
/* Badge styling */
|
||||
.badge-theme {
|
||||
background-color: var(--lego-blue);
|
||||
}
|
||||
|
||||
.badge-year {
|
||||
background-color: var(--lego-yellow);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
/* Stats cards */
|
||||
.stat-card {
|
||||
border-left: 4px solid var(--lego-red);
|
||||
}
|
||||
|
||||
/* Search bar */
|
||||
.search-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto 2rem;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination .page-link {
|
||||
color: var(--lego-red);
|
||||
}
|
||||
|
||||
.pagination .page-item.active .page-link {
|
||||
background-color: var(--lego-red);
|
||||
border-color: var(--lego-red);
|
||||
}
|
||||
|
||||
/* Loading spinner */
|
||||
.spinner-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.set-image {
|
||||
height: 150px;
|
||||
}
|
||||
|
||||
.instruction-thumbnail {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Print styles for instructions */
|
||||
@media print {
|
||||
.navbar, .btn, footer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
BIN
app/static/favicon.ico
Normal file
BIN
app/static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
0
app/static/js/.gitkeep
Normal file
0
app/static/js/.gitkeep
Normal file
260
app/templates/admin/bulk_import.html
Normal file
260
app/templates/admin/bulk_import.html
Normal file
@@ -0,0 +1,260 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Bulk Import Sets - Admin - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h1>
|
||||
<i class="bi bi-cloud-upload"></i> Bulk Import Sets from Brickset
|
||||
</h1>
|
||||
<p class="text-muted">Import multiple official LEGO sets at once using Brickset data</p>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Back to Admin
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not brickset_configured %}
|
||||
<div class="alert alert-warning">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
<strong>Brickset API Not Configured</strong>
|
||||
<p class="mb-0">
|
||||
Please add your Brickset API credentials to the <code>.env</code> file:
|
||||
</p>
|
||||
<pre class="mb-0 mt-2">
|
||||
BRICKSET_API_KEY=your_api_key_here
|
||||
BRICKSET_USERNAME=your_username
|
||||
BRICKSET_PASSWORD=your_password</pre>
|
||||
<p class="mb-0 mt-2">
|
||||
Get your API key at: <a href="https://brickset.com/tools/webservices/requestkey" target="_blank">https://brickset.com/tools/webservices/requestkey</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-list-ol"></i> Import Sets</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('admin.bulk_import') }}">
|
||||
<!-- Set Numbers -->
|
||||
<div class="mb-3">
|
||||
<label for="set_numbers" class="form-label">
|
||||
<strong>Set Numbers</strong>
|
||||
<span class="text-muted">(one per line, or comma/space separated)</span>
|
||||
</label>
|
||||
<textarea class="form-control font-monospace"
|
||||
id="set_numbers"
|
||||
name="set_numbers"
|
||||
rows="10"
|
||||
placeholder="Example: 8860 10497 42100 21318"
|
||||
required
|
||||
{% if not brickset_configured %}disabled{% endif %}></textarea>
|
||||
<small class="form-text text-muted">
|
||||
Enter LEGO set numbers (e.g., 8860, 10497-1, 42100). Variants like -1 are supported.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- User Selection -->
|
||||
<div class="mb-3">
|
||||
<label for="user_id" class="form-label">
|
||||
<strong>Assign to User</strong>
|
||||
</label>
|
||||
<select class="form-select"
|
||||
id="user_id"
|
||||
name="user_id"
|
||||
required
|
||||
{% if not brickset_configured %}disabled{% endif %}>
|
||||
<option value="">Select a user...</option>
|
||||
{% for user in users %}
|
||||
<option value="{{ user.id }}" {% if user.id == current_user.id %}selected{% endif %}>
|
||||
{{ user.username }} ({{ user.email }})
|
||||
{% if user.is_admin %}👑 Admin{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-text text-muted">
|
||||
Sets will be added to this user's collection
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Throttle Delay -->
|
||||
<div class="mb-3">
|
||||
<label for="throttle_delay" class="form-label">
|
||||
<strong>API Throttle Delay</strong>
|
||||
<span class="text-muted">(seconds between requests)</span>
|
||||
</label>
|
||||
<select class="form-select"
|
||||
id="throttle_delay"
|
||||
name="throttle_delay"
|
||||
{% if not brickset_configured %}disabled{% endif %}>
|
||||
<option value="0.3">0.3s - Fast (may hit rate limits)</option>
|
||||
<option value="0.5" selected>0.5s - Balanced (recommended)</option>
|
||||
<option value="1.0">1.0s - Safe (slower but reliable)</option>
|
||||
<option value="2.0">2.0s - Very Safe (for large batches)</option>
|
||||
</select>
|
||||
<small class="form-text text-muted">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
Brickset has API rate limits. Increase delay if you get rate limit errors.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="d-grid gap-2">
|
||||
<button type="submit"
|
||||
class="btn btn-primary btn-lg"
|
||||
{% if not brickset_configured %}disabled{% endif %}>
|
||||
<i class="bi bi-cloud-download"></i>
|
||||
Import Sets from Brickset
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<!-- Info Card -->
|
||||
<div class="card bg-light mb-3">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0"><i class="bi bi-info-circle"></i> How It Works</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ol class="mb-0">
|
||||
<li class="mb-2">Enter set numbers (one per line)</li>
|
||||
<li class="mb-2">Select which user to assign them to</li>
|
||||
<li class="mb-2">Choose throttle delay</li>
|
||||
<li class="mb-2">Click "Import Sets"</li>
|
||||
<li class="mb-2">System fetches data from Brickset</li>
|
||||
<li class="mb-0">Sets are added to database!</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rate Limit Warning -->
|
||||
<div class="card bg-warning text-dark mb-3">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0"><i class="bi bi-exclamation-triangle"></i> API Rate Limits</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-2">
|
||||
<strong>Brickset has API rate limits!</strong>
|
||||
</p>
|
||||
<ul class="mb-0 small">
|
||||
<li class="mb-2">
|
||||
<strong>Recommended:</strong> Import 10-20 sets at a time
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<strong>Throttle:</strong> Use 0.5s-1.0s delay between requests
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<strong>If rate limited:</strong> Wait 5-10 minutes and retry
|
||||
</li>
|
||||
<li class="mb-0">
|
||||
<strong>Large batches:</strong> Split into multiple smaller imports
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- What Gets Imported -->
|
||||
<div class="card bg-info text-white mb-3">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0"><i class="bi bi-box-seam"></i> What Gets Imported</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="mb-0">
|
||||
<li>Set Number</li>
|
||||
<li>Set Name</li>
|
||||
<li>Theme</li>
|
||||
<li>Year Released</li>
|
||||
<li>Piece Count</li>
|
||||
<li>Cover Image (from Brickset)</li>
|
||||
</ul>
|
||||
<hr class="bg-white">
|
||||
<small>
|
||||
<i class="bi bi-lightbulb"></i>
|
||||
You can upload instructions separately later!
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tips -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0"><i class="bi bi-stars"></i> Pro Tips</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="small mb-0">
|
||||
<li class="mb-2">
|
||||
<strong>Start Small:</strong> Try 5-10 sets first to test
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<strong>Duplicates:</strong> Sets already in database will be skipped
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<strong>Not Found:</strong> Invalid set numbers will be reported
|
||||
</li>
|
||||
<li class="mb-0">
|
||||
<strong>Formats:</strong> Works with variants like 10497-1
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Example Sets -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0"><i class="bi bi-clipboard-check"></i> Example Sets You Can Try</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-2">
|
||||
<strong>Technic:</strong><br>
|
||||
<code>8860, 8880, 42100, 42110</code>
|
||||
</div>
|
||||
<div class="col-md-3 mb-2">
|
||||
<strong>Creator Expert:</strong><br>
|
||||
<code>10497, 10294, 10283</code>
|
||||
</div>
|
||||
<div class="col-md-3 mb-2">
|
||||
<strong>Ideas:</strong><br>
|
||||
<code>21318, 21330, 21341</code>
|
||||
</div>
|
||||
<div class="col-md-3 mb-2">
|
||||
<strong>Star Wars:</strong><br>
|
||||
<code>75192, 75313, 75331</code>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="fillExample()">
|
||||
<i class="bi bi-clipboard"></i> Fill Example Sets
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function fillExample() {
|
||||
const examples = `8860
|
||||
8880
|
||||
42100
|
||||
10497
|
||||
10294
|
||||
21318
|
||||
75192`;
|
||||
document.getElementById('set_numbers').value = examples;
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
255
app/templates/admin/bulk_import_results.html
Normal file
255
app/templates/admin/bulk_import_results.html
Normal file
@@ -0,0 +1,255 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Import Results - Admin - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h1>
|
||||
<i class="bi bi-clipboard-check"></i> Bulk Import Results
|
||||
</h1>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a href="{{ url_for('admin.bulk_import') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> Import More Sets
|
||||
</a>
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-speedometer2"></i> Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card bg-success text-white h-100">
|
||||
<div class="card-body text-center">
|
||||
<h1 class="display-3">{{ results.success|length }}</h1>
|
||||
<h5>Successfully Imported</h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card bg-warning text-dark h-100">
|
||||
<div class="card-body text-center">
|
||||
<h1 class="display-3">{{ results.already_exists|length }}</h1>
|
||||
<h5>Already Existed</h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card bg-danger text-white h-100">
|
||||
<div class="card-body text-center">
|
||||
<h1 class="display-3">{{ results.failed|length }}</h1>
|
||||
<h5>Failed to Import</h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card bg-info text-white h-100">
|
||||
<div class="card-body text-center">
|
||||
<h1 class="display-3">{{ results.rate_limited|length }}</h1>
|
||||
<h5>Rate Limited</h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Successful Imports -->
|
||||
{% if results.success %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-success text-white">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-check-circle"></i>
|
||||
Successfully Imported ({{ results.success|length }})
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Set Number</th>
|
||||
<th>Name</th>
|
||||
<th>Theme</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for set in results.success %}
|
||||
<tr>
|
||||
<td><strong>{{ set.set_number }}</strong></td>
|
||||
<td>{{ set.name }}</td>
|
||||
<td><span class="badge bg-primary">{{ set.theme }}</span></td>
|
||||
<td>
|
||||
<!-- We need to find the actual set ID -->
|
||||
<a href="{{ url_for('sets.list_sets') }}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i> View
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Already Exists -->
|
||||
{% if results.already_exists %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-warning text-dark">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
Already in Database ({{ results.already_exists|length }})
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Set Number</th>
|
||||
<th>Name</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for set in results.already_exists %}
|
||||
<tr>
|
||||
<td><strong>{{ set.set_number }}</strong></td>
|
||||
<td>{{ set.name }}</td>
|
||||
<td><span class="badge bg-info">Skipped - Already exists</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Failed Imports -->
|
||||
{% if results.failed %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-danger text-white">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-x-circle"></i>
|
||||
Failed to Import ({{ results.failed|length }})
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Set Number</th>
|
||||
<th>Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for set in results.failed %}
|
||||
<tr>
|
||||
<td><strong>{{ set.set_number }}</strong></td>
|
||||
<td>
|
||||
<span class="badge bg-danger">{{ set.reason }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<small class="text-muted">
|
||||
<i class="bi bi-lightbulb"></i>
|
||||
<strong>Common reasons for failure:</strong>
|
||||
Invalid set number, set doesn't exist in Brickset, or API connection issue.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Rate Limited Sets -->
|
||||
{% if results.rate_limited %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-info text-white">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
Rate Limited ({{ results.rate_limited|length }})
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info mb-3">
|
||||
<h6><i class="bi bi-info-circle"></i> API Rate Limit Reached</h6>
|
||||
<p class="mb-2">
|
||||
Brickset's API has rate limits to prevent abuse. Your import was stopped after
|
||||
{{ results.success|length }} successful import(s) to avoid hitting the limit.
|
||||
</p>
|
||||
<p class="mb-0">
|
||||
<strong>To import these remaining sets:</strong>
|
||||
</p>
|
||||
<ol class="mb-0">
|
||||
<li>Wait 5-10 minutes for the rate limit to reset</li>
|
||||
<li>Use a longer throttle delay (1.0s or 2.0s)</li>
|
||||
<li>Import in smaller batches (10-15 sets at a time)</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Set Number</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for set in results.rate_limited %}
|
||||
<tr>
|
||||
<td><strong>{{ set.set_number }}</strong></td>
|
||||
<td><span class="badge bg-info">{{ set.reason }}</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<strong>Quick Retry:</strong> Copy the set numbers below and try again in a few minutes with a longer delay.
|
||||
<div class="mt-2">
|
||||
<textarea class="form-control font-monospace" rows="3" readonly>{{ results.rate_limited|map(attribute='set_number')|join('\n') }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<h5>What's Next?</h5>
|
||||
<div class="btn-group mt-3" role="group">
|
||||
<a href="{{ url_for('sets.list_sets') }}" class="btn btn-primary">
|
||||
<i class="bi bi-box-seam"></i> View All Sets
|
||||
</a>
|
||||
<a href="{{ url_for('admin.bulk_import') }}" class="btn btn-success">
|
||||
<i class="bi bi-plus-circle"></i> Import More
|
||||
</a>
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-speedometer2"></i> Admin Dashboard
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if results.success %}
|
||||
<div class="mt-3">
|
||||
<p class="text-muted">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
Don't forget to upload instructions for the newly imported sets!
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
284
app/templates/admin/dashboard.html
Normal file
284
app/templates/admin/dashboard.html
Normal file
@@ -0,0 +1,284 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Admin Dashboard - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h1>
|
||||
<i class="bi bi-shield-lock"></i> Admin Dashboard
|
||||
</h1>
|
||||
<p class="text-muted">System overview and management</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Stats -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card bg-primary text-white h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="card-subtitle mb-2 text-white-50">Total Users</h6>
|
||||
<h2 class="mb-0">{{ total_users }}</h2>
|
||||
</div>
|
||||
<i class="bi bi-people display-4 opacity-50"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-primary bg-opacity-75">
|
||||
<a href="{{ url_for('admin.users') }}" class="text-white text-decoration-none">
|
||||
Manage Users <i class="bi bi-arrow-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card bg-success text-white h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="card-subtitle mb-2 text-white-50">Total Sets</h6>
|
||||
<h2 class="mb-0">{{ total_sets }}</h2>
|
||||
</div>
|
||||
<i class="bi bi-box-seam display-4 opacity-50"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-success bg-opacity-75">
|
||||
<a href="{{ url_for('admin.sets') }}" class="text-white text-decoration-none">
|
||||
View All Sets <i class="bi bi-arrow-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card bg-warning text-dark h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="card-subtitle mb-2">MOC Builds</h6>
|
||||
<h2 class="mb-0">{{ total_mocs }}</h2>
|
||||
</div>
|
||||
<i class="bi bi-star-fill display-4 opacity-50"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-warning bg-opacity-75">
|
||||
<a href="{{ url_for('admin.sets', type='mocs') }}" class="text-dark text-decoration-none">
|
||||
View MOCs <i class="bi bi-arrow-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card bg-info text-white h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="card-subtitle mb-2 text-white-50">Instructions</h6>
|
||||
<h2 class="mb-0">{{ total_instructions }}</h2>
|
||||
<small class="text-white-50">{{ total_storage_mb }} MB</small>
|
||||
</div>
|
||||
<i class="bi bi-file-pdf display-4 opacity-50"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-info bg-opacity-75">
|
||||
<span class="text-white">Total Storage Used</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Recent Users -->
|
||||
<div class="col-md-6 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0"><i class="bi bi-person-plus"></i> Recent Users</h5>
|
||||
<a href="{{ url_for('admin.users') }}" class="btn btn-sm btn-outline-primary">View All</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if recent_users %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
<th>Joined</th>
|
||||
<th>Admin</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in recent_users %}
|
||||
<tr>
|
||||
<td>
|
||||
<i class="bi bi-person-circle"></i> {{ user.username }}
|
||||
</td>
|
||||
<td>{{ user.email }}</td>
|
||||
<td>{{ user.created_at.strftime('%Y-%m-%d') }}</td>
|
||||
<td>
|
||||
{% if user.is_admin %}
|
||||
<span class="badge bg-danger">Admin</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">User</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-center text-muted my-4">No users yet</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Contributors -->
|
||||
<div class="col-md-6 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-trophy"></i> Top Contributors</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if top_contributors %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Sets Added</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user, count in top_contributors %}
|
||||
<tr>
|
||||
<td>
|
||||
<i class="bi bi-person-circle"></i> {{ user.username }}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-success">{{ count }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-center text-muted my-4">No data yet</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Popular Themes -->
|
||||
<div class="col-md-6 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-bar-chart"></i> Popular Themes</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if theme_stats %}
|
||||
{% for theme, count in theme_stats %}
|
||||
<div class="mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<span>{{ theme }}</span>
|
||||
<span class="badge bg-primary">{{ count }}</span>
|
||||
</div>
|
||||
<div class="progress" style="height: 20px;">
|
||||
{% set percentage = (count / total_sets * 100) | int %}
|
||||
<div class="progress-bar" role="progressbar"
|
||||
style="width: {{ percentage }}%"
|
||||
aria-valuenow="{{ percentage }}"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100">
|
||||
{{ percentage }}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-center text-muted">No theme data yet</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Sets -->
|
||||
<div class="col-md-6 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0"><i class="bi bi-clock-history"></i> Recently Added Sets</h5>
|
||||
<a href="{{ url_for('admin.sets') }}" class="btn btn-sm btn-outline-primary">View All</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if recent_sets %}
|
||||
<div class="list-group list-group-flush">
|
||||
{% for set in recent_sets[:5] %}
|
||||
<a href="{{ url_for('sets.view_set', set_id=set.id) }}"
|
||||
class="list-group-item list-group-item-action">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong>{{ set.set_number }}</strong>
|
||||
{% if set.is_moc %}
|
||||
<span class="badge bg-warning text-dark ms-1">
|
||||
<i class="bi bi-star-fill"></i>
|
||||
</span>
|
||||
{% endif %}
|
||||
<br>
|
||||
<small class="text-muted">{{ set.set_name }}</small>
|
||||
</div>
|
||||
<span class="badge bg-secondary">{{ set.theme }}</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-center text-muted my-4">No sets yet</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-gear"></i> Quick Actions</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-2">
|
||||
<a href="{{ url_for('admin.users') }}" class="btn btn-outline-primary w-100">
|
||||
<i class="bi bi-people"></i> Manage Users
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-3 mb-2">
|
||||
<a href="{{ url_for('admin.sets') }}" class="btn btn-outline-success w-100">
|
||||
<i class="bi bi-box-seam"></i> Manage Sets
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-3 mb-2">
|
||||
<a href="{{ url_for('admin.bulk_import') }}" class="btn btn-outline-info w-100">
|
||||
<i class="bi bi-cloud-upload"></i> Bulk Import
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-3 mb-2">
|
||||
<a href="{{ url_for('admin.site_settings') }}" class="btn btn-outline-secondary w-100">
|
||||
<i class="bi bi-sliders"></i> Site Settings
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
99
app/templates/admin/sets.html
Normal file
99
app/templates/admin/sets.html
Normal file
@@ -0,0 +1,99 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Set Management - Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h1><i class="bi bi-box-seam"></i> Set Management</h1>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Back
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<form method="GET">
|
||||
<div class="row g-2">
|
||||
<div class="col-md-6">
|
||||
<input type="text" class="form-control" name="search"
|
||||
value="{{ search }}" placeholder="Search sets...">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select class="form-select" name="type">
|
||||
<option value="all" {% if filter_type=='all' %}selected{% endif %}>All Sets</option>
|
||||
<option value="official" {% if filter_type=='official' %}selected{% endif %}>Official Only</option>
|
||||
<option value="mocs" {% if filter_type=='mocs' %}selected{% endif %}>MOCs Only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button class="btn btn-primary w-100" type="submit">
|
||||
<i class="bi bi-search"></i> Filter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
{% if sets %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Set Number</th>
|
||||
<th>Name</th>
|
||||
<th>Theme</th>
|
||||
<th>Year</th>
|
||||
<th>Type</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for set in sets %}
|
||||
<tr>
|
||||
<td><strong>{{ set.set_number }}</strong></td>
|
||||
<td>{{ set.set_name }}</td>
|
||||
<td><span class="badge bg-primary">{{ set.theme }}</span></td>
|
||||
<td>{{ set.year_released }}</td>
|
||||
<td>
|
||||
{% if set.is_moc %}
|
||||
<span class="badge bg-warning text-dark">
|
||||
<i class="bi bi-star-fill"></i> MOC
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Official</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="{{ url_for('sets.view_set', set_id=set.id) }}"
|
||||
class="btn btn-outline-primary">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
<form method="POST" action="{{ url_for('admin.delete_set', set_id=set.id) }}"
|
||||
style="display:inline;"
|
||||
onsubmit="return confirm('Delete {{ set.set_number }}?');">
|
||||
<button type="submit" class="btn btn-outline-danger">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-center text-muted my-4">No sets found</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
63
app/templates/admin/settings.html
Normal file
63
app/templates/admin/settings.html
Normal file
@@ -0,0 +1,63 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Site Settings - Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h1><i class="bi bi-sliders"></i> Site Settings</h1>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Back
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">System Information</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table">
|
||||
<tr>
|
||||
<td>Total Users:</td>
|
||||
<td><strong>{{ stats.total_users }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Total Sets:</td>
|
||||
<td><strong>{{ stats.total_sets }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Total Instructions:</td>
|
||||
<td><strong>{{ stats.total_instructions }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Storage Used:</td>
|
||||
<td><strong>{{ (stats.total_storage / 1024 / 1024) | round(2) }} MB</strong></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Settings</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted">
|
||||
Site settings configuration will be available in future updates.
|
||||
</p>
|
||||
<p>
|
||||
For now, modify settings in <code>config.py</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
211
app/templates/admin/users.html
Normal file
211
app/templates/admin/users.html
Normal file
@@ -0,0 +1,211 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}User Management - Admin - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h1>
|
||||
<i class="bi bi-people"></i> User Management
|
||||
</h1>
|
||||
<p class="text-muted">Manage users and permissions</p>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Back to Admin
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<form method="GET" action="{{ url_for('admin.users') }}">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" name="search"
|
||||
value="{{ search }}" placeholder="Search by username or email...">
|
||||
<button class="btn btn-primary" type="submit">
|
||||
<i class="bi bi-search"></i> Search
|
||||
</button>
|
||||
{% if search %}
|
||||
<a href="{{ url_for('admin.users') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-x"></i> Clear
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Users Table -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-list"></i> Users ({{ pagination.total }})
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if users %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
<th>Joined</th>
|
||||
<th>Sets</th>
|
||||
<th>Instructions</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td>
|
||||
<i class="bi bi-person-circle"></i>
|
||||
<strong>{{ user.username }}</strong>
|
||||
{% if user.id == current_user.id %}
|
||||
<span class="badge bg-info">You</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ user.email }}</td>
|
||||
<td>{{ user.created_at.strftime('%Y-%m-%d') }}</td>
|
||||
<td>
|
||||
<span class="badge bg-success">{{ user_stats[user.id]['sets'] }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-info">{{ user_stats[user.id]['instructions'] }}</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if user.is_admin %}
|
||||
<span class="badge bg-danger">
|
||||
<i class="bi bi-shield-lock"></i> Admin
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">User</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
{% if user.id != current_user.id %}
|
||||
<button class="btn btn-outline-primary toggle-admin-btn"
|
||||
data-user-id="{{ user.id }}"
|
||||
data-username="{{ user.username }}"
|
||||
data-is-admin="{{ user.is_admin|lower }}">
|
||||
<i class="bi bi-shield"></i>
|
||||
{% if user.is_admin %}Revoke{% else %}Grant{% endif %} Admin
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#deleteModal{{ user.id }}">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
<span class="text-muted small">Cannot modify yourself</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Delete Modal -->
|
||||
<div class="modal fade" id="deleteModal{{ user.id }}" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Delete User?</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form method="POST" action="{{ url_for('admin.delete_user', user_id=user.id) }}">
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete <strong>{{ user.username }}</strong>?</p>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox"
|
||||
id="delete_data{{ user.id }}" name="delete_data">
|
||||
<label class="form-check-label" for="delete_data{{ user.id }}">
|
||||
Also delete all their sets and instructions
|
||||
</label>
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
If unchecked, their content will be reassigned to you.
|
||||
</small>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-danger">Delete User</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if pagination.pages > 1 %}
|
||||
<div class="card-footer">
|
||||
<nav>
|
||||
<ul class="pagination justify-content-center mb-0">
|
||||
<li class="page-item {% if not pagination.has_prev %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('admin.users', page=pagination.prev_num, search=search) }}">Previous</a>
|
||||
</li>
|
||||
{% for page_num in pagination.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
|
||||
{% if page_num %}
|
||||
<li class="page-item {% if page_num == pagination.page %}active{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('admin.users', page=page_num, search=search) }}">{{ page_num }}</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled"><span class="page-link">...</span></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<li class="page-item {% if not pagination.has_next %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('admin.users', page=pagination.next_num, search=search) }}">Next</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-inbox display-1 text-muted"></i>
|
||||
<p class="mt-3 text-muted">No users found</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Toggle admin status with AJAX
|
||||
document.querySelectorAll('.toggle-admin-btn').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const userId = this.dataset.userId;
|
||||
const username = this.dataset.username;
|
||||
const isAdmin = this.dataset.isAdmin === 'true';
|
||||
|
||||
if (confirm(`${isAdmin ? 'Revoke' : 'Grant'} admin access for ${username}?`)) {
|
||||
fetch(`/admin/users/${userId}/toggle-admin`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error: ' + data.error);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
alert('Error updating admin status');
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
49
app/templates/auth/login.html
Normal file
49
app/templates/auth/login.html
Normal file
@@ -0,0 +1,49 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Login - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 col-lg-5">
|
||||
<div class="card shadow">
|
||||
<div class="card-body p-5">
|
||||
<h2 class="text-center mb-4">
|
||||
<i class="bi bi-box-arrow-in-right text-danger"></i> Login
|
||||
</h2>
|
||||
|
||||
<form method="POST" action="{{ url_for('auth.login') }}">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input type="text" class="form-control" id="username" name="username" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" class="form-check-input" id="remember" name="remember">
|
||||
<label class="form-check-label" for="remember">
|
||||
Remember me
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-danger btn-lg">
|
||||
<i class="bi bi-box-arrow-in-right"></i> Login
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<p class="text-center text-muted mb-0">
|
||||
Don't have an account?
|
||||
<a href="{{ url_for('auth.register') }}">Register here</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
71
app/templates/auth/profile.html
Normal file
71
app/templates/auth/profile.html
Normal file
@@ -0,0 +1,71 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Profile - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-lg-8 mx-auto">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-danger text-white">
|
||||
<h3 class="mb-0">
|
||||
<i class="bi bi-person-circle"></i> User Profile
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<h5>Account Information</h5>
|
||||
<table class="table table-borderless">
|
||||
<tr>
|
||||
<th>Username:</th>
|
||||
<td>{{ current_user.username }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Email:</th>
|
||||
<td>{{ current_user.email }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Member Since:</th>
|
||||
<td>{{ current_user.created_at.strftime('%B %d, %Y') }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<h5>Statistics</h5>
|
||||
<div class="row text-center">
|
||||
<div class="col-6 mb-3">
|
||||
<div class="card bg-primary text-white">
|
||||
<div class="card-body">
|
||||
<h2 class="mb-0">{{ set_count }}</h2>
|
||||
<small>Sets Added</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 mb-3">
|
||||
<div class="card bg-success text-white">
|
||||
<div class="card-body">
|
||||
<h2 class="mb-0">{{ instruction_count }}</h2>
|
||||
<small>Instructions</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="text-center">
|
||||
<a href="{{ url_for('main.dashboard') }}" class="btn btn-primary">
|
||||
<i class="bi bi-speedometer2"></i> Go to Dashboard
|
||||
</a>
|
||||
<a href="{{ url_for('sets.list_sets') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-grid"></i> View My Sets
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
54
app/templates/auth/register.html
Normal file
54
app/templates/auth/register.html
Normal file
@@ -0,0 +1,54 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Register - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 col-lg-5">
|
||||
<div class="card shadow">
|
||||
<div class="card-body p-5">
|
||||
<h2 class="text-center mb-4">
|
||||
<i class="bi bi-person-plus text-danger"></i> Register
|
||||
</h2>
|
||||
|
||||
<form method="POST" action="{{ url_for('auth.register') }}">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input type="text" class="form-control" id="username" name="username" required autofocus>
|
||||
<div class="form-text">Choose a unique username (letters, numbers, underscore).</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input type="email" class="form-control" id="email" name="email" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
<div class="form-text">Must be at least 6 characters long.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="confirm_password" class="form-label">Confirm Password</label>
|
||||
<input type="password" class="form-control" id="confirm_password" name="confirm_password" required>
|
||||
</div>
|
||||
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-danger btn-lg">
|
||||
<i class="bi bi-person-plus"></i> Create Account
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<p class="text-center text-muted mb-0">
|
||||
Already have an account?
|
||||
<a href="{{ url_for('auth.login') }}">Login here</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
133
app/templates/base.html
Normal file
133
app/templates/base.html
Normal file
@@ -0,0 +1,133 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}{{ app_name }}{% endblock %}</title>
|
||||
|
||||
<!-- Favicons -->
|
||||
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='favicon-32x32.png') }}">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='favicon-16x16.png') }}">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='apple-touch-icon.png') }}">
|
||||
|
||||
<!-- Bootstrap 5 CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
|
||||
<!-- Bootstrap Icons -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||
|
||||
<!-- Custom CSS -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<!-- Navigation -->
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-danger mb-4">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="{{ url_for('main.index') }}">
|
||||
<i class="bi bi-bricks"></i> {{ app_name }}
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
{% if current_user.is_authenticated %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('main.dashboard') }}">
|
||||
<i class="bi bi-speedometer2"></i> Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('sets.list_sets') }}">
|
||||
<i class="bi bi-grid"></i> My Sets
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="addDropdown" role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-plus-circle"></i> Add New
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('sets.add_set') }}">
|
||||
<i class="bi bi-box-seam"></i> Official LEGO Set
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('sets.add_set') }}?type=moc">
|
||||
<i class="bi bi-star-fill text-warning"></i> MOC (Custom Build)
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-person-circle"></i> {{ current_user.username }}
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li><a class="dropdown-item" href="{{ url_for('auth.profile') }}">Profile</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('auth.logout') }}">Logout</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('auth.login') }}">
|
||||
<i class="bi bi-box-arrow-in-right"></i> Login
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('auth.register') }}">
|
||||
<i class="bi bi-person-plus"></i> Register
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
<div class="container">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="mt-5 py-4 bg-light">
|
||||
<div class="container text-center text-muted">
|
||||
<p class="mb-0">
|
||||
<i class="bi bi-bricks"></i> LEGO Instructions Manager © 2024
|
||||
{% if brickset_available %}
|
||||
<span class="badge bg-success ms-2">
|
||||
<i class="bi bi-check-circle"></i> Brickset Connected
|
||||
</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Bootstrap 5 JS Bundle -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<!-- jQuery (for easier AJAX) -->
|
||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
209
app/templates/dashboard.html
Normal file
209
app/templates/dashboard.html
Normal file
@@ -0,0 +1,209 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dashboard - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h1>
|
||||
<i class="bi bi-speedometer2"></i> Dashboard
|
||||
</h1>
|
||||
<p class="text-muted">Welcome back, {{ current_user.username }}!</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6 col-lg-3 mb-3">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-2">Total Sets</h6>
|
||||
<h2 class="mb-0">{{ total_sets }}</h2>
|
||||
</div>
|
||||
<div class="text-primary">
|
||||
<i class="bi bi-box-seam display-4"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 col-lg-3 mb-3">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-2">Instructions</h6>
|
||||
<h2 class="mb-0">{{ total_instructions }}</h2>
|
||||
</div>
|
||||
<div class="text-success">
|
||||
<i class="bi bi-file-pdf display-4"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 col-lg-3 mb-3">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-2">Themes</h6>
|
||||
<h2 class="mb-0">{{ theme_stats|length }}</h2>
|
||||
</div>
|
||||
<div class="text-danger">
|
||||
<i class="bi bi-grid-3x3 display-4"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 col-lg-3 mb-3">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-2">Years Collected</h6>
|
||||
<h2 class="mb-0">{{ year_stats|length }}</h2>
|
||||
</div>
|
||||
<div class="text-warning">
|
||||
<i class="bi bi-calendar-range display-4"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Top Themes -->
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-bar-chart"></i> Top Themes
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if theme_stats %}
|
||||
<ul class="list-group list-group-flush">
|
||||
{% for theme, count in theme_stats %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
{{ theme }}
|
||||
<span class="badge bg-primary rounded-pill">{{ count }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="text-muted">No themes yet. <a href="{{ url_for('sets.add_set') }}">Add your first set!</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Years -->
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-calendar3"></i> Sets by Year
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if year_stats %}
|
||||
<ul class="list-group list-group-flush">
|
||||
{% for year, count in year_stats %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
{{ year }}
|
||||
<span class="badge bg-success rounded-pill">{{ count }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="text-muted">No sets yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Sets -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-clock-history"></i> Recently Added Sets
|
||||
</h5>
|
||||
<a href="{{ url_for('sets.list_sets') }}" class="btn btn-sm btn-outline-primary">
|
||||
View All <i class="bi bi-arrow-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body dashboard">
|
||||
{% if recent_sets %}
|
||||
<div class="row">
|
||||
{% for set in recent_sets %}
|
||||
<div class="col-md-6 col-lg-4 mb-3">
|
||||
<div class="card h-100">
|
||||
<a href="{{ url_for('sets.view_set', set_id=set.id) }}" class="text-decoration-none">
|
||||
{% if set.cover_image %}
|
||||
<img src="{{ url_for('static', filename='uploads/' + set.cover_image.replace('\\', '/')) }}"
|
||||
class="card-img-top set-image" alt="{{ set.set_name }}"
|
||||
style="cursor: pointer;">
|
||||
{% elif set.image_url %}
|
||||
<img src="{{ set.image_url }}" class="card-img-top set-image" alt="{{ set.set_name }}"
|
||||
style="cursor: pointer;">
|
||||
{% else %}
|
||||
<div class="card-img-top set-image d-flex align-items-center justify-content-center bg-light"
|
||||
style="cursor: pointer;">
|
||||
<i class="bi bi-image display-1 text-muted"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
</a>
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">
|
||||
{{ set.set_number }}
|
||||
{% if set.is_moc %}
|
||||
<span class="badge bg-warning text-dark" title="My Own Creation">
|
||||
<i class="bi bi-star-fill"></i>
|
||||
</span>
|
||||
{% endif %}
|
||||
</h6>
|
||||
<p class="card-text small">{{ set.set_name }}</p>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span class="badge badge-theme">{{ set.theme }}</span>
|
||||
<span class="badge badge-year">{{ set.year_released }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent">
|
||||
<a href="{{ url_for('sets.view_set', set_id=set.id) }}" class="btn btn-sm btn-primary w-100">
|
||||
View Details
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-inbox display-1 text-muted"></i>
|
||||
<p class="mt-3 text-muted">No sets in your collection yet.</p>
|
||||
<div class="d-flex justify-content-center gap-2">
|
||||
<a href="{{ url_for('sets.add_set') }}" class="btn btn-danger">
|
||||
<i class="bi bi-box-seam"></i> Add Official Set
|
||||
</a>
|
||||
<a href="{{ url_for('sets.add_set') }}?type=moc" class="btn btn-warning">
|
||||
<i class="bi bi-star-fill"></i> Add MOC
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
162
app/templates/extra_files/upload.html
Normal file
162
app/templates/extra_files/upload.html
Normal file
@@ -0,0 +1,162 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Upload Extra Files - {{ lego_set.set_number }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h1>
|
||||
<i class="bi bi-cloud-upload"></i> Upload Extra Files
|
||||
</h1>
|
||||
<p class="text-muted">
|
||||
{{ lego_set.set_number }}: {{ lego_set.set_name }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a href="{{ url_for('sets.view_set', set_id=lego_set.id) }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Back to Set
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-file-earmark-arrow-up"></i> Upload Files</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
<!-- File Upload -->
|
||||
<div class="mb-3">
|
||||
<label for="files" class="form-label">
|
||||
<strong>Select Files</strong>
|
||||
</label>
|
||||
<input class="form-control"
|
||||
type="file"
|
||||
id="files"
|
||||
name="files"
|
||||
multiple
|
||||
required>
|
||||
<small class="form-text text-muted">
|
||||
Select one or more files to upload. Multiple files can be selected at once.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Category -->
|
||||
<div class="mb-3">
|
||||
<label for="category" class="form-label">
|
||||
<strong>Category</strong>
|
||||
</label>
|
||||
<select class="form-select" id="category" name="category">
|
||||
<option value="auto">Auto-detect from file type</option>
|
||||
<option value="bricklink">BrickLink (XML)</option>
|
||||
<option value="studio">Stud.io Files</option>
|
||||
<option value="ldraw">LDraw Files</option>
|
||||
<option value="ldd">LEGO Digital Designer</option>
|
||||
<option value="box_art">Box Art</option>
|
||||
<option value="photo">Photos</option>
|
||||
<option value="document">Documents</option>
|
||||
<option value="data">Data Files</option>
|
||||
<option value="archive">Archives</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label">
|
||||
<strong>Description</strong> <span class="text-muted">(optional)</span>
|
||||
</label>
|
||||
<textarea class="form-control"
|
||||
id="description"
|
||||
name="description"
|
||||
rows="3"
|
||||
placeholder="Add a description for these files..."></textarea>
|
||||
<small class="form-text text-muted">
|
||||
This description will apply to all uploaded files.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="d-grid gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
<i class="bi bi-cloud-upload"></i> Upload Files
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<!-- Info Card -->
|
||||
<div class="card bg-light mb-3">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0"><i class="bi bi-info-circle"></i> Supported Files</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<strong>Images:</strong>
|
||||
<p class="small mb-2">JPG, PNG, GIF, WebP, BMP, SVG</p>
|
||||
|
||||
<strong>Documents:</strong>
|
||||
<p class="small mb-2">PDF, DOC, DOCX, TXT, RTF</p>
|
||||
|
||||
<strong>Data Files:</strong>
|
||||
<p class="small mb-2">XML, JSON, CSV, XLSX, XLS</p>
|
||||
|
||||
<strong>3D/CAD:</strong>
|
||||
<p class="small mb-2">
|
||||
LDR, MPD (LDraw)<br>
|
||||
IO (Stud.io)<br>
|
||||
LXF, LXFML (LDD)<br>
|
||||
STL, OBJ
|
||||
</p>
|
||||
|
||||
<strong>Archives:</strong>
|
||||
<p class="small mb-0">ZIP, RAR, 7Z, TAR, GZ</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tips Card -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0"><i class="bi bi-lightbulb"></i> Tips</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="small mb-0">
|
||||
<li class="mb-2">
|
||||
<strong>BrickLink XML:</strong> Part lists for ordering
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<strong>Stud.io Files:</strong> Digital building models
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<strong>Box Art:</strong> High-res images of the box
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<strong>Photos:</strong> Your built model pictures
|
||||
</li>
|
||||
<li class="mb-0">
|
||||
<strong>Archives:</strong> Zip multiple files together
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selected Files Preview -->
|
||||
<script>
|
||||
document.getElementById('files').addEventListener('change', function(e) {
|
||||
const files = Array.from(e.target.files);
|
||||
if (files.length > 0) {
|
||||
console.log(`Selected ${files.length} file(s):`);
|
||||
files.forEach(file => {
|
||||
console.log(`- ${file.name} (${(file.size / 1024).toFixed(1)} KB)`);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
112
app/templates/index.html
Normal file
112
app/templates/index.html
Normal file
@@ -0,0 +1,112 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Home - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-lg-8 mx-auto text-center">
|
||||
<div class="py-5">
|
||||
<h1 class="display-3 mb-4">
|
||||
<i class="bi bi-bricks text-danger"></i> LEGO Instructions Manager
|
||||
</h1>
|
||||
<p class="lead mb-5">
|
||||
Organize, manage, and access all your LEGO instruction manuals in one place.
|
||||
Upload PDFs and images, search by theme, set number, or year, and integrate with Brickset for automatic set details.
|
||||
</p>
|
||||
|
||||
{% if not current_user.is_authenticated %}
|
||||
<div class="d-grid gap-2 d-sm-flex justify-content-sm-center">
|
||||
<a href="{{ url_for('auth.register') }}" class="btn btn-danger btn-lg px-4 gap-3">
|
||||
<i class="bi bi-person-plus"></i> Get Started
|
||||
</a>
|
||||
<a href="{{ url_for('auth.login') }}" class="btn btn-outline-secondary btn-lg px-4">
|
||||
<i class="bi bi-box-arrow-in-right"></i> Login
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="d-grid gap-2 d-sm-flex justify-content-sm-center">
|
||||
<a href="{{ url_for('main.dashboard') }}" class="btn btn-danger btn-lg px-4 gap-3">
|
||||
<i class="bi bi-speedometer2"></i> Go to Dashboard
|
||||
</a>
|
||||
<div class="btn-group">
|
||||
<a href="{{ url_for('sets.add_set') }}" class="btn btn-outline-secondary btn-lg px-4">
|
||||
<i class="bi bi-box-seam"></i> Add Official Set
|
||||
</a>
|
||||
<a href="{{ url_for('sets.add_set') }}?type=moc" class="btn btn-outline-warning btn-lg px-4">
|
||||
<i class="bi bi-star-fill"></i> Add MOC
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-5">
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="card h-100 shadow-sm">
|
||||
<div class="card-body text-center">
|
||||
<i class="bi bi-cloud-upload display-1 text-primary mb-3"></i>
|
||||
<h3 class="card-title">Upload & Organize</h3>
|
||||
<p class="card-text">
|
||||
Upload instruction PDFs and images for your LEGO sets. Keep everything organized by theme, year, and set number.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="card h-100 shadow-sm">
|
||||
<div class="card-body text-center">
|
||||
<i class="bi bi-search display-1 text-success mb-3"></i>
|
||||
<h3 class="card-title">Easy Search</h3>
|
||||
<p class="card-text">
|
||||
Quickly find any instruction manual using powerful search and filtering. Sort by theme, year, or set number.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="card h-100 shadow-sm">
|
||||
<div class="card-body text-center">
|
||||
<i class="bi bi-link-45deg display-1 text-danger mb-3"></i>
|
||||
<h3 class="card-title">Brickset Integration</h3>
|
||||
<p class="card-text">
|
||||
Connect with Brickset API to automatically populate set details and access official instructions when available.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-5">
|
||||
<div class="col-lg-10 mx-auto">
|
||||
<div class="card shadow">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title mb-4">
|
||||
<i class="bi bi-info-circle"></i> Features
|
||||
</h3>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<ul class="list-unstyled">
|
||||
<li class="mb-2"><i class="bi bi-check-circle-fill text-success"></i> Upload PDF and image instructions</li>
|
||||
<li class="mb-2"><i class="bi bi-check-circle-fill text-success"></i> Organize by theme and year</li>
|
||||
<li class="mb-2"><i class="bi bi-check-circle-fill text-success"></i> Search and filter capabilities</li>
|
||||
<li class="mb-2"><i class="bi bi-check-circle-fill text-success"></i> User authentication & profiles</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<ul class="list-unstyled">
|
||||
<li class="mb-2"><i class="bi bi-check-circle-fill text-success"></i> Brickset API integration</li>
|
||||
<li class="mb-2"><i class="bi bi-check-circle-fill text-success"></i> Automatic set detail population</li>
|
||||
<li class="mb-2"><i class="bi bi-check-circle-fill text-success"></i> Image gallery view</li>
|
||||
<li class="mb-2"><i class="bi bi-check-circle-fill text-success"></i> Responsive design</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
244
app/templates/instructions/upload.html
Normal file
244
app/templates/instructions/upload.html
Normal file
@@ -0,0 +1,244 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Upload Instructions - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav aria-label="breadcrumb" class="mb-4">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('main.dashboard') }}">Dashboard</a></li>
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('sets.list_sets') }}">Sets</a></li>
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('sets.view_set', set_id=set.id) }}">{{ set.set_number }}</a></li>
|
||||
<li class="breadcrumb-item active">Upload Instructions</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8 mx-auto">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-success text-white">
|
||||
<h3 class="mb-0">
|
||||
<i class="bi bi-cloud-upload"></i> Upload Instructions
|
||||
</h3>
|
||||
<p class="mb-0 mt-2">{{ set.set_number }}: {{ set.set_name }}</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Set Info -->
|
||||
<div class="alert alert-info">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<h5 class="alert-heading">{{ set.set_name }}</h5>
|
||||
<p class="mb-0">
|
||||
<strong>Set:</strong> {{ set.set_number }} |
|
||||
<strong>Theme:</strong> {{ set.theme }} |
|
||||
<strong>Year:</strong> {{ set.year_released }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-md-4 text-end">
|
||||
<p class="mb-0">
|
||||
<strong>Current Instructions:</strong><br>
|
||||
{{ set.instructions.count() }} file(s)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Form -->
|
||||
<form method="POST" action="{{ url_for('instructions.upload', set_id=set.id) }}"
|
||||
enctype="multipart/form-data" id="uploadForm">
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="files" class="form-label">
|
||||
<i class="bi bi-file-earmark-arrow-up"></i> Select Files
|
||||
</label>
|
||||
<input type="file" class="form-control" id="files" name="files[]"
|
||||
multiple accept=".pdf,.png,.jpg,.jpeg,.gif" required>
|
||||
<div class="form-text">
|
||||
Accepted formats: PDF, PNG, JPG, JPEG, GIF (Max 50MB per file)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Drag and Drop Area -->
|
||||
<div class="upload-area mb-4" id="dropZone">
|
||||
<i class="bi bi-cloud-upload display-1 text-muted"></i>
|
||||
<h4 class="mt-3">Drag & Drop Files Here</h4>
|
||||
<p class="text-muted">or click to browse</p>
|
||||
<p class="small text-muted mb-0">
|
||||
<i class="bi bi-info-circle"></i> You can upload multiple files at once
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- File Preview -->
|
||||
<div id="filePreview" class="mb-4" style="display: none;">
|
||||
<h6>Selected Files:</h6>
|
||||
<ul id="fileList" class="list-group"></ul>
|
||||
</div>
|
||||
|
||||
<!-- Upload Instructions -->
|
||||
<div class="alert alert-light">
|
||||
<h6><i class="bi bi-info-circle"></i> Upload Tips:</h6>
|
||||
<ul class="mb-0">
|
||||
<li><strong>PDFs:</strong> Upload complete instruction manuals as single PDF files</li>
|
||||
<li><strong>Images:</strong> Upload individual pages as separate images (they will be numbered automatically)</li>
|
||||
<li><strong>Quality:</strong> Higher resolution images provide better viewing experience</li>
|
||||
<li><strong>Organization:</strong> Files are automatically organized by set number</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{{ url_for('sets.view_set', set_id=set.id) }}" class="btn btn-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-success btn-lg" id="uploadBtn">
|
||||
<i class="bi bi-cloud-upload"></i> Upload Files
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current Instructions Summary -->
|
||||
{% if set.instructions.count() > 0 %}
|
||||
<div class="card shadow mt-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-file-earmark-text"></i> Current Instructions</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6>PDFs: {{ set.pdf_instructions|length }}</h6>
|
||||
{% if set.pdf_instructions %}
|
||||
<ul class="list-unstyled">
|
||||
{% for pdf in set.pdf_instructions %}
|
||||
<li><i class="bi bi-file-pdf text-danger"></i> {{ pdf.file_name }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="text-muted">No PDFs uploaded yet</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6>Images: {{ set.image_instructions|length }}</h6>
|
||||
{% if set.image_instructions %}
|
||||
<p class="text-muted">{{ set.image_instructions|length }} page(s)</p>
|
||||
{% else %}
|
||||
<p class="text-muted">No images uploaded yet</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
const dropZone = $('#dropZone');
|
||||
const fileInput = $('#files');
|
||||
const filePreview = $('#filePreview');
|
||||
const fileList = $('#fileList');
|
||||
|
||||
// Click on drop zone to trigger file input
|
||||
dropZone.on('click', function() {
|
||||
fileInput.click();
|
||||
});
|
||||
|
||||
// Prevent default drag behaviors
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||
dropZone.on(eventName, function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
});
|
||||
});
|
||||
|
||||
// Highlight drop zone when dragging over
|
||||
['dragenter', 'dragover'].forEach(eventName => {
|
||||
dropZone.on(eventName, function() {
|
||||
dropZone.addClass('dragover');
|
||||
});
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(eventName => {
|
||||
dropZone.on(eventName, function() {
|
||||
dropZone.removeClass('dragover');
|
||||
});
|
||||
});
|
||||
|
||||
// Handle dropped files
|
||||
dropZone.on('drop', function(e) {
|
||||
const files = e.originalEvent.dataTransfer.files;
|
||||
fileInput[0].files = files;
|
||||
displayFiles(files);
|
||||
});
|
||||
|
||||
// Handle selected files
|
||||
fileInput.on('change', function() {
|
||||
displayFiles(this.files);
|
||||
});
|
||||
|
||||
// Display selected files
|
||||
function displayFiles(files) {
|
||||
fileList.empty();
|
||||
|
||||
if (files.length === 0) {
|
||||
filePreview.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
filePreview.show();
|
||||
|
||||
Array.from(files).forEach(file => {
|
||||
const fileSize = (file.size / 1024 / 1024).toFixed(2);
|
||||
const fileType = file.type.includes('pdf') ? 'file-pdf' : 'file-image';
|
||||
const fileColor = file.type.includes('pdf') ? 'text-danger' : 'text-primary';
|
||||
|
||||
const listItem = $(`
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<i class="bi bi-${fileType} ${fileColor}"></i>
|
||||
<strong>${file.name}</strong>
|
||||
</div>
|
||||
<span class="badge bg-secondary">${fileSize} MB</span>
|
||||
</li>
|
||||
`);
|
||||
|
||||
fileList.append(listItem);
|
||||
});
|
||||
}
|
||||
|
||||
// Upload progress
|
||||
$('#uploadForm').on('submit', function() {
|
||||
$('#uploadBtn').html('<span class="spinner-border spinner-border-sm" role="status"></span> Uploading...');
|
||||
$('#uploadBtn').prop('disabled', true);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.upload-area {
|
||||
border: 2px dashed #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
padding: 3rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.upload-area:hover {
|
||||
border-color: #198754;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.upload-area.dragover {
|
||||
border-color: #198754;
|
||||
background-color: #d1e7dd;
|
||||
border-style: solid;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
380
app/templates/instructions/viewer.html
Normal file
380
app/templates/instructions/viewer.html
Normal file
@@ -0,0 +1,380 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Instructions Viewer - {{ set.set_number }}: {{ set.set_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
body {
|
||||
background-color: #2b2b2b;
|
||||
}
|
||||
|
||||
.viewer-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.viewer-header {
|
||||
background-color: #1a1a1a;
|
||||
color: white;
|
||||
padding: 15px 20px;
|
||||
border-radius: 8px 8px 0 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.viewer-content {
|
||||
background-color: #3a3a3a;
|
||||
padding: 20px;
|
||||
border-radius: 0 0 8px 8px;
|
||||
min-height: calc(100vh - 250px);
|
||||
}
|
||||
|
||||
.instruction-page {
|
||||
background-color: white;
|
||||
margin: 0 auto 20px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.instruction-page img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.page-number-badge {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 5px 15px;
|
||||
border-radius: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.viewer-controls {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: rgba(0, 0, 0, 0.9);
|
||||
padding: 15px 30px;
|
||||
border-radius: 50px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.viewer-controls button {
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
.fullscreen-mode .viewer-container {
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.zoom-container {
|
||||
position: relative;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.zoom-container.zoomed {
|
||||
cursor: zoom-out;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.zoom-container.zoomed img {
|
||||
cursor: grab;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.zoom-container.zoomed img:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.nav-footer {
|
||||
background-color: #1a1a1a;
|
||||
color: white;
|
||||
padding: 15px;
|
||||
text-align: center;
|
||||
border-radius: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="viewer-container">
|
||||
<div class="viewer-header">
|
||||
<div>
|
||||
<h4 class="mb-0">
|
||||
<i class="bi bi-book"></i> {{ set.set_number }}: {{ set.set_name }}
|
||||
{% if set.is_moc %}
|
||||
<span class="badge bg-warning text-dark ms-2">
|
||||
<i class="bi bi-star-fill"></i> MOC
|
||||
</span>
|
||||
{% endif %}
|
||||
</h4>
|
||||
<small class="text-muted">{{ images|length }} page(s)</small>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-outline-light" id="toggleFullscreen">
|
||||
<i class="bi bi-arrows-fullscreen"></i> Fullscreen
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-light" id="zoomToggle">
|
||||
<i class="bi bi-zoom-in"></i> Zoom
|
||||
</button>
|
||||
<a href="{{ url_for('sets.view_set', set_id=set.id) }}" class="btn btn-sm btn-outline-light">
|
||||
<i class="bi bi-x-lg"></i> Close
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="viewer-content">
|
||||
<!-- Continuous Scroll Mode -->
|
||||
<div id="continuousViewer">
|
||||
{% for image in images %}
|
||||
<div class="instruction-page zoom-container" id="page-{{ image.page_number }}" data-page="{{ image.page_number }}">
|
||||
<div class="position-relative">
|
||||
<span class="page-number-badge">Page {{ image.page_number }} / {{ images|length }}</span>
|
||||
<img src="{{ url_for('static', filename='uploads/' + image.file_path.replace('\\', '/')) }}"
|
||||
alt="Page {{ image.page_number }}"
|
||||
class="instruction-image"
|
||||
loading="lazy">
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav-footer">
|
||||
<p class="mb-2">
|
||||
<strong>Navigation Tips:</strong>
|
||||
Scroll to view all pages • Click image to zoom •
|
||||
Use arrow keys for quick navigation •
|
||||
Press F for fullscreen
|
||||
</p>
|
||||
<div>
|
||||
<a href="{{ url_for('sets.view_set', set_id=set.id) }}" class="btn btn-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Back to Set
|
||||
</a>
|
||||
<a href="{{ url_for('instructions.upload', set_id=set.id) }}" class="btn btn-success">
|
||||
<i class="bi bi-plus-circle"></i> Add More Pages
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Floating Controls -->
|
||||
<div class="viewer-controls">
|
||||
<button class="btn btn-light btn-sm" id="prevPage" title="Previous Page">
|
||||
<i class="bi bi-chevron-up"></i>
|
||||
</button>
|
||||
<span class="text-white mx-3" id="currentPageDisplay">Page 1 / {{ images|length }}</span>
|
||||
<button class="btn btn-light btn-sm" id="nextPage" title="Next Page">
|
||||
<i class="bi bi-chevron-down"></i>
|
||||
</button>
|
||||
<span class="text-white mx-3">|</span>
|
||||
<button class="btn btn-light btn-sm" id="scrollToTop" title="Scroll to Top">
|
||||
<i class="bi bi-arrow-up"></i>
|
||||
</button>
|
||||
<button class="btn btn-light btn-sm" id="scrollToBottom" title="Scroll to Bottom">
|
||||
<i class="bi bi-arrow-down"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
let currentPage = 1;
|
||||
const totalPages = {{ images|length }};
|
||||
let isZoomed = false;
|
||||
let zoomLevel = 1;
|
||||
|
||||
// Update current page based on scroll position
|
||||
function updateCurrentPage() {
|
||||
const scrollTop = $(window).scrollTop();
|
||||
const windowHeight = $(window).height();
|
||||
const scrollMiddle = scrollTop + windowHeight / 2;
|
||||
|
||||
$('.instruction-page').each(function() {
|
||||
const pageTop = $(this).offset().top;
|
||||
const pageBottom = pageTop + $(this).outerHeight();
|
||||
|
||||
if (scrollMiddle >= pageTop && scrollMiddle <= pageBottom) {
|
||||
currentPage = parseInt($(this).data('page'));
|
||||
$('#currentPageDisplay').text(`Page ${currentPage} / ${totalPages}`);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Scroll to specific page
|
||||
function scrollToPage(pageNum) {
|
||||
const targetPage = $(`#page-${pageNum}`);
|
||||
if (targetPage.length) {
|
||||
$('html, body').animate({
|
||||
scrollTop: targetPage.offset().top - 100
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation buttons
|
||||
$('#nextPage').click(function() {
|
||||
if (currentPage < totalPages) {
|
||||
scrollToPage(currentPage + 1);
|
||||
}
|
||||
});
|
||||
|
||||
$('#prevPage').click(function() {
|
||||
if (currentPage > 1) {
|
||||
scrollToPage(currentPage - 1);
|
||||
}
|
||||
});
|
||||
|
||||
$('#scrollToTop').click(function() {
|
||||
$('html, body').animate({ scrollTop: 0 }, 500);
|
||||
});
|
||||
|
||||
$('#scrollToBottom').click(function() {
|
||||
$('html, body').animate({
|
||||
scrollTop: $(document).height()
|
||||
}, 500);
|
||||
});
|
||||
|
||||
// Update page on scroll
|
||||
$(window).scroll(function() {
|
||||
updateCurrentPage();
|
||||
});
|
||||
|
||||
// Keyboard navigation
|
||||
$(document).keydown(function(e) {
|
||||
switch(e.which) {
|
||||
case 38: // up arrow
|
||||
case 33: // page up
|
||||
e.preventDefault();
|
||||
$('#prevPage').click();
|
||||
break;
|
||||
case 40: // down arrow
|
||||
case 34: // page down
|
||||
e.preventDefault();
|
||||
$('#nextPage').click();
|
||||
break;
|
||||
case 36: // home
|
||||
e.preventDefault();
|
||||
$('#scrollToTop').click();
|
||||
break;
|
||||
case 35: // end
|
||||
e.preventDefault();
|
||||
$('#scrollToBottom').click();
|
||||
break;
|
||||
case 70: // F key for fullscreen
|
||||
e.preventDefault();
|
||||
$('#toggleFullscreen').click();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Fullscreen toggle
|
||||
$('#toggleFullscreen').click(function() {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen();
|
||||
$('body').addClass('fullscreen-mode');
|
||||
$(this).html('<i class="bi bi-fullscreen-exit"></i> Exit Fullscreen');
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
$('body').removeClass('fullscreen-mode');
|
||||
$(this).html('<i class="bi bi-arrows-fullscreen"></i> Fullscreen');
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for fullscreen changes
|
||||
document.addEventListener('fullscreenchange', function() {
|
||||
if (!document.fullscreenElement) {
|
||||
$('body').removeClass('fullscreen-mode');
|
||||
$('#toggleFullscreen').html('<i class="bi bi-arrows-fullscreen"></i> Fullscreen');
|
||||
}
|
||||
});
|
||||
|
||||
// Zoom functionality
|
||||
$('#zoomToggle').click(function() {
|
||||
isZoomed = !isZoomed;
|
||||
if (isZoomed) {
|
||||
$(this).html('<i class="bi bi-zoom-out"></i> Zoom Out');
|
||||
$('.instruction-image').css({
|
||||
'transform': 'scale(1.5)',
|
||||
'transition': 'transform 0.3s'
|
||||
});
|
||||
} else {
|
||||
$(this).html('<i class="bi bi-zoom-in"></i> Zoom In');
|
||||
$('.instruction-image').css({
|
||||
'transform': 'scale(1)',
|
||||
'transition': 'transform 0.3s'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Click to zoom individual images
|
||||
$('.zoom-container').click(function(e) {
|
||||
const $img = $(this).find('img');
|
||||
const $container = $(this);
|
||||
|
||||
if ($container.hasClass('zoomed')) {
|
||||
$img.css('transform', 'scale(1)');
|
||||
$container.removeClass('zoomed');
|
||||
} else {
|
||||
$img.css('transform', 'scale(2)');
|
||||
$container.addClass('zoomed');
|
||||
}
|
||||
});
|
||||
|
||||
// Pan zoomed image with mouse drag
|
||||
let isDragging = false;
|
||||
let startX, startY, scrollLeft, scrollTop;
|
||||
|
||||
$('.zoom-container').on('mousedown', function(e) {
|
||||
if (!$(this).hasClass('zoomed')) return;
|
||||
|
||||
isDragging = true;
|
||||
startX = e.pageX - $(this).offset().left;
|
||||
startY = e.pageY - $(this).offset().top;
|
||||
scrollLeft = $(this).scrollLeft();
|
||||
scrollTop = $(this).scrollTop();
|
||||
$(this).css('cursor', 'grabbing');
|
||||
});
|
||||
|
||||
$('.zoom-container').on('mouseup mouseleave', function() {
|
||||
isDragging = false;
|
||||
if ($(this).hasClass('zoomed')) {
|
||||
$(this).css('cursor', 'zoom-out');
|
||||
}
|
||||
});
|
||||
|
||||
$('.zoom-container').on('mousemove', function(e) {
|
||||
if (!isDragging) return;
|
||||
e.preventDefault();
|
||||
|
||||
const x = e.pageX - $(this).offset().left;
|
||||
const y = e.pageY - $(this).offset().top;
|
||||
const walkX = (x - startX) * 2;
|
||||
const walkY = (y - startY) * 2;
|
||||
|
||||
$(this).scrollLeft(scrollLeft - walkX);
|
||||
$(this).scrollTop(scrollTop - walkY);
|
||||
});
|
||||
|
||||
// Initialize
|
||||
updateCurrentPage();
|
||||
|
||||
// Smooth scroll for all pages loaded
|
||||
console.log('Image viewer initialized with', totalPages, 'pages');
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
324
app/templates/sets/add.html
Normal file
324
app/templates/sets/add.html
Normal file
@@ -0,0 +1,324 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Add Set - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-lg-8 mx-auto">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-danger text-white">
|
||||
<h3 class="mb-0">
|
||||
<i class="bi bi-plus-circle"></i> Add New LEGO Set or MOC
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Set Type Selection -->
|
||||
<div class="mb-4 p-4 bg-light rounded border">
|
||||
<h5 class="mb-3"><i class="bi bi-question-circle"></i> What are you adding?</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3 mb-md-0">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="set_type" id="type_official" value="official"
|
||||
{% if request.args.get('type') != 'moc' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="type_official">
|
||||
<strong><i class="bi bi-box-seam"></i> Official LEGO Set</strong>
|
||||
<br>
|
||||
<small class="text-muted">A set produced by LEGO with an official set number</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="set_type" id="type_moc" value="moc"
|
||||
{% if request.args.get('type') == 'moc' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="type_moc">
|
||||
<strong><i class="bi bi-star-fill text-warning"></i> MOC (My Own Creation)</strong>
|
||||
<br>
|
||||
<small class="text-muted">A custom build designed by you or another builder</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if brickset_available %}
|
||||
<!-- Brickset Search - Only for official sets -->
|
||||
<div id="bricksetSection" class="mb-4 p-3 bg-light rounded">
|
||||
<h5><i class="bi bi-search"></i> Search Brickset</h5>
|
||||
<p class="text-muted small mb-3">Search for a set to auto-populate details</p>
|
||||
<div class="input-group">
|
||||
<input type="text" id="bricksetSearch" class="form-control"
|
||||
placeholder="Enter set number or name...">
|
||||
<button class="btn btn-primary" type="button" id="searchBtn">
|
||||
<i class="bi bi-search"></i> Search
|
||||
</button>
|
||||
</div>
|
||||
<div id="searchResults" class="mt-3"></div>
|
||||
</div>
|
||||
<hr id="bricksetDivider">
|
||||
{% endif %}
|
||||
|
||||
<!-- Manual Entry Form -->
|
||||
<form method="POST" action="{{ url_for('sets.add_set') }}" enctype="multipart/form-data">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="set_number" class="form-label">
|
||||
Set Number <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="text" class="form-control" id="set_number"
|
||||
name="set_number" required placeholder="e.g., 10497">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="year_released" class="form-label">
|
||||
Year Released <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="number" class="form-control" id="year_released"
|
||||
name="year_released" required min="1949" max="2030"
|
||||
placeholder="e.g., 2024">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="set_name" class="form-label">
|
||||
Set Name <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="text" class="form-control" id="set_name"
|
||||
name="set_name" required placeholder="e.g., Galaxy Explorer">
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="theme" class="form-label">
|
||||
Theme <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="text" class="form-control" id="theme"
|
||||
name="theme" required placeholder="e.g., Space">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="piece_count" class="form-label">
|
||||
Piece Count
|
||||
</label>
|
||||
<input type="number" class="form-control" id="piece_count"
|
||||
name="piece_count" placeholder="e.g., 1254">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="image_url" class="form-label">
|
||||
Image URL (optional)
|
||||
</label>
|
||||
<input type="url" class="form-control" id="image_url"
|
||||
name="image_url" placeholder="https://...">
|
||||
<div class="form-text">Enter a URL to an image of the set (e.g., from Brickset)</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="cover_image" class="form-label">
|
||||
<i class="bi bi-upload"></i> Upload Cover Picture
|
||||
</label>
|
||||
<input type="file" class="form-control" id="cover_image"
|
||||
name="cover_image" accept="image/*">
|
||||
<div class="form-text">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
Upload your own photo of the set or MOC (JPG, PNG, GIF). Max 800px, optimized automatically.
|
||||
</div>
|
||||
<div id="imagePreview" class="mt-2" style="display: none;">
|
||||
<img id="previewImg" src="" alt="Preview" style="max-width: 200px; max-height: 200px; border-radius: 8px;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MOC (My Own Creation) Section -->
|
||||
<div class="card mb-3 border-warning" id="mocSection" style="display: none;">
|
||||
<div class="card-header bg-warning bg-opacity-25">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-star-fill text-warning"></i> MOC Information
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info mb-3">
|
||||
<small>
|
||||
<i class="bi bi-info-circle"></i>
|
||||
<strong>MOC Tips:</strong>
|
||||
<ul class="mb-0 mt-2">
|
||||
<li>Use any set number format (e.g., MOC-001, CUSTOM-2024, MYBUILD-01)</li>
|
||||
<li>Credit yourself or the original designer</li>
|
||||
<li>Add notes about techniques, inspiration, or building tips</li>
|
||||
</ul>
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<input type="hidden" id="is_moc" name="is_moc" value="off">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="moc_designer" class="form-label">
|
||||
<i class="bi bi-person"></i> Designer / Creator Name <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="text" class="form-control" id="moc_designer"
|
||||
name="moc_designer" placeholder="e.g., Your Name or Original Designer">
|
||||
<div class="form-text">Who designed this MOC?</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="moc_description" class="form-label">
|
||||
<i class="bi bi-card-text"></i> Description / Build Notes
|
||||
</label>
|
||||
<textarea class="form-control" id="moc_description" name="moc_description"
|
||||
rows="4" placeholder="Add details about your MOC, building techniques, inspiration, special features, etc."></textarea>
|
||||
<div class="form-text">Share details about your custom creation</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{{ url_for('sets.list_sets') }}" class="btn btn-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-danger btn-lg">
|
||||
<i class="bi bi-plus-circle"></i> Add Set
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
{% if brickset_available %}
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
$('#searchBtn').click(function() {
|
||||
const query = $('#bricksetSearch').val().trim();
|
||||
if (!query) {
|
||||
alert('Please enter a search term');
|
||||
return;
|
||||
}
|
||||
|
||||
$('#searchResults').html('<div class="text-center"><div class="spinner-border text-primary" role="status"></div></div>');
|
||||
|
||||
$.ajax({
|
||||
url: '{{ url_for("sets.search_brickset") }}',
|
||||
data: { q: query },
|
||||
success: function(data) {
|
||||
if (data.length === 0) {
|
||||
$('#searchResults').html('<div class="alert alert-info">No results found</div>');
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="list-group">';
|
||||
data.forEach(function(set) {
|
||||
html += `
|
||||
<a href="#" class="list-group-item list-group-item-action search-result"
|
||||
data-number="${set.setNumber}"
|
||||
data-name="${set.name}"
|
||||
data-theme="${set.theme}"
|
||||
data-year="${set.year}"
|
||||
data-pieces="${set.pieces || ''}"
|
||||
data-image="${set.imageUrl || ''}">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h6 class="mb-1">${set.setNumber}: ${set.name}</h6>
|
||||
<small>${set.year}</small>
|
||||
</div>
|
||||
<small class="text-muted">${set.theme} - ${set.pieces || 'Unknown'} pieces</small>
|
||||
</a>
|
||||
`;
|
||||
});
|
||||
html += '</div>';
|
||||
$('#searchResults').html(html);
|
||||
|
||||
// Handle click on search result
|
||||
$('.search-result').click(function(e) {
|
||||
e.preventDefault();
|
||||
$('#set_number').val($(this).data('number'));
|
||||
$('#set_name').val($(this).data('name'));
|
||||
$('#theme').val($(this).data('theme'));
|
||||
$('#year_released').val($(this).data('year'));
|
||||
$('#piece_count').val($(this).data('pieces'));
|
||||
$('#image_url').val($(this).data('image'));
|
||||
$('#searchResults').html('<div class="alert alert-success">Form populated! Review and submit.</div>');
|
||||
});
|
||||
},
|
||||
error: function() {
|
||||
$('#searchResults').html('<div class="alert alert-danger">Search failed. Please try again.</div>');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Allow enter key to search
|
||||
$('#bricksetSearch').keypress(function(e) {
|
||||
if (e.which === 13) {
|
||||
e.preventDefault();
|
||||
$('#searchBtn').click();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// Handle set type selection (Official vs MOC)
|
||||
$('input[name="set_type"]').change(function() {
|
||||
const isMoc = $('#type_moc').is(':checked');
|
||||
|
||||
if (isMoc) {
|
||||
// Show MOC section, hide Brickset
|
||||
$('#mocSection').slideDown();
|
||||
$('#is_moc').val('on');
|
||||
$('#bricksetSection').slideUp();
|
||||
$('#bricksetDivider').hide();
|
||||
|
||||
// Clear Brickset populated fields (they might not apply to MOCs)
|
||||
$('#image_url').val('');
|
||||
|
||||
// Update placeholder text for MOC context
|
||||
$('#set_number').attr('placeholder', 'e.g., MOC-001, CUSTOM-2024');
|
||||
$('#theme').attr('placeholder', 'e.g., Custom, Space MOCs, My Creations');
|
||||
|
||||
} else {
|
||||
// Show Brickset, hide MOC section
|
||||
$('#mocSection').slideUp();
|
||||
$('#is_moc').val('off');
|
||||
$('#bricksetSection').slideDown();
|
||||
$('#bricksetDivider').show();
|
||||
|
||||
// Clear MOC fields
|
||||
$('#moc_designer').val('');
|
||||
$('#moc_description').val('');
|
||||
|
||||
// Reset placeholder text for official sets
|
||||
$('#set_number').attr('placeholder', 'e.g., 10497');
|
||||
$('#theme').attr('placeholder', 'e.g., Space');
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize based on current selection (including URL parameter)
|
||||
const selectedType = $('input[name="set_type"]:checked').val();
|
||||
if (selectedType === 'moc') {
|
||||
$('#type_moc').trigger('change');
|
||||
} else {
|
||||
$('#type_official').trigger('change');
|
||||
}
|
||||
|
||||
// Image preview
|
||||
$('#cover_image').change(function() {
|
||||
const file = this.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
$('#previewImg').attr('src', e.target.result);
|
||||
$('#imagePreview').slideDown();
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
$('#imagePreview').slideUp();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
415
app/templates/sets/detail.html
Normal file
415
app/templates/sets/detail.html
Normal file
@@ -0,0 +1,415 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ set.set_number }}: {{ set.set_name }} - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav aria-label="breadcrumb" class="mb-4">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('main.dashboard') }}">Dashboard</a></li>
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('sets.list_sets') }}">Sets</a></li>
|
||||
<li class="breadcrumb-item active">{{ set.set_number }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="row">
|
||||
<!-- Set Image and Details -->
|
||||
<div class="col-lg-4 mb-4">
|
||||
<div class="card shadow-sm">
|
||||
{% if set.cover_image %}
|
||||
<img src="{{ url_for('static', filename='uploads/' + set.cover_image.replace('\\', '/')) }}"
|
||||
class="card-img-top" alt="{{ set.set_name }}"
|
||||
style="max-height: 400px; object-fit: contain; background-color: #f8f9fa; padding: 20px;">
|
||||
{% elif set.image_url %}
|
||||
<img src="{{ set.image_url }}" class="card-img-top" alt="{{ set.set_name }}"
|
||||
style="max-height: 400px; object-fit: contain; background-color: #f8f9fa; padding: 20px;">
|
||||
{% else %}
|
||||
<div class="d-flex align-items-center justify-content-center bg-light" style="height: 400px;">
|
||||
<i class="bi bi-image display-1 text-muted"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
{{ set.set_number }}
|
||||
{% if set.is_moc %}
|
||||
<span class="badge bg-warning text-dark">
|
||||
<i class="bi bi-star-fill"></i> MOC
|
||||
</span>
|
||||
{% endif %}
|
||||
</h5>
|
||||
<h6 class="card-subtitle mb-3 text-muted">{{ set.set_name }}</h6>
|
||||
|
||||
<table class="table table-sm table-borderless">
|
||||
<tr>
|
||||
<th width="40%">Theme:</th>
|
||||
<td><span class="badge bg-primary">{{ set.theme }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Year:</th>
|
||||
<td><span class="badge bg-warning text-dark">{{ set.year_released }}</span></td>
|
||||
</tr>
|
||||
{% if set.is_moc %}
|
||||
<tr>
|
||||
<th>Type:</th>
|
||||
<td><span class="badge bg-info">My Own Creation</span></td>
|
||||
</tr>
|
||||
{% if set.moc_designer %}
|
||||
<tr>
|
||||
<th>Designer:</th>
|
||||
<td>{{ set.moc_designer }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if set.piece_count %}
|
||||
<tr>
|
||||
<th>Pieces:</th>
|
||||
<td>{{ set.piece_count }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<th>Instructions:</th>
|
||||
<td>{{ set.instructions.count() }} file(s)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Added:</th>
|
||||
<td>{{ set.created_at.strftime('%b %d, %Y') }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card-footer bg-transparent">
|
||||
<div class="d-grid gap-2">
|
||||
<a href="{{ url_for('instructions.upload', set_id=set.id) }}" class="btn btn-success">
|
||||
<i class="bi bi-cloud-upload"></i> Upload Instructions
|
||||
</a>
|
||||
<a href="{{ url_for('sets.edit_set', set_id=set.id) }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-pencil"></i> Edit Set Details
|
||||
</a>
|
||||
<form method="POST" action="{{ url_for('sets.delete_set', set_id=set.id) }}"
|
||||
onsubmit="return confirm('Are you sure you want to delete this set and all its instructions?');">
|
||||
<button type="submit" class="btn btn-outline-danger w-100">
|
||||
<i class="bi bi-trash"></i> Delete Set
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Instructions -->
|
||||
<div class="col-lg-8">
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0"><i class="bi bi-file-pdf"></i> Instructions</h5>
|
||||
<a href="{{ url_for('instructions.upload', set_id=set.id) }}" class="btn btn-sm btn-success">
|
||||
<i class="bi bi-plus-circle"></i> Upload
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if pdf_instructions or image_instructions %}
|
||||
|
||||
<!-- PDF Instructions (Books) -->
|
||||
{% if pdf_instructions %}
|
||||
<h6 class="mb-3"><i class="bi bi-book-fill text-danger"></i> PDF Instruction Books</h6>
|
||||
<div class="row mb-4">
|
||||
{% for instruction in pdf_instructions %}
|
||||
<div class="col-md-6 col-lg-4 mb-3">
|
||||
<div class="card h-100">
|
||||
{% if instruction.thumbnail_path %}
|
||||
<a href="{{ url_for('instructions.view', instruction_id=instruction.id) }}" target="_blank">
|
||||
<img src="{{ url_for('static', filename='uploads/' + instruction.thumbnail_path.replace('\\', '/')) }}"
|
||||
class="card-img-top"
|
||||
alt="{{ instruction.file_name }}"
|
||||
style="height: 200px; object-fit: contain; background-color: #f8f9fa; padding: 10px;">
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('instructions.view', instruction_id=instruction.id) }}" target="_blank">
|
||||
<div class="card-img-top d-flex align-items-center justify-content-center bg-light"
|
||||
style="height: 200px;">
|
||||
<i class="bi bi-file-pdf display-1 text-danger"></i>
|
||||
</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">{{ instruction.file_name }}</h6>
|
||||
<p class="card-text small text-muted">
|
||||
<i class="bi bi-file-pdf"></i> {{ instruction.file_size_mb }} MB<br>
|
||||
<i class="bi bi-calendar"></i> {{ instruction.uploaded_at.strftime('%b %d, %Y') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent">
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('instructions.view', instruction_id=instruction.id) }}"
|
||||
class="btn btn-sm btn-primary flex-fill" target="_blank">
|
||||
<i class="bi bi-eye"></i> Open
|
||||
</a>
|
||||
<form method="POST" action="{{ url_for('instructions.delete', instruction_id=instruction.id) }}"
|
||||
onsubmit="return confirm('Delete this PDF?');" class="flex-fill">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger w-100">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Image Instructions (Single Card) -->
|
||||
{% if image_instructions %}
|
||||
<h6 class="mb-3"><i class="bi bi-images text-primary"></i> Scanned Instructions</h6>
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-lg-4 mb-3">
|
||||
<div class="card h-100">
|
||||
{% set first_image = image_instructions[0] %}
|
||||
<a href="{{ url_for('instructions.image_viewer', set_id=set.id) }}">
|
||||
<img src="{{ url_for('static', filename='uploads/' + first_image.file_path.replace('\\', '/')) }}"
|
||||
class="card-img-top"
|
||||
alt="Instructions Preview"
|
||||
style="height: 200px; object-fit: contain; background-color: #f8f9fa; padding: 10px; cursor: pointer;">
|
||||
</a>
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">
|
||||
<i class="bi bi-file-image"></i> Image Instructions
|
||||
</h6>
|
||||
<p class="card-text small text-muted">
|
||||
<i class="bi bi-files"></i> {{ image_instructions|length }} page(s)<br>
|
||||
<i class="bi bi-calendar"></i> Uploaded {{ first_image.uploaded_at.strftime('%b %d, %Y') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent">
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('instructions.image_viewer', set_id=set.id) }}"
|
||||
class="btn btn-sm btn-primary flex-fill">
|
||||
<i class="bi bi-book-half"></i> View Instructions
|
||||
</a>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger flex-fill"
|
||||
data-bs-toggle="modal" data-bs-target="#deleteImagesModal">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete All Images Modal -->
|
||||
<div class="modal fade" id="deleteImagesModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Delete All Image Instructions?</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>This will delete all {{ image_instructions|length }} image instruction pages.</p>
|
||||
<p class="text-danger"><strong>This cannot be undone!</strong></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<form method="POST" action="{{ url_for('instructions.delete_all_images', set_id=set.id) }}" style="display: inline;">
|
||||
<button type="submit" class="btn btn-danger">Delete All Images</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-file-earmark-x display-1 text-muted"></i>
|
||||
<h5 class="mt-3">No Instructions Yet</h5>
|
||||
<p class="text-muted">Upload PDF or image files to get started.</p>
|
||||
<a href="{{ url_for('instructions.upload', set_id=set.id) }}" class="btn btn-success">
|
||||
<i class="bi bi-cloud-upload"></i> Upload Instructions
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Extra Files Section -->
|
||||
<div class="card shadow-sm mt-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0"><i class="bi bi-file-earmark-plus"></i> Extra Files</h5>
|
||||
<a href="{{ url_for('extra_files.upload', set_id=set.id) }}" class="btn btn-sm btn-success">
|
||||
<i class="bi bi-cloud-upload"></i> Upload Files
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% set extra_files_list = set.extra_files.all() %}
|
||||
{% if extra_files_list %}
|
||||
<!-- Files Grid -->
|
||||
<div class="row g-3">
|
||||
{% for file in extra_files_list %}
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="card h-100 border-light">
|
||||
<!-- Preview for images -->
|
||||
{% if file.is_image %}
|
||||
<a href="{{ url_for('extra_files.preview', file_id=file.id) }}" target="_blank">
|
||||
<img src="{{ url_for('extra_files.preview', file_id=file.id) }}"
|
||||
class="card-img-top"
|
||||
alt="{{ file.original_filename }}"
|
||||
style="height: 150px; object-fit: cover; cursor: pointer;">
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="card-img-top bg-light d-flex align-items-center justify-content-center"
|
||||
style="height: 150px;">
|
||||
<i class="bi bi-{{ file.file_icon }} display-3 text-muted"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card-body p-2">
|
||||
<h6 class="card-title small mb-1">
|
||||
<i class="bi bi-{{ file.file_icon }}"></i>
|
||||
{{ file.original_filename }}
|
||||
</h6>
|
||||
|
||||
{% if file.category and file.category != 'other' %}
|
||||
<span class="badge bg-info text-dark small mb-1">
|
||||
{{ file.category|replace('_', ' ')|title }}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<p class="card-text small text-muted mb-1">
|
||||
<i class="bi bi-hdd"></i> {{ file.file_size_formatted }}
|
||||
<br>
|
||||
<i class="bi bi-calendar"></i> {{ file.uploaded_at.strftime('%b %d, %Y') }}
|
||||
</p>
|
||||
|
||||
{% if file.description %}
|
||||
<p class="card-text small text-muted mb-1">
|
||||
{{ file.description|truncate(60) }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card-footer bg-transparent p-2">
|
||||
<div class="d-flex gap-1">
|
||||
{% if file.can_preview %}
|
||||
<a href="{{ url_for('extra_files.preview', file_id=file.id) }}"
|
||||
target="_blank"
|
||||
class="btn btn-sm btn-outline-primary flex-fill"
|
||||
title="Preview">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('extra_files.download', file_id=file.id) }}"
|
||||
class="btn btn-sm btn-outline-success flex-fill"
|
||||
title="Download">
|
||||
<i class="bi bi-download"></i>
|
||||
</a>
|
||||
<form method="POST"
|
||||
action="{{ url_for('extra_files.delete', file_id=file.id) }}"
|
||||
class="flex-fill"
|
||||
onsubmit="return confirm('Delete {{ file.original_filename }}?');">
|
||||
<button type="submit"
|
||||
class="btn btn-sm btn-outline-danger w-100"
|
||||
title="Delete">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-files display-3 text-muted"></i>
|
||||
<p class="text-muted mt-2 mb-1">No extra files yet</p>
|
||||
<p class="small text-muted">
|
||||
Upload BrickLink XMLs, Stud.io files, box art, photos, or any other related files
|
||||
</p>
|
||||
<a href="{{ url_for('extra_files.upload', set_id=set.id) }}" class="btn btn-sm btn-success">
|
||||
<i class="bi bi-cloud-upload"></i> Upload Files
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Set Information -->
|
||||
<div class="card shadow-sm mt-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-info-circle"></i> Additional Information</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-4">Set Number:</dt>
|
||||
<dd class="col-sm-8">{{ set.set_number }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Set Name:</dt>
|
||||
<dd class="col-sm-8">{{ set.set_name }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Theme:</dt>
|
||||
<dd class="col-sm-8">{{ set.theme }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Year Released:</dt>
|
||||
<dd class="col-sm-8">{{ set.year_released }}</dd>
|
||||
|
||||
{% if set.piece_count %}
|
||||
<dt class="col-sm-4">Piece Count:</dt>
|
||||
<dd class="col-sm-8">{{ set.piece_count }} pieces</dd>
|
||||
{% endif %}
|
||||
|
||||
{% if set.is_moc %}
|
||||
<dt class="col-sm-4">Type:</dt>
|
||||
<dd class="col-sm-8">
|
||||
<span class="badge bg-warning text-dark">
|
||||
<i class="bi bi-star-fill"></i> My Own Creation (MOC)
|
||||
</span>
|
||||
</dd>
|
||||
|
||||
{% if set.moc_designer %}
|
||||
<dt class="col-sm-4">Designer:</dt>
|
||||
<dd class="col-sm-8">{{ set.moc_designer }}</dd>
|
||||
{% endif %}
|
||||
|
||||
{% if set.moc_description %}
|
||||
<dt class="col-sm-4">Description:</dt>
|
||||
<dd class="col-sm-8">{{ set.moc_description }}</dd>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if set.brickset_id %}
|
||||
<dt class="col-sm-4">Brickset ID:</dt>
|
||||
<dd class="col-sm-8">{{ set.brickset_id }}</dd>
|
||||
{% endif %}
|
||||
|
||||
<dt class="col-sm-4">Added By:</dt>
|
||||
<dd class="col-sm-8">{{ set.added_by.username }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Date Added:</dt>
|
||||
<dd class="col-sm-8">{{ set.created_at.strftime('%B %d, %Y at %I:%M %p') }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Last Updated:</dt>
|
||||
<dd class="col-sm-8">{{ set.updated_at.strftime('%B %d, %Y at %I:%M %p') }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<a href="{{ url_for('sets.list_sets') }}" class="btn btn-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Back to Sets
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.instruction-thumbnail {
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.instruction-thumbnail:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
211
app/templates/sets/edit.html
Normal file
211
app/templates/sets/edit.html
Normal file
@@ -0,0 +1,211 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Edit Set - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-lg-8 mx-auto">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h3 class="mb-0">
|
||||
<i class="bi bi-pencil"></i> Edit LEGO Set
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('sets.edit_set', set_id=set.id) }}" enctype="multipart/form-data">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="set_number" class="form-label">
|
||||
Set Number <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="text" class="form-control" id="set_number"
|
||||
name="set_number" value="{{ set.set_number }}" disabled>
|
||||
<div class="form-text">Set number cannot be changed</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="year_released" class="form-label">
|
||||
Year Released <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="number" class="form-control" id="year_released"
|
||||
name="year_released" value="{{ set.year_released }}"
|
||||
required min="1949" max="2030">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="set_name" class="form-label">
|
||||
Set Name <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="text" class="form-control" id="set_name"
|
||||
name="set_name" value="{{ set.set_name }}" required>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="theme" class="form-label">
|
||||
Theme <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="text" class="form-control" id="theme"
|
||||
name="theme" value="{{ set.theme }}" required>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="piece_count" class="form-label">
|
||||
Piece Count
|
||||
</label>
|
||||
<input type="number" class="form-control" id="piece_count"
|
||||
name="piece_count" value="{{ set.piece_count or '' }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="image_url" class="form-label">
|
||||
Image URL (optional)
|
||||
</label>
|
||||
<input type="url" class="form-control" id="image_url"
|
||||
name="image_url" value="{{ set.image_url or '' }}">
|
||||
<div class="form-text">Enter a URL to an image of the set</div>
|
||||
</div>
|
||||
|
||||
{% if set.image_url %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Current URL Image Preview:</label>
|
||||
<div class="border rounded p-3 bg-light">
|
||||
<img src="{{ set.image_url }}" alt="{{ set.set_name }}"
|
||||
style="max-height: 200px; max-width: 100%; object-fit: contain;">
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Cover Image Upload -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0"><i class="bi bi-image"></i> Cover Picture</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if set.cover_image %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Current Uploaded Cover:</label>
|
||||
<div class="border rounded p-3 bg-light position-relative">
|
||||
<img src="{{ url_for('static', filename='uploads/' + set.cover_image.replace('\\', '/')) }}"
|
||||
alt="{{ set.set_name }}"
|
||||
style="max-height: 200px; max-width: 100%; object-fit: contain;">
|
||||
</div>
|
||||
<div class="form-check mt-2">
|
||||
<input class="form-check-input" type="checkbox" id="remove_cover_image" name="remove_cover_image">
|
||||
<label class="form-check-label text-danger" for="remove_cover_image">
|
||||
<i class="bi bi-trash"></i> Remove uploaded cover image
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mb-0">
|
||||
<label for="cover_image" class="form-label">
|
||||
<i class="bi bi-upload"></i> {% if set.cover_image %}Replace Cover Picture{% else %}Upload Cover Picture{% endif %}
|
||||
</label>
|
||||
<input type="file" class="form-control" id="cover_image"
|
||||
name="cover_image" accept="image/*">
|
||||
<div class="form-text">
|
||||
Upload your own photo of the set or MOC (JPG, PNG, GIF). Max 800px, optimized automatically.
|
||||
</div>
|
||||
<div id="imagePreview" class="mt-2" style="display: none;">
|
||||
<label class="form-label small">New Image Preview:</label>
|
||||
<img id="previewImg" src="" alt="Preview" style="max-width: 200px; max-height: 200px; border-radius: 8px; border: 2px solid #dee2e6;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MOC (My Own Creation) Section -->
|
||||
<div class="card mb-3 border-info">
|
||||
<div class="card-header bg-info bg-opacity-10">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="is_moc" name="is_moc"
|
||||
{% if set.is_moc %}checked{% endif %}>
|
||||
<label class="form-check-label fw-bold" for="is_moc">
|
||||
<i class="bi bi-star-fill text-warning"></i> This is a MOC (My Own Creation)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body" id="mocFields" {% if not set.is_moc %}style="display: none;"{% endif %}>
|
||||
<div class="mb-3">
|
||||
<label for="moc_designer" class="form-label">
|
||||
Designer / Creator Name
|
||||
</label>
|
||||
<input type="text" class="form-control" id="moc_designer"
|
||||
name="moc_designer" value="{{ set.moc_designer or '' }}"
|
||||
placeholder="e.g., Your Name">
|
||||
<div class="form-text">Who designed this MOC?</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="moc_description" class="form-label">
|
||||
Description / Notes
|
||||
</label>
|
||||
<textarea class="form-control" id="moc_description" name="moc_description"
|
||||
rows="4" placeholder="Add details about your MOC...">{{ set.moc_description or '' }}</textarea>
|
||||
<div class="form-text">Optional notes about your custom creation</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{{ url_for('sets.view_set', set_id=set.id) }}" class="btn btn-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
<i class="bi bi-check-circle"></i> Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow mt-4">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0"><i class="bi bi-info-circle"></i> Set Information</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p><strong>Added by:</strong> {{ set.added_by.username }}</p>
|
||||
<p><strong>Created:</strong> {{ set.created_at.strftime('%B %d, %Y at %I:%M %p') }}</p>
|
||||
<p><strong>Last updated:</strong> {{ set.updated_at.strftime('%B %d, %Y at %I:%M %p') }}</p>
|
||||
<p class="mb-0"><strong>Instructions:</strong> {{ set.instructions.count() }} file(s)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// Toggle MOC fields visibility
|
||||
$('#is_moc').change(function() {
|
||||
if ($(this).is(':checked')) {
|
||||
$('#mocFields').slideDown();
|
||||
} else {
|
||||
$('#mocFields').slideUp();
|
||||
}
|
||||
});
|
||||
|
||||
// Image preview
|
||||
$('#cover_image').change(function() {
|
||||
const file = this.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
$('#previewImg').attr('src', e.target.result);
|
||||
$('#imagePreview').slideDown();
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
$('#imagePreview').slideUp();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
192
app/templates/sets/list.html
Normal file
192
app/templates/sets/list.html
Normal file
@@ -0,0 +1,192 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}My Sets - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-8">
|
||||
<h1><i class="bi bi-grid"></i> My LEGO Sets</h1>
|
||||
<p class="text-muted">{{ pagination.total }} sets in your collection</p>
|
||||
</div>
|
||||
<div class="col-md-4 text-end">
|
||||
<a href="{{ url_for('sets.add_set') }}" class="btn btn-danger">
|
||||
<i class="bi bi-plus-circle"></i> Add New Set
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filters -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<form method="GET" action="{{ url_for('sets.list_sets') }}" class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label for="search" class="form-label">Search</label>
|
||||
<input type="text" class="form-control" id="search" name="q"
|
||||
value="{{ search_query }}" placeholder="Set number or name...">
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label for="theme" class="form-label">Theme</label>
|
||||
<select class="form-select" id="theme" name="theme">
|
||||
<option value="">All Themes</option>
|
||||
{% for theme in themes %}
|
||||
<option value="{{ theme }}" {% if theme == current_theme %}selected{% endif %}>
|
||||
{{ theme }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<label for="year" class="form-label">Year</label>
|
||||
<select class="form-select" id="year" name="year">
|
||||
<option value="">All Years</option>
|
||||
{% for year in years %}
|
||||
<option value="{{ year }}" {% if year == current_year %}selected{% endif %}>
|
||||
{{ year }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<label for="sort" class="form-label">Sort By</label>
|
||||
<select class="form-select" id="sort" name="sort">
|
||||
<option value="set_number" {% if current_sort == 'set_number' %}selected{% endif %}>Set Number</option>
|
||||
<option value="name" {% if current_sort == 'name' %}selected{% endif %}>Name</option>
|
||||
<option value="theme" {% if current_sort == 'theme' %}selected{% endif %}>Theme</option>
|
||||
<option value="year" {% if current_sort == 'year' %}selected{% endif %}>Year</option>
|
||||
<option value="newest" {% if current_sort == 'newest' %}selected{% endif %}>Newest First</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-1 d-flex align-items-end">
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="bi bi-search"></i>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sets Grid -->
|
||||
{% if sets %}
|
||||
<div class="row">
|
||||
{% for set in sets %}
|
||||
<div class="col-md-6 col-lg-4 col-xl-3 mb-4">
|
||||
<div class="card h-100 shadow-sm">
|
||||
<a href="{{ url_for('sets.view_set', set_id=set.id) }}">
|
||||
{% if set.cover_image %}
|
||||
<img src="{{ url_for('static', filename='uploads/' + set.cover_image.replace('\\', '/')) }}"
|
||||
class="card-img-top set-image" alt="{{ set.set_name }}">
|
||||
{% elif set.image_url %}
|
||||
<img src="{{ set.image_url }}" class="card-img-top set-image" alt="{{ set.set_name }}">
|
||||
{% else %}
|
||||
<div class="card-img-top set-image d-flex align-items-center justify-content-center bg-light">
|
||||
<i class="bi bi-image display-1 text-muted"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
</a>
|
||||
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">
|
||||
<a href="{{ url_for('sets.view_set', set_id=set.id) }}" class="text-decoration-none">
|
||||
{{ set.set_number }}
|
||||
</a>
|
||||
{% if set.is_moc %}
|
||||
<span class="badge bg-warning text-dark" title="My Own Creation">
|
||||
<i class="bi bi-star-fill"></i>
|
||||
</span>
|
||||
{% endif %}
|
||||
</h6>
|
||||
<p class="card-text small text-truncate" title="{{ set.set_name }}">
|
||||
{{ set.set_name }}
|
||||
</p>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<span class="badge bg-primary">{{ set.theme }}</span>
|
||||
<span class="badge bg-warning text-dark">{{ set.year_released }}</span>
|
||||
</div>
|
||||
|
||||
{% if set.piece_count %}
|
||||
<p class="card-text small text-muted mb-2">
|
||||
<i class="bi bi-grid-3x3"></i> {{ set.piece_count }} pieces
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<p class="card-text small">
|
||||
<i class="bi bi-file-pdf"></i> {{ set.instructions.count() }} instruction(s)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card-footer bg-transparent">
|
||||
<div class="btn-group w-100" role="group">
|
||||
<a href="{{ url_for('sets.view_set', set_id=set.id) }}"
|
||||
class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i> View
|
||||
</a>
|
||||
<a href="{{ url_for('sets.edit_set', set_id=set.id) }}"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-pencil"></i> Edit
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if pagination.pages > 1 %}
|
||||
<nav aria-label="Page navigation">
|
||||
<ul class="pagination justify-content-center">
|
||||
<li class="page-item {% if not pagination.has_prev %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('sets.list_sets', page=pagination.prev_num, sort=current_sort, theme=current_theme, year=current_year, q=search_query) }}">
|
||||
Previous
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{% for page_num in pagination.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
|
||||
{% if page_num %}
|
||||
<li class="page-item {% if page_num == pagination.page %}active{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('sets.list_sets', page=page_num, sort=current_sort, theme=current_theme, year=current_year, q=search_query) }}">
|
||||
{{ page_num }}
|
||||
</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled"><span class="page-link">...</span></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<li class="page-item {% if not pagination.has_next %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('sets.list_sets', page=pagination.next_num, sort=current_sort, theme=current_theme, year=current_year, q=search_query) }}">
|
||||
Next
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-inbox display-1 text-muted"></i>
|
||||
<h3 class="mt-3">No Sets Found</h3>
|
||||
<p class="text-muted">
|
||||
{% if search_query or current_theme or current_year %}
|
||||
Try adjusting your filters or search terms.
|
||||
{% else %}
|
||||
Start by adding your first LEGO set or MOC to your collection!
|
||||
{% endif %}
|
||||
</p>
|
||||
<div class="d-flex justify-content-center gap-2">
|
||||
<a href="{{ url_for('sets.add_set') }}" class="btn btn-danger">
|
||||
<i class="bi bi-box-seam"></i> Add Official Set
|
||||
</a>
|
||||
<a href="{{ url_for('sets.add_set') }}?type=moc" class="btn btn-warning">
|
||||
<i class="bi bi-star-fill"></i> Add MOC
|
||||
</a>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user