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

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