Initial commit - LEGO Instructions Manager v1.5.0

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

View File

@@ -0,0 +1,260 @@
{% extends "base.html" %}
{% block title %}Bulk Import Sets - Admin - {{ app_name }}{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col">
<h1>
<i class="bi bi-cloud-upload"></i> Bulk Import Sets from Brickset
</h1>
<p class="text-muted">Import multiple official LEGO sets at once using Brickset data</p>
</div>
<div class="col-auto">
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to Admin
</a>
</div>
</div>
{% if not brickset_configured %}
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle"></i>
<strong>Brickset API Not Configured</strong>
<p class="mb-0">
Please add your Brickset API credentials to the <code>.env</code> file:
</p>
<pre class="mb-0 mt-2">
BRICKSET_API_KEY=your_api_key_here
BRICKSET_USERNAME=your_username
BRICKSET_PASSWORD=your_password</pre>
<p class="mb-0 mt-2">
Get your API key at: <a href="https://brickset.com/tools/webservices/requestkey" target="_blank">https://brickset.com/tools/webservices/requestkey</a>
</p>
</div>
{% endif %}
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-list-ol"></i> Import Sets</h5>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('admin.bulk_import') }}">
<!-- Set Numbers -->
<div class="mb-3">
<label for="set_numbers" class="form-label">
<strong>Set Numbers</strong>
<span class="text-muted">(one per line, or comma/space separated)</span>
</label>
<textarea class="form-control font-monospace"
id="set_numbers"
name="set_numbers"
rows="10"
placeholder="Example:&#10;8860&#10;10497&#10;42100&#10;21318"
required
{% if not brickset_configured %}disabled{% endif %}></textarea>
<small class="form-text text-muted">
Enter LEGO set numbers (e.g., 8860, 10497-1, 42100). Variants like -1 are supported.
</small>
</div>
<!-- User Selection -->
<div class="mb-3">
<label for="user_id" class="form-label">
<strong>Assign to User</strong>
</label>
<select class="form-select"
id="user_id"
name="user_id"
required
{% if not brickset_configured %}disabled{% endif %}>
<option value="">Select a user...</option>
{% for user in users %}
<option value="{{ user.id }}" {% if user.id == current_user.id %}selected{% endif %}>
{{ user.username }} ({{ user.email }})
{% if user.is_admin %}👑 Admin{% endif %}
</option>
{% endfor %}
</select>
<small class="form-text text-muted">
Sets will be added to this user's collection
</small>
</div>
<!-- Throttle Delay -->
<div class="mb-3">
<label for="throttle_delay" class="form-label">
<strong>API Throttle Delay</strong>
<span class="text-muted">(seconds between requests)</span>
</label>
<select class="form-select"
id="throttle_delay"
name="throttle_delay"
{% if not brickset_configured %}disabled{% endif %}>
<option value="0.3">0.3s - Fast (may hit rate limits)</option>
<option value="0.5" selected>0.5s - Balanced (recommended)</option>
<option value="1.0">1.0s - Safe (slower but reliable)</option>
<option value="2.0">2.0s - Very Safe (for large batches)</option>
</select>
<small class="form-text text-muted">
<i class="bi bi-info-circle"></i>
Brickset has API rate limits. Increase delay if you get rate limit errors.
</small>
</div>
<!-- Submit -->
<div class="d-grid gap-2">
<button type="submit"
class="btn btn-primary btn-lg"
{% if not brickset_configured %}disabled{% endif %}>
<i class="bi bi-cloud-download"></i>
Import Sets from Brickset
</button>
</div>
</form>
</div>
</div>
</div>
<div class="col-lg-4">
<!-- Info Card -->
<div class="card bg-light mb-3">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-info-circle"></i> How It Works</h6>
</div>
<div class="card-body">
<ol class="mb-0">
<li class="mb-2">Enter set numbers (one per line)</li>
<li class="mb-2">Select which user to assign them to</li>
<li class="mb-2">Choose throttle delay</li>
<li class="mb-2">Click "Import Sets"</li>
<li class="mb-2">System fetches data from Brickset</li>
<li class="mb-0">Sets are added to database!</li>
</ol>
</div>
</div>
<!-- Rate Limit Warning -->
<div class="card bg-warning text-dark mb-3">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-exclamation-triangle"></i> API Rate Limits</h6>
</div>
<div class="card-body">
<p class="mb-2">
<strong>Brickset has API rate limits!</strong>
</p>
<ul class="mb-0 small">
<li class="mb-2">
<strong>Recommended:</strong> Import 10-20 sets at a time
</li>
<li class="mb-2">
<strong>Throttle:</strong> Use 0.5s-1.0s delay between requests
</li>
<li class="mb-2">
<strong>If rate limited:</strong> Wait 5-10 minutes and retry
</li>
<li class="mb-0">
<strong>Large batches:</strong> Split into multiple smaller imports
</li>
</ul>
</div>
</div>
<!-- What Gets Imported -->
<div class="card bg-info text-white mb-3">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-box-seam"></i> What Gets Imported</h6>
</div>
<div class="card-body">
<ul class="mb-0">
<li>Set Number</li>
<li>Set Name</li>
<li>Theme</li>
<li>Year Released</li>
<li>Piece Count</li>
<li>Cover Image (from Brickset)</li>
</ul>
<hr class="bg-white">
<small>
<i class="bi bi-lightbulb"></i>
You can upload instructions separately later!
</small>
</div>
</div>
<!-- Tips -->
<div class="card">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-stars"></i> Pro Tips</h6>
</div>
<div class="card-body">
<ul class="small mb-0">
<li class="mb-2">
<strong>Start Small:</strong> Try 5-10 sets first to test
</li>
<li class="mb-2">
<strong>Duplicates:</strong> Sets already in database will be skipped
</li>
<li class="mb-2">
<strong>Not Found:</strong> Invalid set numbers will be reported
</li>
<li class="mb-0">
<strong>Formats:</strong> Works with variants like 10497-1
</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Example Sets -->
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-clipboard-check"></i> Example Sets You Can Try</h6>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-3 mb-2">
<strong>Technic:</strong><br>
<code>8860, 8880, 42100, 42110</code>
</div>
<div class="col-md-3 mb-2">
<strong>Creator Expert:</strong><br>
<code>10497, 10294, 10283</code>
</div>
<div class="col-md-3 mb-2">
<strong>Ideas:</strong><br>
<code>21318, 21330, 21341</code>
</div>
<div class="col-md-3 mb-2">
<strong>Star Wars:</strong><br>
<code>75192, 75313, 75331</code>
</div>
</div>
<hr>
<button class="btn btn-sm btn-outline-primary" onclick="fillExample()">
<i class="bi bi-clipboard"></i> Fill Example Sets
</button>
</div>
</div>
</div>
</div>
<script>
function fillExample() {
const examples = `8860
8880
42100
10497
10294
21318
75192`;
document.getElementById('set_numbers').value = examples;
}
</script>
{% endblock %}

View File

@@ -0,0 +1,255 @@
{% extends "base.html" %}
{% block title %}Import Results - Admin - {{ app_name }}{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col">
<h1>
<i class="bi bi-clipboard-check"></i> Bulk Import Results
</h1>
</div>
<div class="col-auto">
<a href="{{ url_for('admin.bulk_import') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Import More Sets
</a>
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-outline-secondary">
<i class="bi bi-speedometer2"></i> Dashboard
</a>
</div>
</div>
<!-- Summary Cards -->
<div class="row mb-4">
<div class="col-md-3 mb-3">
<div class="card bg-success text-white h-100">
<div class="card-body text-center">
<h1 class="display-3">{{ results.success|length }}</h1>
<h5>Successfully Imported</h5>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card bg-warning text-dark h-100">
<div class="card-body text-center">
<h1 class="display-3">{{ results.already_exists|length }}</h1>
<h5>Already Existed</h5>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card bg-danger text-white h-100">
<div class="card-body text-center">
<h1 class="display-3">{{ results.failed|length }}</h1>
<h5>Failed to Import</h5>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card bg-info text-white h-100">
<div class="card-body text-center">
<h1 class="display-3">{{ results.rate_limited|length }}</h1>
<h5>Rate Limited</h5>
</div>
</div>
</div>
</div>
<!-- Successful Imports -->
{% if results.success %}
<div class="card mb-4">
<div class="card-header bg-success text-white">
<h5 class="mb-0">
<i class="bi bi-check-circle"></i>
Successfully Imported ({{ results.success|length }})
</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Set Number</th>
<th>Name</th>
<th>Theme</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for set in results.success %}
<tr>
<td><strong>{{ set.set_number }}</strong></td>
<td>{{ set.name }}</td>
<td><span class="badge bg-primary">{{ set.theme }}</span></td>
<td>
<!-- We need to find the actual set ID -->
<a href="{{ url_for('sets.list_sets') }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> View
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
<!-- Already Exists -->
{% if results.already_exists %}
<div class="card mb-4">
<div class="card-header bg-warning text-dark">
<h5 class="mb-0">
<i class="bi bi-info-circle"></i>
Already in Database ({{ results.already_exists|length }})
</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Set Number</th>
<th>Name</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for set in results.already_exists %}
<tr>
<td><strong>{{ set.set_number }}</strong></td>
<td>{{ set.name }}</td>
<td><span class="badge bg-info">Skipped - Already exists</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
<!-- Failed Imports -->
{% if results.failed %}
<div class="card mb-4">
<div class="card-header bg-danger text-white">
<h5 class="mb-0">
<i class="bi bi-x-circle"></i>
Failed to Import ({{ results.failed|length }})
</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Set Number</th>
<th>Reason</th>
</tr>
</thead>
<tbody>
{% for set in results.failed %}
<tr>
<td><strong>{{ set.set_number }}</strong></td>
<td>
<span class="badge bg-danger">{{ set.reason }}</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="card-footer">
<small class="text-muted">
<i class="bi bi-lightbulb"></i>
<strong>Common reasons for failure:</strong>
Invalid set number, set doesn't exist in Brickset, or API connection issue.
</small>
</div>
</div>
{% endif %}
<!-- Rate Limited Sets -->
{% if results.rate_limited %}
<div class="card mb-4">
<div class="card-header bg-info text-white">
<h5 class="mb-0">
<i class="bi bi-exclamation-triangle"></i>
Rate Limited ({{ results.rate_limited|length }})
</h5>
</div>
<div class="card-body">
<div class="alert alert-info mb-3">
<h6><i class="bi bi-info-circle"></i> API Rate Limit Reached</h6>
<p class="mb-2">
Brickset's API has rate limits to prevent abuse. Your import was stopped after
{{ results.success|length }} successful import(s) to avoid hitting the limit.
</p>
<p class="mb-0">
<strong>To import these remaining sets:</strong>
</p>
<ol class="mb-0">
<li>Wait 5-10 minutes for the rate limit to reset</li>
<li>Use a longer throttle delay (1.0s or 2.0s)</li>
<li>Import in smaller batches (10-15 sets at a time)</li>
</ol>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Set Number</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for set in results.rate_limited %}
<tr>
<td><strong>{{ set.set_number }}</strong></td>
<td><span class="badge bg-info">{{ set.reason }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="card-footer">
<strong>Quick Retry:</strong> Copy the set numbers below and try again in a few minutes with a longer delay.
<div class="mt-2">
<textarea class="form-control font-monospace" rows="3" readonly>{{ results.rate_limited|map(attribute='set_number')|join('\n') }}</textarea>
</div>
</div>
</div>
{% endif %}
<!-- Actions -->
<div class="card">
<div class="card-body text-center">
<h5>What's Next?</h5>
<div class="btn-group mt-3" role="group">
<a href="{{ url_for('sets.list_sets') }}" class="btn btn-primary">
<i class="bi bi-box-seam"></i> View All Sets
</a>
<a href="{{ url_for('admin.bulk_import') }}" class="btn btn-success">
<i class="bi bi-plus-circle"></i> Import More
</a>
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-outline-secondary">
<i class="bi bi-speedometer2"></i> Admin Dashboard
</a>
</div>
{% if results.success %}
<div class="mt-3">
<p class="text-muted">
<i class="bi bi-info-circle"></i>
Don't forget to upload instructions for the newly imported sets!
</p>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,284 @@
{% extends "base.html" %}
{% block title %}Admin Dashboard - {{ app_name }}{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col">
<h1>
<i class="bi bi-shield-lock"></i> Admin Dashboard
</h1>
<p class="text-muted">System overview and management</p>
</div>
</div>
<!-- Quick Stats -->
<div class="row mb-4">
<div class="col-md-3 mb-3">
<div class="card bg-primary text-white h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="card-subtitle mb-2 text-white-50">Total Users</h6>
<h2 class="mb-0">{{ total_users }}</h2>
</div>
<i class="bi bi-people display-4 opacity-50"></i>
</div>
</div>
<div class="card-footer bg-primary bg-opacity-75">
<a href="{{ url_for('admin.users') }}" class="text-white text-decoration-none">
Manage Users <i class="bi bi-arrow-right"></i>
</a>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card bg-success text-white h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="card-subtitle mb-2 text-white-50">Total Sets</h6>
<h2 class="mb-0">{{ total_sets }}</h2>
</div>
<i class="bi bi-box-seam display-4 opacity-50"></i>
</div>
</div>
<div class="card-footer bg-success bg-opacity-75">
<a href="{{ url_for('admin.sets') }}" class="text-white text-decoration-none">
View All Sets <i class="bi bi-arrow-right"></i>
</a>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card bg-warning text-dark h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="card-subtitle mb-2">MOC Builds</h6>
<h2 class="mb-0">{{ total_mocs }}</h2>
</div>
<i class="bi bi-star-fill display-4 opacity-50"></i>
</div>
</div>
<div class="card-footer bg-warning bg-opacity-75">
<a href="{{ url_for('admin.sets', type='mocs') }}" class="text-dark text-decoration-none">
View MOCs <i class="bi bi-arrow-right"></i>
</a>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card bg-info text-white h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="card-subtitle mb-2 text-white-50">Instructions</h6>
<h2 class="mb-0">{{ total_instructions }}</h2>
<small class="text-white-50">{{ total_storage_mb }} MB</small>
</div>
<i class="bi bi-file-pdf display-4 opacity-50"></i>
</div>
</div>
<div class="card-footer bg-info bg-opacity-75">
<span class="text-white">Total Storage Used</span>
</div>
</div>
</div>
</div>
<div class="row">
<!-- Recent Users -->
<div class="col-md-6 mb-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-person-plus"></i> Recent Users</h5>
<a href="{{ url_for('admin.users') }}" class="btn btn-sm btn-outline-primary">View All</a>
</div>
<div class="card-body p-0">
{% if recent_users %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Username</th>
<th>Email</th>
<th>Joined</th>
<th>Admin</th>
</tr>
</thead>
<tbody>
{% for user in recent_users %}
<tr>
<td>
<i class="bi bi-person-circle"></i> {{ user.username }}
</td>
<td>{{ user.email }}</td>
<td>{{ user.created_at.strftime('%Y-%m-%d') }}</td>
<td>
{% if user.is_admin %}
<span class="badge bg-danger">Admin</span>
{% else %}
<span class="badge bg-secondary">User</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-center text-muted my-4">No users yet</p>
{% endif %}
</div>
</div>
</div>
<!-- Top Contributors -->
<div class="col-md-6 mb-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-trophy"></i> Top Contributors</h5>
</div>
<div class="card-body p-0">
{% if top_contributors %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>User</th>
<th>Sets Added</th>
</tr>
</thead>
<tbody>
{% for user, count in top_contributors %}
<tr>
<td>
<i class="bi bi-person-circle"></i> {{ user.username }}
</td>
<td>
<span class="badge bg-success">{{ count }}</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-center text-muted my-4">No data yet</p>
{% endif %}
</div>
</div>
</div>
</div>
<div class="row">
<!-- Popular Themes -->
<div class="col-md-6 mb-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-bar-chart"></i> Popular Themes</h5>
</div>
<div class="card-body">
{% if theme_stats %}
{% for theme, count in theme_stats %}
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<span>{{ theme }}</span>
<span class="badge bg-primary">{{ count }}</span>
</div>
<div class="progress" style="height: 20px;">
{% set percentage = (count / total_sets * 100) | int %}
<div class="progress-bar" role="progressbar"
style="width: {{ percentage }}%"
aria-valuenow="{{ percentage }}"
aria-valuemin="0"
aria-valuemax="100">
{{ percentage }}%
</div>
</div>
</div>
{% endfor %}
{% else %}
<p class="text-center text-muted">No theme data yet</p>
{% endif %}
</div>
</div>
</div>
<!-- Recent Sets -->
<div class="col-md-6 mb-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-clock-history"></i> Recently Added Sets</h5>
<a href="{{ url_for('admin.sets') }}" class="btn btn-sm btn-outline-primary">View All</a>
</div>
<div class="card-body p-0">
{% if recent_sets %}
<div class="list-group list-group-flush">
{% for set in recent_sets[:5] %}
<a href="{{ url_for('sets.view_set', set_id=set.id) }}"
class="list-group-item list-group-item-action">
<div class="d-flex justify-content-between align-items-center">
<div>
<strong>{{ set.set_number }}</strong>
{% if set.is_moc %}
<span class="badge bg-warning text-dark ms-1">
<i class="bi bi-star-fill"></i>
</span>
{% endif %}
<br>
<small class="text-muted">{{ set.set_name }}</small>
</div>
<span class="badge bg-secondary">{{ set.theme }}</span>
</div>
</a>
{% endfor %}
</div>
{% else %}
<p class="text-center text-muted my-4">No sets yet</p>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-gear"></i> Quick Actions</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-3 mb-2">
<a href="{{ url_for('admin.users') }}" class="btn btn-outline-primary w-100">
<i class="bi bi-people"></i> Manage Users
</a>
</div>
<div class="col-md-3 mb-2">
<a href="{{ url_for('admin.sets') }}" class="btn btn-outline-success w-100">
<i class="bi bi-box-seam"></i> Manage Sets
</a>
</div>
<div class="col-md-3 mb-2">
<a href="{{ url_for('admin.bulk_import') }}" class="btn btn-outline-info w-100">
<i class="bi bi-cloud-upload"></i> Bulk Import
</a>
</div>
<div class="col-md-3 mb-2">
<a href="{{ url_for('admin.site_settings') }}" class="btn btn-outline-secondary w-100">
<i class="bi bi-sliders"></i> Site Settings
</a>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,99 @@
{% extends "base.html" %}
{% block title %}Set Management - Admin{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col">
<h1><i class="bi bi-box-seam"></i> Set Management</h1>
</div>
<div class="col-auto">
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back
</a>
</div>
</div>
<div class="card mb-4">
<div class="card-body">
<form method="GET">
<div class="row g-2">
<div class="col-md-6">
<input type="text" class="form-control" name="search"
value="{{ search }}" placeholder="Search sets...">
</div>
<div class="col-md-3">
<select class="form-select" name="type">
<option value="all" {% if filter_type=='all' %}selected{% endif %}>All Sets</option>
<option value="official" {% if filter_type=='official' %}selected{% endif %}>Official Only</option>
<option value="mocs" {% if filter_type=='mocs' %}selected{% endif %}>MOCs Only</option>
</select>
</div>
<div class="col-md-3">
<button class="btn btn-primary w-100" type="submit">
<i class="bi bi-search"></i> Filter
</button>
</div>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-body p-0">
{% if sets %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Set Number</th>
<th>Name</th>
<th>Theme</th>
<th>Year</th>
<th>Type</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for set in sets %}
<tr>
<td><strong>{{ set.set_number }}</strong></td>
<td>{{ set.set_name }}</td>
<td><span class="badge bg-primary">{{ set.theme }}</span></td>
<td>{{ set.year_released }}</td>
<td>
{% if set.is_moc %}
<span class="badge bg-warning text-dark">
<i class="bi bi-star-fill"></i> MOC
</span>
{% else %}
<span class="badge bg-secondary">Official</span>
{% endif %}
</td>
<td>
<div class="btn-group btn-group-sm">
<a href="{{ url_for('sets.view_set', set_id=set.id) }}"
class="btn btn-outline-primary">
<i class="bi bi-eye"></i>
</a>
<form method="POST" action="{{ url_for('admin.delete_set', set_id=set.id) }}"
style="display:inline;"
onsubmit="return confirm('Delete {{ set.set_number }}?');">
<button type="submit" class="btn btn-outline-danger">
<i class="bi bi-trash"></i>
</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-center text-muted my-4">No sets found</p>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,63 @@
{% extends "base.html" %}
{% block title %}Site Settings - Admin{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col">
<h1><i class="bi bi-sliders"></i> Site Settings</h1>
</div>
<div class="col-auto">
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back
</a>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">System Information</h5>
</div>
<div class="card-body">
<table class="table">
<tr>
<td>Total Users:</td>
<td><strong>{{ stats.total_users }}</strong></td>
</tr>
<tr>
<td>Total Sets:</td>
<td><strong>{{ stats.total_sets }}</strong></td>
</tr>
<tr>
<td>Total Instructions:</td>
<td><strong>{{ stats.total_instructions }}</strong></td>
</tr>
<tr>
<td>Storage Used:</td>
<td><strong>{{ (stats.total_storage / 1024 / 1024) | round(2) }} MB</strong></td>
</tr>
</table>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Settings</h5>
</div>
<div class="card-body">
<p class="text-muted">
Site settings configuration will be available in future updates.
</p>
<p>
For now, modify settings in <code>config.py</code>
</p>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,211 @@
{% extends "base.html" %}
{% block title %}User Management - Admin - {{ app_name }}{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col">
<h1>
<i class="bi bi-people"></i> User Management
</h1>
<p class="text-muted">Manage users and permissions</p>
</div>
<div class="col-auto">
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to Admin
</a>
</div>
</div>
<!-- Search Bar -->
<div class="card mb-4">
<div class="card-body">
<form method="GET" action="{{ url_for('admin.users') }}">
<div class="input-group">
<input type="text" class="form-control" name="search"
value="{{ search }}" placeholder="Search by username or email...">
<button class="btn btn-primary" type="submit">
<i class="bi bi-search"></i> Search
</button>
{% if search %}
<a href="{{ url_for('admin.users') }}" class="btn btn-outline-secondary">
<i class="bi bi-x"></i> Clear
</a>
{% endif %}
</div>
</form>
</div>
</div>
<!-- Users Table -->
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-list"></i> Users ({{ pagination.total }})
</h5>
</div>
<div class="card-body p-0">
{% if users %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Username</th>
<th>Email</th>
<th>Joined</th>
<th>Sets</th>
<th>Instructions</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>
<i class="bi bi-person-circle"></i>
<strong>{{ user.username }}</strong>
{% if user.id == current_user.id %}
<span class="badge bg-info">You</span>
{% endif %}
</td>
<td>{{ user.email }}</td>
<td>{{ user.created_at.strftime('%Y-%m-%d') }}</td>
<td>
<span class="badge bg-success">{{ user_stats[user.id]['sets'] }}</span>
</td>
<td>
<span class="badge bg-info">{{ user_stats[user.id]['instructions'] }}</span>
</td>
<td>
{% if user.is_admin %}
<span class="badge bg-danger">
<i class="bi bi-shield-lock"></i> Admin
</span>
{% else %}
<span class="badge bg-secondary">User</span>
{% endif %}
</td>
<td>
<div class="btn-group btn-group-sm">
{% if user.id != current_user.id %}
<button class="btn btn-outline-primary toggle-admin-btn"
data-user-id="{{ user.id }}"
data-username="{{ user.username }}"
data-is-admin="{{ user.is_admin|lower }}">
<i class="bi bi-shield"></i>
{% if user.is_admin %}Revoke{% else %}Grant{% endif %} Admin
</button>
<button type="button" class="btn btn-outline-danger"
data-bs-toggle="modal"
data-bs-target="#deleteModal{{ user.id }}">
<i class="bi bi-trash"></i>
</button>
{% else %}
<span class="text-muted small">Cannot modify yourself</span>
{% endif %}
</div>
</td>
</tr>
<!-- Delete Modal -->
<div class="modal fade" id="deleteModal{{ user.id }}" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Delete User?</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form method="POST" action="{{ url_for('admin.delete_user', user_id=user.id) }}">
<div class="modal-body">
<p>Are you sure you want to delete <strong>{{ user.username }}</strong>?</p>
<div class="form-check">
<input class="form-check-input" type="checkbox"
id="delete_data{{ user.id }}" name="delete_data">
<label class="form-check-label" for="delete_data{{ user.id }}">
Also delete all their sets and instructions
</label>
</div>
<small class="text-muted">
If unchecked, their content will be reassigned to you.
</small>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-danger">Delete User</button>
</div>
</form>
</div>
</div>
</div>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if pagination.pages > 1 %}
<div class="card-footer">
<nav>
<ul class="pagination justify-content-center mb-0">
<li class="page-item {% if not pagination.has_prev %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('admin.users', page=pagination.prev_num, search=search) }}">Previous</a>
</li>
{% for page_num in pagination.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
{% if page_num %}
<li class="page-item {% if page_num == pagination.page %}active{% endif %}">
<a class="page-link" href="{{ url_for('admin.users', page=page_num, search=search) }}">{{ page_num }}</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link">...</span></li>
{% endif %}
{% endfor %}
<li class="page-item {% if not pagination.has_next %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('admin.users', page=pagination.next_num, search=search) }}">Next</a>
</li>
</ul>
</nav>
</div>
{% endif %}
{% else %}
<div class="text-center py-5">
<i class="bi bi-inbox display-1 text-muted"></i>
<p class="mt-3 text-muted">No users found</p>
</div>
{% endif %}
</div>
</div>
<script>
// Toggle admin status with AJAX
document.querySelectorAll('.toggle-admin-btn').forEach(button => {
button.addEventListener('click', function() {
const userId = this.dataset.userId;
const username = this.dataset.username;
const isAdmin = this.dataset.isAdmin === 'true';
if (confirm(`${isAdmin ? 'Revoke' : 'Grant'} admin access for ${username}?`)) {
fetch(`/admin/users/${userId}/toggle-admin`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert('Error: ' + data.error);
}
})
.catch(error => {
alert('Error updating admin status');
console.error(error);
});
}
});
});
</script>
{% endblock %}