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 %}
|
||||
Reference in New Issue
Block a user