diff --git a/.playwright-cli/page-2026-03-14T13-03-08-306Z.yml b/.playwright-cli/page-2026-03-14T13-03-08-306Z.yml
new file mode 100644
index 000000000..e69de29bb
diff --git a/.playwright-cli/page-2026-03-14T13-04-28-164Z.yml b/.playwright-cli/page-2026-03-14T13-04-28-164Z.yml
new file mode 100644
index 000000000..d944002d6
--- /dev/null
+++ b/.playwright-cli/page-2026-03-14T13-04-28-164Z.yml
@@ -0,0 +1 @@
+- generic [ref=e2]: loading
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 000000000..48b889858
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,4 @@
+# Changelog
+
+## Unreleased
+- Add filament label printing with separate presets, QR codes, and filament QR scanning support.
diff --git a/client/package-lock.json b/client/package-lock.json
index b19647a12..4efe04a31 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -24,6 +24,7 @@
"i18next": "^25.7.3",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
+ "jszip": "3.10.1",
"react": "^19.2.3",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
@@ -6206,6 +6207,12 @@
"url": "https://opencollective.com/core-js"
}
},
+ "node_modules/core-util-is": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+ "license": "MIT"
+ },
"node_modules/cosmiconfig": {
"version": "8.3.6",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz",
@@ -8656,6 +8663,12 @@
"node": ">= 4"
}
},
+ "node_modules/immediate": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
+ "license": "MIT"
+ },
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -8698,7 +8711,6 @@
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
- "dev": true,
"license": "ISC"
},
"node_modules/ini": {
@@ -9626,6 +9638,54 @@
"node": ">=4.0"
}
},
+ "node_modules/jszip": {
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
+ "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
+ "license": "(MIT OR GPL-3.0-or-later)",
+ "dependencies": {
+ "lie": "~3.3.0",
+ "pako": "~1.0.2",
+ "readable-stream": "~2.3.6",
+ "setimmediate": "^1.0.5"
+ }
+ },
+ "node_modules/jszip/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "license": "MIT"
+ },
+ "node_modules/jszip/node_modules/readable-stream": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+ "license": "MIT",
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/jszip/node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "license": "MIT"
+ },
+ "node_modules/jszip/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
"node_modules/kbar": {
"version": "0.1.0-beta.40",
"resolved": "https://registry.npmjs.org/kbar/-/kbar-0.1.0-beta.40.tgz",
@@ -9687,6 +9747,15 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/lie": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
+ "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+ "license": "MIT",
+ "dependencies": {
+ "immediate": "~3.0.5"
+ }
+ },
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -10857,6 +10926,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "license": "(MIT AND Zlib)"
+ },
"node_modules/papaparse": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
@@ -11316,6 +11391,12 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
+ "node_modules/process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+ "license": "MIT"
+ },
"node_modules/promise-inflight": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz",
@@ -13036,6 +13117,12 @@
"node": ">= 0.4"
}
},
+ "node_modules/setimmediate": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
+ "license": "MIT"
+ },
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -14352,7 +14439,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
- "dev": true,
"license": "MIT"
},
"node_modules/utils-merge": {
diff --git a/client/package.json b/client/package.json
index 1603ff246..4088c8b9e 100644
--- a/client/package.json
+++ b/client/package.json
@@ -23,6 +23,7 @@
"i18next": "^25.7.3",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
+ "jszip": "3.10.1",
"react": "^19.2.3",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
@@ -39,19 +40,19 @@
"@refinedev/cli": "^2.16.50",
"@types/loadable__component": "^5.13.10",
"@types/node": "^25.0.3",
- "@types/react-dom": "^19.2.3",
"@types/react": "^19.2.7",
+ "@types/react-dom": "^19.2.3",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^5.1.2",
+ "eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
+ "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26",
- "eslint-plugin-react": "^7.37.5",
- "eslint": "^9.39.2",
"globals": "^17.0.0",
"prettier": "3.7.4",
- "typescript-eslint": "^8.52.0",
"typescript": "^5.9.3",
+ "typescript-eslint": "^8.52.0",
"vite": "^7.3.0",
"vite-plugin-mkcert": "^1.17.9",
"vite-plugin-pwa": "^1.2.0"
diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json
index 88ff2ae85..290461290 100644
--- a/client/public/locales/en/common.json
+++ b/client/public/locales/en/common.json
@@ -70,6 +70,7 @@
"paperSize": "Paper Size",
"customSize": "Custom",
"dimensions": "Dimensions",
+ "amlLabelSize": "AML Label Size",
"showBorder": "Show Border",
"previewScale": "Preview Scale",
"skipItems": "Skip Items",
@@ -100,15 +101,76 @@
"deleteSettingsConfirm": "Are you sure you want to delete this preset?",
"settingsName": "Preset Name",
"saveSetting": "Save Presets",
- "saveAsImage": "Save as Image"
+ "savePreset": "Save Preset",
+ "saveAsImage": "Save as Image",
+ "saveAsAmlLabels": "Save as AML (Labels)",
+ "saveAsAmlPages": "Save as AML (Pages)",
+ "spoolImagePresets": "Spool Image Presets",
+ "filamentImagePresets": "Filament Image Presets",
+ "exportFormat": "Export Format",
+ "exportFormatOptions": {
+ "png": "PNG",
+ "aml": "AML"
+ },
+ "exportAsZip": "Export as .zip",
+ "exportDpi": "Export DPI",
+ "exportDpiHelp": "Higher DPI improves image quality for exports but increases file size.",
+ "exportLabels": "Export Labels",
+ "filenamePreview": "Filename Preview",
+ "filenamePreviewAdditional": "Additional Example",
+ "zipFilenamePreview": "ZIP Filename"
},
"qrcode": {
"button": "Print Labels",
+ "selectButton": "Print / Export Labels",
+ "exportButton": "Export Labels",
+ "selectTitle": "Export / Print Labels",
+ "exportFilamentTitle": "Export Filament Labels",
+ "exportSpoolTitle": "Export Spool Labels",
+ "printFilamentTitle": "Print Filament Labels",
+ "printSpoolTitle": "Print Spool Labels",
"title": "Label Printing",
+ "sectionLogo": "Logo",
+ "sectionTitle": "Title",
+ "sectionQRCode": "QR Code",
+ "sectionInformation": "Information",
"template": "Label Template",
- "templateHelp": "Use {} to insert values of the spool object as text. For example, {id} will be replaced with the spool id, or {filament.material} will be replaced with the material of the spool. if a value is missing it will be replaced with \"?\". A second set of {} can be used to remove this. In addition, any text between the sets of {} will be removed if the value is missing. For example, {Lot Nr: {lot_nr}} will only show the label if the spool has a lot number. Enclose text with double asterix ** to make it bold. Click the button to view a list of all available tags.",
+ "titleTemplate": "Title Template",
+ "infoTemplate": "Information Template",
+ "templateHelp": "Use {} to insert values of the spool object as text. For example, {id} will be replaced with the spool id, or {filament.material} will be replaced with the material of the spool. if a value is missing it will be replaced with \"?\". A second set of {} can be used to remove this. In addition, any text between the sets of {} will be removed if the value is missing. For example, {Lot Nr: {lot_nr}} will only show the label if the spool has a lot number. Enclose text with double asterix ** to make it bold, and use ==text== to invert text. Click the button to view a list of all available tags.",
+ "templateHelpFilament": "Use {} to insert values of the filament object as text. For example, {id} will be replaced with the filament id, or {vendor.name} will be replaced with the vendor name. If a value is missing it will be replaced with \"?\". A second set of {} can be used to remove this. In addition, any text between the sets of {} will be removed if the value is missing. For example, {Article: {article_number}} will only show the label if a filament has an article number. Enclose text with double asterix ** to make it bold, and use ==text== to invert text. Click the button to view a list of all available tags.",
"textSize": "Label Text Size",
- "showContent": "Print Label",
+ "infoTextSize": "Information Text Size",
+ "titleTextSize": "Title Text Size",
+ "titleMaxTextSize": "Title Max Text Size",
+ "titleAreaHeight": "Title Area Height",
+ "showContent": "Show Information on label",
+ "showManufacturerLogo": "Show Manufacturer Logo",
+ "logoSource": "Logo Source",
+ "logoSourceOptions": {
+ "print": "Print Logo",
+ "color": "Color Logo"
+ },
+ "showTitle": "Show Title",
+ "titleFitToWidth": "Fit Title to Width",
+ "titleFitToWidthOptions": {
+ "on": "On",
+ "off": "Off"
+ },
+ "appliedTextSize": "Applied Size",
+ "logoSize": "Logo Size",
+ "logoAlign": "Logo Horizontal Align",
+ "titleAlign": "Title Horizontal Align",
+ "qrCodePosition": "QR Code Position",
+ "qrCodePositionLeft": "Left",
+ "qrCodePositionRight": "Right",
+ "qrCodeAlign": "QR Code Vertical Align",
+ "qrCodeAlignTop": "Top",
+ "qrCodeAlignCenter": "Center",
+ "qrCodeAlignBottom": "Bottom",
+ "qrCodeSize": "QR Code Size",
+ "infoAlign": "Information Horizontal Align",
+ "infoVerticalAlign": "Information Vertical Align",
"useHTTPUrl": {
"label": "QR code link",
"tooltip": "Will use proprietary link that will work only if scanned from Spoolman's scanning feature (default). URL uses either the base URL specified in settings, or the current page URL if not set.",
@@ -123,16 +185,31 @@
"no": "No",
"simple": "Simple",
"withIcon": "With Icon"
- }
+ },
+ "filenameTemplate": "Filename Template",
+ "filenameTemplateTooltipSpool": "Use {} to insert values of the spool object as text. Refer to the label template rules and available tags for details.",
+ "filenameTemplateTooltipFilament": "Use {} to insert values of the filament object as text. Refer to the label template rules and available tags for details.",
+ "titleTemplateTooltipSpool": "Use {} to insert values of the spool object as text. Refer to the label template rules and available tags for details.",
+ "titleTemplateTooltipFilament": "Use {} to insert values of the filament object as text. Refer to the label template rules and available tags for details."
},
"spoolSelect": {
"title": "Select Spools",
"description": "Select spools to print labels for.",
+ "searchPlaceholder": "Search vendor, name, material, lot #",
"showArchived": "Show Archived",
"noSpoolsSelected": "You have not selected any spools.",
"selectAll": "Select/Unselect All",
"selectedTotal_one": "{{count}} spool selected",
"selectedTotal_other": "{{count}} spools selected"
+ },
+ "filamentSelect": {
+ "title": "Select Filaments",
+ "description": "Select filaments to print labels for.",
+ "searchPlaceholder": "Search vendor, name, material, article #",
+ "noFilamentsSelected": "You have not selected any filaments.",
+ "selectAll": "Select/Unselect All",
+ "selectedTotal_one": "{{count}} filament selected",
+ "selectedTotal_other": "{{count}} filaments selected"
}
},
"scanner": {
@@ -269,14 +346,27 @@
"vendor": "Manufacturers",
"fields": {
"id": "ID",
+ "logo": "Logo",
+ "logo_url": "Logo URL",
+ "print_logo_url": "Print Logo URL",
+ "logo_preview": "Logo Preview",
+ "print_logo_preview": "Print Logo Preview",
"name": "Name",
"empty_spool_weight": "Empty Spool Weight",
"external_id": "External ID",
"registered": "Registered",
- "comment": "Comment"
+ "comment": "Comment",
+ "logo_suggestions": "Logo Suggestions",
+ "print_logo_suggestions": "Print Logo Suggestions",
+ "logo_suggestions_placeholder": "Select a suggested logo path",
+ "logo_suggestions_none": "None"
},
"fields_help": {
- "empty_spool_weight": "The weight of an empty spool from this manufacturer."
+ "empty_spool_weight": "The weight of an empty spool from this manufacturer.",
+ "logo_url": "Optional custom logo used in the UI. Supports absolute URLs or local paths like /vendor-logos/web/bambu-lab-web.png. Use any image your browser can display from that path or URL.",
+ "print_logo_url": "Optional custom logo used for label rendering. Supports absolute URLs or local paths like /vendor-logos/print/bambu-lab.png. Use any image your browser can display from that path or URL.",
+ "logo_suggestions": "Checks available logo files in /vendor-logos/web for names similar to this manufacturer.",
+ "print_logo_suggestions": "Checks available logo files in /vendor-logos/print for names similar to this manufacturer."
},
"titles": {
"create": "Create Manufacturer",
@@ -286,8 +376,36 @@
"show": "Show Manufacturer",
"show_title": "[Manufacturer #{{id}}] {{name}}"
},
+ "buttons": {
+ "sync_logos": "Sync Logos",
+ "clear_logo_url": "Clear URL",
+ "upload_web_logo": "Upload Web Logo",
+ "upload_print_logo": "Upload Print Logo",
+ "sync_this_vendor_from_github": "Refresh Pack + Sync This Manufacturer",
+ "sync_this_vendor_from_github_help": "Refreshes the shared logo pack from GitHub, then applies matched logo paths to this manufacturer only when its logo fields are blank and a local filename match is found (case/punctuation-insensitive, with singular/plural tolerance). The database is not updated until you click Save.",
+ "convert_logo_to_print": "Convert Logo to Print",
+ "convert_logo_to_print_help": "Creates a black-and-white print logo from the current Logo URL and stores it as a separate local print logo file.",
+ "convert_logo_to_print_help_locked": "A Print Logo URL is already set. Clear Print Logo URL to enable creating a new print logo from the current Logo URL."
+ },
+ "logo_filter": {
+ "has_logo": "Has Logo",
+ "no_logo": "No Logo"
+ },
"form": {
- "vendor_updated": "This manufacturer has been updated by someone/something else since you opened this page. Saving will overwrite those changes!"
+ "vendor_updated": "This manufacturer has been updated by someone/something else since you opened this page. Saving will overwrite those changes!",
+ "logo_sync_requires_name": "Set a manufacturer name first.",
+ "logo_sync_no_match": "No matching logos found for this manufacturer name.",
+ "logo_sync_applied": "Suggested logo paths applied.",
+ "logo_sync_applied_pending_save": "Applied {{count}} suggested logo field(s) in this form. Click Save to keep these changes, or leave the page to discard them.",
+ "logo_sync_skipped_existing": "Matching logos were found, but existing logo URL fields are already set and were not overwritten.",
+ "logo_preview_auto_notice": "using auto-matched logo from available logo files",
+ "logo_preview_default_notice": "no logo defined, using default generated text logo",
+ "logo_convert_requires_web_logo": "Set a Logo URL first.",
+ "logo_convert_requires_empty_print_logo": "Clear Print Logo URL first to generate a new print logo.",
+ "logo_convert_success": "Generated print logo from web logo.",
+ "logo_convert_error": "Could not generate a print logo from this Logo URL.",
+ "logo_upload_success": "Uploaded logo file and set {{field}}.",
+ "logo_upload_error": "Could not upload this logo file."
}
},
"home": {
@@ -322,6 +440,16 @@
"round_prices": {
"label": "Round prices",
"tooltip": "Round prices to the nearest whole number."
+ },
+ "logo_sync": {
+ "title": "Global Manufacturer Logo Sync",
+ "description": "Syncs missing logo URLs across the whole manufacturer database using the bundled logo manifest. Existing logo URLs are not overwritten.",
+ "where": "Source: /vendor-logos/manifest.json (bundled static web/print logo files). Matching ignores minor punctuation/case differences in names.",
+ "button": "Sync Logos Now",
+ "not_ready": "Logo manifest is not available yet.",
+ "load_error": "Could not load manufacturers for logo sync.",
+ "done": "Logo sync complete. Matched {{matched}}, updated {{updated}}.",
+ "scope_note": "This action syncs the whole manufacturer database. You can sync a single manufacturer from its Edit page."
}
},
"extra_fields": {
diff --git a/client/public/vendor-logos/manifest.json b/client/public/vendor-logos/manifest.json
new file mode 100644
index 000000000..e69de29bb
diff --git a/client/src/App.tsx b/client/src/App.tsx
index d907b8ee1..24d3ef298 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -194,7 +194,9 @@ function App() {
/>
} />
} />
+ } />
} />
+ } />
} />
@@ -208,6 +210,9 @@ function App() {
/>
} />
} />
+ } />
+ } />
+ } />
} />
diff --git a/client/src/components/otherModels.tsx b/client/src/components/otherModels.tsx
index 0adabd80c..ffb43bd8e 100644
--- a/client/src/components/otherModels.tsx
+++ b/client/src/components/otherModels.tsx
@@ -3,8 +3,9 @@ import { Tooltip } from "antd";
import { ColumnFilterItem } from "antd/es/table/interface";
import { IFilament } from "../pages/filaments/model";
import { IVendor } from "../pages/vendors/model";
-import { getAPIURL } from "../utils/url";
+import { getAPIURL, getBasePath } from "../utils/url";
+// Build shared table-filter options here so selection dialogs can reuse labels and tooltip metadata.
export function useSpoolmanFilamentFilter(enabled: boolean = false) {
return useQuery({
enabled: enabled,
@@ -17,13 +18,13 @@ export function useSpoolmanFilamentFilter(enabled: boolean = false) {
return response.json();
},
select: (data) => {
- // Concatenate vendor name and filament name
+ // Concatenate vendor name and filament name.
const names = data
- // Remove empty names
+ // Remove empty names before building filter entries.
.filter((filament) => {
return filament.name !== null && filament.name !== undefined && filament.name !== "";
})
- // Transform to ColumnFilterItem
+ // Transform each filament into the table filter shape.
.map((filament) => {
let name = "";
if (filament.vendor?.name) {
@@ -73,12 +74,13 @@ export function useSpoolmanFilamentFilter(enabled: boolean = false) {
),
value: filament.id,
+ // Keep a plain string sort key because the rendered label itself is wrapped in JSX.
sortId: name,
};
})
- // Remove duplicates
+ // Remove duplicates so each filament only appears once in the filter dropdown.
.filter((item, index, self) => self.findIndex((t) => t.value === item.value) === index)
- // Sort by name
+ // Sort by the plain string key instead of the JSX label.
.sort((a, b) => a.sortId.localeCompare(b.sortId));
return names;
},
@@ -134,6 +136,29 @@ export function useSpoolmanVendors(enabled: boolean = false) {
});
}
+export function useSpoolmanVendorExternalIds(enabled: boolean = false) {
+ return useQuery({
+ enabled: enabled,
+ queryKey: ["vendorExternalIds"],
+ queryFn: async () => {
+ const response = await fetch(getAPIURL() + "/vendor");
+ if (!response.ok) {
+ throw new Error("Network response was not ok");
+ }
+ return response.json();
+ },
+ select: (data) => {
+ const externalIds = data
+ .map((vendor) => vendor.external_id)
+ .filter(
+ (externalId): externalId is string => externalId !== null && externalId !== undefined && externalId !== "",
+ )
+ .sort();
+ return [...new Set(externalIds)];
+ },
+ });
+}
+
export function useSpoolmanMaterials(enabled: boolean = false) {
return useQuery({
enabled: enabled,
@@ -201,3 +226,23 @@ export function useSpoolmanLocations(enabled: boolean = false) {
},
});
}
+
+interface VendorLogoManifest {
+ web_files?: string[];
+ print_files?: string[];
+}
+
+// Missing bundled/runtime logo packs should degrade to empty suggestions instead of breaking vendor forms.
+export function useVendorLogoManifest(enabled: boolean = true) {
+ return useQuery({
+ enabled,
+ queryKey: ["vendor-logo-manifest"],
+ queryFn: async () => {
+ const response = await fetch(`${getBasePath()}/vendor-logos/manifest.json`, { cache: "no-store" });
+ if (!response.ok) {
+ return { web_files: [], print_files: [] };
+ }
+ return response.json();
+ },
+ });
+}
diff --git a/client/src/components/qrCodeScanner.tsx b/client/src/components/qrCodeScanner.tsx
index ecf97eafb..e683a7812 100644
--- a/client/src/components/qrCodeScanner.tsx
+++ b/client/src/components/qrCodeScanner.tsx
@@ -18,15 +18,28 @@ const QRCodeScannerModal = () => {
const result = detectedCodes[0].rawValue;
// Check for the spoolman ID format
- const match = result.match(/^web\+spoolman:s-(?[0-9]+)$/i);
- if (match && match.groups) {
+ const spoolMatch = result.match(/^web\+spoolman:s-(?[0-9]+)$/i);
+ if (spoolMatch && spoolMatch.groups) {
setVisible(false);
- navigate(`/spool/show/${match.groups.id}`);
+ navigate(`/spool/show/${spoolMatch.groups.id}`);
+ return;
+ }
+ const filamentMatch = result.match(/^web\+spoolman:f-(?[0-9]+)$/i);
+ if (filamentMatch && filamentMatch.groups) {
+ setVisible(false);
+ navigate(`/filament/show/${filamentMatch.groups.id}`);
+ return;
+ }
+ const spoolURLmatch = result.match(/^https?:\/\/[^/]+(?:\/[^/]+)*\/spool\/show\/(?[0-9]+)$/i);
+ if (spoolURLmatch && spoolURLmatch.groups) {
+ setVisible(false);
+ navigate(`/spool/show/${spoolURLmatch.groups.id}`);
+ return;
}
- const fullURLmatch = result.match(/^https?:\/\/[^/]+\/spool\/show\/(?[0-9]+)$/i);
- if (fullURLmatch && fullURLmatch.groups) {
+ const filamentURLmatch = result.match(/^https?:\/\/[^/]+(?:\/[^/]+)*\/filament\/show\/(?[0-9]+)$/i);
+ if (filamentURLmatch && filamentURLmatch.groups) {
setVisible(false);
- navigate(`/spool/show/${fullURLmatch.groups.id}`);
+ navigate(`/filament/show/${filamentURLmatch.groups.id}`);
}
};
diff --git a/client/src/components/vendorLogo.tsx b/client/src/components/vendorLogo.tsx
new file mode 100644
index 000000000..dfeab916c
--- /dev/null
+++ b/client/src/components/vendorLogo.tsx
@@ -0,0 +1,53 @@
+import { CSSProperties, useEffect, useMemo, useState } from "react";
+import { IVendor } from "../pages/vendors/model";
+import { getVendorLogoCandidates } from "../utils/vendorLogo";
+
+interface VendorLogoProps {
+ vendor?: IVendor;
+ usePrintLogo?: boolean;
+ showFallbackText?: boolean;
+ imgStyle?: CSSProperties;
+ fallbackStyle?: CSSProperties;
+}
+
+// Walk the vendor's candidate logo URLs in order, then optionally fall back to plain vendor text.
+export function VendorLogo({
+ vendor,
+ usePrintLogo = false,
+ showFallbackText = false,
+ imgStyle,
+ fallbackStyle,
+}: VendorLogoProps) {
+ const candidates = useMemo(() => getVendorLogoCandidates(vendor, usePrintLogo), [vendor, usePrintLogo]);
+ const [currentCandidateIndex, setCurrentCandidateIndex] = useState(0);
+ useEffect(() => {
+ // Reset to the highest-priority candidate whenever the vendor or logo mode changes.
+ setCurrentCandidateIndex(0);
+ }, [vendor, usePrintLogo]);
+
+ const currentSrc = candidates[currentCandidateIndex];
+ const fallbackText = vendor?.name ?? "";
+
+ if (currentSrc) {
+ return (
+ {
+ // Try the next candidate before giving up so a stale saved path can still fall
+ // back to the local runtime logo filenames inferred from the vendor name.
+ setCurrentCandidateIndex((idx) => idx + 1);
+ }}
+ />
+ );
+ }
+
+ if (showFallbackText && fallbackText) {
+ return