Search spelling suggestions across every list
This commit is contained in:
@@ -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' }
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user