Search spelling suggestions across every list

This commit is contained in:
2026-08-21 11:15:06 -04:00
parent fdf3c51cb0
commit 04107ff8ab
18 changed files with 1005 additions and 82 deletions

View File

@@ -3,9 +3,12 @@
*
* `default` = AppLayout child routes; `toplevel` = full-screen routes.
*
* The index sits inside the app shell. Each tool that PRINTS is toplevel, the
* way every other label page in this repo is: printing from inside AppLayout
* would put the sidebar and header on the label stock.
* The generator sits INSIDE the app shell, unlike the label pages in core.
* Those are toplevel because a print from inside AppLayout would put the
* sidebar on the label stock - which is true unless the print stylesheet says
* otherwise, and style.css now does: the shell is hidden at print time so only
* the labels reach the page. A tool someone spends ten minutes in wants the
* navigation; a one-shot print page does not.
*/
export default [
{
@@ -14,11 +17,8 @@ export default [
component: () => import('./views/ToolsIndex.vue'),
meta: { requiresAuth: true, plugin: 'tools' }
},
]
export const toplevel = [
{
path: '/tools/codes',
path: 'tools/codes',
name: 'tools-codes',
component: () => import('./views/CodeGenerator.vue'),
meta: { requiresAuth: true, plugin: 'tools' }

View File

@@ -1,12 +1,12 @@
<template>
<div>
<div class="code-generator">
<div class="no-print">
<div class="controls">
<div class="controls-head">
<h3>Barcode / QR Generator</h3>
<router-link to="/tools" class="btn btn-secondary">Back to Tech Tools</router-link>
</div>
<div class="page-header">
<h1>Barcode / QR Generator</h1>
<router-link to="/tools" class="btn btn-secondary">Back to Tech Tools</router-link>
</div>
<div class="controls card">
<!-- What to encode -->
<div class="control-row">
<label>
@@ -66,9 +66,9 @@
<p class="control-note" v-if="csvError">{{ csvError }}</p>
<p class="control-hint">
Columns: <code>content</code> (required), <code>label</code>, <code>copies</code><span
v-if="backenabled && backsource === 'column'">, <code>back</code>, <code>backlabel</code></span>.
v-if="backCarriesOwnContent">, <code>back</code>, <code>backlabel</code></span>.
A header row is optional; without one the order is content, label, copies<span
v-if="backenabled && backsource === 'column'">, back, backlabel</span>.
v-if="backCarriesOwnContent">, back, backlabel</span>.
</p>
</div>
@@ -135,7 +135,7 @@
Align
<select v-model="align">
<option value="flex-start">Left</option>
<option value="center">Centre</option>
<option value="center">Center</option>
<option value="flex-end">Right</option>
</select>
</label>
@@ -159,6 +159,47 @@
is for a printer whose origin is off, not for composing a label.
</p>
<!-- Picture -->
<h4 class="section-heading">Picture</h4>
<div class="control-row">
<label>
Image file
<input type="file" accept="image/*" @change="onPictureFile" />
</label>
<button v-if="pictureUrl" class="btn btn-secondary" @click="clearPicture">Remove picture</button>
<template v-if="pictureUrl">
<label>Size (in)<input v-model.number="picturesize" type="number" step="0.01" min="0.05" /></label>
<label>
Where
<select v-model="pictureposition">
<option value="above">Above the code</option>
<option value="beside">Beside the code</option>
<option value="behind">Behind everything (watermark)</option>
</select>
</label>
<label>
Which side
<select v-model="pictureside">
<option value="front">Front only</option>
<option value="back">Back only</option>
<option value="both">Both sides</option>
</select>
</label>
<label v-if="pictureposition === 'behind'">
Opacity
<input v-model.number="pictureopacity" type="number" step="0.05" min="0.05" max="1" />
</label>
</template>
</div>
<p class="control-hint">
The picture never leaves the browser, so this works on an air-gapped
site. It is also NOT remembered between visits, unlike the sizing -
an image is far too big to keep in the settings the page saves.
A watermark behind a code costs the scanner contrast: keep the opacity
low and test a scan before printing a run.
</p>
<!-- Back side -->
<h4 class="section-heading">Back side</h4>
<div class="control-row">
@@ -170,19 +211,27 @@
<label>
Back carries
<select v-model="backsource">
<option value="text">The same fixed text</option>
<option value="code">The same code again</option>
<option value="column">Its own content (per row)</option>
<option value="column">Its own code (per row)</option>
<option value="text">Fixed text, no code</option>
<option value="blank">Nothing - blank back</option>
</select>
</label>
<label v-if="backCarriesCode">
Back code type
<select v-model="backcodetype">
<option value="same">Same as the front</option>
<option value="qr">QR</option>
<option value="barcode">CODE128</option>
</select>
</label>
<label>
Back layout
<select v-model="backlayout">
<option value="side">Code left, label right</option>
<option value="stack">Code above label</option>
<option value="codeonly">Code only</option>
<option value="textonly">Text only</option>
<option value="textonly" :disabled="backCarriesCode">Text only, no code</option>
</select>
</label>
<label>
@@ -196,15 +245,16 @@
</div>
<div v-if="backenabled" class="control-row">
<label class="grow" v-if="backsource === 'text' || backsource === 'column'">
<label class="grow" v-if="source === 'single' && backCarriesOwnContent">
Back content (encoded on the back)
<textarea v-model="singleBack" rows="2"
placeholder="A second code for the reverse - text or URL"></textarea>
</label>
<label class="grow" v-if="backsource === 'text' || backCarriesOwnContent">
Back text
<textarea v-model="backtext" rows="2"
placeholder="Property of GE Aerospace - return to IT"></textarea>
</label>
<label class="grow" v-if="source === 'single' && backsource === 'column'">
Back content (encoded on the back)
<input v-model="singleBack" type="text" placeholder="Text or URL for the reverse" />
</label>
</div>
<p v-if="backenabled" class="control-hint">
@@ -241,20 +291,27 @@
<!-- Preview doubles as the print surface: what is on screen is what prints. -->
<div class="sheet">
<div
v-for="(page, index) in visiblePages"
:key="index"
v-for="item in visible"
:key="item.index"
class="label"
:class="['layout-' + layoutFor(page), 'type-' + codetype, 'side-' + page.side]"
:class="labelClasses(item.page)"
>
<img v-if="showPicture(item.page) && pictureposition === 'behind'"
:src="pictureUrl" class="picture-behind" alt="" />
<div class="content">
<div v-if="layoutFor(page) !== 'textonly'" class="codebox">
<img v-if="images[index]" :src="images[index]" class="code-img" alt="" />
<div v-if="showPicture(item.page) && pictureposition !== 'behind'" class="picturebox">
<img :src="pictureUrl" class="picture-img" alt="" />
</div>
<div v-if="layoutFor(page) !== 'codeonly' && page.label" class="label-text">
{{ page.label }}
<div class="codegroup">
<div v-if="layoutFor(item.page) !== 'textonly'" class="codebox">
<img v-if="images[item.index]" :src="images[item.index]" class="code-img" alt="" />
</div>
<div v-if="layoutFor(item.page) !== 'codeonly' && item.page.label" class="label-text">
{{ item.page.label }}
</div>
</div>
</div>
<span v-if="backenabled" class="side-flag">{{ page.side }}</span>
<span v-if="backenabled" class="side-flag">{{ item.page.side }}</span>
</div>
</div>
</div>
@@ -298,10 +355,20 @@ const nudgex = ref(0)
const nudgey = ref(0)
const dpi = ref(203)
// The image itself is deliberately NOT a saved setting: a data URL runs to
// megabytes and would blow the localStorage quota, taking every sizing value
// with it. Only how it is placed persists.
const pictureUrl = ref('')
const picturesize = ref(0.5)
const pictureposition = ref('above')
const pictureside = ref('front')
const pictureopacity = ref(0.15)
const backenabled = ref(false)
const backsource = ref('text')
const backsource = ref('code')
const backcodetype = ref('same')
const backtext = ref('')
const backlayout = ref('textonly')
const backlayout = ref('stack')
const backorder = ref('interleave')
const images = ref([])
@@ -312,13 +379,61 @@ const SETTING_KEYS = [
'codetype', 'errorcorrection', 'preset', 'labelwidth', 'labelheight', 'padding',
'quiet', 'gap', 'codesize', 'barheight', 'labelfont', 'layout', 'align', 'valign',
'contentx', 'contenty', 'nudgex', 'nudgey', 'dpi',
'backenabled', 'backsource', 'backtext', 'backlayout', 'backorder',
'picturesize', 'pictureposition', 'pictureside', 'pictureopacity',
'backenabled', 'backsource', 'backcodetype', 'backtext', 'backlayout', 'backorder',
]
const settingRefs = {
codetype, errorcorrection, preset, labelwidth, labelheight, padding,
quiet, gap, codesize, barheight, labelfont, layout, align, valign,
contentx, contenty, nudgex, nudgey, dpi,
backenabled, backsource, backtext, backlayout, backorder,
picturesize, pictureposition, pictureside, pictureopacity,
backenabled, backsource, backcodetype, backtext, backlayout, backorder,
}
// --- back side --------------------------------------------------------------
const backCarriesCode = computed(() =>
backsource.value === 'code' || backsource.value === 'column')
const backCarriesOwnContent = computed(() =>
backenabled.value && backsource.value === 'column')
// A back that carries a code and a back laid out as text-only cancel each other
// out, and the result is a blank card that looks like the code failed to
// render. Keep the two honest in both directions, including for settings
// restored from a previous visit.
function reconcileBackLayout() {
if (backCarriesCode.value && backlayout.value === 'textonly') backlayout.value = 'stack'
if (!backCarriesCode.value && backlayout.value !== 'textonly') backlayout.value = 'textonly'
}
watch(backsource, reconcileBackLayout)
/** QR or CODE128 for this page. The back may deliberately differ from the
* front: a QR that opens a record, a CODE128 the bench scanner already
* understands. */
function codeTypeFor(page) {
if (page.side !== 'back') return codetype.value
return backcodetype.value === 'same' ? codetype.value : backcodetype.value
}
function layoutFor(page) {
return page.side === 'back' ? backlayout.value : layout.value
}
function showPicture(page) {
if (!pictureUrl.value) return false
if (pictureside.value === 'both') return true
return pictureside.value === page.side
}
function labelClasses(page) {
return [
'layout-' + layoutFor(page),
'type-' + codeTypeFor(page),
'side-' + page.side,
showPicture(page) && pictureposition.value !== 'behind' ? 'pic-' + pictureposition.value : '',
]
}
// --- rows -------------------------------------------------------------------
@@ -366,19 +481,26 @@ const pages = computed(() => built.value.pages)
const cardCount = computed(() => built.value.fronts.length)
const truncated = computed(() => built.value.dropped)
// Printing one test label prints the first card only - both of its sides when
// there is a back, because a back that lands upside down is the whole reason
// to print one first.
const visiblePages = computed(() => {
if (!testMode.value) return pages.value
// What the preview renders, each item carrying its index into `images`. The
// index has to travel with the page: a test print shows two of the pages and
// the rendered codes are keyed by position in the full run, so pairing them by
// loop position printed the wrong code onto the test card.
const visible = computed(() => {
const all = pages.value.map((page, index) => ({ page, index }))
if (!testMode.value) return all
const { fronts, backs } = built.value
return [fronts[0], backenabled.value ? backs[0] : null].filter(Boolean)
if (!fronts.length) return []
const items = [{ page: fronts[0], index: 0 }]
if (backenabled.value && backs.length) {
// Where the first back sits depends on the order the run is emitted in.
items.push({
page: backs[0],
index: backorder.value === 'grouped' ? fronts.length : 1,
})
}
return items
})
function layoutFor(page) {
return page.side === 'back' ? backlayout.value : layout.value
}
// --- fit advice -------------------------------------------------------------
// Modules across the QR at the current content and error correction, quiet
@@ -416,10 +538,10 @@ const fitAdvice = computed(() => {
//
// margin 0 on the QR: the quiet zone is blank label supplied by --tool-quiet,
// so none of the code box is spent on white we cannot then adjust.
async function renderOne(row) {
const text = row.content
async function renderOne(page) {
const text = page.content
if (!text) return ''
if (codetype.value === 'qr') {
if (codeTypeFor(page) === 'qr') {
return qrSvgDataUri(text, { errorCorrection: errorcorrection.value })
}
return barcodeSvgDataUri(text)
@@ -453,6 +575,7 @@ const VAR_NAMES = [
'--tool-label-w', '--tool-label-h', '--tool-pad', '--tool-code', '--tool-barh',
'--tool-quiet', '--tool-gap', '--tool-font', '--tool-align', '--tool-valign',
'--tool-content-x', '--tool-content-y', '--tool-nudge-x', '--tool-nudge-y',
'--tool-pic', '--tool-pic-opacity',
]
function applyVars() {
@@ -478,6 +601,8 @@ function applyVars() {
root.style.setProperty('--tool-content-y', contenty.value + 'in')
root.style.setProperty('--tool-nudge-x', nudgex.value + 'in')
root.style.setProperty('--tool-nudge-y', nudgey.value + 'in')
root.style.setProperty('--tool-pic', picturesize.value + 'in')
root.style.setProperty('--tool-pic-opacity', String(pictureopacity.value))
}
function clearVars() {
@@ -510,6 +635,21 @@ function resetSettings() {
nudgey.value = 0
}
// --- picture ----------------------------------------------------------------
function onPictureFile(event) {
const file = event.target.files && event.target.files[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => { pictureUrl.value = String(reader.result || '') }
reader.onerror = () => { pictureUrl.value = '' }
reader.readAsDataURL(file)
}
function clearPicture() {
pictureUrl.value = ''
}
// --- csv helpers ------------------------------------------------------------
function onCsvFile(event) {
@@ -524,8 +664,7 @@ function onCsvFile(event) {
function downloadTemplate() {
// The template carries the back columns only when they would be read, so a
// one-sided run is not handed a CSV with two columns it must ignore.
const perRowBack = backenabled.value && backsource.value === 'column'
const csv = perRowBack
const csv = backCarriesOwnContent.value
? [
'content,label,copies,back,backlabel',
'https://example.com/asset/1,Bay 12 press,1,https://example.com/asset/1/history,Service history',
@@ -571,6 +710,7 @@ onMounted(() => {
if (saved[key] !== undefined) settingRefs[key].value = saved[key]
}
} catch { /* ignore a corrupt entry */ }
reconcileBackLayout()
applyVars()
renderAll()
})
@@ -582,7 +722,7 @@ watch(SETTING_KEYS.map(key => settingRefs[key]), () => {
saveSettings()
})
watch([pages, codetype, errorcorrection], renderAll)
watch([pages, codetype, backcodetype, errorcorrection], renderAll)
</script>
<style scoped>
@@ -590,7 +730,7 @@ watch([pages, codetype, errorcorrection], renderAll)
to Margins = None and Scale = 100%. */
@page { size: var(--tool-label-w) var(--tool-label-h); margin: 0; }
.no-print { padding: 20px; }
.code-generator { width: 100%; }
.controls {
background: var(--bg-card);
@@ -598,19 +738,8 @@ watch([pages, codetype, errorcorrection], renderAll)
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
max-width: 66rem;
}
.controls-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.controls-head h3 { margin: 0; }
.section-heading {
margin: 1.25rem 0 0.5rem;
font-size: 0.9rem;
@@ -675,7 +804,7 @@ watch([pages, codetype, errorcorrection], renderAll)
.fit-advice { flex: 1 1 22rem; align-self: center; }
.sheet {
padding: 20px;
padding: 20px 0;
display: flex;
flex-wrap: wrap;
gap: 10px;
@@ -701,21 +830,64 @@ watch([pages, codetype, errorcorrection], renderAll)
}
/* Everything printed sits in here, so it can be aligned and nudged as one
block without the label box following it off the stock. */
block without the label box following it off the stock. No max-width or
max-height: overflow must CLIP against the label, the way it did before this
wrapper existed. Constrain it and the flex children shrink instead, and the
one that gives way is the barcode - it is the only box allowed to flex. */
.content {
display: flex;
align-items: center;
gap: var(--tool-gap);
max-width: 100%;
max-height: 100%;
position: relative;
z-index: 1;
transform: translate(var(--tool-content-x, 0), var(--tool-content-y, 0));
}
.label.layout-stack .content { flex-direction: column; justify-content: center; }
/* The code and its caption, which is the group a picture sits above or beside. */
.codegroup {
display: flex;
align-items: center;
gap: var(--tool-gap);
}
.label.layout-stack .codegroup { flex-direction: column; justify-content: center; }
.label.pic-above .content { flex-direction: column; }
.label.pic-beside .content { flex-direction: row; }
/* A CODE128 wants the width, so its block takes the label rather than sitting
in the middle of it. */
.label.type-barcode .content { width: 100%; }
.label.type-barcode .content,
.label.type-barcode .codegroup { width: 100%; }
.picturebox {
flex: 0 0 auto;
width: var(--tool-pic);
height: var(--tool-pic);
display: flex;
align-items: center;
justify-content: center;
}
.picture-img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
/* A watermark sits under the code, not in it, and only as dark as the setting
says - a scanner reads contrast, and this is the control that costs it. */
.picture-behind {
position: absolute;
top: 50%;
left: 50%;
width: var(--tool-pic);
height: var(--tool-pic);
transform: translate(-50%, -50%);
object-fit: contain;
opacity: var(--tool-pic-opacity, 0.15);
z-index: 0;
}
.codebox {
flex: 0 0 auto;
@@ -764,15 +936,18 @@ watch([pages, codetype, errorcorrection], renderAll)
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--text-light);
z-index: 2;
}
.label.side-back { outline-style: dotted; }
@media print {
.no-print { display: none !important; }
.side-flag { display: none !important; }
.sheet { padding: 0; display: block; gap: 0; }
.label {
/* The back's dotted variant outspecifies a bare .label reset, so it printed
a dashed rule down the edge of every second card. */
.label,
.label.side-back {
outline: none;
break-after: page;
page-break-after: always;