Files

307 lines
14 KiB
HTML

{% 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&hellip;
{% 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 &mdash; 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&hellip;">
</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">&mdash;</span>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="7" class="bl-empty">
{% if stats.parts == 0 %}
Nothing here yet &mdash; 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) }}">&laquo; 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 &raquo;</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 %}