This commit is contained in:
j3d1 2026-08-17 20:11:33 +02:00
parent 95ddb484eb
commit 8622e488a4
16 changed files with 607 additions and 369 deletions

View file

@ -10,12 +10,30 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/4.1/ref/settings/ https://docs.djangoproject.com/en/4.1/ref/settings/
""" """
import os import os
import subprocess
import dotenv import dotenv
from pathlib import Path from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
def _git_commit():
# deploy/dev/docker-compose.yml bind-mounts the repo's real .git dir at
# /git (separate from BASE_DIR, which only has the backend/ subtree) -
# point git at it explicitly there. Bare-metal dev has no such mount, but
# BASE_DIR sits inside the real checkout so plain rev-parse finds it by
# walking up. In prod, the build context is backend/ alone with no .git
# anywhere, so both fail and we fall back to the GIT_COMMIT build-arg/env
# var (see deploy/prod/Dockerfile.backend and playbook.yml).
cmd = ['git', '--git-dir=/git'] if os.path.isdir('/git') else ['git']
try:
return subprocess.check_output(
[*cmd, 'rev-parse', '--short', 'HEAD'], cwd=BASE_DIR, stderr=subprocess.DEVNULL
).decode().strip()
except (subprocess.CalledProcessError, FileNotFoundError, OSError):
return os.environ.get('GIT_COMMIT', 'unknown')
dotenv.load_dotenv(BASE_DIR / '.env') dotenv.load_dotenv(BASE_DIR / '.env')
# Quick-start development settings - unsuitable for production # Quick-start development settings - unsuitable for production
@ -30,6 +48,7 @@ DEBUG = os.environ.get('DEBUG', 'False').lower() == 'true'
# Application definition # Application definition
TOOLSHED_VERSION = "0.0.0-dev.0" TOOLSHED_VERSION = "0.0.0-dev.0"
GIT_COMMIT = _git_commit()
INSTALLED_APPS = [ INSTALLED_APPS = [
'django.contrib.admin', 'django.contrib.admin',

View file

@ -7,14 +7,14 @@ from hostadmin.models import Domain
from authentication.signature_auth import SignatureAuthentication from authentication.signature_auth import SignatureAuthentication
from toolshed.models import Tag, Property, Category, InventoryItem from toolshed.models import Tag, Property, Category, InventoryItem
from toolshed.serializers import CategorySerializer, PropertySerializer from toolshed.serializers import CategorySerializer, PropertySerializer
from backend.settings import TOOLSHED_VERSION from backend.settings import TOOLSHED_VERSION, GIT_COMMIT
@api_view(['GET']) @api_view(['GET'])
@permission_classes([]) @permission_classes([])
@authentication_classes([]) @authentication_classes([])
def get_version(request, format=None): # /version/ def get_version(request, format=None): # /version/
return Response({'version': TOOLSHED_VERSION}) return Response({'version': TOOLSHED_VERSION, 'commit': GIT_COMMIT})
@api_view(['GET']) @api_view(['GET'])

View file

@ -7,7 +7,7 @@ ENV PYTHONUNBUFFERED 1
# Set work directory # Set work directory
WORKDIR /code WORKDIR /code
RUN mkdir /git
# Install dependencies # Install dependencies
COPY requirements.txt /code/ COPY requirements.txt /code/
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt

View file

@ -3,6 +3,7 @@ FROM node:14
# Set work directory # Set work directory
WORKDIR /app WORKDIR /app
RUN mkdir /git
# Install app dependencies # Install app dependencies
# A wildcard is used to ensure both package.json AND package-lock.json are copied # A wildcard is used to ensure both package.json AND package-lock.json are copied

View file

@ -12,6 +12,7 @@ services:
TOOLSHED_SETUP_PATH: /mnt/testdata.py TOOLSHED_SETUP_PATH: /mnt/testdata.py
volumes: volumes:
- ../../backend:/code - ../../backend:/code
- ../../.git:/git:ro
- ./instance_a/a.env:/code/.env - ./instance_a/a.env:/code/.env
- ./instance_a/testdata.py:/mnt/testdata.py - ./instance_a/testdata.py:/mnt/testdata.py
- ./instance_a/a.sqlite3:/mnt/db.sqlite3 - ./instance_a/a.sqlite3:/mnt/db.sqlite3
@ -30,6 +31,7 @@ services:
TOOLSHED_SETUP_PATH: /mnt/testdata.py TOOLSHED_SETUP_PATH: /mnt/testdata.py
volumes: volumes:
- ../../backend:/code - ../../backend:/code
- ../../.git:/git:ro
- ./instance_b/b.env:/code/.env - ./instance_b/b.env:/code/.env
- ./instance_b/testdata.py:/mnt/testdata.py - ./instance_b/testdata.py:/mnt/testdata.py
- ./instance_b/b.sqlite3:/mnt/db.sqlite3 - ./instance_b/b.sqlite3:/mnt/db.sqlite3
@ -44,6 +46,7 @@ services:
dockerfile: ../deploy/dev/Dockerfile.frontend dockerfile: ../deploy/dev/Dockerfile.frontend
volumes: volumes:
- ../../frontend:/app - ../../frontend:/app
- ../../.git:/git:ro
- /app/node_modules - /app/node_modules
expose: expose:
- 5173 - 5173

View file

@ -5,9 +5,15 @@
FROM python:3.11-slim FROM python:3.11-slim
# The build context here is just backend/ (no .git), so settings.py's own
# `git rev-parse` fallback can't find a repo - the actual commit is passed
# in from the real checkout via this build-arg instead (see playbook.yml).
ARG GIT_COMMIT=unknown
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
DJANGO_SETTINGS_MODULE=backend.settings DJANGO_SETTINGS_MODULE=backend.settings \
GIT_COMMIT=$GIT_COMMIT
WORKDIR /app WORKDIR /app

View file

@ -6,6 +6,14 @@
FROM node:20-alpine AS build FROM node:20-alpine AS build
WORKDIR /app WORKDIR /app
# The build context here is just frontend/ (no .git), so vite.config.js's
# own `git rev-parse` fallback can't find a repo - the actual commit is
# passed in from the real checkout via this build-arg instead (see
# playbook.yml).
ARG GIT_COMMIT=unknown
ENV GIT_COMMIT=$GIT_COMMIT
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
COPY extras/ ./extras/ COPY extras/ ./extras/
RUN npm ci RUN npm ci

View file

@ -294,6 +294,7 @@
# pinned commit isn't fetchable from upstream - don't let a broken # pinned commit isn't fetchable from upstream - don't let a broken
# submodule block the checkout. # submodule block the checkout.
recursive: false recursive: false
register: toolshed_checkout
- name: Create toolshed system user - name: Create toolshed system user
ansible.builtin.user: ansible.builtin.user:
@ -375,6 +376,7 @@
ansible.builtin.command: ansible.builtin.command:
cmd: >- cmd: >-
docker build -t {{ toolshed_backend_image }}:{{ toolshed_image_tag }} docker build -t {{ toolshed_backend_image }}:{{ toolshed_image_tag }}
--build-arg GIT_COMMIT={{ toolshed_checkout.after[:7] }}
-f {{ toolshed_src_dir }}/deploy/prod/Dockerfile.backend {{ toolshed_src_dir }}/backend -f {{ toolshed_src_dir }}/deploy/prod/Dockerfile.backend {{ toolshed_src_dir }}/backend
changed_when: true changed_when: true
notify: restart backend notify: restart backend
@ -462,6 +464,7 @@
version: "{{ toolshed_version | default('stable') }}" version: "{{ toolshed_version | default('stable') }}"
force: true force: true
recursive: false recursive: false
register: toolshed_frontend_checkout
delegate_to: localhost delegate_to: localhost
become: false become: false
@ -469,6 +472,7 @@
ansible.builtin.command: ansible.builtin.command:
cmd: >- cmd: >-
docker build -t {{ toolshed_frontend_image }}:{{ toolshed_image_tag }} docker build -t {{ toolshed_frontend_image }}:{{ toolshed_image_tag }}
--build-arg GIT_COMMIT={{ toolshed_frontend_checkout.after[:7] }}
-f {{ toolshed_frontend_build_src_dir }}/deploy/prod/Dockerfile.frontend {{ toolshed_frontend_build_src_dir }}/frontend -f {{ toolshed_frontend_build_src_dir }}/deploy/prod/Dockerfile.frontend {{ toolshed_frontend_build_src_dir }}/frontend
changed_when: true changed_when: true
delegate_to: localhost delegate_to: localhost

View file

@ -74,11 +74,22 @@ export default {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
min-height: 100vh; min-height: 100vh;
margin-left: 260px;
transition: margin-left .35s ease-in-out, left .35s ease-in-out, margin-right .35s ease-in-out, right .35s ease-in-out; transition: margin-left .35s ease-in-out, left .35s ease-in-out, margin-right .35s ease-in-out, right .35s ease-in-out;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
} }
.main.expanded {
margin-left: 0;
}
@media (min-width: 1px) and (max-width: 991.98px) {
.main, .main.expanded {
margin-left: 0;
}
}
.navbar-expand { .navbar-expand {
flex-wrap: nowrap; flex-wrap: nowrap;
justify-content: flex-start; justify-content: flex-start;

View file

@ -0,0 +1,138 @@
<template>
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Label layout</h5>
</div>
<div class="card-body">
<div class="d-flex flex-wrap gap-3">
<div v-for="t in labelTemplates" :key="t.id" class="template-option text-center"
:class="{'template-option-disabled': !isAvailable(t)}"
:title="isAvailable(t) ? '' : 'Not available - open this page from an item to fill in the fields this layout needs.'"
role="button" @click="isAvailable(t) && $emit('input', t.id)">
<canvas :ref="el => setTemplateCanvasRef(t.id, el)"
class="img-thumbnail template-thumb-canvas"
:class="{'border-primary': value === t.id}"></canvas>
<div class="small mt-1"
:class="{'fw-bold text-primary': value === t.id}">
{{ t.name }}
</div>
<div class="small text-muted">{{ t.description }}</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.template-option {
width: 20rem;
}
.template-option-disabled {
opacity: .45;
cursor: not-allowed;
}
.template-thumb-canvas {
display: block;
width: 100%;
height: 10rem;
/* The canvas itself is drawn at whatever size fits its content (see redraw/drawFallbackLabel)
- object-fit scales that down to the thumbnail box the same way it would for an <img>,
no manual zoom math needed. */
object-fit: contain;
background: #fff;
}
</style>
<script>
import {LABEL_TEMPLATES, drawFallbackLabel} from "@/label-drawing.js";
export default {
name: "LabelLayoutPreview",
props: {
// Named content fields the templates draw from (see label-content.js's buildLabelFields)
// - kept in sync by the parent, not owned here. A field missing from this object (rather
// than present-but-empty) means a template that needs it is unavailable right now.
fields: {
type: Object,
required: true
},
// The selected template id.
value: {
type: String,
required: true
}
},
model: {
prop: "value",
event: "input"
},
emits: ["input"],
computed: {
labelTemplates() {
return LABEL_TEMPLATES;
}
},
watch: {
fields() {
this.redraw();
}
},
methods: {
setTemplateCanvasRef(id, el) {
if (el) {
this.templateCanvases[id] = el;
} else {
delete this.templateCanvases[id];
}
},
isAvailable(t) {
return [t.qr, t.text].filter(Boolean).every(key => this.fields[key] !== undefined);
},
contentFor(t) {
return {
qr: t.qr ? this.fields[t.qr] : null,
text: t.text ? this.fields[t.text] : null,
};
},
/* Live per-template thumbnails. Always uses the content-fit fallback renderer (rather
than the tape-fed one), regardless of whether a real printer is connected - these are
illustrative previews sized by CSS object-fit, not the accurate to-be-printed canvas
the main preview is. */
redraw() {
for (const t of LABEL_TEMPLATES) {
const canvas = this.templateCanvases[t.id];
if (!canvas) {
continue;
}
const content = this.contentFor(t);
if (!this.isAvailable(t) || (!content.qr && !content.text)) {
canvas.width = 1;
canvas.height = 1;
continue;
}
try {
drawFallbackLabel(canvas, content);
} catch (e) {
// A thumbnail that can't render at this content length just stays blank
// rather than surfacing an error for every keystroke.
}
}
}
},
created() {
// Keyed by template id, populated by setTemplateCanvasRef() - a plain :ref="t.id" string
// inside v-for would still get Vue's refInFor array-collecting behavior even though
// each iteration uses a different name, turning this.$refs[t.id] into a one-element
// array rather than the canvas itself. A function ref sidesteps that entirely.
this.templateCanvases = {};
},
mounted() {
this.redraw();
}
}
</script>

View file

@ -58,6 +58,7 @@
</router-link> </router-link>
</li> </li>
</ul> </ul>
<div class="sidebar-version" :title="versionTitle">{{ frontendCommit }}{{ backendCommit ? ' / ' + backendCommit : '' }}</div>
</div> </div>
</nav> </nav>
</template> </template>
@ -70,31 +71,37 @@ export default {
components: { components: {
...BIcons ...BIcons
}, },
data() {
return {
frontendCommit: __GIT_COMMIT__,
backendCommit: null,
}
},
computed: {
versionTitle() {
return `frontend build: ${this.frontendCommit}, backend: ${this.backendCommit || 'unknown'}`
}
},
async mounted() {
try {
const {commit} = await this.$store.dispatch('fetchBackendVersion')
this.backendCommit = commit
} catch (e) {
console.error('could not fetch backend version', e)
}
},
} }
</script> </script>
<style scoped> <style scoped>
.sidebar { .sidebar {
position: fixed;
top: 0;
left: 0;
z-index: 1000;
min-width: 260px; min-width: 260px;
max-width: 260px; max-width: 260px;
direction: ltr;
}
.sidebar, .sidebar-content {
transition: margin-left .35s ease-in-out, left .35s ease-in-out, margin-right .35s ease-in-out, right .35s ease-in-out;
background: #222e3c;
}
.sidebar-content {
display: flex;
height: 100vh; height: 100vh;
flex-direction: column;
}
.sidebar {
min-width: 260px;
max-width: 260px;
direction: ltr direction: ltr
} }
@ -239,4 +246,11 @@ export default {
font-size: .75rem; font-size: .75rem;
color: #ced4da color: #ced4da
} }
.sidebar-version {
padding: .5rem 1.5rem 1rem;
font-size: .7rem;
color: #ced4da4d;
text-align: center;
}
</style> </style>

View file

@ -0,0 +1,45 @@
// Turns a {kind, components} prefill into the literal string a print label should show/encode.
// Keeping this keyed by `kind` rather than having each caller build its own string means the
// format for a given kind of label content only has to be gotten right in one place.
export const LABEL_CONTENT_BUILDERS = {
// The self-contained Item URL (see docs/design-in-progress/items-labels.md) - what a
// printed label actually encodes, since scanning it has to resolve the right
// frontend/backend/item with no other context, not just this browser's history.
"item-url": ({user, id}) => `${window.location.origin}/i/${user}/${id}`,
};
export function buildLabelContent(prefill) {
if (!prefill) {
return "";
}
const build = LABEL_CONTENT_BUILDERS[prefill.kind];
return build ? build(prefill.components) : "";
}
// Named fields the field-specific label templates (see label-drawing.js's LABEL_TEMPLATES) draw
// from - keyed by `kind` for the same reason LABEL_CONTENT_BUILDERS is. A field missing from the
// result (rather than present-but-empty) is what LabelLayoutPreview.vue treats as "not available",
// so builders should only include a field once its inputs actually check out.
const LABEL_FIELD_BUILDERS = {
"item-url": ({user, id}) => {
if (!user || !id) {
return {};
}
return {
itemUrl: `${window.location.origin}/i/${user}/${id}`,
// The compact "owner handle + id" form from docs/design-in-progress/items-labels.md -
// meaningful only where context already makes clear it's a Toolshed item, unlike itemUrl.
itemHandle: `${user}:${id}`,
userHandle: user,
itemId: String(id),
};
},
};
export function buildLabelFields(prefill) {
if (!prefill) {
return {};
}
const build = LABEL_FIELD_BUILDERS[prefill.kind];
return build ? build(prefill.components) : {};
}

View file

@ -0,0 +1,281 @@
import QRCode from "qrcode";
const TRAILING_PADDING_PX = 3; /* blank columns after the cut, same idea as the leading margin */
/* A quiet zone narrower than the spec's usual 4 modules: the printer's own
feed margin already keeps the code clear of the tape edge and the cut. */
const QUIET_ZONE_MODULES = 2;
/* Sizing a label needs numbers only the driver can supply. */
export function tapeFromStatus(status) {
const printAreaPx = status?.tape?.printAreaPx;
const dpi = status?.printer?.dpi;
if (!(printAreaPx > 0) || !(dpi > 0)) {
return null;
}
return {
printAreaPx,
dpi,
mediaWidthMm: status.tape.mediaWidthMm,
printLengthPx: status.tape.printLengthPx > 0 ? status.tape.printLengthPx : 0,
/* Brother's documented margin for the mounted tape, in raster columns. */
leadPx: status.tape.marginsMm
? Math.round(status.tape.marginsMm * dpi / 25.4)
: TRAILING_PADDING_PX,
};
}
/*
Draw the QR code as large as the tape allows, centered in a square, with an
integer number of pixels per module so it stays crisp at printer
resolution rather than blurring at a fractional scale.
*/
function drawQrLabel(canvas, qr, tape) {
const modules = qr.modules.size + QUIET_ZONE_MODULES * 2;
const maxLength = tape.printLengthPx
? tape.printLengthPx - tape.leadPx - TRAILING_PADDING_PX
: Infinity;
const scale = Math.floor(Math.min(tape.printAreaPx, maxLength) / modules);
if (!(scale >= 1)) {
throw new Error("This text needs a bigger QR code than the tape allows — "
+ "try a shorter value or a wider tape.");
}
const square = modules * scale;
const width = tape.printLengthPx || (square + tape.leadPx + TRAILING_PADDING_PX);
canvas.width = width;
canvas.height = tape.printAreaPx;
const ctx = canvas.getContext("2d", {willReadFrequently: true});
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, width, canvas.height);
const left = tape.leadPx + Math.floor((width - tape.leadPx - TRAILING_PADDING_PX - square) / 2);
const top = Math.floor((canvas.height - square) / 2);
ctx.fillStyle = "#000";
for (let row = 0; row < qr.modules.size; row++) {
for (let col = 0; col < qr.modules.size; col++) {
if (qr.modules.get(row, col)) {
ctx.fillRect(
left + (col + QUIET_ZONE_MODULES) * scale,
top + (row + QUIET_ZONE_MODULES) * scale,
scale, scale,
);
}
}
}
}
// Each template names which field (see label-content.js's buildLabelFields) feeds its QR code
// and/or its printed text - `null` means that half of the layout is skipped. A template is only
// selectable once every field it names is actually available (see LabelLayoutPreview.vue).
export const LABEL_TEMPLATES = [
{id: "qr", name: "QR code only", description: "Just the code - smallest label, prints fastest.",
qr: "value", text: null},
{id: "qr-text", name: "QR code + text", description: "The code with the encoded text printed next to it.",
qr: "value", text: "value"},
{id: "text", name: "Text only", description: "No code, just the text itself, as large as it fits.",
qr: null, text: "value"},
{id: "item-handle", name: "Item handle", qr: null, text: "itemHandle",
description: "The compact owner@domain:id handle - meaningful in-app, not scannable on its own."},
{id: "item-url", name: "Item URL", qr: null, text: "itemUrl",
description: "The full item URL as text, with no code - for copying rather than scanning."},
{id: "owner-handle", name: "Owner handle", qr: null, text: "userHandle",
description: "Just the owning user's handle, as text only."},
{id: "item-id", name: "Item ID", qr: null, text: "itemId",
description: "Just the bare item id, as text only."},
{id: "item-url-qr-handle", name: "Item URL + handle", qr: "itemUrl", text: "itemHandle",
description: "Scannable item URL, with the item's compact handle printed alongside."},
{id: "item-url-qr-owner", name: "Item URL + owner", qr: "itemUrl", text: "userHandle",
description: "Scannable item URL, with the owner's handle printed alongside."},
{id: "item-url-qr-id", name: "Item URL + item ID", qr: "itemUrl", text: "itemId",
description: "Scannable item URL, with the bare item id printed alongside."},
];
function measureAtHeight(ctx, text, px) {
ctx.font = `${px}px sans-serif`;
return ctx.measureText(text).width;
}
/* Picks the largest integer font size (down to a floor) that fits `text` on one line within
maxWidth - this is a label, not a paragraph, so we shrink to fit rather than wrap. */
function fitTextSize(ctx, text, maxWidth, maxHeight) {
const minPx = 8;
let px = Math.max(minPx, Math.floor(maxHeight));
while (px > minPx && measureAtHeight(ctx, text, px) > maxWidth) {
px -= 1;
}
return px;
}
/* The qr-text/text-only layouts, tape-fed. Kept separate from drawQrLabel above (rather than
generalizing it) so the plain QR-only path - the common case - is untouched by this. */
function drawLabelWithText(canvas, tape, qrContent, textContent) {
const qr = qrContent ? QRCode.create(qrContent) : null;
const availableHeight = tape.printAreaPx;
const maxLength = tape.printLengthPx
? tape.printLengthPx - tape.leadPx - TRAILING_PADDING_PX
: Infinity;
let scale = 0, qrSize = 0;
if (qr) {
const modules = qr.modules.size + QUIET_ZONE_MODULES * 2;
// The QR only gets half the length budget on a fixed-length tape, so a long text value
// can't starve it down to unreadable - the rest goes to the text next to it.
const qrBudget = maxLength === Infinity ? Infinity : maxLength / 2;
scale = Math.floor(Math.min(availableHeight, qrBudget) / modules);
if (!(scale >= 1)) {
throw new Error("This text needs a bigger QR code than the tape allows — "
+ "try a shorter value, a wider tape, or the text-only layout.");
}
qrSize = modules * scale;
}
const gap = qr ? Math.round(availableHeight * 0.15) : 0;
const textBudget = maxLength === Infinity ? Infinity : maxLength - qrSize - gap;
if (!(textBudget > 0)) {
throw new Error("No room left for the text next to the QR code on this tape — "
+ "try a wider tape or the QR-only layout.");
}
const measureCtx = canvas.getContext("2d");
const textPx = fitTextSize(measureCtx, textContent, textBudget, availableHeight);
const textWidth = measureAtHeight(measureCtx, textContent, textPx);
if (textBudget !== Infinity && textWidth > textBudget) {
throw new Error("This text doesn't fit on this tape even at the smallest readable size — "
+ "try a shorter value, a wider tape, or a bigger label.");
}
const contentWidth = qrSize + gap + textWidth;
const width = tape.printLengthPx || Math.ceil(contentWidth + tape.leadPx + TRAILING_PADDING_PX);
canvas.width = width;
canvas.height = availableHeight;
const ctx = canvas.getContext("2d", {willReadFrequently: true});
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, width, canvas.height);
ctx.fillStyle = "#000";
let cursor = tape.leadPx + Math.floor((width - tape.leadPx - TRAILING_PADDING_PX - contentWidth) / 2);
if (qr) {
const top = Math.floor((canvas.height - qrSize) / 2);
for (let row = 0; row < qr.modules.size; row++) {
for (let col = 0; col < qr.modules.size; col++) {
if (qr.modules.get(row, col)) {
ctx.fillRect(
cursor + (col + QUIET_ZONE_MODULES) * scale,
top + (row + QUIET_ZONE_MODULES) * scale,
scale, scale,
);
}
}
}
cursor += qrSize + gap;
}
ctx.font = `${textPx}px sans-serif`;
ctx.textBaseline = "middle";
ctx.textAlign = "left";
ctx.fillText(textContent, cursor, canvas.height / 2);
}
/* Dispatches to the right tape-fed layout - drawQrLabel is untouched so the plain QR-only
layout keeps its exact original pixel output. `content` is {qr, text}, each either the string
to encode/print or null/undefined to skip that half of the layout (see LABEL_TEMPLATES). */
export function drawLabel(canvas, tape, content) {
if (content.qr && !content.text) {
drawQrLabel(canvas, QRCode.create(content.qr), tape);
} else {
drawLabelWithText(canvas, tape, content.qr, content.text);
}
}
const FALLBACK_SCALE_PX = 8; /* pixels per QR module in the no-webusb preview/PNG */
const FALLBACK_QUIET_ZONE_MODULES = 4; /* the spec's usual quiet zone - there's no printer feed margin to lean on here */
/* Same idea as drawQrLabel, but without a real device to ask for tape dimensions: just a
plain square QR code, sized for a PNG someone downloads and prints some other way. */
function drawQrSquare(canvas, qr) {
const modules = qr.modules.size + FALLBACK_QUIET_ZONE_MODULES * 2;
const size = modules * FALLBACK_SCALE_PX;
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d", {willReadFrequently: true});
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, size, size);
ctx.fillStyle = "#000";
for (let row = 0; row < qr.modules.size; row++) {
for (let col = 0; col < qr.modules.size; col++) {
if (qr.modules.get(row, col)) {
ctx.fillRect(
(col + FALLBACK_QUIET_ZONE_MODULES) * FALLBACK_SCALE_PX,
(row + FALLBACK_QUIET_ZONE_MODULES) * FALLBACK_SCALE_PX,
FALLBACK_SCALE_PX, FALLBACK_SCALE_PX,
);
}
}
}
}
const FALLBACK_LABEL_HEIGHT_PX = 200; /* target content height for the qr-text/text-only fallback layouts */
const FALLBACK_TEXT_MARGIN_PX = 16; /* left/right margin around a text-only/qr-text fallback label */
/* The qr-text/text-only layouts for the no-webusb fallback preview/PNG. There's no real tape
to fit into here, so - unlike drawLabelWithText - the canvas just grows to fit its content. */
function drawFallbackLabelWithText(canvas, qrContent, textContent) {
const qr = qrContent ? QRCode.create(qrContent) : null;
let scale = 0, qrSize = 0;
if (qr) {
const modules = qr.modules.size + FALLBACK_QUIET_ZONE_MODULES * 2;
scale = Math.max(1, Math.floor(FALLBACK_LABEL_HEIGHT_PX / modules));
qrSize = modules * scale;
}
const gap = qr ? Math.round(FALLBACK_LABEL_HEIGHT_PX * 0.15) : 0;
const measureCtx = canvas.getContext("2d");
const textPx = fitTextSize(measureCtx, textContent, Infinity, FALLBACK_LABEL_HEIGHT_PX);
const textWidth = measureAtHeight(measureCtx, textContent, textPx);
const height = Math.max(qrSize, FALLBACK_LABEL_HEIGHT_PX);
const width = qrSize + gap + textWidth + FALLBACK_TEXT_MARGIN_PX * 2;
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d", {willReadFrequently: true});
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = "#000";
let cursor = FALLBACK_TEXT_MARGIN_PX;
if (qr) {
const top = Math.floor((height - qrSize) / 2);
for (let row = 0; row < qr.modules.size; row++) {
for (let col = 0; col < qr.modules.size; col++) {
if (qr.modules.get(row, col)) {
ctx.fillRect(
cursor + (col + FALLBACK_QUIET_ZONE_MODULES) * scale,
top + (row + FALLBACK_QUIET_ZONE_MODULES) * scale,
scale, scale,
);
}
}
}
cursor += qrSize + gap;
}
ctx.font = `${textPx}px sans-serif`;
ctx.textBaseline = "middle";
ctx.textAlign = "left";
ctx.fillText(textContent, cursor, height / 2);
}
/* Dispatches to the right fallback layout - drawQrSquare is untouched so the plain QR-only
layout keeps its exact original pixel output. `content` is {qr, text}, see drawLabel above. */
export function drawFallbackLabel(canvas, content) {
if (content.qr && !content.text) {
drawQrSquare(canvas, QRCode.create(content.qr));
} else {
drawFallbackLabelWithText(canvas, content.qr, content.text);
}
}

View file

@ -359,6 +359,10 @@ export default createStore({
async getFriendServers({state, dispatch, commit}, {username}) { async getFriendServers({state, dispatch, commit}, {username}) {
return dispatch('lookupServer', {username}).then(servers => new ServerSet(servers, state.unreachable_neighbors)) return dispatch('lookupServer', {username}).then(servers => new ServerSet(servers, state.unreachable_neighbors))
}, },
async fetchBackendVersion({dispatch, getters}) {
const servers = await dispatch('getHomeServers')
return await servers.get(getters.nullAuth, '/api/version/')
},
async fetchInventoryItems({commit, dispatch, getters}) { async fetchInventoryItems({commit, dispatch, getters}) {
const servers = await dispatch('getHomeServers') const servers = await dispatch('getHomeServers')
const items = await servers.get(getters.signAuth, '/api/inventory_items/') const items = await servers.get(getters.signAuth, '/api/inventory_items/')

View file

@ -25,7 +25,7 @@
placeholder="https://example.com/…" autofocus> placeholder="https://example.com/…" autofocus>
</div> </div>
<div class="label-preview mb-3" v-show="value"> <div class="label-preview mb-3" v-show="selectedContent.qr || selectedContent.text">
<canvas ref="fallbackCanvas"></canvas> <canvas ref="fallbackCanvas"></canvas>
</div> </div>
@ -73,7 +73,7 @@
placeholder="https://example.com/…" autofocus> placeholder="https://example.com/…" autofocus>
</div> </div>
<div class="label-preview mb-3" v-show="value"> <div class="label-preview mb-3" v-show="selectedContent.qr || selectedContent.text">
<canvas ref="labelCanvas"></canvas> <canvas ref="labelCanvas"></canvas>
</div> </div>
@ -133,26 +133,8 @@
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<div class="card"> <label-layout-preview :fields="fields" :value="selectedTemplate"
<div class="card-header"> @input="selectedTemplate = $event"></label-layout-preview>
<h5 class="card-title mb-0">Label layout</h5>
</div>
<div class="card-body">
<div class="d-flex flex-wrap gap-3">
<div v-for="t in labelTemplates" :key="t.id" class="template-option text-center"
role="button" @click="selectedTemplate = t.id">
<canvas :ref="el => setTemplateCanvasRef(t.id, el)"
class="img-thumbnail template-thumb-canvas"
:class="{'border-primary': selectedTemplate === t.id}"></canvas>
<div class="small mt-1"
:class="{'fw-bold text-primary': selectedTemplate === t.id}">
{{ t.name }}
</div>
<div class="small text-muted">{{ t.description }}</div>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -163,11 +145,12 @@
<script> <script>
import * as BIcons from "bootstrap-icons-vue"; import * as BIcons from "bootstrap-icons-vue";
import {markRaw, nextTick} from "vue"; import {markRaw, nextTick} from "vue";
import QRCode from "qrcode";
import BaseLayout from "@/components/BaseLayout.vue"; import BaseLayout from "@/components/BaseLayout.vue";
import LabelLayoutPreview from "@/components/LabelLayoutPreview.vue";
import {MultiPrinterBlob, canvasToBitmap, bitmapToCanvas} from "../../vendor/weblabel.js"; import {MultiPrinterBlob, canvasToBitmap, bitmapToCanvas} from "../../vendor/weblabel.js";
import {buildLabelContent} from "@/label-content.js"; import {buildLabelContent, buildLabelFields} from "@/label-content.js";
import {LABEL_TEMPLATES, tapeFromStatus, drawLabel, drawFallbackLabel} from "@/label-drawing.js";
// Served verbatim from public/vendor/ rather than bundled: libweblabel.js's // Served verbatim from public/vendor/ rather than bundled: libweblabel.js's
// own emscripten glue resolves its .wasm sibling relative to *its own* // own emscripten glue resolves its .wasm sibling relative to *its own*
@ -176,271 +159,12 @@ import {buildLabelContent} from "@/label-content.js";
const BLOB_URL = "/vendor/libweblabel.js"; const BLOB_URL = "/vendor/libweblabel.js";
const MAX_ZOOM = 4; /* never magnify the preview more than this */ const MAX_ZOOM = 4; /* never magnify the preview more than this */
const TRAILING_PADDING_PX = 3; /* blank columns after the cut, same idea as the leading margin */
/* A quiet zone narrower than the spec's usual 4 modules: the printer's own
feed margin already keeps the code clear of the tape edge and the cut. */
const QUIET_ZONE_MODULES = 2;
/* Sizing a label needs numbers only the driver can supply. */
function tapeFromStatus(status) {
const printAreaPx = status?.tape?.printAreaPx;
const dpi = status?.printer?.dpi;
if (!(printAreaPx > 0) || !(dpi > 0)) {
return null;
}
return {
printAreaPx,
dpi,
mediaWidthMm: status.tape.mediaWidthMm,
printLengthPx: status.tape.printLengthPx > 0 ? status.tape.printLengthPx : 0,
/* Brother's documented margin for the mounted tape, in raster columns. */
leadPx: status.tape.marginsMm
? Math.round(status.tape.marginsMm * dpi / 25.4)
: TRAILING_PADDING_PX,
};
}
/*
Draw the QR code as large as the tape allows, centered in a square, with an
integer number of pixels per module so it stays crisp at printer
resolution rather than blurring at a fractional scale.
*/
function drawQrLabel(canvas, qr, tape) {
const modules = qr.modules.size + QUIET_ZONE_MODULES * 2;
const maxLength = tape.printLengthPx
? tape.printLengthPx - tape.leadPx - TRAILING_PADDING_PX
: Infinity;
const scale = Math.floor(Math.min(tape.printAreaPx, maxLength) / modules);
if (!(scale >= 1)) {
throw new Error("This text needs a bigger QR code than the tape allows — "
+ "try a shorter value or a wider tape.");
}
const square = modules * scale;
const width = tape.printLengthPx || (square + tape.leadPx + TRAILING_PADDING_PX);
canvas.width = width;
canvas.height = tape.printAreaPx;
const ctx = canvas.getContext("2d", {willReadFrequently: true});
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, width, canvas.height);
const left = tape.leadPx + Math.floor((width - tape.leadPx - TRAILING_PADDING_PX - square) / 2);
const top = Math.floor((canvas.height - square) / 2);
ctx.fillStyle = "#000";
for (let row = 0; row < qr.modules.size; row++) {
for (let col = 0; col < qr.modules.size; col++) {
if (qr.modules.get(row, col)) {
ctx.fillRect(
left + (col + QUIET_ZONE_MODULES) * scale,
top + (row + QUIET_ZONE_MODULES) * scale,
scale, scale,
);
}
}
}
}
const LABEL_TEMPLATES = [
{id: "qr", name: "QR code only", description: "Just the code - smallest label, prints fastest."},
{id: "qr-text", name: "QR code + text", description: "The code with the encoded text printed next to it."},
{id: "text", name: "Text only", description: "No code, just the text itself, as large as it fits."},
];
function measureAtHeight(ctx, text, px) {
ctx.font = `${px}px sans-serif`;
return ctx.measureText(text).width;
}
/* Picks the largest integer font size (down to a floor) that fits `text` on one line within
maxWidth - this is a label, not a paragraph, so we shrink to fit rather than wrap. */
function fitTextSize(ctx, text, maxWidth, maxHeight) {
const minPx = 8;
let px = Math.max(minPx, Math.floor(maxHeight));
while (px > minPx && measureAtHeight(ctx, text, px) > maxWidth) {
px -= 1;
}
return px;
}
/* The qr-text/text-only layouts, tape-fed. Kept separate from drawQrLabel above (rather than
generalizing it) so the plain QR-only path - the common case - is untouched by this. */
function drawLabelWithText(canvas, value, tape, templateId) {
const showQr = templateId === "qr-text";
const qr = showQr ? QRCode.create(value) : null;
const availableHeight = tape.printAreaPx;
const maxLength = tape.printLengthPx
? tape.printLengthPx - tape.leadPx - TRAILING_PADDING_PX
: Infinity;
let scale = 0, qrSize = 0;
if (qr) {
const modules = qr.modules.size + QUIET_ZONE_MODULES * 2;
// The QR only gets half the length budget on a fixed-length tape, so a long text value
// can't starve it down to unreadable - the rest goes to the text next to it.
const qrBudget = maxLength === Infinity ? Infinity : maxLength / 2;
scale = Math.floor(Math.min(availableHeight, qrBudget) / modules);
if (!(scale >= 1)) {
throw new Error("This text needs a bigger QR code than the tape allows — "
+ "try a shorter value, a wider tape, or the text-only layout.");
}
qrSize = modules * scale;
}
const gap = qr ? Math.round(availableHeight * 0.15) : 0;
const textBudget = maxLength === Infinity ? Infinity : maxLength - qrSize - gap;
if (!(textBudget > 0)) {
throw new Error("No room left for the text next to the QR code on this tape — "
+ "try a wider tape or the QR-only layout.");
}
const measureCtx = canvas.getContext("2d");
const textPx = fitTextSize(measureCtx, value, textBudget, availableHeight);
const textWidth = measureAtHeight(measureCtx, value, textPx);
if (textBudget !== Infinity && textWidth > textBudget) {
throw new Error("This text doesn't fit on this tape even at the smallest readable size — "
+ "try a shorter value, a wider tape, or a bigger label.");
}
const contentWidth = qrSize + gap + textWidth;
const width = tape.printLengthPx || Math.ceil(contentWidth + tape.leadPx + TRAILING_PADDING_PX);
canvas.width = width;
canvas.height = availableHeight;
const ctx = canvas.getContext("2d", {willReadFrequently: true});
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, width, canvas.height);
ctx.fillStyle = "#000";
let cursor = tape.leadPx + Math.floor((width - tape.leadPx - TRAILING_PADDING_PX - contentWidth) / 2);
if (qr) {
const top = Math.floor((canvas.height - qrSize) / 2);
for (let row = 0; row < qr.modules.size; row++) {
for (let col = 0; col < qr.modules.size; col++) {
if (qr.modules.get(row, col)) {
ctx.fillRect(
cursor + (col + QUIET_ZONE_MODULES) * scale,
top + (row + QUIET_ZONE_MODULES) * scale,
scale, scale,
);
}
}
}
cursor += qrSize + gap;
}
ctx.font = `${textPx}px sans-serif`;
ctx.textBaseline = "middle";
ctx.textAlign = "left";
ctx.fillText(value, cursor, canvas.height / 2);
}
/* Dispatches to the right tape-fed layout - drawQrLabel is untouched so the default QR-only
template keeps its exact original pixel output. */
function drawLabel(canvas, value, tape, templateId) {
if (templateId === "qr") {
drawQrLabel(canvas, QRCode.create(value), tape);
} else {
drawLabelWithText(canvas, value, tape, templateId);
}
}
const FALLBACK_SCALE_PX = 8; /* pixels per QR module in the no-webusb preview/PNG */
const FALLBACK_QUIET_ZONE_MODULES = 4; /* the spec's usual quiet zone - there's no printer feed margin to lean on here */
/* Same idea as drawQrLabel, but without a real device to ask for tape dimensions: just a
plain square QR code, sized for a PNG someone downloads and prints some other way. */
function drawQrSquare(canvas, qr) {
const modules = qr.modules.size + FALLBACK_QUIET_ZONE_MODULES * 2;
const size = modules * FALLBACK_SCALE_PX;
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d", {willReadFrequently: true});
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, size, size);
ctx.fillStyle = "#000";
for (let row = 0; row < qr.modules.size; row++) {
for (let col = 0; col < qr.modules.size; col++) {
if (qr.modules.get(row, col)) {
ctx.fillRect(
(col + FALLBACK_QUIET_ZONE_MODULES) * FALLBACK_SCALE_PX,
(row + FALLBACK_QUIET_ZONE_MODULES) * FALLBACK_SCALE_PX,
FALLBACK_SCALE_PX, FALLBACK_SCALE_PX,
);
}
}
}
}
const FALLBACK_LABEL_HEIGHT_PX = 200; /* target content height for the qr-text/text-only fallback layouts */
const FALLBACK_TEXT_MARGIN_PX = 16; /* left/right margin around a text-only/qr-text fallback label */
/* The qr-text/text-only layouts for the no-webusb fallback preview/PNG. There's no real tape
to fit into here, so - unlike drawLabelWithText - the canvas just grows to fit its content. */
function drawFallbackLabelWithText(canvas, value, templateId) {
const showQr = templateId === "qr-text";
const qr = showQr ? QRCode.create(value) : null;
let scale = 0, qrSize = 0;
if (qr) {
const modules = qr.modules.size + FALLBACK_QUIET_ZONE_MODULES * 2;
scale = Math.max(1, Math.floor(FALLBACK_LABEL_HEIGHT_PX / modules));
qrSize = modules * scale;
}
const gap = qr ? Math.round(FALLBACK_LABEL_HEIGHT_PX * 0.15) : 0;
const measureCtx = canvas.getContext("2d");
const textPx = fitTextSize(measureCtx, value, Infinity, FALLBACK_LABEL_HEIGHT_PX);
const textWidth = measureAtHeight(measureCtx, value, textPx);
const height = Math.max(qrSize, FALLBACK_LABEL_HEIGHT_PX);
const width = qrSize + gap + textWidth + FALLBACK_TEXT_MARGIN_PX * 2;
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d", {willReadFrequently: true});
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = "#000";
let cursor = FALLBACK_TEXT_MARGIN_PX;
if (qr) {
const top = Math.floor((height - qrSize) / 2);
for (let row = 0; row < qr.modules.size; row++) {
for (let col = 0; col < qr.modules.size; col++) {
if (qr.modules.get(row, col)) {
ctx.fillRect(
cursor + (col + FALLBACK_QUIET_ZONE_MODULES) * scale,
top + (row + FALLBACK_QUIET_ZONE_MODULES) * scale,
scale, scale,
);
}
}
}
cursor += qrSize + gap;
}
ctx.font = `${textPx}px sans-serif`;
ctx.textBaseline = "middle";
ctx.textAlign = "left";
ctx.fillText(value, cursor, height / 2);
}
/* Dispatches to the right fallback layout - drawQrSquare is untouched so the default QR-only
template keeps its exact original pixel output. */
function drawFallbackLabel(canvas, value, templateId) {
if (templateId === "qr") {
drawQrSquare(canvas, QRCode.create(value));
} else {
drawFallbackLabelWithText(canvas, value, templateId);
}
}
export default { export default {
name: "Print", name: "Print",
components: { components: {
BaseLayout, BaseLayout,
LabelLayoutPreview,
...BIcons ...BIcons
}, },
props: { props: {
@ -476,8 +200,21 @@ export default {
}; };
}, },
computed: { computed: {
labelTemplates() { // Named content fields the field-specific templates draw from, plus the free-text
return LABEL_TEMPLATES; // `value` field the generic qr/qr-text/text templates use. A field this doesn't have
// (rather than one that's merely empty) is what LabelLayoutPreview.vue greys out.
fields() {
return {value: this.value, ...buildLabelFields(this.prefill)};
},
currentTemplate() {
return LABEL_TEMPLATES.find(t => t.id === this.selectedTemplate) || LABEL_TEMPLATES[0];
},
selectedContent() {
const t = this.currentTemplate;
return {
qr: t.qr ? this.fields[t.qr] : null,
text: t.text ? this.fields[t.text] : null,
};
}, },
deviceRows() { deviceRows() {
if (!this.blob) { if (!this.blob) {
@ -495,12 +232,11 @@ export default {
}); });
}, },
canPrint() { canPrint() {
return Boolean(this.value && this.tape && this.labelBitmap && !this.busy); return Boolean(this.tape && this.labelBitmap && !this.busy);
}, },
}, },
watch: { watch: {
value() { value() {
this.redrawTemplatePreviews();
if (this.usbSupported) { if (this.usbSupported) {
this.redraw(); this.redraw();
} else { } else {
@ -593,7 +329,8 @@ export default {
redraw() { redraw() {
this.labelBitmap = null; this.labelBitmap = null;
if (!this.tape || !this.value) { const content = this.selectedContent;
if (!this.tape || (!content.qr && !content.text)) {
return; return;
} }
const canvas = this.$refs.labelCanvas; const canvas = this.$refs.labelCanvas;
@ -602,7 +339,7 @@ export default {
} }
this.resizeObserver.observe(canvas.parentElement); this.resizeObserver.observe(canvas.parentElement);
try { try {
drawLabel(canvas, this.value, this.tape, this.selectedTemplate); drawLabel(canvas, this.tape, content);
} catch (e) { } catch (e) {
this.error = e.message; this.error = e.message;
return; return;
@ -616,7 +353,8 @@ export default {
redrawFallback() { redrawFallback() {
this.fallbackReady = false; this.fallbackReady = false;
if (!this.value) { const content = this.selectedContent;
if (!content.qr && !content.text) {
return; return;
} }
const canvas = this.$refs.fallbackCanvas; const canvas = this.$refs.fallbackCanvas;
@ -625,7 +363,7 @@ export default {
} }
this.resizeObserver.observe(canvas.parentElement); this.resizeObserver.observe(canvas.parentElement);
try { try {
drawFallbackLabel(canvas, this.value, this.selectedTemplate); drawFallbackLabel(canvas, content);
} catch (e) { } catch (e) {
this.error = e.message; this.error = e.message;
return; return;
@ -635,38 +373,6 @@ export default {
this.fitZoom(canvas); this.fitZoom(canvas);
}, },
setTemplateCanvasRef(id, el) {
if (el) {
this.templateCanvases[id] = el;
} else {
delete this.templateCanvases[id];
}
},
/* Live per-template thumbnails in the "Label layout" card. Always uses the
content-fit fallback renderer (rather than the tape-fed one), regardless of
whether a real printer is connected - these are illustrative previews sized by
CSS object-fit, not the accurate to-be-printed canvas the main preview is. */
redrawTemplatePreviews() {
for (const t of LABEL_TEMPLATES) {
const canvas = this.templateCanvases[t.id];
if (!canvas) {
continue;
}
if (!this.value) {
canvas.width = 1;
canvas.height = 1;
continue;
}
try {
drawFallbackLabel(canvas, this.value, t.id);
} catch (e) {
// A thumbnail that can't render at this content length just stays blank
// rather than surfacing an error for every keystroke.
}
}
},
downloadPng() { downloadPng() {
const canvas = this.$refs.fallbackCanvas; const canvas = this.$refs.fallbackCanvas;
if (!canvas) { if (!canvas) {
@ -722,18 +428,9 @@ export default {
this.blob = null; this.blob = null;
this.resizeObserver = null; this.resizeObserver = null;
this.resizeTimer = null; this.resizeTimer = null;
// Keyed by template id, populated by setTemplateCanvasRef() - a plain :ref="t.id" string
// inside v-for would still get Vue's refInFor array-collecting behavior even though
// each iteration uses a different name, turning this.$refs[t.id] into a one-element
// array rather than the canvas itself. A function ref sidesteps that entirely.
this.templateCanvases = {};
}, },
async mounted() { async mounted() {
this.resizeObserver = new ResizeObserver(this.handleContainerResize); this.resizeObserver = new ResizeObserver(this.handleContainerResize);
// Unlike fallbackCanvas/labelCanvas, the thumbnail canvases in the "Label layout" card
// aren't behind a v-if on usbSupported, so their refs already exist here - no nextTick
// needed before this one.
this.redrawTemplatePreviews();
if (!("usb" in navigator)) { if (!("usb" in navigator)) {
this.usbSupported = false; this.usbSupported = false;
await nextTick(); await nextTick();
@ -789,19 +486,4 @@ export default {
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-all; word-break: break-all;
} }
.template-option {
width: 20rem;
}
.template-thumb-canvas {
display: block;
width: 100%;
height: 10rem;
/* The canvas itself is drawn at whatever size fits its content (see
redrawTemplatePreviews/drawFallbackLabel) - object-fit scales that down to the
thumbnail box the same way it would for an <img>, no manual zoom math needed. */
object-fit: contain;
background: #fff;
}
</style> </style>

View file

@ -1,10 +1,32 @@
import {fileURLToPath, URL} from 'node:url' import {fileURLToPath, URL} from 'node:url'
import {execSync} from 'node:child_process'
import {existsSync} from 'node:fs'
import {defineConfig} from 'vite' import {defineConfig} from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
function gitCommit() {
// deploy/dev/docker-compose.yml bind-mounts the repo's real .git dir at
// /git (separate from /app, which only has the frontend/ subtree) -
// point git at it explicitly there. Bare-metal dev has no such mount,
// but this file's cwd sits inside the real checkout so plain rev-parse
// finds it by walking up. In prod, the build context is frontend/ alone
// with no .git anywhere, so both fail and we fall back to the
// GIT_COMMIT build-arg/env var (see deploy/prod/Dockerfile.frontend and
// playbook.yml).
const gitDirFlag = existsSync('/git') ? '--git-dir=/git ' : ''
try {
return execSync(`git ${gitDirFlag}rev-parse --short HEAD`).toString().trim()
} catch {
return process.env.GIT_COMMIT || 'unknown'
}
}
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
define: {
__GIT_COMMIT__: JSON.stringify(gitCommit())
},
resolve: { resolve: {
alias: { alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)) '@': fileURLToPath(new URL('./src', import.meta.url))