Files
lego-instructions-manager/app/templates/admin/labels/tape.html
T
jessikittyandClaude Opus 5 085fe1d701 Tape page: combine/split buttons, text size slider, print guard
Tick labels under the preview to put them on one label; the grouping is
rebuilt from the whole plan, not just the twelve previewed. Text size
gains a slider and Normal/Large/Huge/Max presets. The print button
disables itself after a click so a double-click can't spend the tape
twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vunz4VLxAYDBnU5tLciLDC
2026-10-13 10:31:25 +11:00

449 lines
22 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}Tape Printing - 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 = 'labels' %}
{% set bar_sub = 'Brother VC-500W continuous tape' %}
{% set crumbs = [{'name': 'My Labels', 'url': url_for('labels.index')}, {'name': 'Tape printing'}] %}
{% include 'admin/labels/_nav.html' %}
<form method="POST" action="{{ url_for('tape.index') }}" id="tapeForm">
<input type="hidden" name="ids" value="{{ ids }}">
{# Always submitted, so an unticked checkbox can be told apart from a page
opened with no options at all. Without it, a default-on checkbox could
never be turned off. #}
<input type="hidden" name="_opts" value="1">
{# Which parts share a physical label: "1,2|3". Rewritten by the Combine
and Split buttons under the preview. Empty means "group by drawer". #}
<input type="hidden" name="groups" id="groupSpec" value="{{ request.values.get('groups', '') }}">
<div class="row g-2 mt-0">
<!-- Options -->
<div class="col-lg-4">
<div class="bl-panel">
<div class="bl-panel-head"><span>Label size</span></div>
<div class="bl-panel-body">
<label class="fld">Size</label>
<select name="size_mode" class="w-100 mb-2" id="sizeSelect">
{% for key, text in size_modes %}
<option value="{{ key }}" {% if key == options.size_mode %}selected{% endif %}>{{ text }}</option>
{% endfor %}
</select>
{% if options.is_fixed %}
<div class="k mb-2" style="text-transform:none;">
Labels are {{ label_width_mm|int }}mm wide and lie across the tape, so each one
only uses its height in tape length.
</div>
<label class="d-flex align-items-center gap-2 mb-1" style="font-size:12px;">
<input type="checkbox" name="group_drawers" value="1"
{% if options.group_drawers %}checked{% endif %}>
Combine parts that share a drawer
</label>
<div class="k mb-2" style="text-transform:none;">
Two part types in one drawer become a single 40 &times; 20mm label.
Matched on the drawer / location field.
</div>
{% endif %}
</div>
</div>
<div class="bl-panel">
<div class="bl-panel-head"><span>Tape &amp; layout</span></div>
<div class="bl-panel-body">
<label class="fld">Tape loaded in the printer</label>
<select name="tape" class="w-100 mb-2" id="tapeSelect">
{% for key, preset in tapes.items() %}
<option value="{{ key }}" {% if key == options.tape %}selected{% endif %}>{{ preset.name }}</option>
{% endfor %}
</select>
<div class="k mb-2" style="text-transform:none;">{{ tape.note }}</div>
<div class="row g-2">
<div class="col-6">
<label class="fld">Copies each</label>
<input type="number" name="copies" min="1" max="99" class="w-100"
value="{{ options.copies }}">
</div>
<div class="col-6">
<label class="fld">Text size
<span class="num" id="fontOut">{{ '%.2f'|format(options.font_scale) }}&times;</span>
</label>
<input type="number" name="font_scale" step="0.05" min="0.5" max="3"
class="w-100" value="{{ options.font_scale }}" id="fontNum">
</div>
<div class="col-12">
<input type="range" min="0.5" max="3" step="0.05" class="w-100"
value="{{ options.font_scale }}" id="fontRange">
<div class="d-flex gap-1 flex-wrap">
{% for preset, text in [(1.0, 'Normal'), (1.5, 'Large'), (2.0, 'Huge'), (3.0, 'Max')] %}
<button type="button" class="bl-btn bl-btn-sm font-preset"
data-scale="{{ preset }}">{{ text }}</button>
{% endfor %}
</div>
<div class="k mt-1" style="text-transform:none;">
The part name is sized first and the image gives way to
it, so turning this up shrinks the picture rather than
clipping the text. The whole name always prints - it wraps
and settles at the largest size that fits, so a long name
stops growing before a short one does. Untick
<em>Part image</em> for the biggest type of all.
</div>
</div>
<div class="col-6">
<label class="fld">Image width %</label>
<input type="number" name="image_percent" min="0" max="60"
class="w-100" value="{{ options.image_percent }}">
</div>
<div class="col-6">
<label class="fld">Padding (mm)</label>
<input type="number" name="padding_mm" step="0.1" min="0" max="10"
class="w-100" value="{{ options.padding_mm }}">
</div>
{% if not options.is_fixed %}
<div class="col-6">
<label class="fld">Fixed length (mm)</label>
<input type="number" name="fixed_length_mm" step="1" min="12" max="430"
class="w-100" value="{{ options.fixed_length_mm or '' }}"
placeholder="auto">
</div>
{% endif %}
<div class="col-6">
<label class="fld">Align</label>
<select name="align" class="w-100">
<option value="left" {% if options.align == 'left' %}selected{% endif %}>Left</option>
<option value="center" {% if options.align == 'center' %}selected{% endif %}>Centre</option>
</select>
</div>
</div>
<hr class="my-2">
{% if not options.is_fixed %}
<label class="d-flex align-items-center gap-2 mb-1" style="font-size:12px;">
<input type="checkbox" name="rotate" value="1" {% if options.rotate %}checked{% endif %}>
Rotate for tape feed
</label>
<div class="k mb-2" style="text-transform:none;">
If your print comes out sideways, untick this. To make the change
permanent, set it in <a href="{{ url_for('labels.settings') }}">Settings</a>.
</div>
{% else %}
{# Fixed sizes always lie across the tape, so there is nothing to
choose - carry the value through so the form round-trips. #}
<input type="hidden" name="rotate" value="{{ '1' if options.rotate else '' }}">
{% endif %}
<label class="d-flex align-items-center gap-2" style="font-size:12px;">
<input type="checkbox" name="outline" value="1" {% if options.outline %}checked{% endif %}>
Outline each label
</label>
</div>
</div>
<div class="bl-panel">
<div class="bl-panel-head"><span>Show on label</span></div>
<div class="bl-panel-body">
{% for value, text in field_options %}
<label class="d-flex align-items-center gap-2" style="font-size:12px;">
<input type="checkbox" name="fields" value="{{ value }}"
{% if value in options.fields %}checked{% endif %}> {{ text }}
</label>
{% endfor %}
</div>
</div>
</div>
<!-- Preview and actions -->
<div class="col-lg-8">
<div class="bl-panel">
<div class="bl-panel-head">
<span>Printer</span>
<span>
{% if printer.host %}
<span class="num">{{ printer.host }}:{{ printer.port }}</span>
{% else %}
<span class="bl-tag warn">Not configured</span>
{% endif %}
</span>
</div>
<div class="bl-panel-body">
{% if printer.host %}
<div class="d-flex gap-2 flex-wrap align-items-center">
<button type="button" class="bl-btn" id="testBtn">
<i class="bi bi-plug"></i> Test connection
</button>
<button type="submit" formaction="{{ url_for('tape.print_tape') }}"
class="bl-btn bl-btn-go">
<i class="bi bi-printer"></i> Print {{ plan|length }} label{{ '' if plan|length == 1 else 's' }} to tape
</button>
<button type="submit" formaction="{{ url_for('tape.download_pdf') }}"
class="bl-btn">
<i class="bi bi-file-pdf"></i> Download PDF
</button>
</div>
<div class="k mt-2" style="text-transform:none;">
Jobs are sent one at a time: LIM waits for the printer to
finish each label before sending the next, because the
VC-500W answers "busy" to anything that arrives mid-print.
So a big run takes a while - leave the page alone until it
comes back rather than refreshing, or you'll print twice.
</div>
<div id="testResult" class="mt-2" style="font-size:12px;"></div>
{% else %}
<p class="mb-2">
No tape printer address set yet. Put the VC-500W's IP address into
<a href="{{ url_for('labels.settings') }}">Label Settings</a> and it can be
printed to directly over the network.
</p>
<button type="submit" formaction="{{ url_for('tape.download_pdf') }}" class="bl-btn">
<i class="bi bi-file-pdf"></i> Download PDF instead
</button>
{% endif %}
</div>
</div>
<div class="bl-panel">
<div class="bl-panel-head">
<span>Preview &mdash; {{ plan|length }} label{{ '' if plan|length == 1 else 's' }}
from {{ labels|length }} part{{ '' if labels|length == 1 else 's' }}</span>
<button type="submit" class="bl-btn bl-btn-sm">Refresh preview</button>
</div>
<div class="bl-panel-body">
{% if not options.fields %}
<div class="mb-2"><span class="bl-tag warn">Nothing selected</span>
Every field is unticked, so the labels will print blank.
Tick something under <em>Show on label</em>.</div>
{% endif %}
{% if options.is_fixed and not tape_fits %}
<div class="mb-2"><span class="bl-tag err">Tape too narrow</span>
A {{ label_width_mm|int }}mm label needs 50mm tape.
{{ tape.name }} is {{ tape.printable_mm }}mm printable, so labels
will be scaled down. Load 50mm tape, or pick a different size.</div>
{% endif %}
<div class="d-flex gap-2 flex-wrap align-items-center mb-2" id="groupBar"
data-plan="{{ group_spec }}">
<span class="k" style="text-transform:none;">Tick labels, then:</span>
<button type="button" class="bl-btn bl-btn-sm" id="combineBtn">
<i class="bi bi-box-arrow-in-down"></i> Combine onto one label
</button>
<button type="button" class="bl-btn bl-btn-sm" id="splitBtn">
<i class="bi bi-scissors"></i> Split apart
</button>
<button type="button" class="bl-btn bl-btn-sm" id="resetGroupsBtn">
<i class="bi bi-arrow-counterclockwise"></i> Back to drawers
</button>
</div>
<div class="k mb-2" style="text-transform:none;">
For customs and MOC drawers, where the parts have no drawer in
common. A combined label grows 5mm for each part past the second,
and hand-made groups are kept whatever the size setting says.
</div>
<div class="bl-tape-preview">
{% for item in plan[:12] %}
<div class="mb-2 plan-card" data-ids="{{ item.labels | map(attribute='id') | join(',') }}">
<label class="d-flex align-items-center gap-2 mb-1" style="font-size:12px;">
<input type="checkbox" class="plan-pick">
<span>{{ item.labels | map(attribute='display_title') | join(' + ') }}</span>
</label>
<img alt="{{ item.labels | map(attribute='display_title') | join(' + ') }}"
src="{{ url_for('tape.preview',
ids=item.labels | map(attribute='id') | join(','),
tape=options.tape, _opts='1',
size_mode=options.size_mode,
group_drawers='1' if options.group_drawers else '',
font_scale=options.font_scale,
image_percent=options.image_percent,
padding_mm=options.padding_mm,
align=options.align,
outline='1' if options.outline else '',
fixed_length_mm=options.fixed_length_mm or '',
fields=options.fields) }}">
{% if item.width_mm %}
<div class="k" style="text-transform:none;margin-top:2px;">
{{ item.width_mm|int }} &times; {{ item.height_mm|int }}mm
{%- if item.labels|length > 1 %} &middot;
{{ item.labels|length }} parts in {{ item.drawer or 'one drawer' }}
{%- elif item.drawer %} &middot; {{ item.drawer }}{% endif %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% if plan|length > 12 %}
<div class="k mt-2" style="text-transform:none;">
Showing the first 12 of {{ plan|length }}. All of them print.
</div>
{% endif %}
<div class="k mt-2" style="text-transform:none;">
313 dpi on {{ tape.tape_mm }}mm tape ({{ tape.printable_mm }}mm printable).
{% if options.is_fixed %}
Labels lie across the tape, so this run uses about
<strong>{{ (tape_used_mm / 10)|round(1) }}cm</strong> of tape
({{ options.copies }} cop{{ 'y' if options.copies == 1 else 'ies' }} each).
{% else %}
Preview shown flat; the job is rotated if "Rotate for tape feed" is ticked.
{% endif %}
</div>
</div>
</div>
</div>
</div>
</form>
</div>
{% endblock %}
{% block extra_js %}
<script>
// One click, one print run. A double-click used to queue the batch twice,
// which on tape costs real money.
document.getElementById('tapeForm')?.addEventListener('submit', function (ev) {
const btn = ev.submitter;
if (btn && btn.formAction && btn.formAction.indexOf('/print') !== -1) {
if (btn.dataset.sent) { ev.preventDefault(); return; }
btn.dataset.sent = '1';
btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Printing…';
}
});
document.getElementById('testBtn')?.addEventListener('click', function () {
const out = document.getElementById('testResult');
out.innerHTML = '<span class="bl-tag">Probing…</span>';
const body = new URLSearchParams();
fetch('{{ url_for("tape.test_printer") }}', {method: 'POST', body: body})
.then(r => r.json())
.then(d => {
if (!d.ok) {
out.innerHTML = '<span class="bl-tag err">No answer</span> ' +
(d.error || 'Unknown error');
return;
}
const dash = '—';
const rows = [
['Model', d.model || dash],
['State', (d.state || dash) + (d.state_message ? ' (' + d.state_message + ')' : '')],
['Accepts', (d.formats || []).join(', ') || dash],
['Will send as', d.usable_format || dash],
['Resolution', (d.resolutions || []).join(', ') || dash]
];
out.innerHTML = '<span class="bl-tag ok">Connected</span> ' +
(d.advice || '') +
'<table class="bl-table mt-2"><tbody>' +
rows.map(r => '<tr><td style="width:120px;font-weight:600;">' +
r[0] + '</td><td>' + r[1] + '</td></tr>').join('') +
'</tbody></table>';
})
.catch(e => { out.innerHTML = '<span class="bl-tag err">Failed</span> ' + e; });
});
// Changing the tape or the label size re-renders the preview.
['tapeSelect', 'sizeSelect'].forEach(id => {
document.getElementById(id)?.addEventListener('change',
() => document.getElementById('tapeForm').submit());
});
// Text size: slider, box and presets all drive the one value.
(function () {
const num = document.getElementById('fontNum');
const range = document.getElementById('fontRange');
const out = document.getElementById('fontOut');
if (!num || !range) { return; }
const show = v => { if (out) { out.textContent = Number(v).toFixed(2) + '×'; } };
num.addEventListener('input', () => { range.value = num.value; show(num.value); });
range.addEventListener('input', () => { num.value = range.value; show(range.value); });
range.addEventListener('change', () => document.getElementById('tapeForm').submit());
document.querySelectorAll('.font-preset').forEach(btn => {
btn.addEventListener('click', () => {
num.value = range.value = btn.dataset.scale;
show(btn.dataset.scale);
document.getElementById('tapeForm').submit();
});
});
})();
// Combining parts onto one label by hand.
//
// The grouping lives in the hidden `groups` field as "1,2|3". These buttons
// rewrite it from what is ticked and resubmit, so the preview always shows
// the real physical labels rather than a guess.
//
// The starting point is the whole current plan (data-plan), not just the
// cards on screen - only the first 12 labels are previewed, and a spec built
// from those alone would quietly un-group everything after them.
(function () {
const spec = document.getElementById('groupSpec');
const bar = document.getElementById('groupBar');
const form = document.getElementById('tapeForm');
if (!spec || !bar || !form) { return; }
const plan = () => (bar.dataset.plan || '')
.split('|').filter(Boolean).map(g => g.split(',').filter(Boolean));
function tickedIds() {
const ids = new Set();
document.querySelectorAll('.plan-card').forEach(card => {
if (card.querySelector('.plan-pick')?.checked) {
(card.dataset.ids || '').split(',').filter(Boolean)
.forEach(id => ids.add(id));
}
});
return ids;
}
function write(groups) {
spec.value = groups.filter(g => g.length).map(g => g.join(',')).join('|');
form.submit();
}
document.getElementById('combineBtn')?.addEventListener('click', () => {
const picked = tickedIds();
if (picked.size < 2) {
alert('Tick two or more labels to combine them onto one.');
return;
}
const groups = [];
const merged = [];
let placed = false;
plan().forEach(group => {
const mine = group.filter(id => picked.has(id));
const rest = group.filter(id => !picked.has(id));
if (mine.length) {
merged.push(...mine);
// Keep the combined label where the first ticked part was,
// so the preview does not jump to the end of a long list.
if (!placed) { groups.push(merged); placed = true; }
}
if (rest.length) { groups.push(rest); }
});
write(groups);
});
document.getElementById('splitBtn')?.addEventListener('click', () => {
const picked = tickedIds();
if (!picked.size) {
alert('Tick the labels you want split apart first.');
return;
}
const groups = [];
plan().forEach(group => {
const rest = group.filter(id => !picked.has(id));
group.filter(id => picked.has(id)).forEach(id => groups.push([id]));
if (rest.length) { groups.push(rest); }
});
write(groups);
});
document.getElementById('resetGroupsBtn')?.addEventListener('click', () => {
spec.value = '';
form.submit();
});
})();
</script>
{% endblock %}