diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json
index 88ff2ae85..643c6578c 100644
--- a/client/public/locales/en/common.json
+++ b/client/public/locales/en/common.json
@@ -106,7 +106,26 @@
"button": "Print Labels",
"title": "Label Printing",
"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.",
+ "templateHelp": "Use {} to insert spool values and optional |modifiers for datetime or numeric formatting. Use Available Tags... to copy ready-to-paste values, or open Detailed template help for examples. Enclose text with double asterisks ** to make it bold.",
+ "templateTagButton": "Available Tags...",
+ "templateHelpLink": "Detailed template help",
+ "templateTagDialog": {
+ "title": "Template Tags and Modifiers",
+ "copyHint": "Click a tag or modifier to copy a complete template value and close.",
+ "hoverHint": "Hover a tag or modifier to preview the copied value.",
+ "hoverCopyPrefix": "Copy",
+ "hoverCopySuffix": "to clipboard",
+ "copySuccess": "Copied {{value}}",
+ "copyError": "Unable to copy. Copy manually.",
+ "columns": {
+ "tag": "Tag",
+ "modifiers": "Available |Modifiers"
+ },
+ "modifiers": {
+ "none": "None",
+ "dateOrderPrefix": "Date-order suffixes:"
+ }
+ },
"textSize": "Label Text Size",
"showContent": "Print Label",
"useHTTPUrl": {
@@ -302,7 +321,44 @@
"spool": "Individual physical spools of a specific filament.",
"vendor": "The companies that make the filament."
},
- "description": "
Help
Here are some tips to get you started.
Spoolman holds 3 different types of data:
To add a new spool to the database, start by creating a Filament object. Once that's done, proceed to create a Spool object for that specific spool. If you acquire additional spools of the same filament later on, simply generate additional Spool objects and reuse the same Filament object.
Optionally, you can also generate a Manufacturer object for the company manufacturing the filament if you wish to track that information.
You have the option to link other 3D printer services to Spoolman, like Moonraker, which can automatically monitor filament usage and update the Spool objects for you. Refer to the Spoolman README for guidance on how to set that up.
"
+ "description": "Help
Here are some tips to get you started.
Spoolman holds 3 different types of data:
To add a new spool to the database, start by creating a Filament object. Once that's done, proceed to create a Spool object for that specific spool. If you acquire additional spools of the same filament later on, simply generate additional Spool objects and reuse the same Filament object.
Optionally, you can also generate a Manufacturer object for the company manufacturing the filament if you wish to track that information.
You have the option to link other 3D printer services to Spoolman, like Moonraker, which can automatically monitor filament usage and update the Spool objects for you. Refer to the Spoolman README for guidance on how to set that up.
",
+ "templateSyntax": {
+ "title": "Template Syntax",
+ "intro": "Template syntax is used for label text today and is intended to be reusable anywhere Spoolman accepts tag-based templates.",
+ "basicsHeading": "Basics",
+ "basicsInsertBefore": "Wrap a tag in {} to insert a value, such as",
+ "basicsInsertMiddle": "or",
+ "basicsInsertAfter": "Missing values resolve to",
+ "basicsCopyBefore": "In spool label printing, use the",
+ "basicsCopyAfter": "to copy ready-to-paste values like",
+ "basicsMissingBefore": "Use a second set of braces to hide missing values. For example,",
+ "basicsMissingAfter": "removes the placeholder entirely if the value does not exist.",
+ "basicsConditionalBefore": "You can also make surrounding text conditional. For example,",
+ "basicsConditionalAfter": "only renders when the inner tag has a value.",
+ "basicsBoldBefore": "Wrap text in",
+ "basicsBoldAfter": "to render it in bold on the label.",
+ "datetimeHeading": "Datetime Modifiers",
+ "datetimeSupportedBefore": "Datetime tags can be formatted by appending a modifier after |. Supported modifiers are",
+ "datetimeSupportedAfter": ".",
+ "datetimeOrderBefore": "Date-based modifiers can also define output order with",
+ "datetimeOrderAfter": ". Use",
+ "datetimeLocal": "variants when you want the rendered value converted to the browser's local time zone.",
+ "numberHeading": "Number Modifiers",
+ "numberSupportedBefore": "Numeric tags can also be formatted by appending a modifier after |. Supported modifiers are",
+ "numberSupportedAfter": ".",
+ "numberPurpose": "Use these when a raw value has more precision than you want on a label, such as weight fields that need to fit in limited space.",
+ "examplesHeading": "Examples",
+ "examplesDatetimeBefore": "If the stored datetime is 2026-03-04T18:27:53Z, then",
+ "examplesDatetimeDate": "renders as",
+ "examplesDatetimeDmy": "renders as",
+ "examplesDatetimeTime": "renders as",
+ "examplesDatetimeLocal": "renders in the browser's local time zone.",
+ "examplesNumberBefore": "If a numeric value is 1234.56789, then",
+ "examplesNumberRound": "renders as",
+ "examplesNumberFixed1": "renders as",
+ "examplesNumberFixed2": "renders as",
+ "and": "and"
+ }
},
"table": {
"actions": "Actions"
diff --git a/client/src/pages/help/index.tsx b/client/src/pages/help/index.tsx
index bf493e6fd..fc631404a 100644
--- a/client/src/pages/help/index.tsx
+++ b/client/src/pages/help/index.tsx
@@ -1,20 +1,52 @@
import { FileOutlined, HighlightOutlined, UserOutlined } from "@ant-design/icons";
import { useTranslate } from "@refinedev/core";
-import { List, theme } from "antd";
+import { Divider, List, Typography, theme } from "antd";
import { Content } from "antd/es/layout/layout";
import Title from "antd/es/typography/Title";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
+import { useEffect } from "react";
import { Trans } from "react-i18next";
-import { Link } from "react-router";
+import { Link, useLocation } from "react-router";
dayjs.extend(utc);
const { useToken } = theme;
+const { Paragraph, Text } = Typography;
export const Help = () => {
const { token } = useToken();
const t = useTranslate();
+ const location = useLocation();
+
+ useEffect(() => {
+ if (!location.hash) {
+ return;
+ }
+
+ const targetId = decodeURIComponent(location.hash.replace(/^#/, ""));
+ let attempts = 0;
+ const maxAttempts = 12;
+
+ const focusHashTarget = () => {
+ const element = document.getElementById(targetId);
+ if (!element) {
+ attempts += 1;
+ if (attempts < maxAttempts) {
+ window.setTimeout(focusHashTarget, 100);
+ }
+ return;
+ }
+
+ element.scrollIntoView({ behavior: "auto", block: "start" });
+ if (!element.hasAttribute("tabindex")) {
+ element.setAttribute("tabindex", "-1");
+ }
+ (element as HTMLElement).focus({ preventScroll: true });
+ };
+
+ focusHashTarget();
+ }, [location.hash]);
return (
{
),
}}
/>
+
+
+
+ {t("help.templateSyntax.title")}
+
+
+ {t("help.templateSyntax.intro")}
+
+
+ {t("help.templateSyntax.basicsHeading")}
+
+
+ {t("help.templateSyntax.basicsInsertBefore")} {`{id}`}{" "}
+ {t("help.templateSyntax.basicsInsertMiddle")} {`{filament.material}`}{" "}
+ {t("help.templateSyntax.basicsInsertAfter")} ?.
+
+
+ {t("help.templateSyntax.basicsCopyBefore")} {t("printing.qrcode.templateTagButton")}{" "}
+ {t("help.templateSyntax.basicsCopyAfter")} {`{tag}`} {t("help.templateSyntax.and")}{" "}
+ {`{tag|modifier}`}.
+
+
+ {t("help.templateSyntax.basicsMissingBefore")} {`{{comment}}`}{" "}
+ {t("help.templateSyntax.basicsMissingAfter")}
+
+
+ {t("help.templateSyntax.basicsConditionalBefore")} {`{Lot Nr: {lot_nr}}`}{" "}
+ {t("help.templateSyntax.basicsConditionalAfter")}
+
+
+ {t("help.templateSyntax.basicsBoldBefore")} **text**{" "}
+ {t("help.templateSyntax.basicsBoldAfter")}
+
+
+ {t("help.templateSyntax.datetimeHeading")}
+
+
+ {t("help.templateSyntax.datetimeSupportedBefore")} date, time,{" "}
+ date_local, time_local, datetime_short,{" "}
+ datetime_short_local {t("help.templateSyntax.datetimeSupportedAfter")}
+
+
+ {t("help.templateSyntax.datetimeOrderBefore")} :ymd, :mdy,{" "}
+ :dmy {t("help.templateSyntax.datetimeOrderAfter")} _local{" "}
+ {t("help.templateSyntax.datetimeLocal")}
+
+
+ {t("help.templateSyntax.numberHeading")}
+
+
+ {t("help.templateSyntax.numberSupportedBefore")} round, fixed1,{" "}
+ fixed2 {t("help.templateSyntax.numberSupportedAfter")}
+
+
+ {t("help.templateSyntax.numberPurpose")}
+
+
+ {t("help.templateSyntax.examplesHeading")}
+
+
+ {t("help.templateSyntax.examplesDatetimeBefore")} {`{first_used|date}`}{" "}
+ {t("help.templateSyntax.examplesDatetimeDate")} 2026-03-04,{" "}
+ {`{first_used|date:dmy}`} {t("help.templateSyntax.examplesDatetimeDmy")}{" "}
+ 04/03/2026, {t("help.templateSyntax.and")} {`{first_used|time}`}{" "}
+ {t("help.templateSyntax.examplesDatetimeTime")} 18:27.{" "}
+ {`{first_used|datetime_short_local:mdy}`} {t("help.templateSyntax.examplesDatetimeLocal")}.
+
+
+ {t("help.templateSyntax.examplesNumberBefore")} {`{remaining_weight|round}`}{" "}
+ {t("help.templateSyntax.examplesNumberRound")} 1235,{" "}
+ {`{remaining_weight|fixed1}`} {t("help.templateSyntax.examplesNumberFixed1")}{" "}
+ 1234.6, {t("help.templateSyntax.and")} {`{remaining_weight|fixed2}`}{" "}
+ {t("help.templateSyntax.examplesNumberFixed2")} 1234.57.
+
+
);
};
diff --git a/client/src/pages/printing/printing.tsx b/client/src/pages/printing/printing.tsx
index 59b47895f..e14bdaa03 100644
--- a/client/src/pages/printing/printing.tsx
+++ b/client/src/pages/printing/printing.tsx
@@ -1,8 +1,12 @@
+import dayjs from "dayjs";
+import utc from "dayjs/plugin/utc";
import { ReactElement } from "react";
import { v4 as uuidv4 } from "uuid";
import { useGetSetting, useSetSetting } from "../../utils/querySettings";
import { ISpool } from "../spools/model";
+dayjs.extend(utc);
+
export interface PrintSettings {
id: string;
name?: string;
@@ -35,7 +39,6 @@ export function useGetPrintSettings(): SpoolQRCodePrintSettings[] | undefined {
if (!data) return;
const parsed: SpoolQRCodePrintSettings[] =
data && data.value ? JSON.parse(data.value) : ([] as SpoolQRCodePrintSettings[]);
- // Loop through all parsed and generate a new ID field if it's not set
return parsed.map((settings) => {
if (!settings.labelSettings.printSettings.id) {
settings.labelSettings.printSettings.id = uuidv4();
@@ -58,9 +61,92 @@ interface GenericObject {
extra: { [key: string]: string };
}
+type DateOrder = "ymd" | "mdy" | "dmy";
+
+function getDatePattern(order: DateOrder): string {
+ switch (order) {
+ case "mdy":
+ return "MM/DD/YYYY";
+ case "dmy":
+ return "DD/MM/YYYY";
+ case "ymd":
+ default:
+ return "YYYY-MM-DD";
+ }
+}
+
+function parseDateModifier(modifier?: string): { baseModifier?: string; dateOrder: DateOrder } {
+ if (!modifier) {
+ return { dateOrder: "ymd" };
+ }
+
+ const [baseModifier, rawOrder] = modifier.split(":", 2);
+ if (rawOrder === "mdy" || rawOrder === "dmy" || rawOrder === "ymd") {
+ return { baseModifier, dateOrder: rawOrder };
+ }
+
+ return { baseModifier, dateOrder: "ymd" };
+}
+
+function formatNumberValue(value: unknown, modifier?: string): unknown {
+ if (!modifier || value === "?") {
+ return undefined;
+ }
+
+ const numericValue =
+ typeof value === "number" ? value : typeof value === "string" && value.trim() !== "" ? Number(value) : Number.NaN;
+
+ if (!Number.isFinite(numericValue)) {
+ return undefined;
+ }
+
+ switch (modifier) {
+ case "round":
+ return Math.round(numericValue);
+ case "fixed1":
+ return numericValue.toFixed(1);
+ case "fixed2":
+ return numericValue.toFixed(2);
+ default:
+ return undefined;
+ }
+}
+
+function formatDateTimeValue(value: unknown, modifier?: string): unknown {
+ const { baseModifier, dateOrder } = parseDateModifier(modifier);
+ if (!baseModifier || value === "?") {
+ return value;
+ }
+
+ if (typeof value !== "string") {
+ return value;
+ }
+
+ const parsed = dayjs.utc(value);
+ if (!parsed.isValid()) {
+ return value;
+ }
+
+ switch (baseModifier) {
+ case "date":
+ return parsed.format(getDatePattern(dateOrder));
+ case "time":
+ return parsed.format("HH:mm");
+ case "date_local":
+ return parsed.local().format(getDatePattern(dateOrder));
+ case "time_local":
+ return parsed.local().format("HH:mm");
+ case "datetime_short":
+ return parsed.format(`${getDatePattern(dateOrder)} HH:mm`);
+ case "datetime_short_local":
+ return parsed.local().format(`${getDatePattern(dateOrder)} HH:mm`);
+ default:
+ return value;
+ }
+}
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
-function getTagValue(tag: string, obj: GenericObject): any {
- // Split tag by .
+function getBaseTagValue(tag: string, obj: GenericObject): any {
const tagParts = tag.split(".");
if (tagParts[0] === "extra") {
const extraValue = obj.extra[tagParts[1]];
@@ -71,13 +157,23 @@ function getTagValue(tag: string, obj: GenericObject): any {
}
const value = obj[tagParts[0]] ?? "?";
- // check if value is itself an object. If so, recursively call this and remove the first part of the tag
if (typeof value === "object") {
- return getTagValue(tagParts.slice(1).join("."), value);
+ return getBaseTagValue(tagParts.slice(1).join("."), value);
}
return value;
}
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function getTagValue(tag: string, obj: GenericObject): any {
+ const [baseTag, modifier] = tag.split("|", 2);
+ const tagValue = getBaseTagValue(baseTag, obj);
+ const numericValue = formatNumberValue(tagValue, modifier);
+ if (numericValue !== undefined) {
+ return numericValue;
+ }
+ return formatDateTimeValue(tagValue, modifier);
+}
+
function applyNewline(text: string): ReactElement[] {
return text.split("\n").map((line, idx, arr) => (
@@ -90,38 +186,33 @@ function applyNewline(text: string): ReactElement[] {
function applyTextFormatting(text: string): ReactElement[] {
const regex = /\*\*([\w\W]*?)\*\*/g;
const parts = text.split(regex);
- // Map over the parts and wrap matched text with tags
- const elements = parts.map((part, index) => {
- // Even index: outside asterisks, odd index: inside asterisks (to be bolded)
+ return parts.map((part, index) => {
const node = applyNewline(part);
return index % 2 === 0 ? {node} : {node};
});
- return elements;
}
export function renderLabelContents(template: string, spool: ISpool): ReactElement {
- // Find all {tags} in the template string and loop over them
const matches = [...template.matchAll(/{(?:[^}{]|{[^}{]*})*}/gs)];
- let label_text = template;
+ let labelText = template;
matches.forEach((match) => {
if ((match[0].match(/{/g) || []).length == 1) {
const tag = match[0].replace(/[{}]/g, "");
const tagValue = getTagValue(tag, spool);
- label_text = label_text.replace(match[0], tagValue);
+ labelText = labelText.replace(match[0], tagValue);
} else if ((match[0].match(/{/g) || []).length == 2) {
const structure = match[0].match(/{(.*?){(.*?)}(.*?)}/);
if (structure != null) {
const tag = structure[2];
const tagValue = getTagValue(tag, spool);
if (tagValue == "?") {
- label_text = label_text.replace(match[0], "");
+ labelText = labelText.replace(match[0], "");
} else {
- label_text = label_text.replace(match[0], structure[1] + tagValue + structure[3]);
+ labelText = labelText.replace(match[0], structure[1] + tagValue + structure[3]);
}
}
}
});
- // Split string on \n into individual lines
- return <>{applyTextFormatting(label_text)}>;
+ return <>{applyTextFormatting(labelText)}>;
}
diff --git a/client/src/pages/printing/spoolQrCodePrintingDialog.tsx b/client/src/pages/printing/spoolQrCodePrintingDialog.tsx
index 865c597cf..17159388a 100644
--- a/client/src/pages/printing/spoolQrCodePrintingDialog.tsx
+++ b/client/src/pages/printing/spoolQrCodePrintingDialog.tsx
@@ -1,10 +1,11 @@
import { CopyOutlined, DeleteOutlined, PlusOutlined, SaveOutlined } from "@ant-design/icons";
import { useTranslate } from "@refinedev/core";
-import { Button, Flex, Form, Input, Modal, Popconfirm, Select, Table, Typography, message } from "antd";
+import { Button, Flex, Form, Grid, Input, Modal, Popconfirm, Select, Table, Typography, message } from "antd";
import TextArea from "antd/es/input/TextArea";
import { useState } from "react";
+import { Link } from "react-router";
import { v4 as uuidv4 } from "uuid";
-import { EntityType, useGetFields } from "../../utils/queryFields";
+import { EntityType, FieldType, useGetFields } from "../../utils/queryFields";
import { useGetSetting } from "../../utils/querySettings";
import { useSavedState } from "../../utils/saveload";
import { useGetSpoolsByIds } from "../spools/functions";
@@ -23,8 +24,73 @@ interface SpoolQRCodePrintingDialog {
spoolIds: number[];
}
+const DATETIME_MODIFIERS = [
+ "|date",
+ "|time",
+ "|date_local",
+ "|time_local",
+ "|datetime_short",
+ "|datetime_short_local",
+] as const;
+
+const NUMBER_MODIFIERS = ["|round", "|fixed1", "|fixed2"] as const;
+const DATE_ORDER_SUFFIXES = [":ymd", ":mdy", ":dmy"] as const;
+
+type TemplateModifierType = "datetime" | "number";
+
+interface TemplateTagRow {
+ tag: string;
+ modifierType?: TemplateModifierType;
+}
+
+interface TemplateTagTableRow {
+ key: string;
+ tag: string;
+ modifiers: readonly string[];
+ dateOrderSuffixes?: readonly string[];
+}
+
+function getEstimatedColumnWidth(values: string[], minWidth: number): number {
+ const longestValueLength = values.reduce((longest, value) => Math.max(longest, value.length), 0);
+ const averageMonospaceCharWidth = 6.8;
+ const cellPadding = 22;
+ return Math.max(minWidth, Math.ceil(longestValueLength * averageMonospaceCharWidth + cellPadding));
+}
+
+function getModifierTypeForField(fieldType: FieldType): TemplateModifierType | undefined {
+ if (fieldType === FieldType.datetime) {
+ return "datetime";
+ }
+
+ if (fieldType === FieldType.integer || fieldType === FieldType.float) {
+ return "number";
+ }
+
+ return undefined;
+}
+
+async function copyTextToClipboard(value: string): Promise {
+ try {
+ await navigator.clipboard.writeText(value);
+ return true;
+ } catch {
+ const textarea = document.createElement("textarea");
+ textarea.value = value;
+ textarea.setAttribute("readonly", "");
+ textarea.style.position = "absolute";
+ textarea.style.left = "-9999px";
+ document.body.appendChild(textarea);
+ textarea.select();
+ const copied = document.execCommand("copy");
+ document.body.removeChild(textarea);
+ return copied;
+ }
+}
+
const SpoolQRCodePrintingDialog = ({ spoolIds }: SpoolQRCodePrintingDialog) => {
const t = useTranslate();
+ const screens = Grid.useBreakpoint();
+ const isMobile = !screens.md;
const baseUrlSetting = useGetSetting("base_url");
const baseUrlRoot =
baseUrlSetting.data?.value !== undefined && JSON.parse(baseUrlSetting.data?.value) !== ""
@@ -40,11 +106,7 @@ const SpoolQRCodePrintingDialog = ({ spoolIds }: SpoolQRCodePrintingDialog) => {
})
.filter((item) => item !== null) as ISpool[];
- // Selected preset state
const [selectedPresetState, setSelectedPresetState] = useSavedState("selectedPreset", undefined);
-
- // Keep a local copy of the settings which is what's actually displayed. Use the remote state only for saving.
- // This decouples the debounce stuff from the UI
const [localPresets, setLocalPresets] = useState();
const remotePresets = useGetPrintPresets();
const setRemotePresets = useSetPrintPresets();
@@ -56,7 +118,6 @@ const SpoolQRCodePrintingDialog = ({ spoolIds }: SpoolQRCodePrintingDialog) => {
setRemotePresets(localPresets);
};
- // Functions to update settings
const addNewPreset = () => {
if (!localOrRemotePresets) return;
const newId = uuidv4();
@@ -72,6 +133,7 @@ const SpoolQRCodePrintingDialog = ({ spoolIds }: SpoolQRCodePrintingDialog) => {
setSelectedPresetState(newId);
return newPreset;
};
+
const duplicateCurrentPreset = () => {
if (!localOrRemotePresets) return;
const newPreset = {
@@ -82,6 +144,7 @@ const SpoolQRCodePrintingDialog = ({ spoolIds }: SpoolQRCodePrintingDialog) => {
setLocalPresets([...localOrRemotePresets, newPreset]);
setSelectedPresetState(newPreset.labelSettings.printSettings.id);
};
+
const updateCurrentPreset = (newSettings: SpoolQRCodePrintSettings) => {
if (!localOrRemotePresets) return;
setLocalPresets(
@@ -90,6 +153,7 @@ const SpoolQRCodePrintingDialog = ({ spoolIds }: SpoolQRCodePrintingDialog) => {
),
);
};
+
const deleteCurrentPreset = () => {
if (!localOrRemotePresets) return;
setLocalPresets(
@@ -98,10 +162,8 @@ const SpoolQRCodePrintingDialog = ({ spoolIds }: SpoolQRCodePrintingDialog) => {
setSelectedPresetState(undefined);
};
- // Initialize presets
let curPreset: SpoolQRCodePrintSettings;
if (localOrRemotePresets === undefined) {
- // DB not loaded yet, use a temporary one
curPreset = {
labelSettings: {
printSettings: {
@@ -110,48 +172,38 @@ const SpoolQRCodePrintingDialog = ({ spoolIds }: SpoolQRCodePrintingDialog) => {
},
},
};
- } else {
- // DB is loaded, find the selected setting
- if (localOrRemotePresets.length === 0) {
- // DB loaded, but no settings found, add a new one and select it
- const newSetting = addNewPreset();
- if (!newSetting) {
- console.error("Error adding new setting, this should never happen");
- return;
- }
+ } else if (localOrRemotePresets.length === 0) {
+ const newSetting = addNewPreset();
+ if (!newSetting) {
+ console.error("Error adding new setting, this should never happen");
+ return;
+ }
- // Mutate the allPrintSettings list so that the rest of the UI will work fine
- localOrRemotePresets.push(newSetting);
- curPreset = newSetting;
+ localOrRemotePresets.push(newSetting);
+ curPreset = newSetting;
+ } else if (!selectedPresetState) {
+ curPreset = localOrRemotePresets[0];
+ setSelectedPresetState(localOrRemotePresets[0].labelSettings.printSettings.id);
+ } else {
+ const foundSetting = localOrRemotePresets.find(
+ (settings) => settings.labelSettings.printSettings.id === selectedPresetState,
+ );
+ if (foundSetting) {
+ curPreset = foundSetting;
} else {
- // DB loaded and at least 1 setting exists
- if (!selectedPresetState) {
- // No setting has been selected, select the first one
- curPreset = localOrRemotePresets[0];
- setSelectedPresetState(localOrRemotePresets[0].labelSettings.printSettings.id);
- } else {
- // A setting has been selected, find it
- const foundSetting = localOrRemotePresets.find(
- (settings) => settings.labelSettings.printSettings.id === selectedPresetState,
- );
- if (foundSetting) {
- curPreset = foundSetting;
- } else {
- // Selected setting not found, select a temp one
- curPreset = {
- labelSettings: {
- printSettings: {
- id: "TEMP",
- name: t("printing.generic.newSetting"),
- },
- },
- };
- }
- }
+ curPreset = {
+ labelSettings: {
+ printSettings: {
+ id: "TEMP",
+ name: t("printing.generic.newSetting"),
+ },
+ },
+ };
}
}
const [templateHelpOpen, setTemplateHelpOpen] = useState(false);
+ const [hoveredCopyValue, setHoveredCopyValue] = useState(null);
const template =
curPreset.template ??
`**{filament.vendor.name} - {filament.name}
@@ -164,18 +216,18 @@ Spool Weight: {filament.spool_weight} g
{filament.comment}
{filament.vendor.comment}`;
- const spoolTags = [
+ const spoolTags: TemplateTagRow[] = [
{ tag: "id" },
- { tag: "registered" },
- { tag: "first_used" },
- { tag: "last_used" },
- { tag: "price" },
- { tag: "initial_weight" },
- { tag: "spool_weight" },
- { tag: "remaining_weight" },
- { tag: "used_weight" },
- { tag: "remaining_length" },
- { tag: "used_length" },
+ { tag: "registered", modifierType: "datetime" },
+ { tag: "first_used", modifierType: "datetime" },
+ { tag: "last_used", modifierType: "datetime" },
+ { tag: "price", modifierType: "number" },
+ { tag: "initial_weight", modifierType: "number" },
+ { tag: "spool_weight", modifierType: "number" },
+ { tag: "remaining_weight", modifierType: "number" },
+ { tag: "used_weight", modifierType: "number" },
+ { tag: "remaining_length", modifierType: "number" },
+ { tag: "used_length", modifierType: "number" },
{ tag: "location" },
{ tag: "lot_nr" },
{ tag: "comment" },
@@ -184,23 +236,24 @@ Spool Weight: {filament.spool_weight} g
const spoolFields = useGetFields(EntityType.spool);
if (spoolFields.data !== undefined) {
spoolFields.data.forEach((field) => {
- spoolTags.push({ tag: `extra.${field.key}` });
+ spoolTags.push({ tag: `extra.${field.key}`, modifierType: getModifierTypeForField(field.field_type) });
});
}
- const filamentTags = [
+
+ const filamentTags: TemplateTagRow[] = [
{ tag: "filament.id" },
- { tag: "filament.registered" },
+ { tag: "filament.registered", modifierType: "datetime" },
{ tag: "filament.name" },
{ tag: "filament.material" },
- { tag: "filament.price" },
- { tag: "filament.density" },
- { tag: "filament.diameter" },
- { tag: "filament.weight" },
- { tag: "filament.spool_weight" },
+ { tag: "filament.price", modifierType: "number" },
+ { tag: "filament.density", modifierType: "number" },
+ { tag: "filament.diameter", modifierType: "number" },
+ { tag: "filament.weight", modifierType: "number" },
+ { tag: "filament.spool_weight", modifierType: "number" },
{ tag: "filament.article_number" },
{ tag: "filament.comment" },
- { tag: "filament.settings_extruder_temp" },
- { tag: "filament.settings_bed_temp" },
+ { tag: "filament.settings_extruder_temp", modifierType: "number" },
+ { tag: "filament.settings_bed_temp", modifierType: "number" },
{ tag: "filament.color_hex" },
{ tag: "filament.multi_color_hexes" },
{ tag: "filament.multi_color_direction" },
@@ -209,25 +262,86 @@ Spool Weight: {filament.spool_weight} g
const filamentFields = useGetFields(EntityType.filament);
if (filamentFields.data !== undefined) {
filamentFields.data.forEach((field) => {
- filamentTags.push({ tag: `filament.extra.${field.key}` });
+ filamentTags.push({
+ tag: `filament.extra.${field.key}`,
+ modifierType: getModifierTypeForField(field.field_type),
+ });
});
}
- const vendorTags = [
+
+ const vendorTags: TemplateTagRow[] = [
{ tag: "filament.vendor.id" },
- { tag: "filament.vendor.registered" },
+ { tag: "filament.vendor.registered", modifierType: "datetime" },
{ tag: "filament.vendor.name" },
{ tag: "filament.vendor.comment" },
- { tag: "filament.vendor.empty_spool_weight" },
+ { tag: "filament.vendor.empty_spool_weight", modifierType: "number" },
{ tag: "filament.vendor.external_id" },
];
const vendorFields = useGetFields(EntityType.vendor);
if (vendorFields.data !== undefined) {
vendorFields.data.forEach((field) => {
- vendorTags.push({ tag: `filament.vendor.extra.${field.key}` });
+ vendorTags.push({
+ tag: `filament.vendor.extra.${field.key}`,
+ modifierType: getModifierTypeForField(field.field_type),
+ });
});
}
- const templateTags = [...spoolTags, ...filamentTags, ...vendorTags];
+ const templateTags: TemplateTagTableRow[] = [...spoolTags, ...filamentTags, ...vendorTags].map((tagRow) => ({
+ key: tagRow.tag,
+ tag: tagRow.tag,
+ modifiers:
+ tagRow.modifierType === "datetime"
+ ? DATETIME_MODIFIERS
+ : tagRow.modifierType === "number"
+ ? NUMBER_MODIFIERS
+ : [],
+ dateOrderSuffixes: tagRow.modifierType === "datetime" ? DATE_ORDER_SUFFIXES : undefined,
+ }));
+
+ const tagColumnWidth = getEstimatedColumnWidth(
+ templateTags.map((tagRow) => tagRow.tag),
+ 180,
+ );
+ const modifiersColumnWidth = getEstimatedColumnWidth(
+ templateTags.flatMap((tagRow) => {
+ if (tagRow.modifiers.length === 0) {
+ return [t("printing.qrcode.templateTagDialog.modifiers.none")];
+ }
+ const widthSamples = [tagRow.modifiers.join(", ")];
+ if (tagRow.dateOrderSuffixes) {
+ widthSamples.push(
+ `${t("printing.qrcode.templateTagDialog.modifiers.dateOrderPrefix")} ${tagRow.dateOrderSuffixes.join(", ")}`,
+ );
+ }
+ return widthSamples;
+ }),
+ 160,
+ );
+
+ const tableScrollWidth = tagColumnWidth + modifiersColumnWidth;
+ const viewportWidth = typeof window !== "undefined" ? window.innerWidth : 1366;
+ const desktopMaxDialogWidth = Math.floor(viewportWidth * 0.94);
+ const desktopDialogWidth = Math.min(desktopMaxDialogWidth, tableScrollWidth + 64);
+ const dialogWidth = isMobile ? "96vw" : desktopDialogWidth;
+ const contentWidthForTable = isMobile ? tableScrollWidth : desktopDialogWidth - 32;
+ const enableHorizontalTableScroll = isMobile || tableScrollWidth > contentWidthForTable;
+
+ const closeTemplateHelp = () => {
+ setTemplateHelpOpen(false);
+ setHoveredCopyValue(null);
+ };
+
+ const handleCopyValue = async (value: string) => {
+ const copied = await copyTextToClipboard(value);
+ if (!copied) {
+ messageApi.error(t("printing.qrcode.templateTagDialog.copyError"));
+ return;
+ }
+
+ messageApi.success(t("printing.qrcode.templateTagDialog.copySuccess", { value }));
+ closeTemplateHelp();
+ };
return (
<>
@@ -326,38 +440,127 @@ Spool Weight: {filament.spool_weight} g
}}
/>
- setTemplateHelpOpen(false)}>
+
+