mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
feat(labels): add 40x30 mm template, hex colour code, bolder brand (issue #809 follow-up)
Three enhancements requested by @oliboehm after the V1 label-printing ship in #809: - New box_40x30 single-label template (common DK/Brother roll size, good for filament-bag and storage-bin labels). Routes through the existing roomy layout since height >= 20 mm. - Colour hex code (#RRGGBB, alpha-stripped, uppercase) rendered on every label - useful when several near-identical material/colour spools sit next to each other and the swatch alone isn't enough to tell them apart. Skipped silently when rgba is None or malformed. - Brand line bumped to Helvetica-Bold (was regular) and a couple of points larger on both layouts so it reads cleanly at arm's length. Wired through the SpoolLabelTemplate union, the modal's TEMPLATE_OPTIONS, and the inventory.labels.templates.box40x30 i18n key in all 8 locales (native translations for de/fr/it/ja/pt-BR/ zh-CN/zh-TW). Modal regression test widened from 4 to 5 template buttons. Three new renderer tests pin the hex-code render, the hex-code skip on invalid rgba, and the bold-brand font reference.
This commit is contained in:
@@ -5,6 +5,8 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
## [0.2.4b4] - Unreleased
|
||||
|
||||
### Added
|
||||
- **Spool labels: new 40×30 mm template, hex colour code, bolder brand line** ([#809](https://github.com/maziggy/bambuddy/issues/809) follow-up, requested by @oliboehm) — Three small enhancements to the spool-label printer rolled into one change. **(1) New `box_40x30` template** — 40×30 mm single label, common DK/Brother roll size. Added to `_SINGLE_LABEL_SIZES_MM` in `backend/app/services/label_renderer.py` and to the request body's `Literal[...]` enum in `backend/app/api/routes/labels.py`; height is ≥ 20 mm so it routes through the existing roomy layout (swatch + QR + full text column). **(2) Colour hex code on every label** — new `_hex_code_label()` helper formats `data.rgba` as `#RRGGBB` (alpha-stripped, uppercased to match the inventory UI's colour-picker convention) and returns `""` for missing/malformed input so the caller skips drawing instead of throwing. Rendered as a small line under the material/subtype line in the roomy layout, and as a third line above the spool ID in the tight (AMS) layout — useful when several near-identical material/colour spools sit next to each other in the AMS or on a shelf. **(3) Brand line bigger + bold** — the brand on every label now renders in `Helvetica-Bold` instead of `Helvetica` regular, with size bumped 5.5pt → 6.5pt on the tight layout and 7pt → 8pt on the roomy layout, so it's the most legible non-ID field at arm's length. **Wiring:** `SpoolLabelTemplate` union in `frontend/src/api/client.ts` extended with `'box_40x30'`; `LabelTemplatePickerModal` gets a new `TEMPLATE_OPTIONS` entry for it; `inventory.labels.templates.box40x30.{label,hint}` keys added across all 8 locales (en + de fully translated, fr/it/ja/pt-BR/zh-CN/zh-TW translated to native, with the existing per-key fallback in the modal as a safety net). The 5-template grid still wraps to 2 columns on small viewports per #1230's fix; modal regression test was widened from `4` to `5` template buttons. **Tests:** `ALL_TEMPLATES` parametrize tuple in `test_label_renderer.py` extended with `box_40x30` so all 7 generic invariants (PDF header, empty-input, multi-colour, missing-fields, malformed-rgba, long strings, sheet pagination) cover the new template; new `test_hex_color_code_rendered_when_rgba_set` (asserts `#F5E6D3` appears in the uncompressed PDF for both 40×30 and 62×29), `test_hex_color_code_skipped_when_rgba_invalid` (regex pin: no `#RRGGBB` shape on the label when rgba is malformed, except the spool ID's `#42`), and `test_brand_rendered_in_bold_per_809_followup` (asserts `Helvetica-Bold` font reference is in the PDF — caught a regression if the brand line ever reverts to regular weight). All 33 backend tests + 15 frontend modal tests pass; ruff clean.
|
||||
|
||||
- **Copy spool — duplicate any spool's settings into a fresh inventory row in two clicks** ([#1234](https://github.com/maziggy/bambuddy/issues/1234), [PR #1246](https://github.com/maziggy/bambuddy/pull/1246) by @MiguelAngelLV) — Adds a copy button (`Copy` icon) next to the existing edit button on every spool in the inventory page across all three views (table row, card, grouped table inner row). Clicking it opens the existing `SpoolFormModal` pre-filled with every field from the source spool — material, brand, color, slicer preset, label/core/cost, K-profiles, all of it — except `weight_used` which is reset to 0 (since the new spool starts full) and the RFID identity fields (`tag_uid`, `tray_uuid`, `tag_type`, `data_origin`) which aren't part of the form payload anyway, so the new spool is its own physical roll. Save calls `api.createSpool` (or `api.createSpoolmanInventorySpool` in Spoolman mode — both inherit the dispatch routing for free). Closes the long-running gap where users with many near-identical spools (e.g. five 1 kg PETG-CF rolls bought in a single order) had to re-enter every field from scratch on each one. **Implementation shape:** `SpoolFormModalProps.mode: 'create' | 'edit' | 'copy'` (exported as `SpoolFormMode`) replaces the previous `isEditing = !!spool` heuristic — every existing call site in `InventoryPage.tsx` was updated to pass the explicit mode, and the modal's title / submit-button label / weight-reset gate / submit-route branching all key on `mode` directly. The `onCopy` callback is optional on `SpoolCard`, `SpoolTableRow`, and `SpoolTableGroup` (matches the existing `onPrintLabel?` pattern), so the button is conditionally rendered and other consumers of those subcomponents don't get a copy affordance forced on them. Card-view and table-row buttons stop click propagation so clicking copy doesn't also fire the parent row's edit handler. **Quick Add interaction:** the Quick Add toggle is gated `mode === 'create'` (was `!isEditing`), so it stays out of copy mode — otherwise a user could enable Quick Add and bump quantity to N under the singular "Copy Spool" title and silently bulk-create N copies via `bulkCreateMutation`. **i18n:** new `inventory.copySpool` key across all 8 locales (en + de translated, fr/it/ja/pt-BR/zh-CN/zh-TW seeded with English fallback per project flow). **Tests:** 3 new in `SpoolFormModal.test.tsx` (`SpoolFormModal copy mode` describe block — title shows "Copy Spool", save calls `createSpool` not `updateSpool`, `weight_used` reset to 0 in the create payload when copying a spool with non-zero usage), 2 new in `InventoryPageCopyButton.test.tsx` (table-row copy button click → "Copy Spool" heading, cards-view copy button click → same heading after switching view modes) — guards against the three call sites drifting apart. Existing `SpoolFormBulk.test.tsx` and `SpoolFormModal.test.tsx` renders that omitted the `mode` prop were updated with the explicit `mode="create"` so the tightened Quick Add gate doesn't hide the toggle from them. Both `InventoryPageCopyButton.test.tsx` and `InventoryPageDeepLink.test.tsx` gained MSW handlers for the modal's open-time fetches (`/api/v1/cloud/status`, `/api/v1/cloud/local-presets`, `/api/v1/cloud/builtin-filaments`, `/api/v1/inventory/color-catalog`, `/api/v1/inventory/spool-catalog`, `/api/v1/printers/`) — without them MSW passes through to the real network, ECONNREFUSEs, and the rejected fetch resolves after the test environment is torn down, surfacing as a flaky "window is not defined" unhandled rejection in the modal's `setLoadingCloudPresets(false)` finally block (pre-existing flake hit ~1 in 3 full-suite runs at PR head).
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -38,6 +38,7 @@ router = APIRouter(tags=["labels"])
|
||||
|
||||
_VALID_TEMPLATES: tuple[TemplateName, ...] = (
|
||||
"ams_30x15",
|
||||
"box_40x30",
|
||||
"box_62x29",
|
||||
"avery_5160",
|
||||
"avery_l7160",
|
||||
@@ -50,7 +51,7 @@ MAX_LABELS_PER_REQUEST = 500
|
||||
|
||||
class LabelRequest(BaseModel):
|
||||
spool_ids: list[int] = Field(..., min_length=1, max_length=MAX_LABELS_PER_REQUEST)
|
||||
template: Literal["ams_30x15", "box_62x29", "avery_5160", "avery_l7160"]
|
||||
template: Literal["ams_30x15", "box_40x30", "box_62x29", "avery_5160", "avery_l7160"]
|
||||
|
||||
|
||||
def _split_extra_colors(raw: str | None) -> list[str] | None:
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"""PDF spool label rendering.
|
||||
|
||||
Four fixed templates:
|
||||
Five fixed templates:
|
||||
|
||||
- ``ams_30x15`` — 30×15 mm single label, fits the popular Makerworld AMS
|
||||
Filament Label Holder (model 752566). One label per page.
|
||||
- ``box_40x30`` — 40×30 mm single label, common DK/Brother roll size and a
|
||||
good fit for filament-bag/storage-bin labels (#809 follow-up). Roomy
|
||||
layout — swatch, QR, full text column with hex code.
|
||||
- ``box_62x29`` — 62×29 mm single label, sized for Brother PT/QL and Dymo
|
||||
generic small labels. One label per page.
|
||||
- ``avery_5160`` — US Letter sheet, 25.4×66.7 mm × 30 per sheet.
|
||||
@@ -31,7 +34,7 @@ from reportlab.lib.pagesizes import A4, letter
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.pdfgen import canvas as rl_canvas
|
||||
|
||||
TemplateName = Literal["ams_30x15", "box_62x29", "avery_5160", "avery_l7160"]
|
||||
TemplateName = Literal["ams_30x15", "box_40x30", "box_62x29", "avery_5160", "avery_l7160"]
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -83,6 +86,25 @@ def _luminance(color: Color) -> float:
|
||||
return 0.299 * color.red + 0.587 * color.green + 0.114 * color.blue
|
||||
|
||||
|
||||
def _hex_code_label(rgba: str | None) -> str:
|
||||
"""Format ``data.rgba`` as a printable ``#RRGGBB`` string for the label.
|
||||
|
||||
Drops the alpha channel (printed labels can't show transparency) and
|
||||
upper-cases the hex digits to match the colour-picker convention used in
|
||||
the inventory UI. Returns an empty string for None / malformed input so
|
||||
the caller can ``if hex_code:`` skip drawing without an exception.
|
||||
"""
|
||||
if not rgba:
|
||||
return ""
|
||||
h = rgba.lstrip("#").strip()
|
||||
if len(h) not in (6, 8):
|
||||
return ""
|
||||
rgb = h[:6]
|
||||
if not all(c in "0123456789abcdefABCDEF" for c in rgb):
|
||||
return ""
|
||||
return f"#{rgb.upper()}"
|
||||
|
||||
|
||||
# ── QR generation ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -201,7 +223,7 @@ def _draw_label_tight(
|
||||
pad: float,
|
||||
data: LabelData,
|
||||
) -> None:
|
||||
"""AMS-holder layout (e.g. 30×15 mm). Swatch + 3-line text, no QR."""
|
||||
"""AMS-holder layout (e.g. 30×15 mm). Swatch + brand/material/hex/ID, no QR."""
|
||||
swatch_w = min(inner_h, inner_w * 0.35)
|
||||
swatch_y = inner_y + (inner_h - swatch_w) / 2
|
||||
_draw_swatch(c, inner_x, swatch_y, swatch_w, swatch_w, data)
|
||||
@@ -213,21 +235,33 @@ def _draw_label_tight(
|
||||
|
||||
c.setFillColor(black)
|
||||
|
||||
# Top: brand (small)
|
||||
brand_size = 5.5
|
||||
# Top: brand — bumped to bold + larger per the #809 follow-up so it's the
|
||||
# easiest thing to read on a small AMS holder at arm's length.
|
||||
brand_size = 6.5
|
||||
if data.brand:
|
||||
c.setFont("Helvetica", brand_size)
|
||||
brand = _truncate_to_width(c, data.brand, "Helvetica", brand_size, text_w)
|
||||
c.setFont("Helvetica-Bold", brand_size)
|
||||
brand = _truncate_to_width(c, data.brand, "Helvetica-Bold", brand_size, text_w)
|
||||
c.drawString(text_x, y + h - pad - brand_size, brand)
|
||||
|
||||
# Second line: material + subtype, small
|
||||
sub_size = 5.5
|
||||
sub_size = 5
|
||||
sub_line = " ".join(filter(None, [data.material, data.subtype]))
|
||||
sub_y_baseline = y + h - pad - brand_size - 0.6 - sub_size
|
||||
if sub_line:
|
||||
c.setFont("Helvetica", sub_size)
|
||||
sub_line = _truncate_to_width(c, sub_line, "Helvetica", sub_size, text_w)
|
||||
sub_y = y + h - pad - brand_size - 0.6 - sub_size
|
||||
c.drawString(text_x, sub_y, sub_line)
|
||||
c.drawString(text_x, sub_y_baseline, sub_line)
|
||||
|
||||
# Third line (when there's room): hex code, tiny — useful when the user
|
||||
# has multiple near-identical colours in the same material family.
|
||||
hex_code = _hex_code_label(data.rgba)
|
||||
if hex_code:
|
||||
hex_size = 4.5
|
||||
hex_y = sub_y_baseline - 0.4 - hex_size
|
||||
# Don't render if it'd collide with the spool ID at the bottom.
|
||||
if hex_y > inner_y + 13:
|
||||
c.setFont("Helvetica", hex_size)
|
||||
c.drawString(text_x, hex_y, hex_code)
|
||||
|
||||
# Bottom: BIG spool ID — the killer field at-a-glance.
|
||||
id_size = 13
|
||||
@@ -274,14 +308,16 @@ def _draw_label_roomy(
|
||||
line1 = data.brand or ""
|
||||
line2 = " · ".join(filter(None, [data.material, data.subtype]))
|
||||
name = data.name or ""
|
||||
hex_code = _hex_code_label(data.rgba)
|
||||
|
||||
# Layout from the top of the text column.
|
||||
cursor_y = y + h - pad
|
||||
|
||||
# Brand — bumped to bold + larger per the #809 follow-up.
|
||||
if line1:
|
||||
size = 7
|
||||
c.setFont("Helvetica", size)
|
||||
text = _truncate_to_width(c, line1, "Helvetica", size, text_w)
|
||||
size = 8
|
||||
c.setFont("Helvetica-Bold", size)
|
||||
text = _truncate_to_width(c, line1, "Helvetica-Bold", size, text_w)
|
||||
cursor_y -= size
|
||||
c.drawString(text_x, cursor_y, text)
|
||||
cursor_y -= 1.2
|
||||
@@ -294,6 +330,15 @@ def _draw_label_roomy(
|
||||
c.drawString(text_x, cursor_y, text)
|
||||
cursor_y -= 1.5
|
||||
|
||||
# Hex colour code — useful for telling near-identical material+colour
|
||||
# spools apart when the swatch is small or the user is colour-blind.
|
||||
if hex_code:
|
||||
size = 6.5
|
||||
c.setFont("Helvetica", size)
|
||||
cursor_y -= size
|
||||
c.drawString(text_x, cursor_y, hex_code)
|
||||
cursor_y -= 1.2
|
||||
|
||||
if name and name != line1:
|
||||
size = 9
|
||||
c.setFont("Helvetica-Bold", size)
|
||||
@@ -321,6 +366,7 @@ def _draw_label_roomy(
|
||||
# (label_w_mm, label_h_mm) for single-label-per-page templates.
|
||||
_SINGLE_LABEL_SIZES_MM: dict[str, tuple[float, float]] = {
|
||||
"ams_30x15": (30.0, 15.0),
|
||||
"box_40x30": (40.0, 30.0),
|
||||
"box_62x29": (62.0, 29.0),
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import pytest
|
||||
|
||||
from backend.app.services.label_renderer import LabelData, render_labels
|
||||
|
||||
ALL_TEMPLATES = ("ams_30x15", "box_62x29", "avery_5160", "avery_l7160")
|
||||
ALL_TEMPLATES = ("ams_30x15", "box_40x30", "box_62x29", "avery_5160", "avery_l7160")
|
||||
|
||||
|
||||
def _sample(spool_id: int = 1, **overrides) -> LabelData:
|
||||
@@ -121,8 +121,12 @@ def _render_uncompressed(template, data):
|
||||
from backend.app.services.label_renderer import _draw_label # noqa: PLC0415
|
||||
|
||||
# Mirror the page-size choice from render_labels but force pageCompression=0.
|
||||
if template in ("ams_30x15", "box_62x29"):
|
||||
sizes = {"ams_30x15": (30.0, 15.0), "box_62x29": (62.0, 29.0)}
|
||||
if template in ("ams_30x15", "box_40x30", "box_62x29"):
|
||||
sizes = {
|
||||
"ams_30x15": (30.0, 15.0),
|
||||
"box_40x30": (40.0, 30.0),
|
||||
"box_62x29": (62.0, 29.0),
|
||||
}
|
||||
w_mm, h_mm = sizes[template]
|
||||
page_w, page_h = w_mm * _mm, h_mm * _mm
|
||||
buf = _io.BytesIO()
|
||||
@@ -189,6 +193,74 @@ def test_ams_template_actually_renders_text():
|
||||
)
|
||||
|
||||
|
||||
def test_hex_color_code_rendered_when_rgba_set():
|
||||
"""#809 follow-up: the colour hex code (#RRGGBB, alpha-stripped, uppercase)
|
||||
must appear on the rendered label so the user can tell near-identical
|
||||
spools apart at a glance.
|
||||
"""
|
||||
data = [
|
||||
LabelData(
|
||||
spool_id=12,
|
||||
name="Polymaker Ivory",
|
||||
material="PLA",
|
||||
brand="Polymaker",
|
||||
subtype="Matte",
|
||||
rgba="f5e6d3FF",
|
||||
deeplink_url="https://example.test/inventory?spool=12",
|
||||
)
|
||||
]
|
||||
pdf = _render_uncompressed("box_62x29", data)
|
||||
assert b"#F5E6D3" in pdf, "box label must render the hex colour code"
|
||||
|
||||
pdf = _render_uncompressed("box_40x30", data)
|
||||
assert b"#F5E6D3" in pdf, "40x30 box label must render the hex colour code"
|
||||
|
||||
|
||||
def test_hex_color_code_skipped_when_rgba_invalid():
|
||||
"""Malformed rgba must NOT render any '#' hex string apart from the spool
|
||||
ID — silently skipping the hex line is better than crashing or rendering
|
||||
garbage. The spool ID still uses '#' so we look for the specific shape.
|
||||
"""
|
||||
data = [
|
||||
LabelData(
|
||||
spool_id=99,
|
||||
name="Test",
|
||||
material="PLA",
|
||||
brand="Polymaker",
|
||||
rgba="not-a-color",
|
||||
deeplink_url="https://example.test/inventory?spool=99",
|
||||
)
|
||||
]
|
||||
pdf = _render_uncompressed("box_62x29", data)
|
||||
# No 6-hex-digit '#XXXXXX' substring should appear (only '#99' for the ID).
|
||||
import re
|
||||
|
||||
matches = re.findall(rb"#[0-9A-F]{6}", pdf)
|
||||
assert matches == [], f"expected no hex code on label, found {matches!r}"
|
||||
|
||||
|
||||
def test_brand_rendered_in_bold_per_809_followup():
|
||||
"""#809 follow-up: brand should render in Helvetica-Bold (not regular).
|
||||
Uncompressed PDFs include font-name tokens like '/F2' tied to a font
|
||||
resource; we can grep for the bold font's basename in the resource block.
|
||||
"""
|
||||
data = [
|
||||
LabelData(
|
||||
spool_id=5,
|
||||
name="Acme PLA",
|
||||
material="PLA",
|
||||
brand="Polymaker",
|
||||
rgba="FF8800FF",
|
||||
deeplink_url="https://example.test/inventory?spool=5",
|
||||
)
|
||||
]
|
||||
pdf = _render_uncompressed("box_62x29", data)
|
||||
# ReportLab references the bold variant of Helvetica via /Helvetica-Bold
|
||||
# in the font dictionary — both the spool ID (always bold) and the brand
|
||||
# (now bold per #809 follow-up) cause the resource to be embedded.
|
||||
assert b"Helvetica-Bold" in pdf, "label PDF must reference Helvetica-Bold for the brand line"
|
||||
|
||||
|
||||
def test_box_template_does_not_truncate_normal_brand_or_name():
|
||||
"""Regression: the first cut of the box-label layout sized the swatch and
|
||||
QR each at ~14 mm on a 26-mm-wide text column, leaving only ~16 mm for
|
||||
|
||||
@@ -193,7 +193,9 @@ describe('LabelTemplatePickerModal', () => {
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText(/Blue · Sunlu/)); // uncheck spool 2
|
||||
fireEvent.click(screen.getByText(/Box label/i));
|
||||
// Two "Box label …" templates exist now (40×30 and 62×29) — pin the
|
||||
// specific one we want to send so the assertion below stays meaningful.
|
||||
fireEvent.click(screen.getByText(/Box label \(62 × 29 mm\)/i));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.printSpoolLabels).toHaveBeenCalledWith({
|
||||
@@ -275,15 +277,15 @@ describe('LabelTemplatePickerModal', () => {
|
||||
expect(screen.getByText(/No spools match/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('packs templates into a 2x2 grid so all 4 plus Cancel fit on short viewports (#1230)', () => {
|
||||
// Regression for #1230: with 4 templates stacked vertically (~310px) plus
|
||||
it('packs templates into a 2-column grid so they plus Cancel fit on short viewports (#1230)', () => {
|
||||
// Regression for #1230: with templates stacked vertically (~310-390px) plus
|
||||
// header/search/action bar/footer, the modal blew past max-h-[90vh] on
|
||||
// Windows-11 + Brave-style viewports where browser chrome eats into 90vh.
|
||||
// overflow-hidden on the modal then clipped Avery 5160 and the Cancel
|
||||
// footer with no scroll path. The fix uses sm:grid-cols-2 so the 4
|
||||
// templates render as a 2x2 grid (~155px), trimming ~150px of vertical
|
||||
// and leaving room for the footer. The earlier min-h-0 on the spool list
|
||||
// is kept so it still yields any remaining slack.
|
||||
// overflow-hidden on the modal then clipped the bottom templates and the
|
||||
// Cancel footer with no scroll path. The fix uses sm:grid-cols-2 so the
|
||||
// templates render as a 2-column grid, trimming ~150px of vertical and
|
||||
// leaving room for the footer. The earlier min-h-0 on the spool list is
|
||||
// kept so it still yields any remaining slack.
|
||||
const { container } = render(
|
||||
<LabelTemplatePickerModal
|
||||
isOpen={true}
|
||||
@@ -294,9 +296,11 @@ describe('LabelTemplatePickerModal', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
// All 4 templates must be in the DOM, including the last one.
|
||||
// All five templates must be in the DOM. Use the dimension suffix to
|
||||
// disambiguate the two "Box label …" entries.
|
||||
expect(screen.getByText(/AMS holder/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Box label/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Box label \(40 × 30 mm\)/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Box label \(62 × 29 mm\)/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Avery L7160/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Avery 5160/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument();
|
||||
@@ -307,7 +311,7 @@ describe('LabelTemplatePickerModal', () => {
|
||||
const templatesSection = container.querySelector('div.grid.sm\\:grid-cols-2');
|
||||
expect(templatesSection).not.toBeNull();
|
||||
expect(templatesSection!.className).toContain('grid-cols-1');
|
||||
expect(templatesSection!.querySelectorAll('button').length).toBe(4);
|
||||
expect(templatesSection!.querySelectorAll('button').length).toBe(5);
|
||||
|
||||
// Spool list still uses min-h-0 so it can yield further on very tight viewports.
|
||||
const spoolListScroller = container.querySelector('div.flex-1.overflow-y-auto');
|
||||
|
||||
@@ -2326,7 +2326,7 @@ export interface SpoolmanFilamentEntry {
|
||||
|
||||
// Inventory types
|
||||
// Label printing (#809). Mirror of backend.app.services.label_renderer.TemplateName.
|
||||
export type SpoolLabelTemplate = 'ams_30x15' | 'box_62x29' | 'avery_5160' | 'avery_l7160';
|
||||
export type SpoolLabelTemplate = 'ams_30x15' | 'box_40x30' | 'box_62x29' | 'avery_5160' | 'avery_l7160';
|
||||
|
||||
export interface InventorySpool {
|
||||
id: number;
|
||||
|
||||
@@ -38,6 +38,12 @@ const TEMPLATE_OPTIONS: TemplateOption[] = [
|
||||
fallbackLabel: 'AMS holder (30 × 15 mm)',
|
||||
fallbackHint: 'Single label per page; fits the popular AMS filament label holder.',
|
||||
},
|
||||
{
|
||||
value: 'box_40x30',
|
||||
i18nKey: 'box40x30',
|
||||
fallbackLabel: 'Box label (40 × 30 mm)',
|
||||
fallbackHint: 'Single label per page; common DK/Brother roll size, good for filament-bag and storage-bin labels.',
|
||||
},
|
||||
{
|
||||
value: 'box_62x29',
|
||||
i18nKey: 'box',
|
||||
|
||||
@@ -3443,6 +3443,10 @@ export default {
|
||||
label: 'AMS holder (30 × 15 mm)',
|
||||
hint: 'Single label per page; fits the popular AMS filament label holder.',
|
||||
},
|
||||
box40x30: {
|
||||
label: 'Boxetikett (40 × 30 mm)',
|
||||
hint: 'Ein Etikett pro Seite; gängige DK/Brother-Rollengröße, ideal für Filament-Beutel und Lager-Etiketten.',
|
||||
},
|
||||
box: {
|
||||
label: 'Box label (62 × 29 mm)',
|
||||
hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',
|
||||
|
||||
@@ -3446,6 +3446,10 @@ export default {
|
||||
label: 'AMS holder (30 × 15 mm)',
|
||||
hint: 'Single label per page; fits the popular AMS filament label holder.',
|
||||
},
|
||||
box40x30: {
|
||||
label: 'Box label (40 × 30 mm)',
|
||||
hint: 'Single label per page; common DK/Brother roll size, good for filament-bag and storage-bin labels.',
|
||||
},
|
||||
box: {
|
||||
label: 'Box label (62 × 29 mm)',
|
||||
hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',
|
||||
|
||||
@@ -3432,6 +3432,10 @@ export default {
|
||||
label: 'AMS holder (30 × 15 mm)',
|
||||
hint: 'Single label per page; fits the popular AMS filament label holder.',
|
||||
},
|
||||
box40x30: {
|
||||
label: 'Étiquette boîte (40 × 30 mm)',
|
||||
hint: 'Une étiquette par page ; taille de rouleau DK/Brother courante, idéale pour étiqueter sachets de filament et bacs de stockage.',
|
||||
},
|
||||
box: {
|
||||
label: 'Box label (62 × 29 mm)',
|
||||
hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',
|
||||
|
||||
@@ -3431,6 +3431,10 @@ export default {
|
||||
label: 'AMS holder (30 × 15 mm)',
|
||||
hint: 'Single label per page; fits the popular AMS filament label holder.',
|
||||
},
|
||||
box40x30: {
|
||||
label: 'Etichetta scatola (40 × 30 mm)',
|
||||
hint: 'Una etichetta per pagina; misura comune DK/Brother, ideale per etichette di sacchetti filamento e contenitori.',
|
||||
},
|
||||
box: {
|
||||
label: 'Box label (62 × 29 mm)',
|
||||
hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',
|
||||
|
||||
@@ -3443,6 +3443,10 @@ export default {
|
||||
label: 'AMS holder (30 × 15 mm)',
|
||||
hint: 'Single label per page; fits the popular AMS filament label holder.',
|
||||
},
|
||||
box40x30: {
|
||||
label: 'ボックスラベル (40 × 30 mm)',
|
||||
hint: '1ページに1枚。DK/Brother の一般的なロールサイズで、フィラメント袋や保管箱のラベルに適しています。',
|
||||
},
|
||||
box: {
|
||||
label: 'Box label (62 × 29 mm)',
|
||||
hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',
|
||||
|
||||
@@ -3431,6 +3431,10 @@ export default {
|
||||
label: 'AMS holder (30 × 15 mm)',
|
||||
hint: 'Single label per page; fits the popular AMS filament label holder.',
|
||||
},
|
||||
box40x30: {
|
||||
label: 'Etiqueta de caixa (40 × 30 mm)',
|
||||
hint: 'Uma etiqueta por página; tamanho comum de rolo DK/Brother, ideal para etiquetar sacos de filamento e caixas de armazenamento.',
|
||||
},
|
||||
box: {
|
||||
label: 'Box label (62 × 29 mm)',
|
||||
hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',
|
||||
|
||||
@@ -3431,6 +3431,10 @@ export default {
|
||||
label: 'AMS holder (30 × 15 mm)',
|
||||
hint: 'Single label per page; fits the popular AMS filament label holder.',
|
||||
},
|
||||
box40x30: {
|
||||
label: '盒标签 (40 × 30 mm)',
|
||||
hint: '每页一张;常见的 DK/Brother 卷尺寸,适合给耗材袋和储物盒贴标签。',
|
||||
},
|
||||
box: {
|
||||
label: 'Box label (62 × 29 mm)',
|
||||
hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',
|
||||
|
||||
@@ -3431,6 +3431,10 @@ export default {
|
||||
label: 'AMS holder (30 × 15 mm)',
|
||||
hint: 'Single label per page; fits the popular AMS filament label holder.',
|
||||
},
|
||||
box40x30: {
|
||||
label: '盒標籤 (40 × 30 mm)',
|
||||
hint: '每頁一張;常見的 DK/Brother 捲尺寸,適合用於耗材袋與儲物盒標籤。',
|
||||
},
|
||||
box: {
|
||||
label: 'Box label (62 × 29 mm)',
|
||||
hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -26,7 +26,7 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-TQAsqf5J.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-CvmCjsiR.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BSBzgKvT.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user