Initial commit - LEGO Instructions Manager v1.5.0

This commit is contained in:
2025-12-09 17:20:41 +11:00
commit 63496b1ccd
68 changed files with 9131 additions and 0 deletions

78
app/__init__.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,4 @@
from app.services.brickset_api import BricksetAPI
from app.services.file_handler import FileHandler
__all__ = ['BricksetAPI', 'FileHandler']

View 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 []

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

0
app/static/js/.gitkeep Normal file
View File

View 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:&#10;8860&#10;10497&#10;42100&#10;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 %}

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

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

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

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

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

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

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

View 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
View 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 &copy; 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>

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

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

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

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

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

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

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