Add catalogue browse page with the Update button
This commit is contained in:
1 parent
ee8806c2a6
commit
b1c071ff88
1 file changed
+306
@@ -0,0 +1,306 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Part Catalogue - Admin{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/labels-bricklink.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="bl">
|
||||
{% set section = 'catalog' %}
|
||||
{% set bar_sub = 'Local mirror of the Rebrickable parts catalogue' %}
|
||||
{% set crumbs = [{'name': 'Part Catalogue'}] + ([{'name': active_category.name}] if active_category else []) %}
|
||||
{% include 'admin/labels/_nav.html' %}
|
||||
|
||||
<div class="bl-panel" style="border-top:0;border-radius:0 0 3px 3px;">
|
||||
<div class="bl-panel-body" style="padding:0;">
|
||||
<div class="bl-stats">
|
||||
<div class="bl-stat">
|
||||
<div class="v" id="statParts">{{ '{:,}'.format(stats.parts) }}</div>
|
||||
<div class="k">Parts in catalogue</div>
|
||||
</div>
|
||||
<div class="bl-stat">
|
||||
<div class="v" id="statCats">{{ '{:,}'.format(stats.categories) }}</div>
|
||||
<div class="k">Rebrickable categories</div>
|
||||
</div>
|
||||
<div class="bl-stat">
|
||||
<div class="v">{{ '{:,}'.format(stats.labelled) }}</div>
|
||||
<div class="k">Already labelled</div>
|
||||
</div>
|
||||
<div class="bl-stat">
|
||||
<div class="v">{{ '{:,}'.format(stats.mapped) }}</div>
|
||||
<div class="k">Categories mapped</div>
|
||||
</div>
|
||||
<div class="bl-stat" style="flex:2 1 260px;">
|
||||
<div class="d-flex align-items-center gap-2 mb-1">
|
||||
<button type="button" class="bl-btn bl-btn-primary" id="updateBtn">
|
||||
<i class="bi bi-arrow-repeat"></i> Update catalogue
|
||||
</button>
|
||||
<span id="syncLabel" class="k" style="text-transform:none;">
|
||||
{% if stats.last_sync %}
|
||||
{% if stats.last_sync.status == 'running' %}Running…
|
||||
{% elif stats.last_sync.status == 'failed' %}
|
||||
<span class="bl-tag err">Last run failed</span>
|
||||
{% else %}
|
||||
Last updated {{ stats.last_sync.finished_at.strftime('%d %b %Y %H:%M') if stats.last_sync.finished_at else '' }} UTC
|
||||
{% endif %}
|
||||
{% else %}Never updated{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="bl-progress" id="syncBar" style="display:none;"><span></span></div>
|
||||
<div class="k" id="syncStage" style="text-transform:none;margin-top:3px;">
|
||||
{% if stats.last_sync and stats.last_sync.message %}{{ stats.last_sync.message }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if stats.parts == 0 %}
|
||||
<div class="bl-panel">
|
||||
<div class="bl-panel-body">
|
||||
<strong>The catalogue is empty.</strong>
|
||||
Press <em>Update catalogue</em> to pull Rebrickable's published part list
|
||||
(two gzipped CSV files, about 60,000 parts). It runs in the background and
|
||||
takes a minute or two. No API key is needed for this — a key only adds
|
||||
part photos when you turn a part into a label.
|
||||
<div class="k" style="margin-top:6px;text-transform:none;">
|
||||
Catalogue data from <a href="https://rebrickable.com/" target="_blank" rel="noopener">Rebrickable</a>.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row g-2">
|
||||
<!-- Rebrickable category tree -->
|
||||
<div class="col-lg-3">
|
||||
<div class="bl-panel">
|
||||
<div class="bl-panel-head">
|
||||
<span>Categories</span>
|
||||
<a href="{{ url_for('catalog.index') }}" class="bl-btn bl-btn-sm">All</a>
|
||||
</div>
|
||||
<div class="bl-panel-body tight bl-tree" style="max-height:62vh;overflow-y:auto;">
|
||||
{% for category in categories %}
|
||||
<a href="{{ url_for('catalog.index', category=category.id, q=search, only=only) }}"
|
||||
class="{% if active_category and active_category.id == category.id %}active{% endif %}">
|
||||
<span class="text-truncate">
|
||||
{% if category.drawer_category %}<span class="bl-swatch" style="background:{{ category.drawer_category.accent_colour() }};"></span>{% endif %}
|
||||
{{ category.name }}
|
||||
</span>
|
||||
<span class="bl-count">{{ category.item_count() }}</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="bl-empty">No categories yet.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Parts list -->
|
||||
<div class="col-lg-9">
|
||||
{% if active_category %}
|
||||
<div class="bl-panel">
|
||||
<div class="bl-panel-head"><span>Import mapping for "{{ active_category.name }}"</span></div>
|
||||
<div class="bl-panel-body">
|
||||
<form method="POST" action="{{ url_for('catalog.map_category') }}" class="bl-filters">
|
||||
<input type="hidden" name="catalog_category_id" value="{{ active_category.id }}">
|
||||
<div style="flex:1 1 320px;">
|
||||
<label class="fld">Parts in this category become labels in</label>
|
||||
<select name="drawer_category_id" class="w-100">
|
||||
<option value="">(ask me each time)</option>
|
||||
{% for cid, text in drawer_choices %}
|
||||
<option value="{{ cid }}" {% if active_category.drawer_category_id == cid %}selected{% endif %}>{{ text }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button class="bl-btn bl-btn-primary">Save mapping</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="bl-panel">
|
||||
<div class="bl-panel-body">
|
||||
<form method="GET" class="bl-filters">
|
||||
{% if active_category %}<input type="hidden" name="category" value="{{ active_category.id }}">{% endif %}
|
||||
<div style="flex:2 1 260px;">
|
||||
<label class="fld">Search catalogue</label>
|
||||
<input type="search" name="q" value="{{ search }}" class="w-100"
|
||||
placeholder="3001, brick 2 x 4, wedge plate…">
|
||||
</div>
|
||||
<div style="flex:1 1 150px;">
|
||||
<label class="fld">Show</label>
|
||||
<select name="only" class="w-100">
|
||||
<option value="">All parts</option>
|
||||
<option value="unlabelled" {% if only == 'unlabelled' %}selected{% endif %}>Not yet labelled</option>
|
||||
<option value="labelled" {% if only == 'labelled' %}selected{% endif %}>Already labelled</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="bl-btn bl-btn-primary"><i class="bi bi-search"></i> Search</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('catalog.import_items') }}">
|
||||
<div class="bl-panel">
|
||||
<div class="bl-panel-head">
|
||||
<span>
|
||||
{{ '{:,}'.format(pagination.total) }} part{{ '' if pagination.total == 1 else 's' }}
|
||||
{% if active_category %}in {{ active_category.name }}{% endif %}
|
||||
</span>
|
||||
<span class="d-flex gap-1 align-items-center">
|
||||
<select name="category_id" style="max-width:190px;">
|
||||
<option value="">Drawer: use mapping</option>
|
||||
{% for cid, text in drawer_choices %}
|
||||
<option value="{{ cid }}">{{ text }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label class="d-flex align-items-center gap-1 mb-0" style="text-transform:none;font-size:11px;">
|
||||
<input type="checkbox" name="fetch_images" value="1" checked> fetch images
|
||||
</label>
|
||||
<button class="bl-btn bl-btn-go"><i class="bi bi-tag"></i> Make labels</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="bl-panel-body tight">
|
||||
<table class="bl-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:26px;"><input type="checkbox" id="checkAll"></th>
|
||||
<th style="width:46px;">Img</th>
|
||||
<th style="width:90px;">Part no.</th>
|
||||
<th>Name</th>
|
||||
<th style="width:80px;">Size</th>
|
||||
<th style="width:150px;">Category</th>
|
||||
<th style="width:70px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in items %}
|
||||
<tr>
|
||||
<td><input type="checkbox" name="part_nums" value="{{ item.part_num }}" class="pick"></td>
|
||||
<td>
|
||||
<img class="thumb" alt="" loading="lazy"
|
||||
src="{{ item.preview_url }}"
|
||||
onerror="this.style.visibility='hidden'">
|
||||
</td>
|
||||
<td class="num">{{ item.part_num }}</td>
|
||||
<td class="bl-name">{{ item.name }}</td>
|
||||
<td class="bl-dim">{{ item.dimensions or '' }}</td>
|
||||
<td>{{ item.catalog_category.name if item.catalog_category else '' }}</td>
|
||||
<td class="text-end">
|
||||
{% if item.has_label %}
|
||||
<span class="bl-tag ok">Labelled</span>
|
||||
{% else %}
|
||||
<span class="bl-tag">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="bl-empty">
|
||||
{% if stats.parts == 0 %}
|
||||
Nothing here yet — run <em>Update catalogue</em> above.
|
||||
{% else %}
|
||||
No parts match that search.
|
||||
{% endif %}
|
||||
</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if pagination.pages > 1 %}
|
||||
<div class="bl-pager">
|
||||
<a class="bl-btn bl-btn-sm {% if not pagination.has_prev %}disabled{% endif %}"
|
||||
href="{{ url_for('catalog.index', page=pagination.prev_num, category=active_category.id if active_category else None, q=search, only=only) }}">« Prev</a>
|
||||
<span>Page {{ pagination.page }} of {{ pagination.pages }}</span>
|
||||
<a class="bl-btn bl-btn-sm {% if not pagination.has_next %}disabled{% endif %}"
|
||||
href="{{ url_for('catalog.index', page=pagination.next_num, category=active_category.id if active_category else None, q=search, only=only) }}">Next »</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="k" style="text-transform:none;text-align:right;">
|
||||
Catalogue data from <a href="https://rebrickable.com/" target="_blank" rel="noopener">Rebrickable</a>.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.getElementById('checkAll')?.addEventListener('change', function () {
|
||||
document.querySelectorAll('.pick').forEach(b => { b.checked = this.checked; });
|
||||
});
|
||||
|
||||
(function () {
|
||||
const btn = document.getElementById('updateBtn');
|
||||
const bar = document.getElementById('syncBar');
|
||||
const fill = bar ? bar.querySelector('span') : null;
|
||||
const stage = document.getElementById('syncStage');
|
||||
const label = document.getElementById('syncLabel');
|
||||
if (!btn) return;
|
||||
|
||||
let timer = null;
|
||||
|
||||
function show(data) {
|
||||
if (!data || data.status === 'none') return;
|
||||
const running = data.status === 'running';
|
||||
bar.style.display = running ? 'block' : 'none';
|
||||
if (fill) fill.style.width = (data.progress || 0) + '%';
|
||||
if (running) {
|
||||
btn.disabled = true;
|
||||
label.textContent = 'Running…';
|
||||
stage.textContent = data.stage || '';
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
if (data.status === 'failed') {
|
||||
label.innerHTML = '<span class="bl-tag err">Failed</span>';
|
||||
} else {
|
||||
label.textContent = 'Finished in ' + (data.duration || 0) + 's';
|
||||
}
|
||||
stage.textContent = data.message || '';
|
||||
if (data.total_parts !== undefined) {
|
||||
document.getElementById('statParts').textContent =
|
||||
data.total_parts.toLocaleString();
|
||||
document.getElementById('statCats').textContent =
|
||||
data.total_categories.toLocaleString();
|
||||
}
|
||||
if (timer) { clearInterval(timer); timer = null; }
|
||||
if (data.status === 'done') setTimeout(() => location.reload(), 1200);
|
||||
}
|
||||
}
|
||||
|
||||
function poll() {
|
||||
fetch('{{ url_for("catalog.status") }}')
|
||||
.then(r => r.json()).then(show)
|
||||
.catch(() => { if (timer) { clearInterval(timer); timer = null; }
|
||||
btn.disabled = false; });
|
||||
}
|
||||
|
||||
btn.addEventListener('click', function () {
|
||||
btn.disabled = true;
|
||||
stage.textContent = 'Starting…';
|
||||
bar.style.display = 'block';
|
||||
fetch('{{ url_for("catalog.update") }}', {
|
||||
method: 'POST',
|
||||
headers: {'X-Requested-With': 'XMLHttpRequest'}
|
||||
}).then(r => r.json()).then(data => {
|
||||
show(data);
|
||||
if (!timer) timer = setInterval(poll, 1500);
|
||||
}).catch(() => {
|
||||
btn.disabled = false;
|
||||
stage.textContent = 'Could not start the update.';
|
||||
});
|
||||
});
|
||||
|
||||
{% if syncing %}
|
||||
bar.style.display = 'block';
|
||||
btn.disabled = true;
|
||||
timer = setInterval(poll, 1500);
|
||||
poll();
|
||||
{% endif %}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in new issue
Block a user