diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs new file mode 100644 index 00000000..a5927232 --- /dev/null +++ b/frontend/eslint.config.mjs @@ -0,0 +1,16 @@ +import tseslint from 'typescript-eslint'; +import eslintPluginAstro from 'eslint-plugin-astro'; + +export default tseslint.config( + ...tseslint.configs.recommended, + ...eslintPluginAstro.configs['flat/recommended'], + { + rules: { + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + }, + }, + { + ignores: ['dist/', '.astro/'], + }, +); diff --git a/frontend/src/i18n/de.json b/frontend/src/i18n/de.json index 20cd678d..31d5997e 100644 --- a/frontend/src/i18n/de.json +++ b/frontend/src/i18n/de.json @@ -454,7 +454,59 @@ "colPurchaseDate": "Kaufdatum", "colLastUsed": "Zuletzt verwendet", "colTotalWeight": "Gesamtgewicht", - "colEmptyWeight": "Leergewicht" + "colEmptyWeight": "Leergewicht", + "labelDesigner": "Label Designer", + "dsPresets": "Vorlagen", + "dsPresetsSaved": "Gespeicherte Vorlagen", + "dsPresetsLoad": "Laden", + "dsPresetsDelete": "Ausgewählte Vorlage löschen", + "dsPresetsName": "Vorlagenname", + "dsPresetsSaveNew": "Als Neu speichern", + "dsPresetsOverwrite": "Überschreiben", + "dsLogo": "Logo", + "dsLogoSize": "Logohöhe (mm)", + "dsLogoSpace": "Verfügbare Höhe (mm)", + "dsLogoManual": "Max. Logohöhe (mm)", + "dsLogoFit": "In Begrenzung einpassen", + "dsLabel": "Etikett", + "dsMargin": "Rand (mm)", + "dsBorder": "Druckrahmen", + "dsTitle": "Titel", + "dsTitleSize": "Max. Schriftgröße (mm)", + "dsTitleMargin": "Rand (mm)", + "dsTitleFit": "Breite anpassen", + "dsTitle2Show": "Untertitel", + "dsDividerAbove": "Linie oben", + "dsDividerBelow": "Linie unten", + "dsJustification": "Ausrichtung", + "dsAlign": "Ausrichtung", + "dsHAlign": "Horizontale Ausrichtung", + "dsVAlign": "Vertikale Ausrichtung", + "dsTemplate": "Format", + "dsTitleFormat": "Titelformat", + "dsSubtitleFormat": "Untertitelformat", + "dsInfoFormat": "Informationsformat", + "dsSideColumnFormat": "Seitenformat", + "dsQrMode": "Modus", + "dsQrNone": "Keiner", + "dsQrSimple": "Einfach", + "dsQrIcon": "Logo", + "dsQrColorLogo": "Farbiges Logo", + "dsQrPosition": "Position", + "dsQrLink": "Linktyp", + "dsQrSpoolUrl": "Spul-URL", + "dsQrCustomUrl": "Eigene URL", + "dsQrCustomBase": "Basis-URL", + "dsQrCustomBaseHint": "Die Spulen-ID wird automatisch angehängt.", + "dsInfo": "Informationen", + "dsInfoSize": "Textgröße (mm)", + "dsInfo2Show": "Seitenspalte", + "dsInfo2Vsep": "Vertikaler Trenner", + "dsLeft": "Links", + "dsRight": "Rechts", + "dsTop": "Oben", + "dsMid": "Mitte", + "dsBot": "Unten" }, "colors": { "title": "Farben verwalten", diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 67772607..84969805 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -452,7 +452,59 @@ "colPurchaseDate": "Purchase Date", "colLastUsed": "Last Used", "colTotalWeight": "Total Weight", - "colEmptyWeight": "Empty Weight" + "colEmptyWeight": "Empty Weight", + "labelDesigner": "Label Designer", + "dsPresets": "Presets", + "dsPresetsSaved": "Saved presets", + "dsPresetsLoad": "Load", + "dsPresetsDelete": "Delete selected preset", + "dsPresetsName": "Preset name", + "dsPresetsSaveNew": "Save as New", + "dsPresetsOverwrite": "Overwrite", + "dsLogo": "Logo", + "dsLogoSize": "Logo height (mm)", + "dsLogoSpace": "Space height (mm)", + "dsLogoManual": "Max logo height (mm)", + "dsLogoFit": "Scale to fit bounds", + "dsLabel": "Label", + "dsMargin": "Margin (mm)", + "dsBorder": "Print Border", + "dsTitle": "Title", + "dsTitleSize": "Max font size (mm)", + "dsTitleMargin": "Margin (mm)", + "dsTitleFit": "Fit to width", + "dsTitle2Show": "Subtitle", + "dsDividerAbove": "Line above", + "dsDividerBelow": "Line below", + "dsJustification": "Justification", + "dsAlign": "Alignment", + "dsHAlign": "Horizontal align", + "dsVAlign": "Vertical align", + "dsTemplate": "Format", + "dsTitleFormat": "Title Format", + "dsSubtitleFormat": "Subtitle Format", + "dsInfoFormat": "Information Format", + "dsSideColumnFormat": "Side Column Format", + "dsQrMode": "Mode", + "dsQrNone": "None", + "dsQrSimple": "Simple", + "dsQrIcon": "Logo", + "dsQrColorLogo": "Color Logo", + "dsQrPosition": "Position", + "dsQrLink": "Link mode", + "dsQrSpoolUrl": "Spool URL", + "dsQrCustomUrl": "Custom URL", + "dsQrCustomBase": "Custom URL base", + "dsQrCustomBaseHint": "Spool ID is appended automatically.", + "dsInfo": "Information", + "dsInfoSize": "Text size (mm)", + "dsInfo2Show": "Side Column", + "dsInfo2Vsep": "Vertical separator", + "dsLeft": "Left", + "dsRight": "Right", + "dsTop": "Top", + "dsMid": "Mid", + "dsBot": "Bot" }, "colors": { "title": "Manage Colors", diff --git a/frontend/src/lib/label-template.ts b/frontend/src/lib/label-template.ts new file mode 100644 index 00000000..fa195980 --- /dev/null +++ b/frontend/src/lib/label-template.ts @@ -0,0 +1,227 @@ +/** + * Label template parser for the Advanced Label Designer. + * + * Template syntax: + * {token} — simple dot-path substitution; resolves to "?" if missing + * {prefix{token}suffix} — optional block: rendered as prefix+value+suffix if token is not "?" + * omitted entirely when token resolves to "?" + * **bold** — text + * *italic* — text (single asterisk, not part of **) + * ==inverse== — inverted text (black bg, white text) + * @@inverse@@ — inverted text using filament color with automatic black/white text + * [size=120]text[/size] — inline relative size in percent (50..300) + * [size=120%]text[/size] — same as above; percent sign is optional + * {color_swatch[8]} — inline color bar using filament.color_hex; width is in ch units (default 1) + * \n — line-break (
) + * + * SpoolData is a flat object passed from the print page; the "extra" key holds + * extra-field values keyed by field key. + */ + +export interface SpoolData { + id: string | number + 'filament.name': string + 'filament.material': string + 'filament.color': string + 'filament.color_hex': string + 'filament.manufacturer': string + 'filament.extruder_temp': string | number + 'filament.bed_temp': string | number + 'filament.weight': string | number + extra?: Record + [key: string]: unknown +} + +const SWATCH_MARKER_RE = /^\[\[FM_SWATCH\|(\d{1,3})\|(#[0-9A-F]{6})\]\]$/ +const MAX_TEMPLATE_CHARS = 8000 +const MAX_MARKUP_CHARS = 12000 + +export function normalizeHexColor(raw: unknown): string | null { + + if (raw === undefined || raw === null) return null + const hex = String(raw).trim().replace(/^#/, '') + if (!hex) return null + if (/^[0-9a-fA-F]{3}$/.test(hex)) { + const [a, b, c] = hex.split('') + return `#${(a + a + b + b + c + c).toUpperCase()}` + } + if (/^[0-9a-fA-F]{6}$/.test(hex)) return `#${hex.toUpperCase()}` + return null +} + +export function getReadableTextColor(backgroundHex: string | null): '#000' | '#fff' { + if (!backgroundHex) return '#fff' + const hex = backgroundHex.replace('#', '') + const rgb = [0, 2, 4].map((offset) => Number.parseInt(hex.slice(offset, offset + 2), 16) / 255) + const linear = rgb.map((channel) => (channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4)) + const luminance = (0.2126 * linear[0]) + (0.7152 * linear[1]) + (0.0722 * linear[2]) + const contrastWithBlack = (luminance + 0.05) / 0.05 + const contrastWithWhite = 1.05 / (luminance + 0.05) + return contrastWithBlack >= contrastWithWhite ? '#000' : '#fff' +} + +function getFilamentColorTheme(data: SpoolData): { background: string; foreground: '#000' | '#fff' } { + const background = normalizeHexColor(data['filament.color_hex']) ?? '#000000' + return { background, foreground: getReadableTextColor(background) } +} + +function parseColorSwatchToken(token: string): number | null { + const m = token.trim().match(/^color(?:-|_)swatch(?:\[(\d{1,3})\])?$/i) + if (!m) return null + const width = m[1] ? Number(m[1]) : 1 + return Math.max(1, Math.min(40, width)) +} + +function renderColorSwatchMarker(token: string, data: SpoolData): string | null { + const widthCh = parseColorSwatchToken(token) + if (widthCh === null) return null + const hex = normalizeHexColor(data['filament.color_hex']) + if (!hex) return '' + return `[[FM_SWATCH|${widthCh}|${hex}]]` +} + +/** Resolve a dot-path token against the spool data object. */ +function resolveToken(token: string, data: SpoolData): string { + if (token.startsWith('extra.')) { + const key = token.slice(6) + const val = data.extra?.[key] + return val !== undefined && val !== '' ? String(val) : '?' + } + const val = (data as Record)[token] + if (val === undefined || val === null || val === '') return '?' + return String(val) +} + +/** Expand {token} and {prefix{token}suffix} placeholders to plain text. */ +export function renderTemplateText(template: string, data: SpoolData): string { + const boundedTemplate = template.length > MAX_TEMPLATE_CHARS + ? template.slice(0, MAX_TEMPLATE_CHARS) + : template + // Match both optional-block {{inner}} style and simple {token} + // Process longest matches first (optional blocks) before simple tokens. + return boundedTemplate.replace( + /{(?:[^{}]|{[^{}]*})*}/g, + (match) => { + // Optional block: {prefix{token}suffix} + const optional = match.match(/^\{(.*?)\{([^{}]+)\}(.*?)\}$/) + if (optional) { + const [, prefix, token, suffix] = optional + const swatchMarker = renderColorSwatchMarker(token, data) + if (swatchMarker !== null) return swatchMarker === '' ? '' : prefix + swatchMarker + suffix + const resolved = resolveToken(token, data) + return resolved === '?' ? '' : prefix + resolved + suffix + } + // Simple token: {token} + const token = match.slice(1, -1) + const swatchMarker = renderColorSwatchMarker(token, data) + if (swatchMarker !== null) return swatchMarker + const resolved = resolveToken(token, data) + return resolved === '?' ? '' : resolved + } + ) +} + +/** Apply **bold**, *italic*, ==inverse==, @@inverse@@ and [size=..] inline markup. */ +function applyMarkup(text: string, frag: DocumentFragment | HTMLElement, data: SpoolData): void { + // Regex: match swatch marker, [size=NNN]...[/size] (case-insensitive), + // bold (**…**), italic (*…*), inverse (==…==), filament inverse (@@…@@) + const regex = /(\[\[FM_SWATCH\|\d{1,3}\|#[0-9A-F]{6}\]\]|\[size=\d{1,3}%?\][\s\S]*?\[\/size\]|\*\*[\s\S]*?\*\*|\*(?!\*)([\s\S]*?)\*(?!\*)|==[\s\S]*?==|@@[\s\S]*?@@)/gi + let last = 0 + + const appendPlainText = (raw: string, container: DocumentFragment | HTMLElement) => { + // Split on newlines and insert
+ const lines = raw.split('\n') + lines.forEach((line, i) => { + if (line) container.appendChild(document.createTextNode(line)) + if (i < lines.length - 1) container.appendChild(document.createElement('br')) + }) + } + + let match: RegExpExecArray | null + while ((match = regex.exec(text)) !== null) { + // Text before this match + if (match.index > last) { + appendPlainText(text.slice(last, match.index), frag) + } + + const part = match[0] + + const swatch = part.match(SWATCH_MARKER_RE) + if (swatch) { + const [, widthCh, hex] = swatch + const el = document.createElement('span') + el.style.display = 'inline-block' + el.style.width = `${Number(widthCh)}ch` + el.style.height = '0.82em' + el.style.backgroundColor = hex + el.style.borderRadius = '0.14em' + el.style.border = '1px solid rgba(0,0,0,0.28)' + el.style.verticalAlign = 'baseline' + el.style.margin = '0 0.2ch' + frag.appendChild(el) + } else if (/^\[size=/i.test(part) && /\[\/size\]$/i.test(part)) { + const sized = part.match(/^\[size=(\d{1,3})%?\]([\s\S]*?)\[\/size\]$/i) + if (sized) { + const [, rawPct, inner] = sized + const pct = Math.max(50, Math.min(300, Number(rawPct))) + const el = document.createElement('span') + el.style.fontSize = `${pct}%` + applyMarkup(inner, el, data) + frag.appendChild(el) + } else { + appendPlainText(part, frag) + } + } else if (part.startsWith('**') && part.endsWith('**')) { + const inner = part.slice(2, -2) + const el = document.createElement('strong') + applyMarkup(inner, el, data) + frag.appendChild(el) + } else if (part.startsWith('==') && part.endsWith('==')) { + const inner = part.slice(2, -2) + const el = document.createElement('span') + el.style.backgroundColor = '#000' + el.style.color = '#fff' + el.style.padding = '0 0.6mm' + el.style.display = 'inline-block' + applyMarkup(inner, el, data) + frag.appendChild(el) + } else if (part.startsWith('@@') && part.endsWith('@@')) { + const inner = part.slice(2, -2) + const theme = getFilamentColorTheme(data) + const el = document.createElement('span') + el.style.backgroundColor = theme.background + el.style.color = theme.foreground + el.style.padding = '0 0.6mm' + el.style.display = 'inline-block' + applyMarkup(inner, el, data) + frag.appendChild(el) + } else if (part.startsWith('*') && part.endsWith('*')) { + const inner = part.slice(1, -1) + const el = document.createElement('em') + applyMarkup(inner, el, data) + frag.appendChild(el) + } + + last = match.index + part.length + } + + // Remaining text after last match + if (last < text.length) { + appendPlainText(text.slice(last), frag) + } +} + +/** + * Parse a template string with spool data and return a DocumentFragment + * ready to append into the DOM. + */ +export function parseTemplate(template: string, data: SpoolData): DocumentFragment { + const plainText = renderTemplateText(template, data) + const frag = document.createDocumentFragment() + if (plainText.length > MAX_MARKUP_CHARS) { + frag.appendChild(document.createTextNode(plainText.slice(0, MAX_MARKUP_CHARS))) + return frag + } + applyMarkup(plainText, frag, data) + return frag +} diff --git a/frontend/src/pages/spools/[id]/index.astro b/frontend/src/pages/spools/[id]/index.astro index d95e17e3..798ae25e 100644 --- a/frontend/src/pages/spools/[id]/index.astro +++ b/frontend/src/pages/spools/[id]/index.astro @@ -35,7 +35,7 @@ const { id } = Astro.params - + Print Label @@ -419,8 +419,34 @@ const { id } = Astro.params ` : ''} ` + const printUrl = `/spools/${spool.id}/print?designation=${encodeURIComponent(spool.filament?.designation || '')}&mfr=${encodeURIComponent(spool.filament?.manufacturer?.name || '')}&type=${encodeURIComponent(spool.filament?.material_type || '')}&subtype=${encodeURIComponent(spool.filament?.material_subgroup || '')}&color=${encodeURIComponent(spool.filament?.manufacturer_color_name || spool.filament?.colors?.[0]?.color?.name || '')}&mfr_id=${encodeURIComponent(spool.filament?.manufacturer?.id || '')}&hex_code=${encodeURIComponent(spool.filament?.colors?.[0]?.color?.hex_code || '')}&extruder_temp=${encodeURIComponent(spool.filament?.settings_extruder_temp ?? '')}&bed_temp=${encodeURIComponent(spool.filament?.settings_bed_temp ?? '')}&weight=${encodeURIComponent(spool.filament?.weight ?? '')}&diameter=${encodeURIComponent(spool.filament?.diameter_mm ?? '')}&finish=${encodeURIComponent(spool.filament?.finish_type || '')}&density=${encodeURIComponent(spool.filament?.density_g_cm3 ?? '')}&price=${encodeURIComponent(spool.filament?.price ?? '')}` + + // Store extra fields for label printing (spool + filament). + // Iterate ALL system-defined fields so they appear in the designer even when + // the spool hasn't had a value set for a field yet. + const efForPrint: {key: string, label: string, value: string, source: string}[] = [] + const spoolFlat = spool.custom_fields ? flattenObject(spool.custom_fields) : {} + for (const [key, def] of Object.entries(spoolSystemFieldMap)) { + const raw = spoolFlat[key] + efForPrint.push({ key: `spool.${key}`, label: def.label, value: raw !== undefined && raw !== null ? String(raw) : '', source: 'spool' }) + } + // Also include any spool custom fields not covered by system definitions + for (const [key, value] of Object.entries(spoolFlat)) { + if (!spoolSystemFieldMap[key]) efForPrint.push({ key: `spool.${key}`, label: key, value: value !== undefined && value !== null ? String(value) : '', source: 'spool' }) + } + const filamentFlat = spool.filament?.custom_fields ? flattenObject(spool.filament.custom_fields) : {} + for (const [key, def] of Object.entries(filamentSystemFieldMap)) { + const raw = filamentFlat[key] + efForPrint.push({ key: `filament.${key}`, label: def.label, value: raw !== undefined && raw !== null ? String(raw) : '', source: 'filament' }) + } + // Also include any filament custom fields not covered by system definitions + for (const [key, value] of Object.entries(filamentFlat)) { + if (!filamentSystemFieldMap[key]) efForPrint.push({ key: `filament.${key}`, label: key, value: value !== undefined && value !== null ? String(value) : '', source: 'filament' }) + } + sessionStorage.setItem('filaman-label-extra-fields', JSON.stringify(efForPrint)) + const printBtn = document.getElementById('btn-print-label') as HTMLAnchorElement - if (printBtn) printBtn.href = `/spools/print?ids=${spool.id}&back=${spool.id}` + if (printBtn) printBtn.href = printUrl document.getElementById('actions-section')!.classList.remove('hidden') @@ -597,7 +623,7 @@ const { id } = Astro.params const url = window.location.origin + '/spools/' + spool.id - // @ts-ignore + // @ts-expect-error -- qrcodejs has no TypeScript types new QRCode(qrContainer, { text: url, width: 84, // approx 22mm at 96dpi diff --git a/frontend/src/pages/spools/[id]/print.astro b/frontend/src/pages/spools/[id]/print.astro new file mode 100644 index 00000000..ad4775fb --- /dev/null +++ b/frontend/src/pages/spools/[id]/print.astro @@ -0,0 +1,3925 @@ +--- +import Layout from '../../../layouts/Layout.astro' + +export function getStaticPaths() { + return [ + {params: {id: 'detail'}}, + ]; +} + +const { id } = Astro.params +--- + + + + + + + + + +