Compare commits
3 commits
accbaf3603
...
eaae7c286a
| Author | SHA1 | Date | |
|---|---|---|---|
| eaae7c286a | |||
| 8622e488a4 | |||
| 95ddb484eb |
18 changed files with 675 additions and 136 deletions
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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'])
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
137
frontend/src/components/LabelLayoutPreview.vue
Normal file
137
frontend/src/components/LabelLayoutPreview.vue
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
<template>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">Label layout</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="template-grid d-flex flex-wrap align-items-start">
|
||||||
|
<div v-for="t in labelTemplates" :key="t.id" class="template-option d-flex flex-column 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"
|
||||||
|
: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>
|
||||||
|
/* This build pins Bootstrap 4 (no gap-* utilities, those are Bootstrap 5.1+), so the spacing
|
||||||
|
here is plain CSS gap rather than a Bootstrap gap-N class. */
|
||||||
|
.template-grid {
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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, templateIsAvailable, templateContent} 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 templateIsAvailable(t, this.fields);
|
||||||
|
},
|
||||||
|
|
||||||
|
/* 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 = templateContent(t, this.fields);
|
||||||
|
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>
|
||||||
|
|
@ -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>
|
||||||
45
frontend/src/label-content.js
Normal file
45
frontend/src/label-content.js
Normal 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) : {};
|
||||||
|
}
|
||||||
329
frontend/src/label-drawing.js
Normal file
329
frontend/src/label-drawing.js
Normal file
|
|
@ -0,0 +1,329 @@
|
||||||
|
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. `text` can also be
|
||||||
|
// an array of field names, one per printed line, for a stacked multi-line layout. A template is
|
||||||
|
// only selectable once every field it names is actually available (see templateIsAvailable).
|
||||||
|
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: "owner-id-text", name: "Owner + item ID", qr: null, text: ["userHandle", "itemId"],
|
||||||
|
description: "The owner's handle and the item id, as two lines of text - no code."},
|
||||||
|
{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."},
|
||||||
|
{id: "item-url-qr-owner-id", name: "Item URL + owner + ID", qr: "itemUrl", text: ["userHandle", "itemId"],
|
||||||
|
description: "Scannable item URL, with the owner's handle and the item id on two lines alongside."},
|
||||||
|
];
|
||||||
|
|
||||||
|
// keysOf/templateIsAvailable/templateContent are the single place that understands the `qr`/
|
||||||
|
// `text` field-name shape above (including `text` sometimes being an array) - both
|
||||||
|
// LabelLayoutPreview.vue's thumbnail grid and Print.vue's big preview resolve a template through
|
||||||
|
// these rather than each re-implementing the same lookup.
|
||||||
|
function keysOf(spec) {
|
||||||
|
if (!spec) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Array.isArray(spec) ? spec : [spec];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function templateIsAvailable(t, fields) {
|
||||||
|
return [...keysOf(t.qr), ...keysOf(t.text)].every(key => fields[key] !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Resolves a template's field names against actual field values. `text` comes back as an array
|
||||||
|
whenever the template's `text` spec is an array (multi-line), or a plain string otherwise -
|
||||||
|
drawLabel/drawFallbackLabel below accept either. */
|
||||||
|
export function templateContent(t, fields) {
|
||||||
|
const resolve = (spec) => {
|
||||||
|
if (!spec) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Array.isArray(spec) ? spec.map(key => fields[key]) : fields[spec];
|
||||||
|
};
|
||||||
|
return {qr: resolve(t.qr), text: resolve(t.text)};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 every one of `lines` within
|
||||||
|
maxWidth, stacked within maxHeight - this is a label, not a paragraph, so each line shrinks to
|
||||||
|
fit rather than wrapping. */
|
||||||
|
function fitTextSize(ctx, lines, maxWidth, maxHeight) {
|
||||||
|
const minPx = 8;
|
||||||
|
let px = Math.max(minPx, Math.floor(maxHeight / lines.length));
|
||||||
|
while (px > minPx && lines.some(line => measureAtHeight(ctx, line, 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 lines = Array.isArray(textContent) ? textContent : [textContent];
|
||||||
|
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, lines, textBudget, availableHeight);
|
||||||
|
const textWidth = Math.max(...lines.map(line => measureAtHeight(measureCtx, line, 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";
|
||||||
|
// Lines stack as a block vertically centered in the label, rather than each line centered on
|
||||||
|
// its own - keeps a two-line block reading as one unit instead of drifting apart.
|
||||||
|
const lineHeight = Math.ceil(textPx * 1.15);
|
||||||
|
let y = (canvas.height - lineHeight * lines.length) / 2 + lineHeight / 2;
|
||||||
|
for (const line of lines) {
|
||||||
|
ctx.fillText(line, cursor, y);
|
||||||
|
y += lineHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
const lines = Array.isArray(textContent) ? textContent : [textContent];
|
||||||
|
|
||||||
|
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, lines, Infinity, FALLBACK_LABEL_HEIGHT_PX);
|
||||||
|
const textWidth = Math.max(...lines.map(line => measureAtHeight(measureCtx, line, 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";
|
||||||
|
const lineHeight = Math.ceil(textPx * 1.15);
|
||||||
|
let y = (height - lineHeight * lines.length) / 2 + lineHeight / 2;
|
||||||
|
for (const line of lines) {
|
||||||
|
ctx.fillText(line, cursor, y);
|
||||||
|
y += lineHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -78,7 +78,10 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
|
||||||
path: '/print',
|
path: '/print',
|
||||||
component: Print,
|
component: Print,
|
||||||
meta: {requiresAuth: true},
|
meta: {requiresAuth: true},
|
||||||
props: route => ({prefill: route.query.text})
|
props: route => {
|
||||||
|
const {kind, ...components} = route.query;
|
||||||
|
return {prefill: kind ? {kind, components} : null};
|
||||||
|
}
|
||||||
}, {
|
}, {
|
||||||
path: '/search/:query',
|
path: '/search/:query',
|
||||||
component: Search,
|
component: Search,
|
||||||
|
|
|
||||||
|
|
@ -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/')
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-secondary"
|
<button class="btn btn-secondary"
|
||||||
@click="$router.push({path: '/print', query: {text: itemUrl}})">
|
@click="$router.push({path: '/print', query: {kind: 'item-url', user, id}})">
|
||||||
<b-icon-printer></b-icon-printer>
|
<b-icon-printer></b-icon-printer>
|
||||||
Print label
|
Print label
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -86,12 +86,6 @@ export default {
|
||||||
},
|
},
|
||||||
location() {
|
location() {
|
||||||
return this.storage_locations.find(loc => loc.id === this.item.storage_location) || null
|
return this.storage_locations.find(loc => loc.id === this.item.storage_location) || null
|
||||||
},
|
|
||||||
itemUrl() {
|
|
||||||
// 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.
|
|
||||||
return `${window.location.origin}/i/${this.user}/${this.id}`
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
@ -130,6 +130,13 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<label-layout-preview :fields="fields" :value="selectedTemplate"
|
||||||
|
@input="selectedTemplate = $event"></label-layout-preview>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
@ -138,10 +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, buildLabelFields} from "@/label-content.js";
|
||||||
|
import {LABEL_TEMPLATES, tapeFromStatus, drawLabel, drawFallbackLabel, templateContent} 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*
|
||||||
|
|
@ -150,111 +159,21 @@ import {MultiPrinterBlob, canvasToBitmap, bitmapToCanvas} from "../../vendor/web
|
||||||
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 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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "Print",
|
name: "Print",
|
||||||
components: {
|
components: {
|
||||||
BaseLayout,
|
BaseLayout,
|
||||||
|
LabelLayoutPreview,
|
||||||
...BIcons
|
...BIcons
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
// Prefilled from ?text=… when arriving from e.g. an item's "Print label" button
|
// {kind, components} prefilled from the ?kind=…&… query params when arriving from e.g.
|
||||||
// (see InventoryDetail.vue) - the router turns that query param into this prop
|
// an item's "Print label" button (see InventoryDetail.vue) - the router turns those
|
||||||
// (router.js's /print route), rather than the component reading $route directly.
|
// query params into this prop (router.js's /print route), rather than the component
|
||||||
|
// reading $route directly. buildLabelContent turns it into the literal string below.
|
||||||
prefill: {
|
prefill: {
|
||||||
type: String,
|
type: Object,
|
||||||
default: null
|
default: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -270,8 +189,9 @@ export default {
|
||||||
tape: null,
|
tape: null,
|
||||||
labelBitmap: null,
|
labelBitmap: null,
|
||||||
|
|
||||||
value: this.prefill || "",
|
value: buildLabelContent(this.prefill),
|
||||||
copies: 1,
|
copies: 1,
|
||||||
|
selectedTemplate: LABEL_TEMPLATES[0].id,
|
||||||
|
|
||||||
fallbackReady: false,
|
fallbackReady: false,
|
||||||
// TODO: replace with the real commands for our printers.
|
// TODO: replace with the real commands for our printers.
|
||||||
|
|
@ -280,6 +200,18 @@ export default {
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
// Named content fields the field-specific templates draw from, plus the free-text
|
||||||
|
// `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() {
|
||||||
|
return templateContent(this.currentTemplate, this.fields);
|
||||||
|
},
|
||||||
deviceRows() {
|
deviceRows() {
|
||||||
if (!this.blob) {
|
if (!this.blob) {
|
||||||
return [];
|
return [];
|
||||||
|
|
@ -296,7 +228,7 @@ export default {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
canPrint() {
|
canPrint() {
|
||||||
return Boolean(this.value && this.tape && this.labelBitmap && !this.busy);
|
return Boolean(this.tape && this.labelBitmap && !this.busy);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
|
|
@ -307,6 +239,13 @@ export default {
|
||||||
this.redrawFallback();
|
this.redrawFallback();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
selectedTemplate() {
|
||||||
|
if (this.usbSupported) {
|
||||||
|
this.redraw();
|
||||||
|
} else {
|
||||||
|
this.redrawFallback();
|
||||||
|
}
|
||||||
|
},
|
||||||
// Covers connecting/disconnecting/switching printers - anything that changes the
|
// Covers connecting/disconnecting/switching printers - anything that changes the
|
||||||
// tape dimensions redraw() sizes the canvas from. flush: 'post' because the canvas
|
// tape dimensions redraw() sizes the canvas from. flush: 'post' because the canvas
|
||||||
// itself only exists once `tape` is truthy (see the v-if/v-else in the template), so
|
// itself only exists once `tape` is truthy (see the v-if/v-else in the template), so
|
||||||
|
|
@ -386,7 +325,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;
|
||||||
|
|
@ -395,8 +335,7 @@ export default {
|
||||||
}
|
}
|
||||||
this.resizeObserver.observe(canvas.parentElement);
|
this.resizeObserver.observe(canvas.parentElement);
|
||||||
try {
|
try {
|
||||||
const qr = QRCode.create(this.value);
|
drawLabel(canvas, this.tape, content);
|
||||||
drawQrLabel(canvas, qr, this.tape);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.error = e.message;
|
this.error = e.message;
|
||||||
return;
|
return;
|
||||||
|
|
@ -410,7 +349,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;
|
||||||
|
|
@ -419,8 +359,7 @@ export default {
|
||||||
}
|
}
|
||||||
this.resizeObserver.observe(canvas.parentElement);
|
this.resizeObserver.observe(canvas.parentElement);
|
||||||
try {
|
try {
|
||||||
const qr = QRCode.create(this.value);
|
drawFallbackLabel(canvas, content);
|
||||||
drawQrSquare(canvas, qr);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.error = e.message;
|
this.error = e.message;
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue