stash
This commit is contained in:
parent
25cef95711
commit
7d7730354e
8 changed files with 381 additions and 39 deletions
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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/')
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue