Initial commit - LEGO Instructions Manager v1.5.0
This commit is contained in:
260
app/templates/admin/bulk_import.html
Normal file
260
app/templates/admin/bulk_import.html
Normal file
@@ -0,0 +1,260 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Bulk Import Sets - Admin - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h1>
|
||||
<i class="bi bi-cloud-upload"></i> Bulk Import Sets from Brickset
|
||||
</h1>
|
||||
<p class="text-muted">Import multiple official LEGO sets at once using Brickset data</p>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Back to Admin
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not brickset_configured %}
|
||||
<div class="alert alert-warning">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
<strong>Brickset API Not Configured</strong>
|
||||
<p class="mb-0">
|
||||
Please add your Brickset API credentials to the <code>.env</code> file:
|
||||
</p>
|
||||
<pre class="mb-0 mt-2">
|
||||
BRICKSET_API_KEY=your_api_key_here
|
||||
BRICKSET_USERNAME=your_username
|
||||
BRICKSET_PASSWORD=your_password</pre>
|
||||
<p class="mb-0 mt-2">
|
||||
Get your API key at: <a href="https://brickset.com/tools/webservices/requestkey" target="_blank">https://brickset.com/tools/webservices/requestkey</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-list-ol"></i> Import Sets</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('admin.bulk_import') }}">
|
||||
<!-- Set Numbers -->
|
||||
<div class="mb-3">
|
||||
<label for="set_numbers" class="form-label">
|
||||
<strong>Set Numbers</strong>
|
||||
<span class="text-muted">(one per line, or comma/space separated)</span>
|
||||
</label>
|
||||
<textarea class="form-control font-monospace"
|
||||
id="set_numbers"
|
||||
name="set_numbers"
|
||||
rows="10"
|
||||
placeholder="Example: 8860 10497 42100 21318"
|
||||
required
|
||||
{% if not brickset_configured %}disabled{% endif %}></textarea>
|
||||
<small class="form-text text-muted">
|
||||
Enter LEGO set numbers (e.g., 8860, 10497-1, 42100). Variants like -1 are supported.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- User Selection -->
|
||||
<div class="mb-3">
|
||||
<label for="user_id" class="form-label">
|
||||
<strong>Assign to User</strong>
|
||||
</label>
|
||||
<select class="form-select"
|
||||
id="user_id"
|
||||
name="user_id"
|
||||
required
|
||||
{% if not brickset_configured %}disabled{% endif %}>
|
||||
<option value="">Select a user...</option>
|
||||
{% for user in users %}
|
||||
<option value="{{ user.id }}" {% if user.id == current_user.id %}selected{% endif %}>
|
||||
{{ user.username }} ({{ user.email }})
|
||||
{% if user.is_admin %}👑 Admin{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-text text-muted">
|
||||
Sets will be added to this user's collection
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Throttle Delay -->
|
||||
<div class="mb-3">
|
||||
<label for="throttle_delay" class="form-label">
|
||||
<strong>API Throttle Delay</strong>
|
||||
<span class="text-muted">(seconds between requests)</span>
|
||||
</label>
|
||||
<select class="form-select"
|
||||
id="throttle_delay"
|
||||
name="throttle_delay"
|
||||
{% if not brickset_configured %}disabled{% endif %}>
|
||||
<option value="0.3">0.3s - Fast (may hit rate limits)</option>
|
||||
<option value="0.5" selected>0.5s - Balanced (recommended)</option>
|
||||
<option value="1.0">1.0s - Safe (slower but reliable)</option>
|
||||
<option value="2.0">2.0s - Very Safe (for large batches)</option>
|
||||
</select>
|
||||
<small class="form-text text-muted">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
Brickset has API rate limits. Increase delay if you get rate limit errors.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="d-grid gap-2">
|
||||
<button type="submit"
|
||||
class="btn btn-primary btn-lg"
|
||||
{% if not brickset_configured %}disabled{% endif %}>
|
||||
<i class="bi bi-cloud-download"></i>
|
||||
Import Sets from Brickset
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<!-- Info Card -->
|
||||
<div class="card bg-light mb-3">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0"><i class="bi bi-info-circle"></i> How It Works</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ol class="mb-0">
|
||||
<li class="mb-2">Enter set numbers (one per line)</li>
|
||||
<li class="mb-2">Select which user to assign them to</li>
|
||||
<li class="mb-2">Choose throttle delay</li>
|
||||
<li class="mb-2">Click "Import Sets"</li>
|
||||
<li class="mb-2">System fetches data from Brickset</li>
|
||||
<li class="mb-0">Sets are added to database!</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rate Limit Warning -->
|
||||
<div class="card bg-warning text-dark mb-3">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0"><i class="bi bi-exclamation-triangle"></i> API Rate Limits</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-2">
|
||||
<strong>Brickset has API rate limits!</strong>
|
||||
</p>
|
||||
<ul class="mb-0 small">
|
||||
<li class="mb-2">
|
||||
<strong>Recommended:</strong> Import 10-20 sets at a time
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<strong>Throttle:</strong> Use 0.5s-1.0s delay between requests
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<strong>If rate limited:</strong> Wait 5-10 minutes and retry
|
||||
</li>
|
||||
<li class="mb-0">
|
||||
<strong>Large batches:</strong> Split into multiple smaller imports
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- What Gets Imported -->
|
||||
<div class="card bg-info text-white mb-3">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0"><i class="bi bi-box-seam"></i> What Gets Imported</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="mb-0">
|
||||
<li>Set Number</li>
|
||||
<li>Set Name</li>
|
||||
<li>Theme</li>
|
||||
<li>Year Released</li>
|
||||
<li>Piece Count</li>
|
||||
<li>Cover Image (from Brickset)</li>
|
||||
</ul>
|
||||
<hr class="bg-white">
|
||||
<small>
|
||||
<i class="bi bi-lightbulb"></i>
|
||||
You can upload instructions separately later!
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tips -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0"><i class="bi bi-stars"></i> Pro Tips</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="small mb-0">
|
||||
<li class="mb-2">
|
||||
<strong>Start Small:</strong> Try 5-10 sets first to test
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<strong>Duplicates:</strong> Sets already in database will be skipped
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<strong>Not Found:</strong> Invalid set numbers will be reported
|
||||
</li>
|
||||
<li class="mb-0">
|
||||
<strong>Formats:</strong> Works with variants like 10497-1
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Example Sets -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0"><i class="bi bi-clipboard-check"></i> Example Sets You Can Try</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-2">
|
||||
<strong>Technic:</strong><br>
|
||||
<code>8860, 8880, 42100, 42110</code>
|
||||
</div>
|
||||
<div class="col-md-3 mb-2">
|
||||
<strong>Creator Expert:</strong><br>
|
||||
<code>10497, 10294, 10283</code>
|
||||
</div>
|
||||
<div class="col-md-3 mb-2">
|
||||
<strong>Ideas:</strong><br>
|
||||
<code>21318, 21330, 21341</code>
|
||||
</div>
|
||||
<div class="col-md-3 mb-2">
|
||||
<strong>Star Wars:</strong><br>
|
||||
<code>75192, 75313, 75331</code>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="fillExample()">
|
||||
<i class="bi bi-clipboard"></i> Fill Example Sets
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function fillExample() {
|
||||
const examples = `8860
|
||||
8880
|
||||
42100
|
||||
10497
|
||||
10294
|
||||
21318
|
||||
75192`;
|
||||
document.getElementById('set_numbers').value = examples;
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
255
app/templates/admin/bulk_import_results.html
Normal file
255
app/templates/admin/bulk_import_results.html
Normal file
@@ -0,0 +1,255 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Import Results - Admin - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h1>
|
||||
<i class="bi bi-clipboard-check"></i> Bulk Import Results
|
||||
</h1>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a href="{{ url_for('admin.bulk_import') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> Import More Sets
|
||||
</a>
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-speedometer2"></i> Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card bg-success text-white h-100">
|
||||
<div class="card-body text-center">
|
||||
<h1 class="display-3">{{ results.success|length }}</h1>
|
||||
<h5>Successfully Imported</h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card bg-warning text-dark h-100">
|
||||
<div class="card-body text-center">
|
||||
<h1 class="display-3">{{ results.already_exists|length }}</h1>
|
||||
<h5>Already Existed</h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card bg-danger text-white h-100">
|
||||
<div class="card-body text-center">
|
||||
<h1 class="display-3">{{ results.failed|length }}</h1>
|
||||
<h5>Failed to Import</h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card bg-info text-white h-100">
|
||||
<div class="card-body text-center">
|
||||
<h1 class="display-3">{{ results.rate_limited|length }}</h1>
|
||||
<h5>Rate Limited</h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Successful Imports -->
|
||||
{% if results.success %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-success text-white">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-check-circle"></i>
|
||||
Successfully Imported ({{ results.success|length }})
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Set Number</th>
|
||||
<th>Name</th>
|
||||
<th>Theme</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for set in results.success %}
|
||||
<tr>
|
||||
<td><strong>{{ set.set_number }}</strong></td>
|
||||
<td>{{ set.name }}</td>
|
||||
<td><span class="badge bg-primary">{{ set.theme }}</span></td>
|
||||
<td>
|
||||
<!-- We need to find the actual set ID -->
|
||||
<a href="{{ url_for('sets.list_sets') }}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i> View
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Already Exists -->
|
||||
{% if results.already_exists %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-warning text-dark">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
Already in Database ({{ results.already_exists|length }})
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Set Number</th>
|
||||
<th>Name</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for set in results.already_exists %}
|
||||
<tr>
|
||||
<td><strong>{{ set.set_number }}</strong></td>
|
||||
<td>{{ set.name }}</td>
|
||||
<td><span class="badge bg-info">Skipped - Already exists</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Failed Imports -->
|
||||
{% if results.failed %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-danger text-white">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-x-circle"></i>
|
||||
Failed to Import ({{ results.failed|length }})
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Set Number</th>
|
||||
<th>Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for set in results.failed %}
|
||||
<tr>
|
||||
<td><strong>{{ set.set_number }}</strong></td>
|
||||
<td>
|
||||
<span class="badge bg-danger">{{ set.reason }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<small class="text-muted">
|
||||
<i class="bi bi-lightbulb"></i>
|
||||
<strong>Common reasons for failure:</strong>
|
||||
Invalid set number, set doesn't exist in Brickset, or API connection issue.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Rate Limited Sets -->
|
||||
{% if results.rate_limited %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-info text-white">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
Rate Limited ({{ results.rate_limited|length }})
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info mb-3">
|
||||
<h6><i class="bi bi-info-circle"></i> API Rate Limit Reached</h6>
|
||||
<p class="mb-2">
|
||||
Brickset's API has rate limits to prevent abuse. Your import was stopped after
|
||||
{{ results.success|length }} successful import(s) to avoid hitting the limit.
|
||||
</p>
|
||||
<p class="mb-0">
|
||||
<strong>To import these remaining sets:</strong>
|
||||
</p>
|
||||
<ol class="mb-0">
|
||||
<li>Wait 5-10 minutes for the rate limit to reset</li>
|
||||
<li>Use a longer throttle delay (1.0s or 2.0s)</li>
|
||||
<li>Import in smaller batches (10-15 sets at a time)</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Set Number</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for set in results.rate_limited %}
|
||||
<tr>
|
||||
<td><strong>{{ set.set_number }}</strong></td>
|
||||
<td><span class="badge bg-info">{{ set.reason }}</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<strong>Quick Retry:</strong> Copy the set numbers below and try again in a few minutes with a longer delay.
|
||||
<div class="mt-2">
|
||||
<textarea class="form-control font-monospace" rows="3" readonly>{{ results.rate_limited|map(attribute='set_number')|join('\n') }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<h5>What's Next?</h5>
|
||||
<div class="btn-group mt-3" role="group">
|
||||
<a href="{{ url_for('sets.list_sets') }}" class="btn btn-primary">
|
||||
<i class="bi bi-box-seam"></i> View All Sets
|
||||
</a>
|
||||
<a href="{{ url_for('admin.bulk_import') }}" class="btn btn-success">
|
||||
<i class="bi bi-plus-circle"></i> Import More
|
||||
</a>
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-speedometer2"></i> Admin Dashboard
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if results.success %}
|
||||
<div class="mt-3">
|
||||
<p class="text-muted">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
Don't forget to upload instructions for the newly imported sets!
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
284
app/templates/admin/dashboard.html
Normal file
284
app/templates/admin/dashboard.html
Normal file
@@ -0,0 +1,284 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Admin Dashboard - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h1>
|
||||
<i class="bi bi-shield-lock"></i> Admin Dashboard
|
||||
</h1>
|
||||
<p class="text-muted">System overview and management</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Stats -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card bg-primary text-white h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="card-subtitle mb-2 text-white-50">Total Users</h6>
|
||||
<h2 class="mb-0">{{ total_users }}</h2>
|
||||
</div>
|
||||
<i class="bi bi-people display-4 opacity-50"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-primary bg-opacity-75">
|
||||
<a href="{{ url_for('admin.users') }}" class="text-white text-decoration-none">
|
||||
Manage Users <i class="bi bi-arrow-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card bg-success text-white h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="card-subtitle mb-2 text-white-50">Total Sets</h6>
|
||||
<h2 class="mb-0">{{ total_sets }}</h2>
|
||||
</div>
|
||||
<i class="bi bi-box-seam display-4 opacity-50"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-success bg-opacity-75">
|
||||
<a href="{{ url_for('admin.sets') }}" class="text-white text-decoration-none">
|
||||
View All Sets <i class="bi bi-arrow-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card bg-warning text-dark h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="card-subtitle mb-2">MOC Builds</h6>
|
||||
<h2 class="mb-0">{{ total_mocs }}</h2>
|
||||
</div>
|
||||
<i class="bi bi-star-fill display-4 opacity-50"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-warning bg-opacity-75">
|
||||
<a href="{{ url_for('admin.sets', type='mocs') }}" class="text-dark text-decoration-none">
|
||||
View MOCs <i class="bi bi-arrow-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card bg-info text-white h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="card-subtitle mb-2 text-white-50">Instructions</h6>
|
||||
<h2 class="mb-0">{{ total_instructions }}</h2>
|
||||
<small class="text-white-50">{{ total_storage_mb }} MB</small>
|
||||
</div>
|
||||
<i class="bi bi-file-pdf display-4 opacity-50"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-info bg-opacity-75">
|
||||
<span class="text-white">Total Storage Used</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Recent Users -->
|
||||
<div class="col-md-6 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0"><i class="bi bi-person-plus"></i> Recent Users</h5>
|
||||
<a href="{{ url_for('admin.users') }}" class="btn btn-sm btn-outline-primary">View All</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if recent_users %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
<th>Joined</th>
|
||||
<th>Admin</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in recent_users %}
|
||||
<tr>
|
||||
<td>
|
||||
<i class="bi bi-person-circle"></i> {{ user.username }}
|
||||
</td>
|
||||
<td>{{ user.email }}</td>
|
||||
<td>{{ user.created_at.strftime('%Y-%m-%d') }}</td>
|
||||
<td>
|
||||
{% if user.is_admin %}
|
||||
<span class="badge bg-danger">Admin</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">User</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-center text-muted my-4">No users yet</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Contributors -->
|
||||
<div class="col-md-6 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-trophy"></i> Top Contributors</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if top_contributors %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Sets Added</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user, count in top_contributors %}
|
||||
<tr>
|
||||
<td>
|
||||
<i class="bi bi-person-circle"></i> {{ user.username }}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-success">{{ count }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-center text-muted my-4">No data yet</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Popular Themes -->
|
||||
<div class="col-md-6 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-bar-chart"></i> Popular Themes</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if theme_stats %}
|
||||
{% for theme, count in theme_stats %}
|
||||
<div class="mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<span>{{ theme }}</span>
|
||||
<span class="badge bg-primary">{{ count }}</span>
|
||||
</div>
|
||||
<div class="progress" style="height: 20px;">
|
||||
{% set percentage = (count / total_sets * 100) | int %}
|
||||
<div class="progress-bar" role="progressbar"
|
||||
style="width: {{ percentage }}%"
|
||||
aria-valuenow="{{ percentage }}"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100">
|
||||
{{ percentage }}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-center text-muted">No theme data yet</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Sets -->
|
||||
<div class="col-md-6 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0"><i class="bi bi-clock-history"></i> Recently Added Sets</h5>
|
||||
<a href="{{ url_for('admin.sets') }}" class="btn btn-sm btn-outline-primary">View All</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if recent_sets %}
|
||||
<div class="list-group list-group-flush">
|
||||
{% for set in recent_sets[:5] %}
|
||||
<a href="{{ url_for('sets.view_set', set_id=set.id) }}"
|
||||
class="list-group-item list-group-item-action">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong>{{ set.set_number }}</strong>
|
||||
{% if set.is_moc %}
|
||||
<span class="badge bg-warning text-dark ms-1">
|
||||
<i class="bi bi-star-fill"></i>
|
||||
</span>
|
||||
{% endif %}
|
||||
<br>
|
||||
<small class="text-muted">{{ set.set_name }}</small>
|
||||
</div>
|
||||
<span class="badge bg-secondary">{{ set.theme }}</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-center text-muted my-4">No sets yet</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-gear"></i> Quick Actions</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-2">
|
||||
<a href="{{ url_for('admin.users') }}" class="btn btn-outline-primary w-100">
|
||||
<i class="bi bi-people"></i> Manage Users
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-3 mb-2">
|
||||
<a href="{{ url_for('admin.sets') }}" class="btn btn-outline-success w-100">
|
||||
<i class="bi bi-box-seam"></i> Manage Sets
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-3 mb-2">
|
||||
<a href="{{ url_for('admin.bulk_import') }}" class="btn btn-outline-info w-100">
|
||||
<i class="bi bi-cloud-upload"></i> Bulk Import
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-3 mb-2">
|
||||
<a href="{{ url_for('admin.site_settings') }}" class="btn btn-outline-secondary w-100">
|
||||
<i class="bi bi-sliders"></i> Site Settings
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
99
app/templates/admin/sets.html
Normal file
99
app/templates/admin/sets.html
Normal file
@@ -0,0 +1,99 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Set Management - Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h1><i class="bi bi-box-seam"></i> Set Management</h1>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Back
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<form method="GET">
|
||||
<div class="row g-2">
|
||||
<div class="col-md-6">
|
||||
<input type="text" class="form-control" name="search"
|
||||
value="{{ search }}" placeholder="Search sets...">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select class="form-select" name="type">
|
||||
<option value="all" {% if filter_type=='all' %}selected{% endif %}>All Sets</option>
|
||||
<option value="official" {% if filter_type=='official' %}selected{% endif %}>Official Only</option>
|
||||
<option value="mocs" {% if filter_type=='mocs' %}selected{% endif %}>MOCs Only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button class="btn btn-primary w-100" type="submit">
|
||||
<i class="bi bi-search"></i> Filter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
{% if sets %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Set Number</th>
|
||||
<th>Name</th>
|
||||
<th>Theme</th>
|
||||
<th>Year</th>
|
||||
<th>Type</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for set in sets %}
|
||||
<tr>
|
||||
<td><strong>{{ set.set_number }}</strong></td>
|
||||
<td>{{ set.set_name }}</td>
|
||||
<td><span class="badge bg-primary">{{ set.theme }}</span></td>
|
||||
<td>{{ set.year_released }}</td>
|
||||
<td>
|
||||
{% if set.is_moc %}
|
||||
<span class="badge bg-warning text-dark">
|
||||
<i class="bi bi-star-fill"></i> MOC
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Official</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="{{ url_for('sets.view_set', set_id=set.id) }}"
|
||||
class="btn btn-outline-primary">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
<form method="POST" action="{{ url_for('admin.delete_set', set_id=set.id) }}"
|
||||
style="display:inline;"
|
||||
onsubmit="return confirm('Delete {{ set.set_number }}?');">
|
||||
<button type="submit" class="btn btn-outline-danger">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-center text-muted my-4">No sets found</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
63
app/templates/admin/settings.html
Normal file
63
app/templates/admin/settings.html
Normal file
@@ -0,0 +1,63 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Site Settings - Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h1><i class="bi bi-sliders"></i> Site Settings</h1>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Back
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">System Information</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table">
|
||||
<tr>
|
||||
<td>Total Users:</td>
|
||||
<td><strong>{{ stats.total_users }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Total Sets:</td>
|
||||
<td><strong>{{ stats.total_sets }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Total Instructions:</td>
|
||||
<td><strong>{{ stats.total_instructions }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Storage Used:</td>
|
||||
<td><strong>{{ (stats.total_storage / 1024 / 1024) | round(2) }} MB</strong></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Settings</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted">
|
||||
Site settings configuration will be available in future updates.
|
||||
</p>
|
||||
<p>
|
||||
For now, modify settings in <code>config.py</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
211
app/templates/admin/users.html
Normal file
211
app/templates/admin/users.html
Normal file
@@ -0,0 +1,211 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}User Management - Admin - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h1>
|
||||
<i class="bi bi-people"></i> User Management
|
||||
</h1>
|
||||
<p class="text-muted">Manage users and permissions</p>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Back to Admin
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<form method="GET" action="{{ url_for('admin.users') }}">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" name="search"
|
||||
value="{{ search }}" placeholder="Search by username or email...">
|
||||
<button class="btn btn-primary" type="submit">
|
||||
<i class="bi bi-search"></i> Search
|
||||
</button>
|
||||
{% if search %}
|
||||
<a href="{{ url_for('admin.users') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-x"></i> Clear
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Users Table -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-list"></i> Users ({{ pagination.total }})
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if users %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
<th>Joined</th>
|
||||
<th>Sets</th>
|
||||
<th>Instructions</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td>
|
||||
<i class="bi bi-person-circle"></i>
|
||||
<strong>{{ user.username }}</strong>
|
||||
{% if user.id == current_user.id %}
|
||||
<span class="badge bg-info">You</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ user.email }}</td>
|
||||
<td>{{ user.created_at.strftime('%Y-%m-%d') }}</td>
|
||||
<td>
|
||||
<span class="badge bg-success">{{ user_stats[user.id]['sets'] }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-info">{{ user_stats[user.id]['instructions'] }}</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if user.is_admin %}
|
||||
<span class="badge bg-danger">
|
||||
<i class="bi bi-shield-lock"></i> Admin
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">User</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
{% if user.id != current_user.id %}
|
||||
<button class="btn btn-outline-primary toggle-admin-btn"
|
||||
data-user-id="{{ user.id }}"
|
||||
data-username="{{ user.username }}"
|
||||
data-is-admin="{{ user.is_admin|lower }}">
|
||||
<i class="bi bi-shield"></i>
|
||||
{% if user.is_admin %}Revoke{% else %}Grant{% endif %} Admin
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#deleteModal{{ user.id }}">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
<span class="text-muted small">Cannot modify yourself</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Delete Modal -->
|
||||
<div class="modal fade" id="deleteModal{{ user.id }}" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Delete User?</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form method="POST" action="{{ url_for('admin.delete_user', user_id=user.id) }}">
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete <strong>{{ user.username }}</strong>?</p>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox"
|
||||
id="delete_data{{ user.id }}" name="delete_data">
|
||||
<label class="form-check-label" for="delete_data{{ user.id }}">
|
||||
Also delete all their sets and instructions
|
||||
</label>
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
If unchecked, their content will be reassigned to you.
|
||||
</small>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-danger">Delete User</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if pagination.pages > 1 %}
|
||||
<div class="card-footer">
|
||||
<nav>
|
||||
<ul class="pagination justify-content-center mb-0">
|
||||
<li class="page-item {% if not pagination.has_prev %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('admin.users', page=pagination.prev_num, search=search) }}">Previous</a>
|
||||
</li>
|
||||
{% for page_num in pagination.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
|
||||
{% if page_num %}
|
||||
<li class="page-item {% if page_num == pagination.page %}active{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('admin.users', page=page_num, search=search) }}">{{ page_num }}</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled"><span class="page-link">...</span></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<li class="page-item {% if not pagination.has_next %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('admin.users', page=pagination.next_num, search=search) }}">Next</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-inbox display-1 text-muted"></i>
|
||||
<p class="mt-3 text-muted">No users found</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Toggle admin status with AJAX
|
||||
document.querySelectorAll('.toggle-admin-btn').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const userId = this.dataset.userId;
|
||||
const username = this.dataset.username;
|
||||
const isAdmin = this.dataset.isAdmin === 'true';
|
||||
|
||||
if (confirm(`${isAdmin ? 'Revoke' : 'Grant'} admin access for ${username}?`)) {
|
||||
fetch(`/admin/users/${userId}/toggle-admin`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error: ' + data.error);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
alert('Error updating admin status');
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
49
app/templates/auth/login.html
Normal file
49
app/templates/auth/login.html
Normal file
@@ -0,0 +1,49 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Login - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 col-lg-5">
|
||||
<div class="card shadow">
|
||||
<div class="card-body p-5">
|
||||
<h2 class="text-center mb-4">
|
||||
<i class="bi bi-box-arrow-in-right text-danger"></i> Login
|
||||
</h2>
|
||||
|
||||
<form method="POST" action="{{ url_for('auth.login') }}">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input type="text" class="form-control" id="username" name="username" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" class="form-check-input" id="remember" name="remember">
|
||||
<label class="form-check-label" for="remember">
|
||||
Remember me
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-danger btn-lg">
|
||||
<i class="bi bi-box-arrow-in-right"></i> Login
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<p class="text-center text-muted mb-0">
|
||||
Don't have an account?
|
||||
<a href="{{ url_for('auth.register') }}">Register here</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
71
app/templates/auth/profile.html
Normal file
71
app/templates/auth/profile.html
Normal file
@@ -0,0 +1,71 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Profile - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-lg-8 mx-auto">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-danger text-white">
|
||||
<h3 class="mb-0">
|
||||
<i class="bi bi-person-circle"></i> User Profile
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<h5>Account Information</h5>
|
||||
<table class="table table-borderless">
|
||||
<tr>
|
||||
<th>Username:</th>
|
||||
<td>{{ current_user.username }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Email:</th>
|
||||
<td>{{ current_user.email }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Member Since:</th>
|
||||
<td>{{ current_user.created_at.strftime('%B %d, %Y') }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<h5>Statistics</h5>
|
||||
<div class="row text-center">
|
||||
<div class="col-6 mb-3">
|
||||
<div class="card bg-primary text-white">
|
||||
<div class="card-body">
|
||||
<h2 class="mb-0">{{ set_count }}</h2>
|
||||
<small>Sets Added</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 mb-3">
|
||||
<div class="card bg-success text-white">
|
||||
<div class="card-body">
|
||||
<h2 class="mb-0">{{ instruction_count }}</h2>
|
||||
<small>Instructions</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="text-center">
|
||||
<a href="{{ url_for('main.dashboard') }}" class="btn btn-primary">
|
||||
<i class="bi bi-speedometer2"></i> Go to Dashboard
|
||||
</a>
|
||||
<a href="{{ url_for('sets.list_sets') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-grid"></i> View My Sets
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
54
app/templates/auth/register.html
Normal file
54
app/templates/auth/register.html
Normal file
@@ -0,0 +1,54 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Register - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 col-lg-5">
|
||||
<div class="card shadow">
|
||||
<div class="card-body p-5">
|
||||
<h2 class="text-center mb-4">
|
||||
<i class="bi bi-person-plus text-danger"></i> Register
|
||||
</h2>
|
||||
|
||||
<form method="POST" action="{{ url_for('auth.register') }}">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input type="text" class="form-control" id="username" name="username" required autofocus>
|
||||
<div class="form-text">Choose a unique username (letters, numbers, underscore).</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input type="email" class="form-control" id="email" name="email" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
<div class="form-text">Must be at least 6 characters long.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="confirm_password" class="form-label">Confirm Password</label>
|
||||
<input type="password" class="form-control" id="confirm_password" name="confirm_password" required>
|
||||
</div>
|
||||
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-danger btn-lg">
|
||||
<i class="bi bi-person-plus"></i> Create Account
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<p class="text-center text-muted mb-0">
|
||||
Already have an account?
|
||||
<a href="{{ url_for('auth.login') }}">Login here</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
133
app/templates/base.html
Normal file
133
app/templates/base.html
Normal file
@@ -0,0 +1,133 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}{{ app_name }}{% endblock %}</title>
|
||||
|
||||
<!-- Favicons -->
|
||||
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='favicon-32x32.png') }}">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='favicon-16x16.png') }}">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='apple-touch-icon.png') }}">
|
||||
|
||||
<!-- Bootstrap 5 CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
|
||||
<!-- Bootstrap Icons -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||
|
||||
<!-- Custom CSS -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<!-- Navigation -->
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-danger mb-4">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="{{ url_for('main.index') }}">
|
||||
<i class="bi bi-bricks"></i> {{ app_name }}
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
{% if current_user.is_authenticated %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('main.dashboard') }}">
|
||||
<i class="bi bi-speedometer2"></i> Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('sets.list_sets') }}">
|
||||
<i class="bi bi-grid"></i> My Sets
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="addDropdown" role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-plus-circle"></i> Add New
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('sets.add_set') }}">
|
||||
<i class="bi bi-box-seam"></i> Official LEGO Set
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('sets.add_set') }}?type=moc">
|
||||
<i class="bi bi-star-fill text-warning"></i> MOC (Custom Build)
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-person-circle"></i> {{ current_user.username }}
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li><a class="dropdown-item" href="{{ url_for('auth.profile') }}">Profile</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('auth.logout') }}">Logout</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('auth.login') }}">
|
||||
<i class="bi bi-box-arrow-in-right"></i> Login
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('auth.register') }}">
|
||||
<i class="bi bi-person-plus"></i> Register
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
<div class="container">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="mt-5 py-4 bg-light">
|
||||
<div class="container text-center text-muted">
|
||||
<p class="mb-0">
|
||||
<i class="bi bi-bricks"></i> LEGO Instructions Manager © 2024
|
||||
{% if brickset_available %}
|
||||
<span class="badge bg-success ms-2">
|
||||
<i class="bi bi-check-circle"></i> Brickset Connected
|
||||
</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Bootstrap 5 JS Bundle -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<!-- jQuery (for easier AJAX) -->
|
||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
209
app/templates/dashboard.html
Normal file
209
app/templates/dashboard.html
Normal file
@@ -0,0 +1,209 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dashboard - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h1>
|
||||
<i class="bi bi-speedometer2"></i> Dashboard
|
||||
</h1>
|
||||
<p class="text-muted">Welcome back, {{ current_user.username }}!</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6 col-lg-3 mb-3">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-2">Total Sets</h6>
|
||||
<h2 class="mb-0">{{ total_sets }}</h2>
|
||||
</div>
|
||||
<div class="text-primary">
|
||||
<i class="bi bi-box-seam display-4"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 col-lg-3 mb-3">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-2">Instructions</h6>
|
||||
<h2 class="mb-0">{{ total_instructions }}</h2>
|
||||
</div>
|
||||
<div class="text-success">
|
||||
<i class="bi bi-file-pdf display-4"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 col-lg-3 mb-3">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-2">Themes</h6>
|
||||
<h2 class="mb-0">{{ theme_stats|length }}</h2>
|
||||
</div>
|
||||
<div class="text-danger">
|
||||
<i class="bi bi-grid-3x3 display-4"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 col-lg-3 mb-3">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-2">Years Collected</h6>
|
||||
<h2 class="mb-0">{{ year_stats|length }}</h2>
|
||||
</div>
|
||||
<div class="text-warning">
|
||||
<i class="bi bi-calendar-range display-4"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Top Themes -->
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-bar-chart"></i> Top Themes
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if theme_stats %}
|
||||
<ul class="list-group list-group-flush">
|
||||
{% for theme, count in theme_stats %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
{{ theme }}
|
||||
<span class="badge bg-primary rounded-pill">{{ count }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="text-muted">No themes yet. <a href="{{ url_for('sets.add_set') }}">Add your first set!</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Years -->
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-calendar3"></i> Sets by Year
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if year_stats %}
|
||||
<ul class="list-group list-group-flush">
|
||||
{% for year, count in year_stats %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
{{ year }}
|
||||
<span class="badge bg-success rounded-pill">{{ count }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="text-muted">No sets yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Sets -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-clock-history"></i> Recently Added Sets
|
||||
</h5>
|
||||
<a href="{{ url_for('sets.list_sets') }}" class="btn btn-sm btn-outline-primary">
|
||||
View All <i class="bi bi-arrow-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body dashboard">
|
||||
{% if recent_sets %}
|
||||
<div class="row">
|
||||
{% for set in recent_sets %}
|
||||
<div class="col-md-6 col-lg-4 mb-3">
|
||||
<div class="card h-100">
|
||||
<a href="{{ url_for('sets.view_set', set_id=set.id) }}" class="text-decoration-none">
|
||||
{% if set.cover_image %}
|
||||
<img src="{{ url_for('static', filename='uploads/' + set.cover_image.replace('\\', '/')) }}"
|
||||
class="card-img-top set-image" alt="{{ set.set_name }}"
|
||||
style="cursor: pointer;">
|
||||
{% elif set.image_url %}
|
||||
<img src="{{ set.image_url }}" class="card-img-top set-image" alt="{{ set.set_name }}"
|
||||
style="cursor: pointer;">
|
||||
{% else %}
|
||||
<div class="card-img-top set-image d-flex align-items-center justify-content-center bg-light"
|
||||
style="cursor: pointer;">
|
||||
<i class="bi bi-image display-1 text-muted"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
</a>
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">
|
||||
{{ set.set_number }}
|
||||
{% if set.is_moc %}
|
||||
<span class="badge bg-warning text-dark" title="My Own Creation">
|
||||
<i class="bi bi-star-fill"></i>
|
||||
</span>
|
||||
{% endif %}
|
||||
</h6>
|
||||
<p class="card-text small">{{ set.set_name }}</p>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span class="badge badge-theme">{{ set.theme }}</span>
|
||||
<span class="badge badge-year">{{ set.year_released }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent">
|
||||
<a href="{{ url_for('sets.view_set', set_id=set.id) }}" class="btn btn-sm btn-primary w-100">
|
||||
View Details
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-inbox display-1 text-muted"></i>
|
||||
<p class="mt-3 text-muted">No sets in your collection yet.</p>
|
||||
<div class="d-flex justify-content-center gap-2">
|
||||
<a href="{{ url_for('sets.add_set') }}" class="btn btn-danger">
|
||||
<i class="bi bi-box-seam"></i> Add Official Set
|
||||
</a>
|
||||
<a href="{{ url_for('sets.add_set') }}?type=moc" class="btn btn-warning">
|
||||
<i class="bi bi-star-fill"></i> Add MOC
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
162
app/templates/extra_files/upload.html
Normal file
162
app/templates/extra_files/upload.html
Normal file
@@ -0,0 +1,162 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Upload Extra Files - {{ lego_set.set_number }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h1>
|
||||
<i class="bi bi-cloud-upload"></i> Upload Extra Files
|
||||
</h1>
|
||||
<p class="text-muted">
|
||||
{{ lego_set.set_number }}: {{ lego_set.set_name }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a href="{{ url_for('sets.view_set', set_id=lego_set.id) }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Back to Set
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-file-earmark-arrow-up"></i> Upload Files</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
<!-- File Upload -->
|
||||
<div class="mb-3">
|
||||
<label for="files" class="form-label">
|
||||
<strong>Select Files</strong>
|
||||
</label>
|
||||
<input class="form-control"
|
||||
type="file"
|
||||
id="files"
|
||||
name="files"
|
||||
multiple
|
||||
required>
|
||||
<small class="form-text text-muted">
|
||||
Select one or more files to upload. Multiple files can be selected at once.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Category -->
|
||||
<div class="mb-3">
|
||||
<label for="category" class="form-label">
|
||||
<strong>Category</strong>
|
||||
</label>
|
||||
<select class="form-select" id="category" name="category">
|
||||
<option value="auto">Auto-detect from file type</option>
|
||||
<option value="bricklink">BrickLink (XML)</option>
|
||||
<option value="studio">Stud.io Files</option>
|
||||
<option value="ldraw">LDraw Files</option>
|
||||
<option value="ldd">LEGO Digital Designer</option>
|
||||
<option value="box_art">Box Art</option>
|
||||
<option value="photo">Photos</option>
|
||||
<option value="document">Documents</option>
|
||||
<option value="data">Data Files</option>
|
||||
<option value="archive">Archives</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label">
|
||||
<strong>Description</strong> <span class="text-muted">(optional)</span>
|
||||
</label>
|
||||
<textarea class="form-control"
|
||||
id="description"
|
||||
name="description"
|
||||
rows="3"
|
||||
placeholder="Add a description for these files..."></textarea>
|
||||
<small class="form-text text-muted">
|
||||
This description will apply to all uploaded files.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="d-grid gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
<i class="bi bi-cloud-upload"></i> Upload Files
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<!-- Info Card -->
|
||||
<div class="card bg-light mb-3">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0"><i class="bi bi-info-circle"></i> Supported Files</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<strong>Images:</strong>
|
||||
<p class="small mb-2">JPG, PNG, GIF, WebP, BMP, SVG</p>
|
||||
|
||||
<strong>Documents:</strong>
|
||||
<p class="small mb-2">PDF, DOC, DOCX, TXT, RTF</p>
|
||||
|
||||
<strong>Data Files:</strong>
|
||||
<p class="small mb-2">XML, JSON, CSV, XLSX, XLS</p>
|
||||
|
||||
<strong>3D/CAD:</strong>
|
||||
<p class="small mb-2">
|
||||
LDR, MPD (LDraw)<br>
|
||||
IO (Stud.io)<br>
|
||||
LXF, LXFML (LDD)<br>
|
||||
STL, OBJ
|
||||
</p>
|
||||
|
||||
<strong>Archives:</strong>
|
||||
<p class="small mb-0">ZIP, RAR, 7Z, TAR, GZ</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tips Card -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0"><i class="bi bi-lightbulb"></i> Tips</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="small mb-0">
|
||||
<li class="mb-2">
|
||||
<strong>BrickLink XML:</strong> Part lists for ordering
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<strong>Stud.io Files:</strong> Digital building models
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<strong>Box Art:</strong> High-res images of the box
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<strong>Photos:</strong> Your built model pictures
|
||||
</li>
|
||||
<li class="mb-0">
|
||||
<strong>Archives:</strong> Zip multiple files together
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selected Files Preview -->
|
||||
<script>
|
||||
document.getElementById('files').addEventListener('change', function(e) {
|
||||
const files = Array.from(e.target.files);
|
||||
if (files.length > 0) {
|
||||
console.log(`Selected ${files.length} file(s):`);
|
||||
files.forEach(file => {
|
||||
console.log(`- ${file.name} (${(file.size / 1024).toFixed(1)} KB)`);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
112
app/templates/index.html
Normal file
112
app/templates/index.html
Normal file
@@ -0,0 +1,112 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Home - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-lg-8 mx-auto text-center">
|
||||
<div class="py-5">
|
||||
<h1 class="display-3 mb-4">
|
||||
<i class="bi bi-bricks text-danger"></i> LEGO Instructions Manager
|
||||
</h1>
|
||||
<p class="lead mb-5">
|
||||
Organize, manage, and access all your LEGO instruction manuals in one place.
|
||||
Upload PDFs and images, search by theme, set number, or year, and integrate with Brickset for automatic set details.
|
||||
</p>
|
||||
|
||||
{% if not current_user.is_authenticated %}
|
||||
<div class="d-grid gap-2 d-sm-flex justify-content-sm-center">
|
||||
<a href="{{ url_for('auth.register') }}" class="btn btn-danger btn-lg px-4 gap-3">
|
||||
<i class="bi bi-person-plus"></i> Get Started
|
||||
</a>
|
||||
<a href="{{ url_for('auth.login') }}" class="btn btn-outline-secondary btn-lg px-4">
|
||||
<i class="bi bi-box-arrow-in-right"></i> Login
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="d-grid gap-2 d-sm-flex justify-content-sm-center">
|
||||
<a href="{{ url_for('main.dashboard') }}" class="btn btn-danger btn-lg px-4 gap-3">
|
||||
<i class="bi bi-speedometer2"></i> Go to Dashboard
|
||||
</a>
|
||||
<div class="btn-group">
|
||||
<a href="{{ url_for('sets.add_set') }}" class="btn btn-outline-secondary btn-lg px-4">
|
||||
<i class="bi bi-box-seam"></i> Add Official Set
|
||||
</a>
|
||||
<a href="{{ url_for('sets.add_set') }}?type=moc" class="btn btn-outline-warning btn-lg px-4">
|
||||
<i class="bi bi-star-fill"></i> Add MOC
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-5">
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="card h-100 shadow-sm">
|
||||
<div class="card-body text-center">
|
||||
<i class="bi bi-cloud-upload display-1 text-primary mb-3"></i>
|
||||
<h3 class="card-title">Upload & Organize</h3>
|
||||
<p class="card-text">
|
||||
Upload instruction PDFs and images for your LEGO sets. Keep everything organized by theme, year, and set number.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="card h-100 shadow-sm">
|
||||
<div class="card-body text-center">
|
||||
<i class="bi bi-search display-1 text-success mb-3"></i>
|
||||
<h3 class="card-title">Easy Search</h3>
|
||||
<p class="card-text">
|
||||
Quickly find any instruction manual using powerful search and filtering. Sort by theme, year, or set number.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="card h-100 shadow-sm">
|
||||
<div class="card-body text-center">
|
||||
<i class="bi bi-link-45deg display-1 text-danger mb-3"></i>
|
||||
<h3 class="card-title">Brickset Integration</h3>
|
||||
<p class="card-text">
|
||||
Connect with Brickset API to automatically populate set details and access official instructions when available.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-5">
|
||||
<div class="col-lg-10 mx-auto">
|
||||
<div class="card shadow">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title mb-4">
|
||||
<i class="bi bi-info-circle"></i> Features
|
||||
</h3>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<ul class="list-unstyled">
|
||||
<li class="mb-2"><i class="bi bi-check-circle-fill text-success"></i> Upload PDF and image instructions</li>
|
||||
<li class="mb-2"><i class="bi bi-check-circle-fill text-success"></i> Organize by theme and year</li>
|
||||
<li class="mb-2"><i class="bi bi-check-circle-fill text-success"></i> Search and filter capabilities</li>
|
||||
<li class="mb-2"><i class="bi bi-check-circle-fill text-success"></i> User authentication & profiles</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<ul class="list-unstyled">
|
||||
<li class="mb-2"><i class="bi bi-check-circle-fill text-success"></i> Brickset API integration</li>
|
||||
<li class="mb-2"><i class="bi bi-check-circle-fill text-success"></i> Automatic set detail population</li>
|
||||
<li class="mb-2"><i class="bi bi-check-circle-fill text-success"></i> Image gallery view</li>
|
||||
<li class="mb-2"><i class="bi bi-check-circle-fill text-success"></i> Responsive design</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
244
app/templates/instructions/upload.html
Normal file
244
app/templates/instructions/upload.html
Normal file
@@ -0,0 +1,244 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Upload Instructions - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav aria-label="breadcrumb" class="mb-4">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('main.dashboard') }}">Dashboard</a></li>
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('sets.list_sets') }}">Sets</a></li>
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('sets.view_set', set_id=set.id) }}">{{ set.set_number }}</a></li>
|
||||
<li class="breadcrumb-item active">Upload Instructions</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8 mx-auto">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-success text-white">
|
||||
<h3 class="mb-0">
|
||||
<i class="bi bi-cloud-upload"></i> Upload Instructions
|
||||
</h3>
|
||||
<p class="mb-0 mt-2">{{ set.set_number }}: {{ set.set_name }}</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Set Info -->
|
||||
<div class="alert alert-info">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<h5 class="alert-heading">{{ set.set_name }}</h5>
|
||||
<p class="mb-0">
|
||||
<strong>Set:</strong> {{ set.set_number }} |
|
||||
<strong>Theme:</strong> {{ set.theme }} |
|
||||
<strong>Year:</strong> {{ set.year_released }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-md-4 text-end">
|
||||
<p class="mb-0">
|
||||
<strong>Current Instructions:</strong><br>
|
||||
{{ set.instructions.count() }} file(s)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Form -->
|
||||
<form method="POST" action="{{ url_for('instructions.upload', set_id=set.id) }}"
|
||||
enctype="multipart/form-data" id="uploadForm">
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="files" class="form-label">
|
||||
<i class="bi bi-file-earmark-arrow-up"></i> Select Files
|
||||
</label>
|
||||
<input type="file" class="form-control" id="files" name="files[]"
|
||||
multiple accept=".pdf,.png,.jpg,.jpeg,.gif" required>
|
||||
<div class="form-text">
|
||||
Accepted formats: PDF, PNG, JPG, JPEG, GIF (Max 50MB per file)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Drag and Drop Area -->
|
||||
<div class="upload-area mb-4" id="dropZone">
|
||||
<i class="bi bi-cloud-upload display-1 text-muted"></i>
|
||||
<h4 class="mt-3">Drag & Drop Files Here</h4>
|
||||
<p class="text-muted">or click to browse</p>
|
||||
<p class="small text-muted mb-0">
|
||||
<i class="bi bi-info-circle"></i> You can upload multiple files at once
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- File Preview -->
|
||||
<div id="filePreview" class="mb-4" style="display: none;">
|
||||
<h6>Selected Files:</h6>
|
||||
<ul id="fileList" class="list-group"></ul>
|
||||
</div>
|
||||
|
||||
<!-- Upload Instructions -->
|
||||
<div class="alert alert-light">
|
||||
<h6><i class="bi bi-info-circle"></i> Upload Tips:</h6>
|
||||
<ul class="mb-0">
|
||||
<li><strong>PDFs:</strong> Upload complete instruction manuals as single PDF files</li>
|
||||
<li><strong>Images:</strong> Upload individual pages as separate images (they will be numbered automatically)</li>
|
||||
<li><strong>Quality:</strong> Higher resolution images provide better viewing experience</li>
|
||||
<li><strong>Organization:</strong> Files are automatically organized by set number</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{{ url_for('sets.view_set', set_id=set.id) }}" class="btn btn-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-success btn-lg" id="uploadBtn">
|
||||
<i class="bi bi-cloud-upload"></i> Upload Files
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current Instructions Summary -->
|
||||
{% if set.instructions.count() > 0 %}
|
||||
<div class="card shadow mt-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-file-earmark-text"></i> Current Instructions</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6>PDFs: {{ set.pdf_instructions|length }}</h6>
|
||||
{% if set.pdf_instructions %}
|
||||
<ul class="list-unstyled">
|
||||
{% for pdf in set.pdf_instructions %}
|
||||
<li><i class="bi bi-file-pdf text-danger"></i> {{ pdf.file_name }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="text-muted">No PDFs uploaded yet</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6>Images: {{ set.image_instructions|length }}</h6>
|
||||
{% if set.image_instructions %}
|
||||
<p class="text-muted">{{ set.image_instructions|length }} page(s)</p>
|
||||
{% else %}
|
||||
<p class="text-muted">No images uploaded yet</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
const dropZone = $('#dropZone');
|
||||
const fileInput = $('#files');
|
||||
const filePreview = $('#filePreview');
|
||||
const fileList = $('#fileList');
|
||||
|
||||
// Click on drop zone to trigger file input
|
||||
dropZone.on('click', function() {
|
||||
fileInput.click();
|
||||
});
|
||||
|
||||
// Prevent default drag behaviors
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||
dropZone.on(eventName, function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
});
|
||||
});
|
||||
|
||||
// Highlight drop zone when dragging over
|
||||
['dragenter', 'dragover'].forEach(eventName => {
|
||||
dropZone.on(eventName, function() {
|
||||
dropZone.addClass('dragover');
|
||||
});
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(eventName => {
|
||||
dropZone.on(eventName, function() {
|
||||
dropZone.removeClass('dragover');
|
||||
});
|
||||
});
|
||||
|
||||
// Handle dropped files
|
||||
dropZone.on('drop', function(e) {
|
||||
const files = e.originalEvent.dataTransfer.files;
|
||||
fileInput[0].files = files;
|
||||
displayFiles(files);
|
||||
});
|
||||
|
||||
// Handle selected files
|
||||
fileInput.on('change', function() {
|
||||
displayFiles(this.files);
|
||||
});
|
||||
|
||||
// Display selected files
|
||||
function displayFiles(files) {
|
||||
fileList.empty();
|
||||
|
||||
if (files.length === 0) {
|
||||
filePreview.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
filePreview.show();
|
||||
|
||||
Array.from(files).forEach(file => {
|
||||
const fileSize = (file.size / 1024 / 1024).toFixed(2);
|
||||
const fileType = file.type.includes('pdf') ? 'file-pdf' : 'file-image';
|
||||
const fileColor = file.type.includes('pdf') ? 'text-danger' : 'text-primary';
|
||||
|
||||
const listItem = $(`
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<i class="bi bi-${fileType} ${fileColor}"></i>
|
||||
<strong>${file.name}</strong>
|
||||
</div>
|
||||
<span class="badge bg-secondary">${fileSize} MB</span>
|
||||
</li>
|
||||
`);
|
||||
|
||||
fileList.append(listItem);
|
||||
});
|
||||
}
|
||||
|
||||
// Upload progress
|
||||
$('#uploadForm').on('submit', function() {
|
||||
$('#uploadBtn').html('<span class="spinner-border spinner-border-sm" role="status"></span> Uploading...');
|
||||
$('#uploadBtn').prop('disabled', true);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.upload-area {
|
||||
border: 2px dashed #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
padding: 3rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.upload-area:hover {
|
||||
border-color: #198754;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.upload-area.dragover {
|
||||
border-color: #198754;
|
||||
background-color: #d1e7dd;
|
||||
border-style: solid;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
380
app/templates/instructions/viewer.html
Normal file
380
app/templates/instructions/viewer.html
Normal file
@@ -0,0 +1,380 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Instructions Viewer - {{ set.set_number }}: {{ set.set_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
body {
|
||||
background-color: #2b2b2b;
|
||||
}
|
||||
|
||||
.viewer-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.viewer-header {
|
||||
background-color: #1a1a1a;
|
||||
color: white;
|
||||
padding: 15px 20px;
|
||||
border-radius: 8px 8px 0 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.viewer-content {
|
||||
background-color: #3a3a3a;
|
||||
padding: 20px;
|
||||
border-radius: 0 0 8px 8px;
|
||||
min-height: calc(100vh - 250px);
|
||||
}
|
||||
|
||||
.instruction-page {
|
||||
background-color: white;
|
||||
margin: 0 auto 20px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.instruction-page img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.page-number-badge {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 5px 15px;
|
||||
border-radius: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.viewer-controls {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: rgba(0, 0, 0, 0.9);
|
||||
padding: 15px 30px;
|
||||
border-radius: 50px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.viewer-controls button {
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
.fullscreen-mode .viewer-container {
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.zoom-container {
|
||||
position: relative;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.zoom-container.zoomed {
|
||||
cursor: zoom-out;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.zoom-container.zoomed img {
|
||||
cursor: grab;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.zoom-container.zoomed img:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.nav-footer {
|
||||
background-color: #1a1a1a;
|
||||
color: white;
|
||||
padding: 15px;
|
||||
text-align: center;
|
||||
border-radius: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="viewer-container">
|
||||
<div class="viewer-header">
|
||||
<div>
|
||||
<h4 class="mb-0">
|
||||
<i class="bi bi-book"></i> {{ set.set_number }}: {{ set.set_name }}
|
||||
{% if set.is_moc %}
|
||||
<span class="badge bg-warning text-dark ms-2">
|
||||
<i class="bi bi-star-fill"></i> MOC
|
||||
</span>
|
||||
{% endif %}
|
||||
</h4>
|
||||
<small class="text-muted">{{ images|length }} page(s)</small>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-outline-light" id="toggleFullscreen">
|
||||
<i class="bi bi-arrows-fullscreen"></i> Fullscreen
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-light" id="zoomToggle">
|
||||
<i class="bi bi-zoom-in"></i> Zoom
|
||||
</button>
|
||||
<a href="{{ url_for('sets.view_set', set_id=set.id) }}" class="btn btn-sm btn-outline-light">
|
||||
<i class="bi bi-x-lg"></i> Close
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="viewer-content">
|
||||
<!-- Continuous Scroll Mode -->
|
||||
<div id="continuousViewer">
|
||||
{% for image in images %}
|
||||
<div class="instruction-page zoom-container" id="page-{{ image.page_number }}" data-page="{{ image.page_number }}">
|
||||
<div class="position-relative">
|
||||
<span class="page-number-badge">Page {{ image.page_number }} / {{ images|length }}</span>
|
||||
<img src="{{ url_for('static', filename='uploads/' + image.file_path.replace('\\', '/')) }}"
|
||||
alt="Page {{ image.page_number }}"
|
||||
class="instruction-image"
|
||||
loading="lazy">
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav-footer">
|
||||
<p class="mb-2">
|
||||
<strong>Navigation Tips:</strong>
|
||||
Scroll to view all pages • Click image to zoom •
|
||||
Use arrow keys for quick navigation •
|
||||
Press F for fullscreen
|
||||
</p>
|
||||
<div>
|
||||
<a href="{{ url_for('sets.view_set', set_id=set.id) }}" class="btn btn-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Back to Set
|
||||
</a>
|
||||
<a href="{{ url_for('instructions.upload', set_id=set.id) }}" class="btn btn-success">
|
||||
<i class="bi bi-plus-circle"></i> Add More Pages
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Floating Controls -->
|
||||
<div class="viewer-controls">
|
||||
<button class="btn btn-light btn-sm" id="prevPage" title="Previous Page">
|
||||
<i class="bi bi-chevron-up"></i>
|
||||
</button>
|
||||
<span class="text-white mx-3" id="currentPageDisplay">Page 1 / {{ images|length }}</span>
|
||||
<button class="btn btn-light btn-sm" id="nextPage" title="Next Page">
|
||||
<i class="bi bi-chevron-down"></i>
|
||||
</button>
|
||||
<span class="text-white mx-3">|</span>
|
||||
<button class="btn btn-light btn-sm" id="scrollToTop" title="Scroll to Top">
|
||||
<i class="bi bi-arrow-up"></i>
|
||||
</button>
|
||||
<button class="btn btn-light btn-sm" id="scrollToBottom" title="Scroll to Bottom">
|
||||
<i class="bi bi-arrow-down"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
let currentPage = 1;
|
||||
const totalPages = {{ images|length }};
|
||||
let isZoomed = false;
|
||||
let zoomLevel = 1;
|
||||
|
||||
// Update current page based on scroll position
|
||||
function updateCurrentPage() {
|
||||
const scrollTop = $(window).scrollTop();
|
||||
const windowHeight = $(window).height();
|
||||
const scrollMiddle = scrollTop + windowHeight / 2;
|
||||
|
||||
$('.instruction-page').each(function() {
|
||||
const pageTop = $(this).offset().top;
|
||||
const pageBottom = pageTop + $(this).outerHeight();
|
||||
|
||||
if (scrollMiddle >= pageTop && scrollMiddle <= pageBottom) {
|
||||
currentPage = parseInt($(this).data('page'));
|
||||
$('#currentPageDisplay').text(`Page ${currentPage} / ${totalPages}`);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Scroll to specific page
|
||||
function scrollToPage(pageNum) {
|
||||
const targetPage = $(`#page-${pageNum}`);
|
||||
if (targetPage.length) {
|
||||
$('html, body').animate({
|
||||
scrollTop: targetPage.offset().top - 100
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation buttons
|
||||
$('#nextPage').click(function() {
|
||||
if (currentPage < totalPages) {
|
||||
scrollToPage(currentPage + 1);
|
||||
}
|
||||
});
|
||||
|
||||
$('#prevPage').click(function() {
|
||||
if (currentPage > 1) {
|
||||
scrollToPage(currentPage - 1);
|
||||
}
|
||||
});
|
||||
|
||||
$('#scrollToTop').click(function() {
|
||||
$('html, body').animate({ scrollTop: 0 }, 500);
|
||||
});
|
||||
|
||||
$('#scrollToBottom').click(function() {
|
||||
$('html, body').animate({
|
||||
scrollTop: $(document).height()
|
||||
}, 500);
|
||||
});
|
||||
|
||||
// Update page on scroll
|
||||
$(window).scroll(function() {
|
||||
updateCurrentPage();
|
||||
});
|
||||
|
||||
// Keyboard navigation
|
||||
$(document).keydown(function(e) {
|
||||
switch(e.which) {
|
||||
case 38: // up arrow
|
||||
case 33: // page up
|
||||
e.preventDefault();
|
||||
$('#prevPage').click();
|
||||
break;
|
||||
case 40: // down arrow
|
||||
case 34: // page down
|
||||
e.preventDefault();
|
||||
$('#nextPage').click();
|
||||
break;
|
||||
case 36: // home
|
||||
e.preventDefault();
|
||||
$('#scrollToTop').click();
|
||||
break;
|
||||
case 35: // end
|
||||
e.preventDefault();
|
||||
$('#scrollToBottom').click();
|
||||
break;
|
||||
case 70: // F key for fullscreen
|
||||
e.preventDefault();
|
||||
$('#toggleFullscreen').click();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Fullscreen toggle
|
||||
$('#toggleFullscreen').click(function() {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen();
|
||||
$('body').addClass('fullscreen-mode');
|
||||
$(this).html('<i class="bi bi-fullscreen-exit"></i> Exit Fullscreen');
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
$('body').removeClass('fullscreen-mode');
|
||||
$(this).html('<i class="bi bi-arrows-fullscreen"></i> Fullscreen');
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for fullscreen changes
|
||||
document.addEventListener('fullscreenchange', function() {
|
||||
if (!document.fullscreenElement) {
|
||||
$('body').removeClass('fullscreen-mode');
|
||||
$('#toggleFullscreen').html('<i class="bi bi-arrows-fullscreen"></i> Fullscreen');
|
||||
}
|
||||
});
|
||||
|
||||
// Zoom functionality
|
||||
$('#zoomToggle').click(function() {
|
||||
isZoomed = !isZoomed;
|
||||
if (isZoomed) {
|
||||
$(this).html('<i class="bi bi-zoom-out"></i> Zoom Out');
|
||||
$('.instruction-image').css({
|
||||
'transform': 'scale(1.5)',
|
||||
'transition': 'transform 0.3s'
|
||||
});
|
||||
} else {
|
||||
$(this).html('<i class="bi bi-zoom-in"></i> Zoom In');
|
||||
$('.instruction-image').css({
|
||||
'transform': 'scale(1)',
|
||||
'transition': 'transform 0.3s'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Click to zoom individual images
|
||||
$('.zoom-container').click(function(e) {
|
||||
const $img = $(this).find('img');
|
||||
const $container = $(this);
|
||||
|
||||
if ($container.hasClass('zoomed')) {
|
||||
$img.css('transform', 'scale(1)');
|
||||
$container.removeClass('zoomed');
|
||||
} else {
|
||||
$img.css('transform', 'scale(2)');
|
||||
$container.addClass('zoomed');
|
||||
}
|
||||
});
|
||||
|
||||
// Pan zoomed image with mouse drag
|
||||
let isDragging = false;
|
||||
let startX, startY, scrollLeft, scrollTop;
|
||||
|
||||
$('.zoom-container').on('mousedown', function(e) {
|
||||
if (!$(this).hasClass('zoomed')) return;
|
||||
|
||||
isDragging = true;
|
||||
startX = e.pageX - $(this).offset().left;
|
||||
startY = e.pageY - $(this).offset().top;
|
||||
scrollLeft = $(this).scrollLeft();
|
||||
scrollTop = $(this).scrollTop();
|
||||
$(this).css('cursor', 'grabbing');
|
||||
});
|
||||
|
||||
$('.zoom-container').on('mouseup mouseleave', function() {
|
||||
isDragging = false;
|
||||
if ($(this).hasClass('zoomed')) {
|
||||
$(this).css('cursor', 'zoom-out');
|
||||
}
|
||||
});
|
||||
|
||||
$('.zoom-container').on('mousemove', function(e) {
|
||||
if (!isDragging) return;
|
||||
e.preventDefault();
|
||||
|
||||
const x = e.pageX - $(this).offset().left;
|
||||
const y = e.pageY - $(this).offset().top;
|
||||
const walkX = (x - startX) * 2;
|
||||
const walkY = (y - startY) * 2;
|
||||
|
||||
$(this).scrollLeft(scrollLeft - walkX);
|
||||
$(this).scrollTop(scrollTop - walkY);
|
||||
});
|
||||
|
||||
// Initialize
|
||||
updateCurrentPage();
|
||||
|
||||
// Smooth scroll for all pages loaded
|
||||
console.log('Image viewer initialized with', totalPages, 'pages');
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
324
app/templates/sets/add.html
Normal file
324
app/templates/sets/add.html
Normal file
@@ -0,0 +1,324 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Add Set - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-lg-8 mx-auto">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-danger text-white">
|
||||
<h3 class="mb-0">
|
||||
<i class="bi bi-plus-circle"></i> Add New LEGO Set or MOC
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Set Type Selection -->
|
||||
<div class="mb-4 p-4 bg-light rounded border">
|
||||
<h5 class="mb-3"><i class="bi bi-question-circle"></i> What are you adding?</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3 mb-md-0">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="set_type" id="type_official" value="official"
|
||||
{% if request.args.get('type') != 'moc' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="type_official">
|
||||
<strong><i class="bi bi-box-seam"></i> Official LEGO Set</strong>
|
||||
<br>
|
||||
<small class="text-muted">A set produced by LEGO with an official set number</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="set_type" id="type_moc" value="moc"
|
||||
{% if request.args.get('type') == 'moc' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="type_moc">
|
||||
<strong><i class="bi bi-star-fill text-warning"></i> MOC (My Own Creation)</strong>
|
||||
<br>
|
||||
<small class="text-muted">A custom build designed by you or another builder</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if brickset_available %}
|
||||
<!-- Brickset Search - Only for official sets -->
|
||||
<div id="bricksetSection" class="mb-4 p-3 bg-light rounded">
|
||||
<h5><i class="bi bi-search"></i> Search Brickset</h5>
|
||||
<p class="text-muted small mb-3">Search for a set to auto-populate details</p>
|
||||
<div class="input-group">
|
||||
<input type="text" id="bricksetSearch" class="form-control"
|
||||
placeholder="Enter set number or name...">
|
||||
<button class="btn btn-primary" type="button" id="searchBtn">
|
||||
<i class="bi bi-search"></i> Search
|
||||
</button>
|
||||
</div>
|
||||
<div id="searchResults" class="mt-3"></div>
|
||||
</div>
|
||||
<hr id="bricksetDivider">
|
||||
{% endif %}
|
||||
|
||||
<!-- Manual Entry Form -->
|
||||
<form method="POST" action="{{ url_for('sets.add_set') }}" enctype="multipart/form-data">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="set_number" class="form-label">
|
||||
Set Number <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="text" class="form-control" id="set_number"
|
||||
name="set_number" required placeholder="e.g., 10497">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="year_released" class="form-label">
|
||||
Year Released <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="number" class="form-control" id="year_released"
|
||||
name="year_released" required min="1949" max="2030"
|
||||
placeholder="e.g., 2024">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="set_name" class="form-label">
|
||||
Set Name <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="text" class="form-control" id="set_name"
|
||||
name="set_name" required placeholder="e.g., Galaxy Explorer">
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="theme" class="form-label">
|
||||
Theme <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="text" class="form-control" id="theme"
|
||||
name="theme" required placeholder="e.g., Space">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="piece_count" class="form-label">
|
||||
Piece Count
|
||||
</label>
|
||||
<input type="number" class="form-control" id="piece_count"
|
||||
name="piece_count" placeholder="e.g., 1254">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="image_url" class="form-label">
|
||||
Image URL (optional)
|
||||
</label>
|
||||
<input type="url" class="form-control" id="image_url"
|
||||
name="image_url" placeholder="https://...">
|
||||
<div class="form-text">Enter a URL to an image of the set (e.g., from Brickset)</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="cover_image" class="form-label">
|
||||
<i class="bi bi-upload"></i> Upload Cover Picture
|
||||
</label>
|
||||
<input type="file" class="form-control" id="cover_image"
|
||||
name="cover_image" accept="image/*">
|
||||
<div class="form-text">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
Upload your own photo of the set or MOC (JPG, PNG, GIF). Max 800px, optimized automatically.
|
||||
</div>
|
||||
<div id="imagePreview" class="mt-2" style="display: none;">
|
||||
<img id="previewImg" src="" alt="Preview" style="max-width: 200px; max-height: 200px; border-radius: 8px;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MOC (My Own Creation) Section -->
|
||||
<div class="card mb-3 border-warning" id="mocSection" style="display: none;">
|
||||
<div class="card-header bg-warning bg-opacity-25">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-star-fill text-warning"></i> MOC Information
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info mb-3">
|
||||
<small>
|
||||
<i class="bi bi-info-circle"></i>
|
||||
<strong>MOC Tips:</strong>
|
||||
<ul class="mb-0 mt-2">
|
||||
<li>Use any set number format (e.g., MOC-001, CUSTOM-2024, MYBUILD-01)</li>
|
||||
<li>Credit yourself or the original designer</li>
|
||||
<li>Add notes about techniques, inspiration, or building tips</li>
|
||||
</ul>
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<input type="hidden" id="is_moc" name="is_moc" value="off">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="moc_designer" class="form-label">
|
||||
<i class="bi bi-person"></i> Designer / Creator Name <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="text" class="form-control" id="moc_designer"
|
||||
name="moc_designer" placeholder="e.g., Your Name or Original Designer">
|
||||
<div class="form-text">Who designed this MOC?</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="moc_description" class="form-label">
|
||||
<i class="bi bi-card-text"></i> Description / Build Notes
|
||||
</label>
|
||||
<textarea class="form-control" id="moc_description" name="moc_description"
|
||||
rows="4" placeholder="Add details about your MOC, building techniques, inspiration, special features, etc."></textarea>
|
||||
<div class="form-text">Share details about your custom creation</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{{ url_for('sets.list_sets') }}" class="btn btn-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-danger btn-lg">
|
||||
<i class="bi bi-plus-circle"></i> Add Set
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
{% if brickset_available %}
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
$('#searchBtn').click(function() {
|
||||
const query = $('#bricksetSearch').val().trim();
|
||||
if (!query) {
|
||||
alert('Please enter a search term');
|
||||
return;
|
||||
}
|
||||
|
||||
$('#searchResults').html('<div class="text-center"><div class="spinner-border text-primary" role="status"></div></div>');
|
||||
|
||||
$.ajax({
|
||||
url: '{{ url_for("sets.search_brickset") }}',
|
||||
data: { q: query },
|
||||
success: function(data) {
|
||||
if (data.length === 0) {
|
||||
$('#searchResults').html('<div class="alert alert-info">No results found</div>');
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="list-group">';
|
||||
data.forEach(function(set) {
|
||||
html += `
|
||||
<a href="#" class="list-group-item list-group-item-action search-result"
|
||||
data-number="${set.setNumber}"
|
||||
data-name="${set.name}"
|
||||
data-theme="${set.theme}"
|
||||
data-year="${set.year}"
|
||||
data-pieces="${set.pieces || ''}"
|
||||
data-image="${set.imageUrl || ''}">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h6 class="mb-1">${set.setNumber}: ${set.name}</h6>
|
||||
<small>${set.year}</small>
|
||||
</div>
|
||||
<small class="text-muted">${set.theme} - ${set.pieces || 'Unknown'} pieces</small>
|
||||
</a>
|
||||
`;
|
||||
});
|
||||
html += '</div>';
|
||||
$('#searchResults').html(html);
|
||||
|
||||
// Handle click on search result
|
||||
$('.search-result').click(function(e) {
|
||||
e.preventDefault();
|
||||
$('#set_number').val($(this).data('number'));
|
||||
$('#set_name').val($(this).data('name'));
|
||||
$('#theme').val($(this).data('theme'));
|
||||
$('#year_released').val($(this).data('year'));
|
||||
$('#piece_count').val($(this).data('pieces'));
|
||||
$('#image_url').val($(this).data('image'));
|
||||
$('#searchResults').html('<div class="alert alert-success">Form populated! Review and submit.</div>');
|
||||
});
|
||||
},
|
||||
error: function() {
|
||||
$('#searchResults').html('<div class="alert alert-danger">Search failed. Please try again.</div>');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Allow enter key to search
|
||||
$('#bricksetSearch').keypress(function(e) {
|
||||
if (e.which === 13) {
|
||||
e.preventDefault();
|
||||
$('#searchBtn').click();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// Handle set type selection (Official vs MOC)
|
||||
$('input[name="set_type"]').change(function() {
|
||||
const isMoc = $('#type_moc').is(':checked');
|
||||
|
||||
if (isMoc) {
|
||||
// Show MOC section, hide Brickset
|
||||
$('#mocSection').slideDown();
|
||||
$('#is_moc').val('on');
|
||||
$('#bricksetSection').slideUp();
|
||||
$('#bricksetDivider').hide();
|
||||
|
||||
// Clear Brickset populated fields (they might not apply to MOCs)
|
||||
$('#image_url').val('');
|
||||
|
||||
// Update placeholder text for MOC context
|
||||
$('#set_number').attr('placeholder', 'e.g., MOC-001, CUSTOM-2024');
|
||||
$('#theme').attr('placeholder', 'e.g., Custom, Space MOCs, My Creations');
|
||||
|
||||
} else {
|
||||
// Show Brickset, hide MOC section
|
||||
$('#mocSection').slideUp();
|
||||
$('#is_moc').val('off');
|
||||
$('#bricksetSection').slideDown();
|
||||
$('#bricksetDivider').show();
|
||||
|
||||
// Clear MOC fields
|
||||
$('#moc_designer').val('');
|
||||
$('#moc_description').val('');
|
||||
|
||||
// Reset placeholder text for official sets
|
||||
$('#set_number').attr('placeholder', 'e.g., 10497');
|
||||
$('#theme').attr('placeholder', 'e.g., Space');
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize based on current selection (including URL parameter)
|
||||
const selectedType = $('input[name="set_type"]:checked').val();
|
||||
if (selectedType === 'moc') {
|
||||
$('#type_moc').trigger('change');
|
||||
} else {
|
||||
$('#type_official').trigger('change');
|
||||
}
|
||||
|
||||
// Image preview
|
||||
$('#cover_image').change(function() {
|
||||
const file = this.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
$('#previewImg').attr('src', e.target.result);
|
||||
$('#imagePreview').slideDown();
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
$('#imagePreview').slideUp();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
415
app/templates/sets/detail.html
Normal file
415
app/templates/sets/detail.html
Normal file
@@ -0,0 +1,415 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ set.set_number }}: {{ set.set_name }} - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav aria-label="breadcrumb" class="mb-4">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('main.dashboard') }}">Dashboard</a></li>
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('sets.list_sets') }}">Sets</a></li>
|
||||
<li class="breadcrumb-item active">{{ set.set_number }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="row">
|
||||
<!-- Set Image and Details -->
|
||||
<div class="col-lg-4 mb-4">
|
||||
<div class="card shadow-sm">
|
||||
{% if set.cover_image %}
|
||||
<img src="{{ url_for('static', filename='uploads/' + set.cover_image.replace('\\', '/')) }}"
|
||||
class="card-img-top" alt="{{ set.set_name }}"
|
||||
style="max-height: 400px; object-fit: contain; background-color: #f8f9fa; padding: 20px;">
|
||||
{% elif set.image_url %}
|
||||
<img src="{{ set.image_url }}" class="card-img-top" alt="{{ set.set_name }}"
|
||||
style="max-height: 400px; object-fit: contain; background-color: #f8f9fa; padding: 20px;">
|
||||
{% else %}
|
||||
<div class="d-flex align-items-center justify-content-center bg-light" style="height: 400px;">
|
||||
<i class="bi bi-image display-1 text-muted"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
{{ set.set_number }}
|
||||
{% if set.is_moc %}
|
||||
<span class="badge bg-warning text-dark">
|
||||
<i class="bi bi-star-fill"></i> MOC
|
||||
</span>
|
||||
{% endif %}
|
||||
</h5>
|
||||
<h6 class="card-subtitle mb-3 text-muted">{{ set.set_name }}</h6>
|
||||
|
||||
<table class="table table-sm table-borderless">
|
||||
<tr>
|
||||
<th width="40%">Theme:</th>
|
||||
<td><span class="badge bg-primary">{{ set.theme }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Year:</th>
|
||||
<td><span class="badge bg-warning text-dark">{{ set.year_released }}</span></td>
|
||||
</tr>
|
||||
{% if set.is_moc %}
|
||||
<tr>
|
||||
<th>Type:</th>
|
||||
<td><span class="badge bg-info">My Own Creation</span></td>
|
||||
</tr>
|
||||
{% if set.moc_designer %}
|
||||
<tr>
|
||||
<th>Designer:</th>
|
||||
<td>{{ set.moc_designer }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if set.piece_count %}
|
||||
<tr>
|
||||
<th>Pieces:</th>
|
||||
<td>{{ set.piece_count }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<th>Instructions:</th>
|
||||
<td>{{ set.instructions.count() }} file(s)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Added:</th>
|
||||
<td>{{ set.created_at.strftime('%b %d, %Y') }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card-footer bg-transparent">
|
||||
<div class="d-grid gap-2">
|
||||
<a href="{{ url_for('instructions.upload', set_id=set.id) }}" class="btn btn-success">
|
||||
<i class="bi bi-cloud-upload"></i> Upload Instructions
|
||||
</a>
|
||||
<a href="{{ url_for('sets.edit_set', set_id=set.id) }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-pencil"></i> Edit Set Details
|
||||
</a>
|
||||
<form method="POST" action="{{ url_for('sets.delete_set', set_id=set.id) }}"
|
||||
onsubmit="return confirm('Are you sure you want to delete this set and all its instructions?');">
|
||||
<button type="submit" class="btn btn-outline-danger w-100">
|
||||
<i class="bi bi-trash"></i> Delete Set
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Instructions -->
|
||||
<div class="col-lg-8">
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0"><i class="bi bi-file-pdf"></i> Instructions</h5>
|
||||
<a href="{{ url_for('instructions.upload', set_id=set.id) }}" class="btn btn-sm btn-success">
|
||||
<i class="bi bi-plus-circle"></i> Upload
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if pdf_instructions or image_instructions %}
|
||||
|
||||
<!-- PDF Instructions (Books) -->
|
||||
{% if pdf_instructions %}
|
||||
<h6 class="mb-3"><i class="bi bi-book-fill text-danger"></i> PDF Instruction Books</h6>
|
||||
<div class="row mb-4">
|
||||
{% for instruction in pdf_instructions %}
|
||||
<div class="col-md-6 col-lg-4 mb-3">
|
||||
<div class="card h-100">
|
||||
{% if instruction.thumbnail_path %}
|
||||
<a href="{{ url_for('instructions.view', instruction_id=instruction.id) }}" target="_blank">
|
||||
<img src="{{ url_for('static', filename='uploads/' + instruction.thumbnail_path.replace('\\', '/')) }}"
|
||||
class="card-img-top"
|
||||
alt="{{ instruction.file_name }}"
|
||||
style="height: 200px; object-fit: contain; background-color: #f8f9fa; padding: 10px;">
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('instructions.view', instruction_id=instruction.id) }}" target="_blank">
|
||||
<div class="card-img-top d-flex align-items-center justify-content-center bg-light"
|
||||
style="height: 200px;">
|
||||
<i class="bi bi-file-pdf display-1 text-danger"></i>
|
||||
</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">{{ instruction.file_name }}</h6>
|
||||
<p class="card-text small text-muted">
|
||||
<i class="bi bi-file-pdf"></i> {{ instruction.file_size_mb }} MB<br>
|
||||
<i class="bi bi-calendar"></i> {{ instruction.uploaded_at.strftime('%b %d, %Y') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent">
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('instructions.view', instruction_id=instruction.id) }}"
|
||||
class="btn btn-sm btn-primary flex-fill" target="_blank">
|
||||
<i class="bi bi-eye"></i> Open
|
||||
</a>
|
||||
<form method="POST" action="{{ url_for('instructions.delete', instruction_id=instruction.id) }}"
|
||||
onsubmit="return confirm('Delete this PDF?');" class="flex-fill">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger w-100">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Image Instructions (Single Card) -->
|
||||
{% if image_instructions %}
|
||||
<h6 class="mb-3"><i class="bi bi-images text-primary"></i> Scanned Instructions</h6>
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-lg-4 mb-3">
|
||||
<div class="card h-100">
|
||||
{% set first_image = image_instructions[0] %}
|
||||
<a href="{{ url_for('instructions.image_viewer', set_id=set.id) }}">
|
||||
<img src="{{ url_for('static', filename='uploads/' + first_image.file_path.replace('\\', '/')) }}"
|
||||
class="card-img-top"
|
||||
alt="Instructions Preview"
|
||||
style="height: 200px; object-fit: contain; background-color: #f8f9fa; padding: 10px; cursor: pointer;">
|
||||
</a>
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">
|
||||
<i class="bi bi-file-image"></i> Image Instructions
|
||||
</h6>
|
||||
<p class="card-text small text-muted">
|
||||
<i class="bi bi-files"></i> {{ image_instructions|length }} page(s)<br>
|
||||
<i class="bi bi-calendar"></i> Uploaded {{ first_image.uploaded_at.strftime('%b %d, %Y') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent">
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('instructions.image_viewer', set_id=set.id) }}"
|
||||
class="btn btn-sm btn-primary flex-fill">
|
||||
<i class="bi bi-book-half"></i> View Instructions
|
||||
</a>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger flex-fill"
|
||||
data-bs-toggle="modal" data-bs-target="#deleteImagesModal">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete All Images Modal -->
|
||||
<div class="modal fade" id="deleteImagesModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Delete All Image Instructions?</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>This will delete all {{ image_instructions|length }} image instruction pages.</p>
|
||||
<p class="text-danger"><strong>This cannot be undone!</strong></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<form method="POST" action="{{ url_for('instructions.delete_all_images', set_id=set.id) }}" style="display: inline;">
|
||||
<button type="submit" class="btn btn-danger">Delete All Images</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-file-earmark-x display-1 text-muted"></i>
|
||||
<h5 class="mt-3">No Instructions Yet</h5>
|
||||
<p class="text-muted">Upload PDF or image files to get started.</p>
|
||||
<a href="{{ url_for('instructions.upload', set_id=set.id) }}" class="btn btn-success">
|
||||
<i class="bi bi-cloud-upload"></i> Upload Instructions
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Extra Files Section -->
|
||||
<div class="card shadow-sm mt-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0"><i class="bi bi-file-earmark-plus"></i> Extra Files</h5>
|
||||
<a href="{{ url_for('extra_files.upload', set_id=set.id) }}" class="btn btn-sm btn-success">
|
||||
<i class="bi bi-cloud-upload"></i> Upload Files
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% set extra_files_list = set.extra_files.all() %}
|
||||
{% if extra_files_list %}
|
||||
<!-- Files Grid -->
|
||||
<div class="row g-3">
|
||||
{% for file in extra_files_list %}
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="card h-100 border-light">
|
||||
<!-- Preview for images -->
|
||||
{% if file.is_image %}
|
||||
<a href="{{ url_for('extra_files.preview', file_id=file.id) }}" target="_blank">
|
||||
<img src="{{ url_for('extra_files.preview', file_id=file.id) }}"
|
||||
class="card-img-top"
|
||||
alt="{{ file.original_filename }}"
|
||||
style="height: 150px; object-fit: cover; cursor: pointer;">
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="card-img-top bg-light d-flex align-items-center justify-content-center"
|
||||
style="height: 150px;">
|
||||
<i class="bi bi-{{ file.file_icon }} display-3 text-muted"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card-body p-2">
|
||||
<h6 class="card-title small mb-1">
|
||||
<i class="bi bi-{{ file.file_icon }}"></i>
|
||||
{{ file.original_filename }}
|
||||
</h6>
|
||||
|
||||
{% if file.category and file.category != 'other' %}
|
||||
<span class="badge bg-info text-dark small mb-1">
|
||||
{{ file.category|replace('_', ' ')|title }}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<p class="card-text small text-muted mb-1">
|
||||
<i class="bi bi-hdd"></i> {{ file.file_size_formatted }}
|
||||
<br>
|
||||
<i class="bi bi-calendar"></i> {{ file.uploaded_at.strftime('%b %d, %Y') }}
|
||||
</p>
|
||||
|
||||
{% if file.description %}
|
||||
<p class="card-text small text-muted mb-1">
|
||||
{{ file.description|truncate(60) }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card-footer bg-transparent p-2">
|
||||
<div class="d-flex gap-1">
|
||||
{% if file.can_preview %}
|
||||
<a href="{{ url_for('extra_files.preview', file_id=file.id) }}"
|
||||
target="_blank"
|
||||
class="btn btn-sm btn-outline-primary flex-fill"
|
||||
title="Preview">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('extra_files.download', file_id=file.id) }}"
|
||||
class="btn btn-sm btn-outline-success flex-fill"
|
||||
title="Download">
|
||||
<i class="bi bi-download"></i>
|
||||
</a>
|
||||
<form method="POST"
|
||||
action="{{ url_for('extra_files.delete', file_id=file.id) }}"
|
||||
class="flex-fill"
|
||||
onsubmit="return confirm('Delete {{ file.original_filename }}?');">
|
||||
<button type="submit"
|
||||
class="btn btn-sm btn-outline-danger w-100"
|
||||
title="Delete">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-files display-3 text-muted"></i>
|
||||
<p class="text-muted mt-2 mb-1">No extra files yet</p>
|
||||
<p class="small text-muted">
|
||||
Upload BrickLink XMLs, Stud.io files, box art, photos, or any other related files
|
||||
</p>
|
||||
<a href="{{ url_for('extra_files.upload', set_id=set.id) }}" class="btn btn-sm btn-success">
|
||||
<i class="bi bi-cloud-upload"></i> Upload Files
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Set Information -->
|
||||
<div class="card shadow-sm mt-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-info-circle"></i> Additional Information</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-4">Set Number:</dt>
|
||||
<dd class="col-sm-8">{{ set.set_number }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Set Name:</dt>
|
||||
<dd class="col-sm-8">{{ set.set_name }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Theme:</dt>
|
||||
<dd class="col-sm-8">{{ set.theme }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Year Released:</dt>
|
||||
<dd class="col-sm-8">{{ set.year_released }}</dd>
|
||||
|
||||
{% if set.piece_count %}
|
||||
<dt class="col-sm-4">Piece Count:</dt>
|
||||
<dd class="col-sm-8">{{ set.piece_count }} pieces</dd>
|
||||
{% endif %}
|
||||
|
||||
{% if set.is_moc %}
|
||||
<dt class="col-sm-4">Type:</dt>
|
||||
<dd class="col-sm-8">
|
||||
<span class="badge bg-warning text-dark">
|
||||
<i class="bi bi-star-fill"></i> My Own Creation (MOC)
|
||||
</span>
|
||||
</dd>
|
||||
|
||||
{% if set.moc_designer %}
|
||||
<dt class="col-sm-4">Designer:</dt>
|
||||
<dd class="col-sm-8">{{ set.moc_designer }}</dd>
|
||||
{% endif %}
|
||||
|
||||
{% if set.moc_description %}
|
||||
<dt class="col-sm-4">Description:</dt>
|
||||
<dd class="col-sm-8">{{ set.moc_description }}</dd>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if set.brickset_id %}
|
||||
<dt class="col-sm-4">Brickset ID:</dt>
|
||||
<dd class="col-sm-8">{{ set.brickset_id }}</dd>
|
||||
{% endif %}
|
||||
|
||||
<dt class="col-sm-4">Added By:</dt>
|
||||
<dd class="col-sm-8">{{ set.added_by.username }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Date Added:</dt>
|
||||
<dd class="col-sm-8">{{ set.created_at.strftime('%B %d, %Y at %I:%M %p') }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Last Updated:</dt>
|
||||
<dd class="col-sm-8">{{ set.updated_at.strftime('%B %d, %Y at %I:%M %p') }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<a href="{{ url_for('sets.list_sets') }}" class="btn btn-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Back to Sets
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.instruction-thumbnail {
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.instruction-thumbnail:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
211
app/templates/sets/edit.html
Normal file
211
app/templates/sets/edit.html
Normal file
@@ -0,0 +1,211 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Edit Set - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-lg-8 mx-auto">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h3 class="mb-0">
|
||||
<i class="bi bi-pencil"></i> Edit LEGO Set
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('sets.edit_set', set_id=set.id) }}" enctype="multipart/form-data">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="set_number" class="form-label">
|
||||
Set Number <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="text" class="form-control" id="set_number"
|
||||
name="set_number" value="{{ set.set_number }}" disabled>
|
||||
<div class="form-text">Set number cannot be changed</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="year_released" class="form-label">
|
||||
Year Released <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="number" class="form-control" id="year_released"
|
||||
name="year_released" value="{{ set.year_released }}"
|
||||
required min="1949" max="2030">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="set_name" class="form-label">
|
||||
Set Name <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="text" class="form-control" id="set_name"
|
||||
name="set_name" value="{{ set.set_name }}" required>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="theme" class="form-label">
|
||||
Theme <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="text" class="form-control" id="theme"
|
||||
name="theme" value="{{ set.theme }}" required>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="piece_count" class="form-label">
|
||||
Piece Count
|
||||
</label>
|
||||
<input type="number" class="form-control" id="piece_count"
|
||||
name="piece_count" value="{{ set.piece_count or '' }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="image_url" class="form-label">
|
||||
Image URL (optional)
|
||||
</label>
|
||||
<input type="url" class="form-control" id="image_url"
|
||||
name="image_url" value="{{ set.image_url or '' }}">
|
||||
<div class="form-text">Enter a URL to an image of the set</div>
|
||||
</div>
|
||||
|
||||
{% if set.image_url %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Current URL Image Preview:</label>
|
||||
<div class="border rounded p-3 bg-light">
|
||||
<img src="{{ set.image_url }}" alt="{{ set.set_name }}"
|
||||
style="max-height: 200px; max-width: 100%; object-fit: contain;">
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Cover Image Upload -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0"><i class="bi bi-image"></i> Cover Picture</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if set.cover_image %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Current Uploaded Cover:</label>
|
||||
<div class="border rounded p-3 bg-light position-relative">
|
||||
<img src="{{ url_for('static', filename='uploads/' + set.cover_image.replace('\\', '/')) }}"
|
||||
alt="{{ set.set_name }}"
|
||||
style="max-height: 200px; max-width: 100%; object-fit: contain;">
|
||||
</div>
|
||||
<div class="form-check mt-2">
|
||||
<input class="form-check-input" type="checkbox" id="remove_cover_image" name="remove_cover_image">
|
||||
<label class="form-check-label text-danger" for="remove_cover_image">
|
||||
<i class="bi bi-trash"></i> Remove uploaded cover image
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mb-0">
|
||||
<label for="cover_image" class="form-label">
|
||||
<i class="bi bi-upload"></i> {% if set.cover_image %}Replace Cover Picture{% else %}Upload Cover Picture{% endif %}
|
||||
</label>
|
||||
<input type="file" class="form-control" id="cover_image"
|
||||
name="cover_image" accept="image/*">
|
||||
<div class="form-text">
|
||||
Upload your own photo of the set or MOC (JPG, PNG, GIF). Max 800px, optimized automatically.
|
||||
</div>
|
||||
<div id="imagePreview" class="mt-2" style="display: none;">
|
||||
<label class="form-label small">New Image Preview:</label>
|
||||
<img id="previewImg" src="" alt="Preview" style="max-width: 200px; max-height: 200px; border-radius: 8px; border: 2px solid #dee2e6;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MOC (My Own Creation) Section -->
|
||||
<div class="card mb-3 border-info">
|
||||
<div class="card-header bg-info bg-opacity-10">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="is_moc" name="is_moc"
|
||||
{% if set.is_moc %}checked{% endif %}>
|
||||
<label class="form-check-label fw-bold" for="is_moc">
|
||||
<i class="bi bi-star-fill text-warning"></i> This is a MOC (My Own Creation)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body" id="mocFields" {% if not set.is_moc %}style="display: none;"{% endif %}>
|
||||
<div class="mb-3">
|
||||
<label for="moc_designer" class="form-label">
|
||||
Designer / Creator Name
|
||||
</label>
|
||||
<input type="text" class="form-control" id="moc_designer"
|
||||
name="moc_designer" value="{{ set.moc_designer or '' }}"
|
||||
placeholder="e.g., Your Name">
|
||||
<div class="form-text">Who designed this MOC?</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="moc_description" class="form-label">
|
||||
Description / Notes
|
||||
</label>
|
||||
<textarea class="form-control" id="moc_description" name="moc_description"
|
||||
rows="4" placeholder="Add details about your MOC...">{{ set.moc_description or '' }}</textarea>
|
||||
<div class="form-text">Optional notes about your custom creation</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{{ url_for('sets.view_set', set_id=set.id) }}" class="btn btn-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
<i class="bi bi-check-circle"></i> Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow mt-4">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0"><i class="bi bi-info-circle"></i> Set Information</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p><strong>Added by:</strong> {{ set.added_by.username }}</p>
|
||||
<p><strong>Created:</strong> {{ set.created_at.strftime('%B %d, %Y at %I:%M %p') }}</p>
|
||||
<p><strong>Last updated:</strong> {{ set.updated_at.strftime('%B %d, %Y at %I:%M %p') }}</p>
|
||||
<p class="mb-0"><strong>Instructions:</strong> {{ set.instructions.count() }} file(s)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// Toggle MOC fields visibility
|
||||
$('#is_moc').change(function() {
|
||||
if ($(this).is(':checked')) {
|
||||
$('#mocFields').slideDown();
|
||||
} else {
|
||||
$('#mocFields').slideUp();
|
||||
}
|
||||
});
|
||||
|
||||
// Image preview
|
||||
$('#cover_image').change(function() {
|
||||
const file = this.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
$('#previewImg').attr('src', e.target.result);
|
||||
$('#imagePreview').slideDown();
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
$('#imagePreview').slideUp();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
192
app/templates/sets/list.html
Normal file
192
app/templates/sets/list.html
Normal file
@@ -0,0 +1,192 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}My Sets - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-8">
|
||||
<h1><i class="bi bi-grid"></i> My LEGO Sets</h1>
|
||||
<p class="text-muted">{{ pagination.total }} sets in your collection</p>
|
||||
</div>
|
||||
<div class="col-md-4 text-end">
|
||||
<a href="{{ url_for('sets.add_set') }}" class="btn btn-danger">
|
||||
<i class="bi bi-plus-circle"></i> Add New Set
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filters -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<form method="GET" action="{{ url_for('sets.list_sets') }}" class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label for="search" class="form-label">Search</label>
|
||||
<input type="text" class="form-control" id="search" name="q"
|
||||
value="{{ search_query }}" placeholder="Set number or name...">
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label for="theme" class="form-label">Theme</label>
|
||||
<select class="form-select" id="theme" name="theme">
|
||||
<option value="">All Themes</option>
|
||||
{% for theme in themes %}
|
||||
<option value="{{ theme }}" {% if theme == current_theme %}selected{% endif %}>
|
||||
{{ theme }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<label for="year" class="form-label">Year</label>
|
||||
<select class="form-select" id="year" name="year">
|
||||
<option value="">All Years</option>
|
||||
{% for year in years %}
|
||||
<option value="{{ year }}" {% if year == current_year %}selected{% endif %}>
|
||||
{{ year }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<label for="sort" class="form-label">Sort By</label>
|
||||
<select class="form-select" id="sort" name="sort">
|
||||
<option value="set_number" {% if current_sort == 'set_number' %}selected{% endif %}>Set Number</option>
|
||||
<option value="name" {% if current_sort == 'name' %}selected{% endif %}>Name</option>
|
||||
<option value="theme" {% if current_sort == 'theme' %}selected{% endif %}>Theme</option>
|
||||
<option value="year" {% if current_sort == 'year' %}selected{% endif %}>Year</option>
|
||||
<option value="newest" {% if current_sort == 'newest' %}selected{% endif %}>Newest First</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-1 d-flex align-items-end">
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="bi bi-search"></i>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sets Grid -->
|
||||
{% if sets %}
|
||||
<div class="row">
|
||||
{% for set in sets %}
|
||||
<div class="col-md-6 col-lg-4 col-xl-3 mb-4">
|
||||
<div class="card h-100 shadow-sm">
|
||||
<a href="{{ url_for('sets.view_set', set_id=set.id) }}">
|
||||
{% if set.cover_image %}
|
||||
<img src="{{ url_for('static', filename='uploads/' + set.cover_image.replace('\\', '/')) }}"
|
||||
class="card-img-top set-image" alt="{{ set.set_name }}">
|
||||
{% elif set.image_url %}
|
||||
<img src="{{ set.image_url }}" class="card-img-top set-image" alt="{{ set.set_name }}">
|
||||
{% else %}
|
||||
<div class="card-img-top set-image d-flex align-items-center justify-content-center bg-light">
|
||||
<i class="bi bi-image display-1 text-muted"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
</a>
|
||||
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">
|
||||
<a href="{{ url_for('sets.view_set', set_id=set.id) }}" class="text-decoration-none">
|
||||
{{ set.set_number }}
|
||||
</a>
|
||||
{% if set.is_moc %}
|
||||
<span class="badge bg-warning text-dark" title="My Own Creation">
|
||||
<i class="bi bi-star-fill"></i>
|
||||
</span>
|
||||
{% endif %}
|
||||
</h6>
|
||||
<p class="card-text small text-truncate" title="{{ set.set_name }}">
|
||||
{{ set.set_name }}
|
||||
</p>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<span class="badge bg-primary">{{ set.theme }}</span>
|
||||
<span class="badge bg-warning text-dark">{{ set.year_released }}</span>
|
||||
</div>
|
||||
|
||||
{% if set.piece_count %}
|
||||
<p class="card-text small text-muted mb-2">
|
||||
<i class="bi bi-grid-3x3"></i> {{ set.piece_count }} pieces
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<p class="card-text small">
|
||||
<i class="bi bi-file-pdf"></i> {{ set.instructions.count() }} instruction(s)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card-footer bg-transparent">
|
||||
<div class="btn-group w-100" role="group">
|
||||
<a href="{{ url_for('sets.view_set', set_id=set.id) }}"
|
||||
class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i> View
|
||||
</a>
|
||||
<a href="{{ url_for('sets.edit_set', set_id=set.id) }}"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-pencil"></i> Edit
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if pagination.pages > 1 %}
|
||||
<nav aria-label="Page navigation">
|
||||
<ul class="pagination justify-content-center">
|
||||
<li class="page-item {% if not pagination.has_prev %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('sets.list_sets', page=pagination.prev_num, sort=current_sort, theme=current_theme, year=current_year, q=search_query) }}">
|
||||
Previous
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{% for page_num in pagination.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
|
||||
{% if page_num %}
|
||||
<li class="page-item {% if page_num == pagination.page %}active{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('sets.list_sets', page=page_num, sort=current_sort, theme=current_theme, year=current_year, q=search_query) }}">
|
||||
{{ page_num }}
|
||||
</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled"><span class="page-link">...</span></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<li class="page-item {% if not pagination.has_next %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('sets.list_sets', page=pagination.next_num, sort=current_sort, theme=current_theme, year=current_year, q=search_query) }}">
|
||||
Next
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-inbox display-1 text-muted"></i>
|
||||
<h3 class="mt-3">No Sets Found</h3>
|
||||
<p class="text-muted">
|
||||
{% if search_query or current_theme or current_year %}
|
||||
Try adjusting your filters or search terms.
|
||||
{% else %}
|
||||
Start by adding your first LEGO set or MOC to your collection!
|
||||
{% endif %}
|
||||
</p>
|
||||
<div class="d-flex justify-content-center gap-2">
|
||||
<a href="{{ url_for('sets.add_set') }}" class="btn btn-danger">
|
||||
<i class="bi bi-box-seam"></i> Add Official Set
|
||||
</a>
|
||||
<a href="{{ url_for('sets.add_set') }}?type=moc" class="btn btn-warning">
|
||||
<i class="bi bi-star-fill"></i> Add MOC
|
||||
</a>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user