This commit is contained in:
j3d1 2026-08-17 18:28:01 +02:00
parent 25cef95711
commit 7d7730354e
8 changed files with 381 additions and 39 deletions

View file

@ -52,6 +52,12 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
component: InventoryDetailForeign,
meta: {requiresAuth: true, foreign: true},
props: true
}, {
path: '/i/:handle/:id',
redirect: to => {
const {handle, id} = to.params
return handle === store.state.user ? '/inventory/' + id : '/inventory/shared/' + handle + '/' + id
}
}, {path: '/inventory/new', component: InventoryNew, meta: {requiresAuth: true}}, {
path: '/friends',
component: Friends,
@ -68,7 +74,12 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
}, {path: '/admin',
component: Admin,
meta: {requiresAuth: true}
}, {path: '/swatch', component: Swatch, meta: {requiresAuth: true}}, {path: '/print', component: Print, meta: {requiresAuth: true}}, {
}, {path: '/swatch', component: Swatch, meta: {requiresAuth: true}}, {
path: '/print',
component: Print,
meta: {requiresAuth: true},
props: route => ({prefill: route.query.text})
}, {
path: '/search/:query',
component: Search,
meta: {requiresAuth: true},

View file

@ -412,6 +412,22 @@ export default createStore({
return null;
}
},
async fetchForeignItem({dispatch, getters}, {owner, id}) {
try {
const servers = await dispatch('getFriendServers', {username: owner});
// owner here is a full handle (username@domain) - see the /api/inventory_items/<handle>/<id>/
// endpoint (toolshed/api/inventory.py get_shared_item), which looks the item up by owner rather
// than by requester, unlike the plain /api/inventory_items/ list/detail endpoints.
const item = await servers.get(getters.signAuth, '/api/inventory_items/' + owner + '/' + id + '/');
if (item && item.files) {
item.files.forEach(file => file.owner = item.owner)
}
return item;
} catch (error) {
console.error(`Failed to fetch item ${id} for ${owner}:`, error);
return null;
}
},
async fetchFriendRequests({state, dispatch, getters}) {
const servers = await dispatch('getHomeServers')
return await servers.get(getters.signAuth, '/api/friendrequests/')

View file

@ -46,6 +46,11 @@
<b-icon-trash></b-icon-trash>
Delete
</button>
<button class="btn btn-secondary"
@click="$router.push({path: '/print', query: {text: itemUrl}})">
<b-icon-printer></b-icon-printer>
Print label
</button>
</div>
</div>
@ -75,12 +80,18 @@ export default {
},
computed: {
...mapGetters(["loaded_items", "getNameFromHandle"]),
...mapState(["storage_locations"]),
...mapState(["storage_locations", "user"]),
item() {
return this.loaded_items.find(item => item.id === parseInt(this.id)) || {}
},
location() {
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: {

View file

@ -6,9 +6,13 @@
<div class="card">
<div class="card-header">{{ item.name }}</div>
<div class="card-body">
<div class="mb-3">
<label for="owner" class="form-label">Owner</label>
{{ item.owner }}
</div>
<div class="mb-3">
<label for="description" class="form-label">Description</label>
Foreighn {{ item.description }}
{{ item.description }}
</div>
<div class="mb-3">
<label for="tags" class="form-label">Tags</label>
@ -36,18 +40,6 @@
</div>
</div>
</div>
<div class="card">
<button class="btn btn-primary" @click="$router.push('/inventory/' + id + '/edit')">
<b-icon-pencil-square></b-icon-pencil-square>
Edit
</button>
<button type="submit" class="btn btn-danger"
@click="deleteInventoryItem(item).then(() => $router.push('/inventory'))">
<b-icon-trash></b-icon-trash>
Delete
</button>
</div>
</div>
</div>
</main>
@ -57,38 +49,46 @@
<script>
import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue";
import {mapActions, mapGetters, mapState} from "vuex";
import {mapActions, mapGetters} from "vuex";
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
export default {
name: "InventoryDetail",
name: "InventoryDetailForeign",
components: {
AuthenticatedImage,
BaseLayout,
...BIcons
},
props: {
user: {
type: String,
required: true
},
id: {
type: String,
required: true
}
},
computed: {
...mapGetters(["loaded_items", "getNameFromHandle"]),
...mapState(["storage_locations"]),
item() {
return this.loaded_items.find(item => item.id === parseInt(this.id)) || {}
},
location() {
return this.storage_locations.find(loc => loc.id === this.item.storage_location) || null
data() {
return {
item: {}
}
},
methods: {
...mapActions(["fetchInventoryItems", "deleteInventoryItem", "fetchFilesByItem", "fetchStorageLocations"]),
computed: {
...mapGetters(["getNameFromHandle"]),
},
async mounted() {
await this.fetchInventoryItems()
await this.fetchStorageLocations()
methods: {
...mapActions(["fetchForeignItem"]),
async loadItem() {
this.item = await this.fetchForeignItem({owner: this.user, id: this.id}) || {}
}
},
watch: {
user: 'loadItem',
id: 'loadItem'
},
mounted() {
this.loadItem()
}
}
</script>
@ -99,4 +99,4 @@ img {
height: 107px;
object-fit: contain;
}
</style>
</style>

View file

@ -7,8 +7,51 @@
<div v-if="error" class="alert alert-danger" role="alert">{{ error }}</div>
<div v-if="!usbSupported" class="alert alert-warning">
This browser can't talk to USB label printers. Open this page in Chrome, Edge or Opera
over https:// (or http://localhost).
This browser can't talk to USB label printers directly. Open this page in Chrome, Edge or
Opera over https:// (or http://localhost) to print straight from here, or use one of the
alternatives below.
</div>
<div v-if="!usbSupported" class="row">
<div class="col-lg-7">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Label preview</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">QR code content</label>
<input type="text" class="form-control" v-model="value"
placeholder="https://example.com/…" autofocus>
</div>
<div class="label-preview mb-3" v-show="value">
<canvas ref="fallbackCanvas"></canvas>
</div>
<button class="btn btn-primary" :disabled="!fallbackReady" @click="downloadPng">
<b-icon-download class="me-1"></b-icon-download>
Download label as PNG
</button>
</div>
</div>
</div>
<div class="col-lg-5 mb-4">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Print from the command line</h5>
</div>
<div class="card-body">
<p class="text-muted">
Download the PNG above, then send it to a label printer with its CLI tool.
</p>
<p class="mb-1"><strong>Brother QL-series</strong></p>
<pre class="code-block"><code>{{ brotherCliExample }}</code></pre>
<p class="mb-1"><strong>Niimbot</strong></p>
<pre class="code-block"><code>{{ niimbotCliExample }}</code></pre>
</div>
</div>
</div>
</div>
<div v-else class="row">
@ -172,12 +215,49 @@ function drawQrLabel(canvas, qr, tape) {
}
}
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 {
name: "Print",
components: {
BaseLayout,
...BIcons
},
props: {
// Prefilled from ?text= when arriving from e.g. an item's "Print label" button
// (see InventoryDetail.vue) - the router turns that query param into this prop
// (router.js's /print route), rather than the component reading $route directly.
prefill: {
type: String,
default: null
}
},
data() {
return {
usbSupported: true,
@ -188,8 +268,13 @@ export default {
devices: [],
connected: null,
value: "",
value: this.prefill || "",
copies: 1,
fallbackReady: false,
// TODO: replace with the real commands for our printers.
brotherCliExample: "brother_ql --model QL-000 --printer usb://0000:0000 print --label 00 label.png",
niimbotCliExample: "niimprint --model b00 --conn usb print --density 3 --image label.png",
};
},
computed: {
@ -214,7 +299,11 @@ export default {
},
watch: {
value() {
this.redraw();
if (this.usbSupported) {
this.redraw();
} else {
this.redrawFallback();
}
},
},
methods: {
@ -299,6 +388,38 @@ export default {
this.fitZoom(canvas);
},
redrawFallback() {
this.fallbackReady = false;
if (!this.value) {
return;
}
const canvas = this.$refs.fallbackCanvas;
if (!canvas) {
return;
}
try {
const qr = QRCode.create(this.value);
drawQrSquare(canvas, qr);
} catch (e) {
this.error = e.message;
return;
}
this.error = null;
this.fallbackReady = true;
this.fitZoom(canvas);
},
downloadPng() {
const canvas = this.$refs.fallbackCanvas;
if (!canvas) {
return;
}
const link = document.createElement("a");
link.download = "label.png";
link.href = canvas.toDataURL("image/png");
link.click();
},
/* Fit the preview to its card without ever needing a horizontal
scrollbar for a label this small, magnifying short labels up to
MAX_ZOOM rather than showing them at native (tiny) size. */
@ -322,7 +443,13 @@ export default {
handleResize() {
clearTimeout(this.resizeTimer);
this.resizeTimer = setTimeout(() => this.redraw(), 100);
this.resizeTimer = setTimeout(() => {
if (this.usbSupported) {
this.redraw();
} else {
this.redrawFallback();
}
}, 100);
},
onUsbChange() {
@ -336,8 +463,11 @@ export default {
this.resizeTimer = null;
},
async mounted() {
window.addEventListener("resize", this.handleResize);
if (!("usb" in navigator)) {
this.usbSupported = false;
await nextTick();
this.redrawFallback();
return;
}
try {
@ -349,7 +479,6 @@ export default {
this.blobReady = true;
navigator.usb.addEventListener("connect", this.onUsbChange);
navigator.usb.addEventListener("disconnect", this.onUsbChange);
window.addEventListener("resize", this.handleResize);
await this.guard(() => this.refreshDevices());
},
beforeUnmount() {
@ -381,4 +510,13 @@ export default {
.copies-input {
width: 5.5rem;
}
.code-block {
background: rgba(127, 127, 127, .08);
border-radius: .35rem;
padding: .75rem;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
}
</style>