258 lines
14 KiB
HTML
258 lines
14 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}Parts Labels - Admin{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="row mb-4">
|
|
<div class="col">
|
|
<h1><i class="bi bi-tags"></i> Parts Drawer Labels</h1>
|
|
<p class="text-muted mb-0">{{ total_labels }} label{{ '' if total_labels == 1 else 's' }} defined</p>
|
|
</div>
|
|
<div class="col-auto">
|
|
<a href="{{ url_for('labels.new_label', category=active_category.id if active_category else None) }}" class="btn btn-danger me-2">
|
|
<i class="bi bi-plus-circle"></i> New Label
|
|
</a>
|
|
<a href="{{ url_for('labels.categories') }}" class="btn btn-outline-secondary me-2">
|
|
<i class="bi bi-diagram-3"></i> Categories
|
|
</a>
|
|
<a href="{{ url_for('labels.settings') }}" class="btn btn-outline-secondary me-2">
|
|
<i class="bi bi-sliders"></i> Settings
|
|
</a>
|
|
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-outline-secondary">
|
|
<i class="bi bi-arrow-left"></i> Back
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
{% if not rebrickable_ready %}
|
|
<div class="alert alert-info">
|
|
<i class="bi bi-info-circle"></i>
|
|
No Rebrickable API key yet, so part lookup is off. You can still add labels by hand and upload your own images.
|
|
<a href="{{ url_for('labels.settings') }}" class="alert-link">Add a key</a> — it's free.
|
|
</div>
|
|
{% endif %}
|
|
|
|
<div class="row">
|
|
<!-- Category tree -->
|
|
<div class="col-lg-3 mb-4">
|
|
<div class="card">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<strong><i class="bi bi-diagram-3"></i> Categories</strong>
|
|
<a href="{{ url_for('labels.index') }}" class="small text-decoration-none">All</a>
|
|
</div>
|
|
<div class="list-group list-group-flush" style="max-height: 70vh; overflow-y: auto;">
|
|
{% for root in roots %}
|
|
<a href="{{ url_for('labels.index', category=root.id) }}"
|
|
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center {% if active_category and active_category.id == root.id %}active{% endif %}">
|
|
<span>
|
|
<span class="badge rounded-pill me-1" style="background: {{ root.accent_colour() }};"> </span>
|
|
{{ root.display_code }} {{ root.name }}
|
|
</span>
|
|
<span class="badge bg-secondary rounded-pill">{{ root.label_count() }}</span>
|
|
</a>
|
|
{% for child in root.children %}
|
|
<a href="{{ url_for('labels.index', category=child.id) }}"
|
|
class="list-group-item list-group-item-action ps-4 small d-flex justify-content-between align-items-center {% if active_category and active_category.id == child.id %}active{% endif %}">
|
|
<span class="text-truncate">↳ {{ child.name }}</span>
|
|
<span class="badge bg-light text-dark rounded-pill">{{ child.label_count() }}</span>
|
|
</a>
|
|
{% endfor %}
|
|
{% else %}
|
|
<div class="list-group-item text-muted small">No categories yet.</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Labels -->
|
|
<div class="col-lg-9">
|
|
<div class="card mb-3">
|
|
<div class="card-body">
|
|
<form method="GET" class="row g-2 align-items-end">
|
|
{% if active_category %}<input type="hidden" name="category" value="{{ active_category.id }}">{% endif %}
|
|
<div class="col-md-8">
|
|
<label class="form-label small mb-1">Search labels</label>
|
|
<input type="search" name="q" value="{{ search }}" class="form-control"
|
|
placeholder="Part number, name, dimensions or drawer...">
|
|
</div>
|
|
<div class="col-md-4">
|
|
<button class="btn btn-outline-danger w-100"><i class="bi bi-search"></i> Search</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
{% if rebrickable_ready %}
|
|
<div class="card mb-3">
|
|
<div class="card-body">
|
|
<label class="form-label small mb-1"><i class="bi bi-cloud-arrow-down"></i> Look up a part on Rebrickable</label>
|
|
<div class="input-group">
|
|
<input type="text" id="lookupQuery" class="form-control" placeholder="e.g. 3001 or "brick 2 x 4"">
|
|
<button class="btn btn-outline-secondary" type="button" id="lookupBtn">Search</button>
|
|
</div>
|
|
<div id="lookupResults" class="row g-2 mt-2"></div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<form method="POST" action="{{ url_for('labels.print_sheet') }}" id="printForm">
|
|
<div class="card">
|
|
<div class="card-header d-flex flex-wrap gap-2 justify-content-between align-items-center">
|
|
<div>
|
|
<input type="checkbox" class="form-check-input me-1" id="checkAll">
|
|
<label for="checkAll" class="small">Select all on this page</label>
|
|
</div>
|
|
<div class="d-flex gap-2 align-items-center">
|
|
<select name="preset" class="form-select form-select-sm" style="min-width: 260px;">
|
|
{% for key, preset in presets.items() %}
|
|
<option value="{{ key }}" {% if key == default_preset %}selected{% endif %}>{{ preset.name }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
<button class="btn btn-sm btn-danger" type="submit">
|
|
<i class="bi bi-printer"></i> Print selected
|
|
</button>
|
|
{% if active_category %}
|
|
<a href="{{ url_for('labels.print_sheet', category=active_category.id, preset=default_preset) }}"
|
|
class="btn btn-sm btn-outline-danger">Print whole category</a>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="table-responsive">
|
|
<table class="table table-hover align-middle mb-0">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th style="width: 38px;"></th>
|
|
<th style="width: 60px;">Image</th>
|
|
<th>Part</th>
|
|
<th>Category</th>
|
|
<th>Drawer</th>
|
|
<th style="width: 110px;"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for label in labels %}
|
|
<tr>
|
|
<td><input type="checkbox" class="form-check-input label-check" name="ids" value="{{ label.id }}"></td>
|
|
<td>
|
|
{% if label.get_image() %}
|
|
<img src="{{ label.get_image() }}" alt="" style="width: 44px; height: 44px; object-fit: contain;">
|
|
{% else %}
|
|
<span class="text-muted"><i class="bi bi-image"></i></span>
|
|
{% endif %}
|
|
</td>
|
|
<td>
|
|
<strong>{{ label.display_title }}</strong>
|
|
{% if label.part_numbers_text %}
|
|
<div class="small text-muted">{{ label.part_numbers_text }}</div>
|
|
{% endif %}
|
|
{% if label.colour_name %}
|
|
<span class="badge bg-light text-dark">{{ label.colour_name }}</span>
|
|
{% endif %}
|
|
</td>
|
|
<td class="small">
|
|
{% if label.category %}
|
|
<span class="badge rounded-pill" style="background: {{ label.category.accent_colour() }};"> </span>
|
|
{{ label.category.full_name }}
|
|
{% else %}
|
|
<span class="text-muted">Uncategorised</span>
|
|
{% endif %}
|
|
</td>
|
|
<td class="small">{{ label.location or '-' }}</td>
|
|
<td class="text-end">
|
|
<a href="{{ url_for('labels.edit_label', label_id=label.id) }}" class="btn btn-sm btn-outline-secondary">
|
|
<i class="bi bi-pencil"></i>
|
|
</a>
|
|
<a href="{{ url_for('labels.print_sheet', ids=label.id, preset=default_preset) }}" class="btn btn-sm btn-outline-danger">
|
|
<i class="bi bi-printer"></i>
|
|
</a>
|
|
</td>
|
|
</tr>
|
|
{% else %}
|
|
<tr><td colspan="6" class="text-center text-muted py-4">
|
|
No labels here yet.
|
|
<a href="{{ url_for('labels.new_label', category=active_category.id if active_category else None) }}">Add the first one</a>.
|
|
</td></tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
|
|
{% if pagination.pages > 1 %}
|
|
<nav class="mt-3">
|
|
<ul class="pagination justify-content-center">
|
|
<li class="page-item {% if not pagination.has_prev %}disabled{% endif %}">
|
|
<a class="page-link" href="{{ url_for('labels.index', page=pagination.prev_num, category=active_category.id if active_category else None, q=search) }}">Previous</a>
|
|
</li>
|
|
<li class="page-item disabled"><span class="page-link">{{ pagination.page }} / {{ pagination.pages }}</span></li>
|
|
<li class="page-item {% if not pagination.has_next %}disabled{% endif %}">
|
|
<a class="page-link" href="{{ url_for('labels.index', page=pagination.next_num, category=active_category.id if active_category else None, q=search) }}">Next</a>
|
|
</li>
|
|
</ul>
|
|
</nav>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Hidden form used to import a Rebrickable result -->
|
|
<form method="POST" action="{{ url_for('labels.import_part') }}" id="importForm">
|
|
<input type="hidden" name="part_number" id="importPartNumber">
|
|
<input type="hidden" name="category_id" value="{{ active_category.id if active_category else '' }}">
|
|
</form>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script>
|
|
document.getElementById('checkAll')?.addEventListener('change', function () {
|
|
document.querySelectorAll('.label-check').forEach(box => { box.checked = this.checked; });
|
|
});
|
|
|
|
const lookupBtn = document.getElementById('lookupBtn');
|
|
if (lookupBtn) {
|
|
const runLookup = async () => {
|
|
const query = document.getElementById('lookupQuery').value.trim();
|
|
const target = document.getElementById('lookupResults');
|
|
if (!query) { target.innerHTML = ''; return; }
|
|
target.innerHTML = '<div class="col-12 text-muted small">Searching Rebrickable...</div>';
|
|
try {
|
|
const response = await fetch('{{ url_for("labels.lookup") }}?q=' + encodeURIComponent(query));
|
|
const data = await response.json();
|
|
if (!data.ok) {
|
|
target.innerHTML = '<div class="col-12 text-danger small">' + (data.reason || 'Lookup failed.') + '</div>';
|
|
return;
|
|
}
|
|
if (!data.results.length) {
|
|
target.innerHTML = '<div class="col-12 text-muted small">Nothing found.</div>';
|
|
return;
|
|
}
|
|
target.innerHTML = data.results.map(part => `
|
|
<div class="col-6 col-md-4 col-xl-3">
|
|
<div class="card h-100">
|
|
<div class="card-body p-2 text-center">
|
|
${part.image_url ? `<img src="${part.image_url}" alt="" style="height:60px;object-fit:contain;">` : ''}
|
|
<div class="small fw-semibold text-truncate" title="${part.name}">${part.name}</div>
|
|
<div class="small text-muted">${part.part_number}</div>
|
|
<button type="button" class="btn btn-sm btn-outline-danger mt-1 import-part" data-part="${part.part_number}">Add label</button>
|
|
</div>
|
|
</div>
|
|
</div>`).join('');
|
|
target.querySelectorAll('.import-part').forEach(button => {
|
|
button.addEventListener('click', () => {
|
|
document.getElementById('importPartNumber').value = button.dataset.part;
|
|
document.getElementById('importForm').submit();
|
|
});
|
|
});
|
|
} catch (err) {
|
|
target.innerHTML = '<div class="col-12 text-danger small">Lookup failed.</div>';
|
|
}
|
|
};
|
|
lookupBtn.addEventListener('click', runLookup);
|
|
document.getElementById('lookupQuery').addEventListener('keydown', e => {
|
|
if (e.key === 'Enter') { e.preventDefault(); runLookup(); }
|
|
});
|
|
}
|
|
</script>
|
|
{% endblock %}
|