From 0c025db7998bf0ccec1b4ad2ccc6f2454a3b5c7b Mon Sep 17 00:00:00 2001 From: jedi Date: Mon, 24 Aug 2026 21:35:38 +0200 Subject: [PATCH] stash --- backend/toolshed/api/inventory.py | 4 + backend/toolshed/models.py | 33 ++ backend/toolshed/offlinedata.py | 18 +- backend/toolshed/serializers.py | 43 ++- backend/toolshed/tests/fixtures.py | 12 +- .../assets/fonts/label/Inter-Regular.woff2 | Bin 0 -> 21564 bytes frontend/src/components/CameraScanner.vue | 359 +++++++++++++++++- .../src/components/LabelLayoutPreview.vue | 23 +- frontend/src/label-layouts.js | 67 +++- frontend/src/label.js | 57 +-- frontend/src/router.js | 4 +- frontend/src/scss/toolshed.scss | 1 + frontend/src/views/Inventory.vue | 2 +- frontend/src/views/InventoryDetail.vue | 2 +- frontend/src/views/Print.vue | 33 +- frontend/src/views/StorageLocation.vue | 2 +- 16 files changed, 555 insertions(+), 105 deletions(-) create mode 100644 frontend/src/assets/fonts/label/Inter-Regular.woff2 diff --git a/backend/toolshed/api/inventory.py b/backend/toolshed/api/inventory.py index 40f2b97..833c00e 100644 --- a/backend/toolshed/api/inventory.py +++ b/backend/toolshed/api/inventory.py @@ -149,6 +149,10 @@ class StorageLocationViewSet(viewsets.ModelViewSet): serializer_class = StorageLocationSerializer authentication_classes = [SignatureAuthentication] permission_classes = [IsAuthenticated] + # Detail routes address a location by its owner-scoped id, not the internal row id. See + # docs/implementation.md#inventory-detail-routes-use-owner-scoped-ids. + lookup_field = 'id' + lookup_url_kwarg = 'pk' def get_queryset(self): if type(self.request.user) == KnownIdentity and self.request.user.user.exists(): diff --git a/backend/toolshed/models.py b/backend/toolshed/models.py index 6fdb9ff..6136a86 100644 --- a/backend/toolshed/models.py +++ b/backend/toolshed/models.py @@ -169,7 +169,27 @@ class ItemTag(models.Model): inventory_item = models.ForeignKey(InventoryItem, on_delete=models.CASCADE) +class OwnerStorageLocationSequence(models.Model): + """Tracks the last StorageLocation id handed out per owner for sequential, gapless allocation + (see StorageLocation.create_for_owner).""" + owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='+', unique=True) + last_id = models.PositiveIntegerField(default=0) + + @classmethod + def allocate(cls, *, owner): + with transaction.atomic(): + seq, _ = cls.objects.select_for_update().get_or_create(owner=owner) + seq.last_id += 1 + seq.save(update_fields=['last_id']) + return seq.last_id + + class StorageLocation(models.Model): + internal_id = models.AutoField(primary_key=True) + # Externally visible id, sequential/gapless within the owner's own locations (see + # OwnerStorageLocationSequence), never internal_id; always allocate via create_for_owner, not + # .objects.create(). + id = models.PositiveIntegerField(editable=False) name = models.CharField(max_length=255) description = models.TextField(null=True, blank=True) category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, blank=True, @@ -177,10 +197,23 @@ class StorageLocation(models.Model): parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children') owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='storage_locations') + class Meta: + constraints = [ + models.UniqueConstraint(fields=['owner', 'id'], name='storagelocation_unique_owner_scoped_id'), + ] + def __str__(self): parent = str(self.parent) + "/" if self.parent else "" return parent + self.name + @classmethod + def create_for_owner(cls, *, owner, **kwargs): + """The only supported way to create a StorageLocation: atomically allocates the next id + for this owner's scope.""" + with transaction.atomic(): + next_id = OwnerStorageLocationSequence.allocate(owner=owner) + return cls.objects.create(owner=owner, id=next_id, **kwargs) + class WorkflowInstance(models.Model): slug = models.CharField(max_length=255) diff --git a/backend/toolshed/offlinedata.py b/backend/toolshed/offlinedata.py index 1175e63..ee9f383 100644 --- a/backend/toolshed/offlinedata.py +++ b/backend/toolshed/offlinedata.py @@ -339,13 +339,17 @@ def import_locations(user, data): if category_path: category = get_or_create_category(category_path) - location, _ = StorageLocation.objects.update_or_create( - owner=user, name=name, parent=parent, - defaults={ - 'description': row.get('description', '') or '', - 'category': category, - }, - ) + defaults = { + 'description': row.get('description', '') or '', + 'category': category, + } + try: + location = StorageLocation.objects.get(owner=user, name=name, parent=parent) + for field, value in defaults.items(): + setattr(location, field, value) + location.save(update_fields=list(defaults.keys())) + except StorageLocation.DoesNotExist: + location = StorageLocation.create_for_owner(owner=user, name=name, parent=parent, **defaults) resolved_by_path[path] = location imported += 1 except Exception as error: diff --git a/backend/toolshed/serializers.py b/backend/toolshed/serializers.py index 4c864f3..369222f 100644 --- a/backend/toolshed/serializers.py +++ b/backend/toolshed/serializers.py @@ -1,3 +1,4 @@ +from django.core.exceptions import ObjectDoesNotExist from rest_framework import serializers from authentication.models import KnownIdentity, ToolshedUser, FriendRequestIncoming, Group, GroupInviteIncoming from authentication.serializers import OwnerSerializer, GroupOwnerSerializer @@ -150,15 +151,50 @@ class CategorySerializer(serializers.ModelSerializer): return resolve_category_handle(data.split("/")[-1]) +class OwnerScopedPrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField): + """Resolves/represents by the owner-scoped `id` rather than the model's internal pk, scoped to + the requesting user - StorageLocation.parent points at another StorageLocation, whose publicly + visible identity is now the owner-scoped id (see StorageLocation.create_for_owner), not + internal_id.""" + + def use_pk_only_optimization(self): + # False: to_representation needs the owner-scoped `id`, not just the internal pk that the + # PKOnlyObject optimization would otherwise limit us to. + return False + + def get_queryset(self): + queryset = super().get_queryset() + request = self.context.get('request') + if request is not None and type(request.user) == KnownIdentity and request.user.user.exists(): + return queryset.filter(owner=request.user.user.get()) + return queryset.none() + + def to_internal_value(self, data): + queryset = self.get_queryset() + try: + if isinstance(data, bool): + raise TypeError + return queryset.get(id=data) + except ObjectDoesNotExist: + self.fail('does_not_exist', pk_value=data) + except (TypeError, ValueError): + self.fail('incorrect_type', data_type=type(data).__name__) + + def to_representation(self, value): + return value.id + + class StorageLocationSerializer(serializers.ModelSerializer): owner = OwnerSerializer(read_only=True) category = serializers.CharField(required=False, allow_null=True, allow_blank=True) + parent = OwnerScopedPrimaryKeyRelatedField(queryset=StorageLocation.objects.all(), required=False, + allow_null=True) path = serializers.SerializerMethodField() class Meta: model = StorageLocation fields = ['id', 'name', 'description', 'path', 'category', 'owner', 'parent'] - read_only_fields = ['path'] + read_only_fields = ['id', 'path'] @staticmethod def get_path(obj): @@ -166,6 +202,9 @@ class StorageLocationSerializer(serializers.ModelSerializer): return StorageLocationSerializer.get_path(obj.parent) + "/" + obj.name return obj.name + def create(self, validated_data): + return StorageLocation.create_for_owner(**validated_data) + class ItemPropertySerializer(serializers.ModelSerializer): property = PropertySerializer(read_only=True) @@ -195,6 +234,8 @@ class InventoryItemSerializer(serializers.ModelSerializer): properties = ItemPropertySerializer(many=True, required=False, source='itemproperty_set') category = CategorySerializer(required=False, allow_null=True) files = FileSerializer(many=True, read_only=True) + storage_location = OwnerScopedPrimaryKeyRelatedField(queryset=StorageLocation.objects.all(), required=False, + allow_null=True) class Meta: model = InventoryItem diff --git a/backend/toolshed/tests/fixtures.py b/backend/toolshed/tests/fixtures.py index 942de7e..69c2e42 100644 --- a/backend/toolshed/tests/fixtures.py +++ b/backend/toolshed/tests/fixtures.py @@ -49,12 +49,12 @@ class InventoryTestMixin(CategoryTestMixin, TagTestMixin, PropertyTestMixin): class LocationTestMixin: def prepare_locations(self): - self.f['loc1'] = StorageLocation.objects.create(name='loc1', owner=self.f['local_user1']) - self.f['loc2'] = StorageLocation.objects.create(name='loc2', owner=self.f['local_user1'], - category=self.f['cat1']) - self.f['loc3'] = StorageLocation.objects.create(name='loc3', owner=self.f['local_user1'], parent=self.f['loc1']) - self.f['loc4'] = StorageLocation.objects.create(name='loc4', owner=self.f['local_user1'], parent=self.f['loc1'], - category=self.f['cat1']) + self.f['loc1'] = StorageLocation.create_for_owner(name='loc1', owner=self.f['local_user1']) + self.f['loc2'] = StorageLocation.create_for_owner(name='loc2', owner=self.f['local_user1'], + category=self.f['cat1']) + self.f['loc3'] = StorageLocation.create_for_owner(name='loc3', owner=self.f['local_user1'], parent=self.f['loc1']) + self.f['loc4'] = StorageLocation.create_for_owner(name='loc4', owner=self.f['local_user1'], parent=self.f['loc1'], + category=self.f['cat1']) class WorkflowTestMixin: diff --git a/frontend/src/assets/fonts/label/Inter-Regular.woff2 b/frontend/src/assets/fonts/label/Inter-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..d228a4af2fcc51e12c8a744e6c35d2363bd4b98a GIT binary patch literal 21564 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 literal 0 HcmV?d00001 diff --git a/frontend/src/components/CameraScanner.vue b/frontend/src/components/CameraScanner.vue index a6cb277..a786981 100644 --- a/frontend/src/components/CameraScanner.vue +++ b/frontend/src/components/CameraScanner.vue @@ -32,13 +32,156 @@ diff --git a/frontend/src/components/LabelLayoutPreview.vue b/frontend/src/components/LabelLayoutPreview.vue index d697178..e0a68b3 100644 --- a/frontend/src/components/LabelLayoutPreview.vue +++ b/frontend/src/components/LabelLayoutPreview.vue @@ -90,12 +90,12 @@ background: #fff; } -/* Corner badge flagging *why* a layout that's otherwise available (fields filled in) still - failed to render - e.g. label.js's "bigger code than the tape allows" or "doesn't fit on this - tape" errors (see redraw()'s catch). Shows the short label directly (e.g. "too big") rather than - just an icon, so the reason reads at a glance; the full sentence is still the title tooltip. Not - shown for the more common "fields not filled in yet" case (see isSelectable/unavailableReason) - since that's already conveyed by the greyed-out thumbnail. */ +/* Corner badge flagging *why* a layout failed to render - a capacity error (label.js's "bigger + code than the tape allows"/"doesn't fit on this tape") or label-layouts.js's templateContent + throwing "missing data" because a field it needs isn't filled in yet (see redraw()'s catch). + Shows the short label directly (e.g. "too big", "missing data") rather than just an icon, so the + reason reads at a glance; the full sentence is still the title tooltip. Layered on top of the + greyed-out/unselectable state isSelectable/unavailableReason already apply for the same case. */ .template-thumb-warning { position: absolute; top: .35rem; @@ -276,17 +276,8 @@ export default { if (!canvas) { continue; } - const content = templateContent(t, this.fields); - if (!this.isAvailable(t) || !content) { - canvas.width = 1; - canvas.height = 1; - delete this.failed[t.id]; - delete this.warned[t.id]; - delete this.qrInfo[t.id]; - delete this.textInfo[t.id]; - continue; - } try { + const content = templateContent(t, this.fields); const {warnings, qrInfo, textInfo} = this.tape ? drawLabel(canvas, this.tape, content, "along") : drawFallbackLabel(canvas, content, "along"); diff --git a/frontend/src/label-layouts.js b/frontend/src/label-layouts.js index 018b69d..84dc502 100644 --- a/frontend/src/label-layouts.js +++ b/frontend/src/label-layouts.js @@ -86,14 +86,39 @@ export const LABEL_TEMPLATES = [ }, { id: "rmqr-url", name: "rMQR Token", description: "The code with the encoded text printed next to it.", - required_vars: ["shortUrl","itemId"], tags: ["external"], - layout: [{type: "rmqr", content: c => c.shortUrl},GAP,{type: "text", content: c => c.itemId?.toString().padStart(4, "0")}] + required_vars: ["shortUrl", "itemId"], tags: ["external"], + layout: [{type: "rmqr", content: c => c.shortUrl}, GAP, { + type: "text", + content: c => c.itemId?.toString().padStart(4, "0") + }] }, { id: "mqr-tokem-id", name: "rMQR Token", description: "The code with the encoded text printed next to it.", - required_vars: ["shortId","itemId"], tags: ["internal"], - layout: [{type: "mqr", content: c => c.shortId},GAP,{type: "text", content: c => c.itemId?.toString().padStart(4, "0")}] - },...QR_ONLY_TEMPLATES, + required_vars: ["shortId", "itemId"], tags: ["internal"], + layout: [{type: "mqr", content: c => c.shortId}, GAP, { + type: "text", + content: c => c.itemId?.toString().padStart(4, "0") + }] + }, + { + id: "location-rmqr-url", name: "rMQR Token", description: "The code with the encoded text printed next to it.", + required_vars: ["shortUrl", "locationId"], tags: ["external"], + layout: [{type: "rmqr", content: c => c.shortUrl}, GAP, { + type: "text", + content: c => c.locationId?.toString().padStart(4, "0") + }] + }, + { + id: "location-mqr-tokem-id", + name: "rMQR Token", + description: "The code with the encoded text printed next to it.", + required_vars: ["shortId", "locationId"], + tags: ["internal"], + layout: [{type: "mqr", content: c => c.shortId}, GAP, { + type: "text", + content: c => c.locationId?.toString().padStart(4, "0") + }] + }, ...QR_ONLY_TEMPLATES, { id: "qr-text", name: "QR code + text", description: "The code with the encoded text printed next to it.", @@ -110,7 +135,7 @@ export const LABEL_TEMPLATES = [ id: "id-qr-text-vertical", name: "ID + QR code + text below", description: "The code with the encoded text printed below it.", required_vars: ["itemId", "text", "userHandle"], - layout: [[{type: "text", content: c => "Item: "+c.itemId}, GAP, { + layout: [[{type: "text", content: c => "Item: " + c.itemId}, GAP, { type: "qr", content: c => c.text }, GAP, {type: "text", content: c => c.userHandle}]] @@ -148,6 +173,17 @@ export const LABEL_TEMPLATES = [ required_vars: ["userHandle", "itemId"], tags: ["internal"], layout: [{type: "text", content: c => [c.userHandle, c.itemId]}] }, + { + id: "location-id", name: "Location ID", description: "Just the bare storage location id, as text only.", + required_vars: ["locationId"], tags: ["internal"], + layout: [{type: "text", content: c => c.locationId}] + }, + { + id: "owner-id-text-location", name: "Owner + location ID", + description: "The owner's handle and the location id, as two lines of text - no code.", + required_vars: ["userHandle", "locationId"], tags: ["internal"], + layout: [{type: "text", content: c => [c.userHandle, c.locationId]}] + }, { id: "item-url-qr-handle", name: "Item URL + handle", description: "Scannable item URL, with the item's compact handle printed alongside.", @@ -272,14 +308,15 @@ export function templateIsAvailable(t, fields) { } // Resolves t's content leaves against fields into a tree for label.js's -// drawLabel/drawFallbackLabel, or null if nothing to render yet. +// drawLabel/drawFallbackLabel. Throws the same {message, short} shape as label.js's own +// capacity errors (see docs/implementation.md#missing-data-is-a-templatecontent-error) when a +// leaf isn't resolved yet, rather than returning null, so a caller's single catch around +// drawLabel/drawFallbackLabel handles both without a separate isAvailable pre-check. export function templateContent(t, fields) { - const tree = mapTree(t.layout, leaf => leaf.type === "empty" ? leaf : {...leaf, value: leaf.content(fields)}); - let hasContent = false; - walkLeaves(tree, leaf => { - if (leaf.type !== "empty" && leaf.value && (!Array.isArray(leaf.value) || leaf.value.some(Boolean))) { - hasContent = true; - } - }); - return hasContent ? tree : null; + if (!templateIsAvailable(t, fields)) { + const err = new Error("This layout needs more fields filled in before it can be drawn."); + err.short = "missing data"; + throw err; + } + return mapTree(t.layout, leaf => leaf.type === "empty" ? leaf : {...leaf, value: leaf.content(fields)}); } diff --git a/frontend/src/label.js b/frontend/src/label.js index e2ec350..ac26a18 100644 --- a/frontend/src/label.js +++ b/frontend/src/label.js @@ -81,14 +81,18 @@ const TEXT_REFERENCE_PX = 100; /* font size text leaves measure their natural a // Below 10px, a general-purpose sans-serif gets illegible, so drawTextLeaf switches to a bitmap // font (Tom Thumb/Silkscreen) instead; see the empirical rationale (font choice, `scale`, and why -// sizes aren't dpi-adjusted) at docs/implementation.md#pixel-font-selection. -const PIXEL_FONT_TIERS = [ - {belowPx: 8, family: "Tom Thumb", scale: 3.2}, - {belowPx: 10, family: "Silkscreen"}, +// sizes aren't dpi-adjusted) at docs/implementation.md#pixel-font-selection. The 10px+ tier names +// Inter explicitly (see ../scss/_label-fonts.scss) rather than falling back to the CSS generic +// "sans-serif" keyword, which resolves to a different real font per browser/OS and would make the +// same label print differently depending on where it was rendered from. +const FONT_TIERS = [ + {belowPx: 8, family: "Tom Thumb", scale: 3.2, pixel: true}, + {belowPx: 10, family: "Silkscreen", pixel: true}, + {belowPx: Infinity, family: "Inter"}, ]; function fontFamilyFor(fontPx) { - return PIXEL_FONT_TIERS.find(t => fontPx < t.belowPx) ?? {family: "sans-serif"}; + return FONT_TIERS.find(t => fontPx < t.belowPx); } // Below this font size (px) or physical height (mm) - Tom Thumb's real-Chromium-tested legibility @@ -291,11 +295,12 @@ function checkTextSizes(node, referencePx, pxPerMm, warnings, textInfo) { } } -// Always measures in sans-serif at the reference size, since the eventual font (see -// PIXEL_FONT_TIERS) isn't known until layoutTree sizes the box this aspect ratio feeds into; the -// resulting mismatch is invisible in practice, and checkTextSizes still catches real failures. +// Always measures in Inter at the reference size, since the eventual font (see FONT_TIERS) isn't +// known until layoutTree sizes the box this aspect ratio feeds into; the resulting mismatch (when +// a pixel font tier ends up chosen instead) is invisible in practice, and checkTextSizes still +// catches real failures. function measureTextBlock(ctx, lines, referencePx) { - ctx.font = `${referencePx}px sans-serif`; + ctx.font = `${referencePx}px "Inter"`; const width = Math.max(...lines.map(line => ctx.measureText(line).width)); const height = referencePx * 1.15 * lines.length; return {width, height}; @@ -342,20 +347,18 @@ function drawQrLeaf(ctx, node) { // there's no "too small to draw" case left to special-case here. function drawTextLeaf(ctx, node, referencePx) { const fontPx = effectiveFontPx(node, referencePx); - const {family, scale = 1} = fontFamilyFor(fontPx); - const isPixelFont = family !== "sans-serif"; + const {family, scale = 1, pixel: isPixelFont = false} = fontFamilyFor(fontPx); // Position (not size - fontPx is already a whole pixel) still needs snapping for pixel fonts - // to stay grid-aligned, since node.box.x/y are ordinary (fractional) layout math; sans-serif is + // to stay grid-aligned, since node.box.x/y are ordinary (fractional) layout math; Inter is // left exact since anti-aliasing handles fractional positions fine. const snap = isPixelFont ? Math.round : (v) => v; - // `scale` (Tom Thumb only, see PIXEL_FONT_TIERS above) corrects the size handed to ctx.font - // for its real ink; fontPx itself stays the logical size used for centering/stacking math. + // `scale` (Tom Thumb only, see FONT_TIERS above) corrects the size handed to ctx.font for its + // real ink; fontPx itself stays the logical size used for centering/stacking math. ctx.font = `${fontPx * scale}px "${family}"`; // Canvas text silently falls back if drawn before a not-yet-loaded font resolves, unlike DOM - // text. See docs/implementation.md#canvas-font-loading. - if (isPixelFont) { - document.fonts.load(ctx.font); - } + // text. See docs/implementation.md#canvas-font-loading. Every tier now names a real, + // self-hosted webfont (see FONT_TIERS above), so this always applies, not just to pixel fonts. + document.fonts.load(ctx.font); ctx.textAlign = "center"; const centerX = snap(node.box.x + node.box.width / 2); const lineHeight = node.box.height / node.lines.length; @@ -527,9 +530,9 @@ export function drawFallbackLabel(canvas, content, orientation = "along") { // print label should show/encode; keyed by `kind` so each kind's format is defined in one place. export const LABEL_CONTENT_BUILDERS = { // The self-contained Item URL (see docs/design-in-progress/items-labels.md), built from just - // the prefill's {userHandle, id}; the short link (Print.vue's `shortUrl`) needs an async store + // the prefill's {userHandle, item}; the short link (Print.vue's `shortUrl`) needs an async store // lookup, so it stays a separate field/template rather than being baked in here. - "item": ({userHandle, id}) => `${window.location.origin}/i/${encodeHandleForUrl(userHandle)}/${id}`, + "item": ({userHandle, item}) => `${window.location.origin}/i/${encodeHandleForUrl(userHandle)}/${item}`, // Storage locations have no long-form URL route yet (see router.js), so `text` starts blank; // the short link and any future location template still work via the base vars below. }; @@ -542,7 +545,7 @@ export function buildLabelContent(prefill) { return build ? build(prefill.components) : ""; } -// Splits a prefill's {userHandle, id} into label-layouts.js's user/domain base vars (the same way +// Splits a prefill's {userHandle, item/location} into label-layouts.js's user/domain base vars (the same way // store.js's lookupServer does), tagging on whichever id field the resource's templates key // required_vars by. function splitUserHandle(userHandle) { @@ -560,19 +563,19 @@ function splitUserHandle(userHandle) { // computed live elsewhere (see DERIVED_VARS, Print.vue's `shortUrl`), and omitting a field // (rather than leaving it present-but-empty) signals "not available" to templateIsAvailable. const LABEL_FIELD_BUILDERS = { - "item": ({userHandle, id}) => { + "item": ({userHandle, item}) => { const split = splitUserHandle(userHandle); - if (!split || !id) { + if (!split || !item) { return {}; } - return {...split, itemId: String(id)}; + return {...split, itemId: String(item)}; }, - "storage-location": ({userHandle, id}) => { + "storage-location": ({userHandle, location}) => { const split = splitUserHandle(userHandle); - if (!split || !id) { + if (!split || !location) { return {}; } - return {...split, locationId: String(id)}; + return {...split, locationId: String(location)}; }, }; diff --git a/frontend/src/router.js b/frontend/src/router.js index d3f3597..9198495 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -139,7 +139,9 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, { meta: {requiresAuth: true}, props: route => { const {kind, ...components} = route.query; - return {prefill: kind ? {kind, components} : null}; + // queryFields lets any base var (e.g. ?text=...) be set directly, without going through + // a `kind` builder - see Print.vue's varValues. + return {prefill: kind ? {kind, components} : null, queryFields: components}; } }, { path: '/scan', diff --git a/frontend/src/scss/toolshed.scss b/frontend/src/scss/toolshed.scss index 22ce7f3..5be483d 100644 --- a/frontend/src/scss/toolshed.scss +++ b/frontend/src/scss/toolshed.scss @@ -94,6 +94,7 @@ $body-color: $gray-700; @import "dropdown"; @import "pixel-fonts"; @import "pixel-fonts-candidates"; +@import "label-fonts"; #root, body, html { height: 100%; diff --git a/frontend/src/views/Inventory.vue b/frontend/src/views/Inventory.vue index fd2808d..5adca48 100644 --- a/frontend/src/views/Inventory.vue +++ b/frontend/src/views/Inventory.vue @@ -154,7 +154,7 @@ export default { // See docs/implementation.md#print-link-shape-for-personal-items. printLinkFor(item) { if (!item.owner) return null - return {path: '/print', query: {kind: 'item', userHandle: item.owner, id: item.id}} + return {path: '/print', query: {kind: 'item', userHandle: item.owner, item: item.id}} }, }, async mounted() { diff --git a/frontend/src/views/InventoryDetail.vue b/frontend/src/views/InventoryDetail.vue index 6268ae2..0685771 100644 --- a/frontend/src/views/InventoryDetail.vue +++ b/frontend/src/views/InventoryDetail.vue @@ -51,7 +51,7 @@ Delete diff --git a/frontend/src/views/Print.vue b/frontend/src/views/Print.vue index 9fa4d78..21195bc 100644 --- a/frontend/src/views/Print.vue +++ b/frontend/src/views/Print.vue @@ -57,7 +57,7 @@

- Nine pixel/bitmap-style fonts found in frontend/public, rendered live from the "Text" + Ten pixel/bitmap-style fonts found in frontend/public, rendered live from the "Text" field above at a range of sizes so they can be judged the same way Tom Thumb/Silkscreen were. CodersCrux and 712Serif fail to load as web fonts at all - Chromium's OTS sanitizer rejects their cmap table - so their cells below @@ -86,7 +86,7 @@

Label preview
-
+
@@ -144,7 +144,7 @@ Connect a printer to preview and print a label.