/* Iteration-2 shared behaviour: shell, navigation badges, command palette, * drawers (spool / print / load), toasts, freshness and demo states. * Plain browser JS, no dependencies, works offline. */ (function () { 'use strict'; /* ── helpers ─────────────────────────────────────────────── */ var $ = function (s, r) { return (r || document).querySelector(s); }; var $$ = function (s, r) { return Array.prototype.slice.call((r || document).querySelectorAll(s)); }; function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); } function g(n) { return n == null ? '—' : Math.round(n).toLocaleString('en-US') + ' g'; } function kg(n) { return (n / 1000).toFixed(2) + ' kg'; } function usd(n) { return n == null ? '—' : '$' + n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } function dur(s) { if (s == null) return '—'; if (s < 60) return '<1m'; var h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60); return h ? h + 'h ' + String(m).padStart(2, '0') + 'm' : m + 'm'; } function when(iso) { if (!iso) return '—'; var d = new Date(iso), t = d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }); var days = Math.round((new Date(NOW.toDateString()) - new Date(d.toDateString())) / 864e5); if (days === 0) return 'Today ' + t; if (days === 1) return 'Yesterday ' + t; return d.getDate() + ' ' + d.toLocaleString('en-GB', { month: 'short' }) + ' ' + t; } function ago(sec) { if (sec < 60) return sec + 's ago'; var m = Math.floor(sec / 60); if (m < 60) return m + 'm ago'; var h = Math.floor(m / 60); return h < 24 ? h + 'h ago' : Math.floor(h / 24) + 'd ago'; } function since(iso) { return Math.max(0, Math.round((NOW - new Date(iso)) / 1000)); } var byId = function (arr, id) { for (var i = 0; i < arr.length; i++) if (arr[i].id === id) return arr[i]; return null; }; function spool(id) { return byId(SPOOLS, id); } function printer(id) { return byId(PRINTERS, id); } function print(id) { return byId(PRINTS, id); } function container(cid) { for (var i = 0; i < PRINTERS.length; i++) for (var j = 0; j < PRINTERS[i].containers.length; j++) if (PRINTERS[i].containers[j].id === cid) return { printer: PRINTERS[i], c: PRINTERS[i].containers[j] }; return null; } function contents(cid) { return SPOOLS.filter(function (s) { return s.cont === cid; }); } function fullName(s) { return [s.brand, s.speed, s.mat, s.type, s.fin].filter(Boolean).join(' '); } function short(s) { return s.brand + ' ' + s.color; } function where(s) { if (!s.cont) return { loaded: false, text: s.loc || 'Storage', short: s.loc || 'Storage' }; var c = container(s.cont); return { loaded: true, text: c.printer.name + ' · ' + c.c.label, short: c.printer.model + ' · ' + c.c.label, printer: c.printer }; } /* Unknown, or an estimated figure nobody weighed: the weigh-in would rest on a guess. */ function unverified(s) { return s.tare == null || s.tsrc === 'estimated'; } function gross(s) { return s.tare == null ? null : s.tare + s.rem; } function value(s) { return s.rem / 1000 * s.kg; } function low(s) { return s.rem < LOW; } function sw(colors, cls) { return ''; } function meter(s) { var pct = Math.max(0, Math.min(100, s.rem / s.tot * 100)), tick = Math.min(100, LOW / s.tot * 100); return '
'; } /* provenance: quiet icon + words */ var TSRC = { spool: ['⚖', 'weighed bare', ''], manufacturer: ['◆', 'manufacturer', ''], community: ['◇', 'community', ''], estimated: ['≈', 'estimated', 'warn'] }; function tareProv(s) { if (s.tare == null) return '?empty spool weight unknown'; var t = TSRC[s.tsrc]; return '' + t[0] + '' + t[1] + ''; } function remProv(s) { return s.remSrc === 'weighed' ? '⚖weighed' + (s.weighedAt ? ' ' + esc(s.weighedAt) : '') + '' : '✎typed'; } var WSRC = { typed: ['✎', 'typed', '', 'A measurement someone entered. Survives an identity correction.'], sliced: ['▦', 'sliced', 'info', "Read from this run's 3MF over FTPS."], recalled: ['⎘', 'recalled', 'info', 'Copied from the weight remembered for this gcode path. No longer written.'], unknown: ['?', 'source unknown', 'warn', 'Written before the source was recorded. Neither correction path will move it.'] }; function weightProv(p) { if (!p.wsrc) return ''; var w = WSRC[p.wsrc]; return '' + w[0] + '' + w[1] + ''; } function durCell(p) { if (p.status === 'running') return '' + dur(since(p.start)) + 'so far'; if (p.status === 'unknown') return 'no durationend not seen'; if (p.gaps) return '≤ ' + dur(p.dur) + 'upper bound · ' + p.gaps + ' gap' + (p.gaps > 1 ? 's' : '') + ''; if (p.gaps == null) return '' + dur(p.dur) + 'gaps not counted'; return '' + dur(p.dur) + ''; } var PST = { running: ['run', 'Running'], finished: ['ok', 'Finished'], failed: ['err', 'Failed'], unknown: ['warn', 'Unknown'] }; function printSt(p) { var s = PST[p.status]; return '' + s[1] + ''; } function printerSt(pr) { if (!pr.connected) return '' + (pr.lifecycle === 'reconnecting' ? 'Reconnecting' : 'Offline') + ''; if (pr.state === 'RUNNING') return 'Printing'; if (pr.state === 'PAUSE') return 'Paused'; if (pr.state === 'FAILED') return 'Failed'; return 'Idle'; } /* what a print still needs; empty while running */ function needs(p) { if (p.status === 'running') return []; var n = []; if (p.grams == null) n.push({ k: 'weight', t: 'filament weight' }); if (p.filaments) p.filaments.forEach(function (f) { if (f.spool == null) n.push({ k: 'spool', t: 'spool for color ' + (f.slot + 1), f: f }); }); else if (p.spool == null) n.push({ k: 'spool', t: 'spool' }); return n; } function printsNeeding() { return PRINTS.filter(function (p) { return needs(p).length && !p.archived; }); } function spoolLinks(p) { if (p.filaments) return p.filaments.map(function (f) { var s = f.spool && spool(f.spool); return s ? '' + sw(s.c, 'swatch-sm') + ' ' + esc(short(s)) + '' : '!color ' + (f.slot + 1) + ' unattributed'; }).join('
'); var s = p.spool && spool(p.spool); return s ? '' + sw(s.c, 'swatch-sm') + ' ' + esc(short(s)) + '' : (p.status === 'running' ? '—' : '!no spool'); } /* ── shell ───────────────────────────────────────────────── */ var ICON = { dashboard: '', filament: '', prints: '', printers: '', lists: '', labels: '', access: '', system: '', more: '', search: '', sun: '' }; var MANAGE = [['lists', 'Lists', '05-lists.html', 0], ['labels', 'Labels', '06-labels.html', 0], ['access', 'Access', '07-access.html', 1], ['system', 'System', '08-system.html', 1]]; var PAGES = [['dashboard', 'Dashboard', '01-dashboard.html'], ['filament', 'Filament', '02-filament.html'], ['prints', 'Prints', '03-prints.html'], ['printers', 'Printers', '04-printers.html']]; function badges() { var need = printsNeeding().length, down = PRINTERS.filter(function (p) { return !p.connected; }).length; var unv = SPOOLS.filter(unverified), unk = unv.filter(function (s) { return s.tare == null; }).length; return { filament: unv.length ? '' + unv.length + '' : '', prints: need ? '' + need + '' : '', printers: down ? '' + down + '' : '' }; } function shell(page) { var b = badges(); var nav = PAGES.map(function (p) { return '' + ICON[p[0]] + '' + p[1] + '' + (b[p[0]] || '') + ''; }).join(''); var unl = SPOOLS.filter(function (s) { return !s.label; }).length; var rail = ''; var inManage = MANAGE.some(function (m) { return m[0] === page; }); var tabbar = '' + ''; var top = '
' + 'Live · updated 3s ago' + '
'; var mock = '
'; var drawer = '
'; var pal = ''; return { rail: rail, tabbar: tabbar, top: top, rest: mock + drawer + pal + '
' }; } /* ── theme ───────────────────────────────────────────────── */ function setTheme(v) { document.documentElement.setAttribute('data-theme', v); $$('[data-set-theme]').forEach(function (b) { b.setAttribute('aria-pressed', String(b.getAttribute('data-set-theme') === v)); }); try { localStorage.setItem('extrudex-v3-theme', v); } catch (e) {} } /* ── toasts ──────────────────────────────────────────────── */ function updateBadges() { var b = badges(); $$('.nav a, .tabbar a').forEach(function (a) { var key = { '02-filament.html': 'filament', '03-prints.html': 'prints', '04-printers.html': 'printers' }[a.getAttribute('href')]; if (!key) return; var old = a.querySelector('.count'); if (old) old.parentNode.removeChild(old); if (b[key]) a.querySelector('span').insertAdjacentHTML(a.closest('.tabbar') ? 'beforebegin' : 'afterend', b[key]); }); } function toast(html, kind) { setTimeout(updateBadges, 0); var t = document.createElement('div'); t.className = 'toast ' + (kind || ''); t.setAttribute('role', 'status'); t.innerHTML = '' + html + ''; $('#toasts').appendChild(t); var kill = function () { if (t.parentNode) t.parentNode.removeChild(t); }; t.querySelector('button').onclick = kill; setTimeout(kill, 6000); } /* ── drawer ──────────────────────────────────────────────── */ var D = { kind: null, id: null, tab: 'overview', state: 'normal', list: null, lastFocus: null, dirty: false }; function openDrawer(kind, id, tab, push) { D.kind = kind; D.id = id; D.tab = tab || 'overview'; D.state = 'normal'; D.dirty = false; if (!document.body.classList.contains('drawer-open')) D.lastFocus = document.activeElement; renderDrawer(); var el = $('#drawer'); el.hidden = false; requestAnimationFrame(function () { document.body.classList.add('drawer-open'); var f = el.querySelector('[autofocus]') || el.querySelector('h2'); if (f) { f.setAttribute('tabindex', f.tagName === 'H2' ? '-1' : f.getAttribute('tabindex')); f.focus(); } }); var h = '#' + kind + '=' + id + (D.tab !== 'overview' ? '&tab=' + D.tab : ''); if (push !== false && location.hash !== h) history.pushState({ d: 1 }, '', h); $$('[data-row-spool],[data-row-print]').forEach(function (r) { r.classList.toggle('is-selected', (kind === 'spool' && +r.getAttribute('data-row-spool') === id) || (kind === 'print' && +r.getAttribute('data-row-print') === id)); }); } function closeDrawer(push) { if (D.dirty && !confirmDiscard()) return; document.body.classList.remove('drawer-open'); D.kind = null; D.session = false; D.brand = null; setTimeout(function () { if (!D.kind) $('#drawer').hidden = true; }, 240); if (push !== false && location.hash) history.pushState({}, '', location.pathname + location.search); $$('.is-selected').forEach(function (r) { r.classList.remove('is-selected'); }); if (D.lastFocus && D.lastFocus.focus) D.lastFocus.focus(); } function confirmDiscard() { var bar = $('#discard-bar'); if (bar) { bar.hidden = false; bar.querySelector('button').focus(); return false; } return true; } function renderDrawer() { var el = $('#drawer'); if (D.kind === 'spool') el.innerHTML = spoolDrawer(spool(D.id)); else if (D.kind === 'new') el.innerHTML = editDrawer(null); else if (D.kind === 'print') el.innerHTML = printDrawer(print(D.id)); else if (D.kind === 'load') el.innerHTML = loadDrawer(D.id); else if (D.kind === 'pedit') el.innerHTML = peditDrawer(D.id); else if (D.kind === 'holders') el.innerHTML = holdersDrawer(D.id); else if (D.kind === 'premove') el.innerHTML = premoveDrawer(D.id); if (window.PAGE && PAGE.onDrawer) PAGE.onDrawer(); } function head(icon, title, sub, extra) { var list = D.list || [], i = list.indexOf(D.id); var nav = (D.kind === 'spool' && i >= 0 && list.length > 1) ? '
' : ''; return '
' + icon + '

' + title + '

' + sub + '' + (extra || '') + '
' + nav + '
'; } function tabs(list) { return '
' + list.map(function (t) { return ''; }).join('') + '
'; } function spoolDrawer(s) { var w = where(s); var h = head(sw(s.c, '" style="width:44px;height:44px'), esc(s.color) + ' #' + s.id + '', esc(fullName(s)) + ' · ' + esc(w.text)); var t = tabs([['overview', 'Overview'], ['weigh', 'Weigh'], ['move', w.loaded ? 'Move' : 'Load / move'], ['edit', 'Edit']]); var body = '', foot = ''; if (D.tab === 'overview') { var used = PRINTS.filter(function (p) { return p.spool === s.id || (p.filaments || []).some(function (f) { return f.spool === s.id; }); }); var gr = gross(s); body = '
' + Math.round(s.rem).toLocaleString('en-US') + ' gof ' + g(s.tot) + ' net' + remProv(s) + '
' + '
' + meter(s) + '
' + (low(s) ? '

⚠ Below the 200 g line. Reorder or finish it on a small job.

' : '') + '
' + '

On the scale

' + (gr == null ? '

Gross is unknown because no empty spool weight is known. It is never treated as 0.

' : '
Empty spool
' + g(s.tare) + ' ' + tareProv(s) + '
+ Filament (net)
' + g(s.rem) + '
= Gross, on a scale
' + g(gr) + '
') + '
' + '
Location
' + (w.loaded ? '' + esc(w.text) + '' : esc(w.text)) + ' ·
' + '
Value left
' + usd(value(s)) + ' at ' + usd(s.kg) + '/kg
' + '
Spool type
' + (s.stype ? esc(s.stype) : 'none chosen') + '
' + '
Label
' + (s.label ? 'printed' : '!not printed ') + '
' + '
Record
version ' + s.v + '
' + '

Prints from this spool · ' + used.length + '

' + (used.length ? '
' + used.map(function (p) { var f = (p.filaments || []).filter(function (f) { return f.spool === s.id; })[0]; return '' + printSt(p) + '' + esc(p.job) + '' + g(f ? f.grams : p.grams) + '' + when(p.start) + ''; }).join('') + '
' : '

No prints drew from this spool yet.

') + '
' + '

⚠API gap Location history is stored in spool_location_event but there is no endpoint to read it.

'; foot = ''; } if (D.tab === 'weigh') { if (s.tare == null) { body = '' + '' + '

or, more accurate
' + ''; foot = ''; } else { var wl = where(s), li = D.list ? D.list.indexOf(s.id) : -1; body = (D.session && li >= 0 ? '
Weigh sessionspool ' + (li + 1) + ' of ' + D.list.length + ' · lightest first
' : '') + '
Find it' + esc(wl.text) + '' + sw(s.c, 'swatch-sm') + '' + esc(s.color) + ' · #' + s.id + '
' + '
' + '

Result

Now' + g(s.rem) + '→—

Type the reading and the result appears here before you save.

'; foot = '' + (D.list && D.list.indexOf(s.id) < D.list.length - 1 ? '' : ''); } } if (D.tab === 'move') { var dests = PRINTERS.map(function (pr) { return '
' + esc(pr.name) + '' + (pr.connected ? '' : '⚡disconnected (loading still recorded)') + '
' + pr.containers.map(function (c) { var n = contents(c.id).length, full = n >= c.cap && s.cont !== c.id, here = s.cont === c.id; return ''; }).join('') + '
'; }).join(''); body = '

Where is the spool going? Full holders are disabled. Extrudex records which holder a spool is in, not its tray position.

' + '
Storage
' + '
' + dests; foot = ''; } if (D.tab === 'edit') { return editDrawer(s); } return h + t + '
' + body + '
' + foot + '
'; } function editDrawer(s) { var isNew = !s; s = s || { id: null, c: ['#888888'], color: '', brand: 'Sunlu', speed: null, mat: 'PLA', type: null, fin: 'Basic', rem: 1000, tot: 1000, tare: 140, tsrc: 'community', stype: 'Sunlu — 1 kg plastic', kg: 0, loc: '', v: null }; var st = D.state; var h = isNew ? head('', 'Add spool', 'New spools carry no version') : head(sw(s.c, '" style="width:44px;height:44px'), 'Edit ' + esc(s.color) + ' #' + s.id + '', 'Opened 09:32 at version ' + (st === 'conflict' ? s.v - 1 : s.v)); var t = isNew ? '' : tabs([['overview', 'Overview'], ['weigh', 'Weigh'], ['move', where(s).loaded ? 'Move' : 'Load / move'], ['edit', 'Edit']]); function sel(label, val, req, opts) { return ''; } var demo = '
Demo state
' + [['normal', 'Normal'], ['errors', '400 errors'], ['conflict', '409 conflict']].map(function (x) { return ''; }).join('') + '
'; var conflict = st === 'conflict' ? '' : ''; var errsum = st === 'errors' ? '
⚠ 2 fields need fixing. Nothing was saved.
' : ''; var name = '
Reads as
' + esc(fullName(s)) + '
brand · speed · material · type · finish (italic = optional)
'; var body = demo + conflict + errsum + name + '
' + sel('Brand', s.brand, 1) + sel('Print speed', s.speed, 0, '') + sel('Material', s.mat, 1) + sel('Filament type', s.type, 0, '') + sel('Finish', s.fin, 1) + '
' + '
' + (st === 'errors' ? '

Give the color a name, even "Unknown". server: color_name is required

' : '') + '
' + '
' + '
' + '
' + (st === 'errors' ? '

1,040 g is more than this spool holds (' + g(s.tot) + '). server: remaining_grams cannot exceed total_grams

' : 'To measure it, use the Weigh tab instead.') + '
' + '
' + '
' + '
' + ''; var foot = (st === 'conflict' ? 'Save is off until you choose a version.' : '') + ''; return h + t + '
' + body + '
' + foot + '
'; } function printDrawer(p) { var pr = printer(p.printer), n = needs(p); var h = head('' + ICON.prints + '', esc(p.job), printSt(p) + ' · ' + esc(pr.name) + ' · print #' + p.id); var fix = ''; if (n.length) { fix = '

Needs ' + n.map(function (x) { return x.t; }).join(' + ') + '

' + fixers(p) + '
'; } var body = fix + '
Printer
' + esc(pr.name) + '
Started
' + when(p.start) + '
Ended
' + (p.end ? when(p.end) : p.status === 'running' ? 'still running' : '—') + '
' + '
Duration
' + durCell(p) + '
' + '
Job key
' + esc(p.key) + 'The identity. Two jobs can share a name, never a path.
' + '
Filament
' + (p.grams == null ? 'not known yet' : '' + g(p.grams) + ' ' + weightProv(p) + (p.status === 'failed' ? 'Failed prints record the full job weight. Correct by hand if needed.' : '')) + '
' + '
Spool
' + spoolLinks(p) + changeSpool(p) + '
Cost
' + (p.cost == null ? 'pending: needs weight and spool' : usd(p.cost) + (p.filaments && p.filaments.some(function (f) { return f.spool == null; }) ? ' so far' : '')) + '
' + '
Use
' + (p.use == null ? 'Not recorded' : '') + '
' + (p.filaments ? '

Colors

' + p.filaments.map(function (f) { var s = f.spool && spool(f.spool); return ''; }).join('') + '
#ColorSpoolGramsCost
' + (f.slot + 1) + ' ' + f.hex + '' + (s ? '' + esc(short(s)) + '' : '!unattributed') + '' + g(f.grams) + '' + usd(f.cost) + '
' : ''); var foot = p.status === 'running' ? 'Name and weight are captured when the print ends.' : ''; return h + '
' + body + '
' + foot + '
'; } function changeSpool(p) { var attributed = p.filaments ? p.filaments.filter(function (f) { return f.spool; }) : (p.spool ? [{ spool: p.spool, grams: p.grams, slot: null }] : []); if (!attributed.length || p.status === 'running') return ''; if (p.unreconciled) return ''; return attributed.map(function (f) { var from = spool(f.spool); return '
Change spool' + (f.slot != null ? ' for color ' + (f.slot + 1) : '') + '
' + '' + (f.grams != null ? '' + g(f.grams) + ' goes back to ' + esc(short(from)) + ' #' + from.id + ' and comes off the spool you pick. Cost is re-rated at that spool\'s $/kg.' : 'No weight yet, so nothing moves until one is entered.') + '' + '
'; }).join(''); } function fixers(p) { var out = ''; if (p.grams == null) out += ''; var slots = p.filaments ? p.filaments.filter(function (f) { return f.spool == null; }) : (p.spool == null ? [{ suggested: p.suggested, slot: null }] : []); slots.forEach(function (f) { var sug = f.suggested && spool(f.suggested); out += '
Spool' + (f.slot != null ? ' for color ' + (f.slot + 1) + ' ' : '') + '' + (sug ? '
' + sw(sug.c, 'swatch-sm') + '' + esc(short(sug)) + 'Suggested: same color, loaded in ' + esc(where(sug).short) + '. Not charged until you confirm.
' : '') + '
'; }); return out; } var MODELS = { bambu: ['A1', 'A1 mini', 'P1P', 'P1S', 'X1 Carbon', 'X1E', 'H2D', 'H2C', 'H2S'], elegoo: ['Centauri', 'Centauri Carbon', 'OrangeStorm Giga'] }; function peditDrawer(id) { var pr = id === 'new' ? null : printer(+id), st = D.state; var h = head('' + ICON.printers + '', pr ? 'Edit ' + esc(pr.name) : 'Add printer', pr ? 'Connection details · admin' : 'Extrudex connects read-only; nothing is sent to the printer'); var brand = pr ? 'bambu' : (D.brand || 'bambu'); function f(label, hint, input, err) { return '
' + input + (err ? '

' + err + '

' : '') + '
'; } var body = '
Demo state
' + [['normal', 'Normal'], ['errors', '400 errors']].map(function (x) { return ''; }).join('') + '
' + '
' + f('Brand', '', '') + f('Model', '', '') + '
' + f('Name', 'shown everywhere; must be unique', '', st === 'errors' && !pr ? 'A printer called “P1S - Prints Charming” already exists. Names must be unique.' : '') + '
' + f('IP address', 'same network as Extrudex', '', st === 'errors' ? '10.60.1.300 isn\'t a valid IPv4 address.' : '') + f('Serial', 'on the printer\'s label', '') + '
' + (brand === 'bambu' ? f('Access code', pr ? 'leave blank to keep the stored code' : 'from the printer screen: Settings → LAN', '') + '

Stored, never shown again. Read-only MQTT needs only IP, serial and access code, no Developer Mode.

' : '

Elegoo Centauri uses unauthenticated SDCP, so there is no access code.

') + (pr ? '' : '
After you add it
'); return h + '
' + body + '
'; } function holdersDrawer(id) { var pr = printer(+id); var h = head('' + ICON.printers + '', 'Holders · ' + esc(pr.model), esc(pr.name) + ' · where filament can be loaded'); var body = pr.containers.map(function (c) { var n = contents(c.id).length, ext = c.kind === 'external'; return '
' + esc(c.label) + '' + { ams: 'AMS', ams_lite: 'AMS Lite', external: 'External' }[c.kind] + ' · holds ' + c.cap + ' · #' + HOLDER_IDS[c.id] + '' + n + ' loaded
' + '
' + (ext ? 'Every printer keeps exactly one external holder.' : n ? 'Unload its ' + n + ' spool' + (n > 1 ? 's' : '') + ' first.' : '') + '
'; }).join('') + '

Add an AMS unit

' + '

Capacity comes from the type, so a holder can\'t disagree with its hardware. Tray positions aren\'t recorded.

'; return h + '
' + body + '
'; } function premoveDrawer(id) { var pr = printer(+id), loaded = pr.containers.reduce(function (a, c) { return a + contents(c.id).length; }, 0), open = pr.openPrint && print(pr.openPrint); var h = head('!', 'Remove ' + esc(pr.name) + '?', esc(pr.model) + ' · ' + esc(pr.ip)); var body = '' + (loaded ? '' : '') + ''; return h + '
' + body + '
'; } function loadDrawer(cid) { var c = container(cid), inIt = contents(cid); var h = head('' + ICON.printers + '', 'Load into ' + esc(c.c.label), esc(c.printer.name) + ' · ' + inIt.length + ' of ' + c.c.cap + ' in use'); var cands = SPOOLS.filter(function (s) { return !s.cont; }); var body = '
' + cands.map(function (s) { return ''; }).join('') + '
' + '

Scanning the holder\'s QR label does the same thing from a phone (existing /c/:id scan session).

'; return h + '
' + body + '
'; } /* ── palette ─────────────────────────────────────────────── */ var P = { items: [], sel: 0 }; function palItems(q) { q = q.replace(/^\s+/, '').toLowerCase(); if (!/^(load|attribute)\s/.test(q)) q = q.trim(); else q = q.replace(/\s+$/, function (m) { return /\s(into|to)\s+$/.test(q) ? ' ' : ''; }); var acts = [ { sec: 'Actions', ico: '+', t: 'Add spool', m: 'new', run: function () { openDrawer('new', 0, 'edit'); } }, { sec: 'Actions', ico: '⚖', t: 'Weigh a spool…', m: 'type its name', run: function () { $('#pal-q').value = 'weigh '; palRender(); }, keep: 1 }, { sec: 'Actions', ico: '⇥', t: 'Load a spool into a holder…', m: 'load ‹spool› into ‹holder›', run: function () { $('#pal-q').value = 'load '; palRender(); }, keep: 1 }, { sec: 'Actions', ico: '⇄', t: 'Attribute a print to a spool…', m: 'attribute ‹print› to ‹spool›', run: function () { $('#pal-q').value = 'attribute '; palRender(); }, keep: 1 }, { sec: 'Actions', ico: '!', t: 'Prints needing attention', m: printsNeeding().length + ' open', href: '03-prints.html?tab=needs' }, { sec: 'Actions', ico: '◐', t: 'Switch color theme', m: 'light / dark', run: function () { setTheme(document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark'); } } ].concat(PAGES.map(function (p) { return { sec: 'Go to', ico: '→', t: p[1], m: 'page', href: p[2] }; })); var lm = /^load(?:\s|$)/.test(q) && /^load\s*(.*?)(?:\s+into\b\s*(.*))?$/.exec(q), am = /^attribute(?:\s|$)/.test(q) && /^attribute\s*(.*?)(?:\s+to\b\s*(.*))?$/.exec(q); if (lm) return loadCmd(lm[1] || '', lm[2]); if (am) return attrCmd(am[1] || '', am[2]); var weigh = /^weigh\s*/.test(q); if (weigh) q = q.replace(/^weigh\s*/, ''); var sp = SPOOLS.map(function (s) { return { sec: weigh ? 'Weigh which spool?' : 'Spools', html: sw(s.c, 'swatch-sm'), t: s.color + ' · ' + fullName(s), m: '#' + s.id + ' · ' + g(s.rem) + ' · ' + where(s).short, hay: ('#' + s.id + ' ' + s.id + ' ' + fullName(s) + ' ' + s.color + ' ' + where(s).text).toLowerCase(), run: function () { go('spool', s.id, weigh ? 'weigh' : 'overview'); } }; }); var pr = PRINTS.map(function (p) { return { sec: 'Prints', ico: '▤', t: p.job, m: PST[p.status][1] + ' · ' + when(p.start), hay: (p.job + ' ' + p.key + ' ' + printer(p.printer).name).toLowerCase(), run: function () { go('print', p.id); } }; }); var pt = PRINTERS.map(function (p) { return { sec: 'Printers', ico: '▣', t: p.name, m: p.connected ? (p.state === 'RUNNING' ? 'printing' : 'idle') : 'reconnecting', hay: p.name.toLowerCase(), href: '04-printers.html#printer=' + p.id }; }); if (weigh) return sp.filter(function (i) { return !q || i.hay.indexOf(q) >= 0; }).slice(0, 12); if (!q) return acts.concat(sp.filter(function (i, k) { return k < 3; }).map(function (i) { i.sec = 'Recent spools'; return i; })); var match = function (i) { return (i.hay || i.t.toLowerCase()).indexOf(q) >= 0; }; return acts.filter(match).concat(sp.filter(match).slice(0, 8), pr.filter(match).slice(0, 5), pt.filter(match)); } function holders() { var out = []; PRINTERS.forEach(function (pr) { pr.containers.forEach(function (c) { out.push({ id: c.id, pr: pr, c: c, n: contents(c.id).length, hay: (pr.name + ' ' + pr.model + ' ' + c.label + ' ' + c.kind).toLowerCase() }); }); }); return out; } function spoolHay(s) { return ('#' + s.id + ' ' + s.id + ' ' + fullName(s) + ' ' + s.color + ' ' + where(s).text).toLowerCase(); } function loadCmd(sq, hq) { var sp = SPOOLS.filter(function (s) { return !sq || spoolHay(s).indexOf(sq) >= 0; }).slice(0, 6); if (hq == null) return sp.map(function (s) { return { sec: 'Load which spool? (then type “ into …”)', html: sw(s.c, 'swatch-sm'), t: s.color + ' · ' + fullName(s), m: '#' + s.id + ' · ' + where(s).short, keep: 1, run: function () { $('#pal-q').value = 'load #' + s.id + ' into '; palRender(); } }; }); var s1 = sp[0]; if (!s1) return []; return holders().filter(function (h) { return !hq || h.hay.indexOf(hq) >= 0; }).map(function (h) { var full = h.n >= h.c.cap && s1.cont !== h.id; return { sec: 'Load ' + s1.color + ' · ' + s1.brand + ' (#' + s1.id + ') into…', ico: full ? '✕' : '⇥', t: h.pr.model + ' · ' + h.c.label, m: full ? 'full · ' + h.n + '/' + h.c.cap : h.n + '/' + h.c.cap + ' in use', run: function () { if (full) return toast(h.c.label + ' on ' + h.pr.model + ' is full. Unload a spool first.', 'warn'); s1.cont = h.id; s1.v++; toast('Loaded ' + esc(s1.color + ' · ' + s1.brand) + ' into ' + esc(h.pr.model + ' · ' + h.c.label)); if (PAGE.refresh) PAGE.refresh(); } }; }); } function attrCmd(pq, sq) { var pr = printsNeeding().filter(function (p) { return !pq || (p.job + ' ' + p.key).toLowerCase().indexOf(pq) >= 0; }); if (sq == null) return pr.map(function (p) { return { sec: 'Attribute which print? (then type “ to …”)', ico: '▤', t: p.job, m: needs(p).map(function (n) { return n.t; }).join(', '), keep: 1, run: function () { $('#pal-q').value = 'attribute ' + p.job.toLowerCase() + ' to '; palRender(); } }; }); var p1 = pr[0]; if (!p1) return []; return SPOOLS.filter(function (s) { return !sq || spoolHay(s).indexOf(sq) >= 0; }).slice(0, 8).map(function (s) { return { sec: 'Attribute ' + p1.job + ' to…', html: sw(s.c, 'swatch-sm'), t: s.color + ' · ' + fullName(s), m: '#' + s.id + ' · ' + g(s.rem), run: function () { if (p1.filaments) p1.filaments.forEach(function (f) { if (f.spool == null) f.spool = s.id; }); else p1.spool = s.id; toast('Attributed ' + esc(p1.job) + ' to ' + esc(short(s)) + (p1.grams != null ? ' · ' + g(p1.grams) + ' deducted' : '')); if (PAGE.refresh) PAGE.refresh(); } }; }); } function go(kind, id, tab) { var pageFor = { spool: null, print: null }; if (window.PAGE && PAGE.name) openDrawer(kind, id, tab); } function palRender() { P.items = palItems($('#pal-q').value); if (P.sel >= P.items.length) P.sel = 0; var last = '', html = ''; P.items.forEach(function (it, i) { if (it.sec !== last) { html += '
' + esc(it.sec) + '
'; last = it.sec; } html += '
' + (it.html || '') + '' + esc(it.t) + '' + esc(it.m || '') + '
'; }); $('#pal-list').innerHTML = html || '

No matches

Try a color, brand, #47, a job name or a printer.

'; $('#pal-q').setAttribute('aria-activedescendant', P.items.length ? 'pi' + P.sel : ''); var cur = $('#pi' + P.sel); if (cur) cur.scrollIntoView({ block: 'nearest' }); } function palOpen(prefill) { $('#pal').classList.add('is-open'); $('#pal-q').value = prefill || ''; P.sel = 0; palRender(); $('#pal-q').focus(); } function palClose() { $('#pal').classList.remove('is-open'); } function palRun(i) { var it = P.items[i]; if (!it) return; if (!it.keep) palClose(); if (it.href) location.href = it.href; else it.run(); } /* ── freshness + demos ───────────────────────────────────── */ var fresh = { n: 3, stale: false }; function tick() { fresh.n = fresh.stale ? fresh.n + 1 : (fresh.n >= 10 ? 0 : fresh.n + 1); var f = $('#fresh'); if (!f) return; f.classList.toggle('is-stale', fresh.stale); $('#fresh-t').innerHTML = fresh.stale ? 'Not updating · data from 09:41:07 · retrying' : 'Live · updated ' + fresh.n + 's ago'; } function demo(kind) { var main = $('#page'); if (kind === 'loading') { var keep = main.innerHTML; main.innerHTML = '
' + [1, 2, 3, 4].map(function () { return '
'; }).join('') + '
' + [1, 2, 3, 4, 5].map(function () { return '
'; }).join('') + '
'; setTimeout(function () { main.innerHTML = keep; if (PAGE.after) PAGE.after(); }, 1600); } if (kind === 'offline') { fresh.stale = true; fresh.n = 0; tick(); var b = $('#offline-banner'); if (!b) { b = document.createElement('div'); b.id = 'offline-banner'; b.className = 'banner'; b.setAttribute('role', 'alert'); b.innerHTML = '⚠Can\'t reach Extrudex. You are looking at data from 09:41:07, and it is not updating. Retrying every 10 s. Saving is paused.'; main.parentNode.insertBefore(b, main); } } if (kind === 'online') { fresh.stale = false; fresh.n = 0; tick(); var ob = $('#offline-banner'); if (ob) ob.parentNode.removeChild(ob); } } /* ── wiring ──────────────────────────────────────────────── */ function parseHash() { var m = /^#(spool|print|load|pedit|holders|premove)=([\w-]+)(?:&tab=(\w+))?(?:&state=(\w+))?/.exec(location.hash); if (m) { var id = (m[1] === 'load' || m[2] === 'new') ? m[2] : +m[2]; openDrawer(m[1], id, m[3], false); if (m[4]) { D.state = m[4]; renderDrawer(); } } else if (D.kind) closeDrawer(false); } function init(page, render) { var s = shell(page.name); document.body.insertAdjacentHTML('afterbegin', 'Skip to content
' + s.rail + '
' + s.top + '
' + s.tabbar + s.rest); window.PAGE = page; $('#page').innerHTML = render(); if (page.after) page.after(); setTheme(document.documentElement.getAttribute('data-theme')); setInterval(tick, 1000); parseHash(); var pq = /[?&]palette=([^&]*)/.exec(location.search); if (pq) palOpen(decodeURIComponent(pq[1]).replace(/\+/g, ' ')); } document.addEventListener('click', function (e) { var t = e.target, a; if ((a = t.closest('[data-set-theme]'))) return setTheme(a.getAttribute('data-set-theme')); if (t.closest('[data-toggle-theme]')) return setTheme(document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark'); if (t.closest('[data-palette]')) return palOpen(); if (t.id === 'pal') return palClose(); if ((a = t.closest('.pal-item'))) return palRun(+a.getAttribute('data-i')); if ((a = t.closest('[data-menu]'))) { e.preventDefault(); var m = $('#' + a.getAttribute('data-menu')), open = !m.classList.contains('is-open'); $$('.menu.is-open').forEach(function (x) { x.classList.remove('is-open'); }); m.classList.toggle('is-open', open); a.setAttribute('aria-expanded', String(open)); if (open) { var f = m.querySelector('button,a'); if (f) f.focus(); } e.stopPropagation(); return; } if (!t.closest('.menu')) $$('.menu.is-open').forEach(function (x) { x.classList.remove('is-open'); }); if ((a = t.closest('[data-demo]'))) { $$('.menu.is-open').forEach(function (x) { x.classList.remove('is-open'); }); return demo(a.getAttribute('data-demo')); } if ((a = t.closest('[data-soon]'))) { e.preventDefault(); return toast('' + a.getAttribute('data-soon') + ' keeps its current screen on dev. It isn\'t redesigned in this iteration.'); } if ((a = t.closest('[data-spool]'))) { e.preventDefault(); D.list = null; return openDrawer('spool', +a.getAttribute('data-spool'), a.getAttribute('data-tab-open') || 'overview'); } if ((a = t.closest('[data-print]'))) { e.preventDefault(); return openDrawer('print', +a.getAttribute('data-print')); } if ((a = t.closest('[data-load-into]'))) { e.preventDefault(); return openDrawer('load', a.getAttribute('data-load-into')); } if ((a = t.closest('[data-pedit]'))) { e.preventDefault(); var pv = a.getAttribute('data-pedit'); return openDrawer('pedit', pv === 'new' ? 'new' : +pv); } if ((a = t.closest('[data-holders]'))) { e.preventDefault(); return openDrawer('holders', +a.getAttribute('data-holders')); } if ((a = t.closest('[data-premove]'))) { e.preventDefault(); return openDrawer('premove', +a.getAttribute('data-premove')); } if (t.id === 'rm-go') { closeDrawer(); return toast('Removed ' + esc(t.getAttribute('data-name')) + '. Its print history is kept.'); } if ((a = t.closest('[data-add-spool]'))) { e.preventDefault(); return openDrawer('new', 0, 'edit'); } if (t.closest('[data-close-drawer]')) return closeDrawer(); if ((a = t.closest('#drawer [data-tab]'))) { D.tab = a.getAttribute('data-tab'); D.state = 'normal'; renderDrawer(); history.replaceState({ d: 1 }, '', '#' + D.kind + '=' + D.id + (D.tab !== 'overview' ? '&tab=' + D.tab : '')); var f = $('#drawer [autofocus]'); if (f) f.focus(); return; } if ((a = t.closest('[data-state]'))) { D.state = a.getAttribute('data-state'); return renderDrawer(); } if ((a = t.closest('[data-step]'))) { var i = D.list.indexOf(D.id) + +a.getAttribute('data-step'); if (D.list[i] != null) openDrawer('spool', D.list[i], D.tab, false); history.replaceState({ d: 1 }, '', '#spool=' + D.id + (D.tab !== 'overview' ? '&tab=' + D.tab : '')); return; } if ((a = t.closest('[data-dest]'))) { $$('[data-dest]').forEach(function (b) { b.setAttribute('aria-pressed', String(b === a)); }); $('#m-save').disabled = false; var dc = a.getAttribute('data-dest'), cc = dc !== 'storage' && container(dc); $('#m-save').textContent = cc ? 'Load into ' + cc.printer.model + ' · ' + cc.c.label : 'Move to storage'; return; } if ((a = t.closest('[data-load]'))) { $$('[data-load]').forEach(function (b) { b.setAttribute('aria-pressed', String(b === a)); }); var ls = spool(+a.getAttribute('data-load')); $('#l-save').disabled = false; $('#l-save').textContent = 'Load ' + ls.color + ' · ' + ls.brand; $('#l-save').setAttribute('data-sid', ls.id); return; } if (t.id === 'l-save') { var sid = +t.getAttribute('data-sid'), ss = spool(sid), cc2 = container(D.id); ss.cont = D.id; closeDrawer(); toast('Loaded ' + esc(ss.color + ' · ' + ss.brand) + ' into ' + esc(cc2.printer.model + ' · ' + cc2.c.label) + ' View'); if (PAGE.refresh) PAGE.refresh(); return; } if (t.id === 'm-save') { var dest = $('[data-dest][aria-pressed="true"]').getAttribute('data-dest'), sp = spool(D.id); sp.cont = dest === 'storage' ? null : dest; sp.v++; D.tab = 'overview'; renderDrawer(); toast('Moved ' + esc(sp.color) + ' to ' + esc(where(sp).text)); if (PAGE.refresh) PAGE.refresh(); return; } if (t.id === 'w-save' || t.id === 'w-next') { var ws = spool(D.id), before = ws.rem, nv = Math.round(+$('#scale').value.replace(/[^\d.]/g, '') - ws.tare); ws.rem = nv; ws.remSrc = 'weighed'; ws.weighedAt = '18 Sep 09:41'; ws.v++; toast('Weighed ' + esc(ws.color + ' · ' + ws.brand) + ': ' + g(before) + ' → ' + g(nv)); if (PAGE.refresh) PAGE.refresh(); if (t.id === 'w-next') { var nx = D.list[D.list.indexOf(D.id) + 1]; openDrawer('spool', nx, 'weigh', false); history.replaceState({ d: 1 }, '', '#spool=' + nx + '&tab=weigh'); } else { D.tab = 'overview'; renderDrawer(); } return; } if (t.closest('[data-save]')) { var sv = D.kind === 'new'; closeDrawer(); D.dirty = false; return toast(sv ? 'Added spool #57. Print its label' : 'Saved changes'); } if (t.closest('[data-discard]')) { D.dirty = false; return closeDrawer(); } if (t.closest('[data-keep]')) { $('#discard-bar').hidden = true; return; } if ((a = t.closest('[data-fake]'))) { toast(a.getAttribute('data-fake')); return; } if ((a = t.closest('[data-delete]'))) { var ds = spool(+a.getAttribute('data-delete')); $('#drawer .drawer-foot').innerHTML = '
Delete ' + esc(ds.color + ' · ' + fullName(ds)) + '?Its print history stays. Prints keep their grams and cost and only lose the link to this spool. This can\'t be undone.
'; $('#drawer .drawer-foot [autofocus]').focus(); return; } }); document.addEventListener('input', function (e) { if (e.target.id === 'pal-q') { P.sel = 0; return palRender(); } if (e.target.closest('#drawer .drawer-body') && D.tab === 'edit') D.dirty = true; if (e.target.id === 'scale') { var s = spool(D.id), v = +e.target.value.replace(/[^\d.]/g, ''), r = Math.round(v - s.tare); var ok = v > 0 && v >= s.tare && r <= s.tot + 1; $('#w-after').textContent = v ? g(r) : '—'; $('#w-diff').innerHTML = v && ok ? '' + (r - s.rem > 0 ? '+' : '') + (r - s.rem).toLocaleString('en-US') + ' g' : ''; $('#w-note').innerHTML = !v ? 'Type the reading and the result appears here before you save.' : v < s.tare ? '⚠ ' + g(v) + ' is less than the ' + g(s.tare) + ' empty spool. One of them is wrong. Nothing will be saved.' : r > s.tot + 1 ? '⚠ That leaves ' + g(r) + ', more than the spool holds full (' + g(s.tot) + '). Check the total or the empty spool weight.' : 'Saved with the empty spool weight it used, so correcting that weight later re-bases this reading.'; $('#w-save').disabled = !ok; if ($('#w-next')) $('#w-next').disabled = !ok; } if (e.target.id === 'rm-name') { $('#rm-go').disabled = e.target.value !== $('#rm-go').getAttribute('data-name'); } if (e.target.id === 'load-q') { var q = e.target.value.toLowerCase(); $$('[data-load]').forEach(function (b) { b.hidden = q && b.getAttribute('data-hay').indexOf(q) < 0; }); } }); document.addEventListener('change', function (e) { if (e.target.id === 'pe-brand') { D.brand = e.target.value; renderDrawer(); } }); document.addEventListener('keydown', function (e) { var typing = /INPUT|SELECT|TEXTAREA/.test(document.activeElement.tagName); if ((e.key === 'k' && (e.metaKey || e.ctrlKey)) || (e.key === '/' && !typing)) { e.preventDefault(); return palOpen(); } if ($('#pal').classList.contains('is-open')) { if (e.key === 'Escape') { e.preventDefault(); return palClose(); } if (e.key === 'ArrowDown') { e.preventDefault(); P.sel = Math.min(P.items.length - 1, P.sel + 1); return palRender(); } if (e.key === 'ArrowUp') { e.preventDefault(); P.sel = Math.max(0, P.sel - 1); return palRender(); } if (e.key === 'Enter') { e.preventDefault(); return palRun(P.sel); } return; } if (e.key === 'Escape') { if ($('.menu.is-open')) { $$('.menu.is-open').forEach(function (x) { x.classList.remove('is-open'); }); return; } if (D.kind) return closeDrawer(); } if (D.kind === 'spool' && !typing && D.list && (e.key === 'j' || e.key === 'k')) { var b = $('[data-step="' + (e.key === 'j' ? 1 : -1) + '"]'); if (b && !b.disabled) b.click(); } if (e.key === 'Enter' && document.activeElement.matches('tr[data-row-spool],tr[data-row-print]')) document.activeElement.click(); if (D.kind && e.key === 'Tab') { var dr = $('#drawer'), f = $$('button:not([disabled]),a[href],input,select,[tabindex="0"]', dr).filter(function (x) { return x.offsetParent; }); if (!f.length) return; if (e.shiftKey && document.activeElement === f[0]) { e.preventDefault(); f[f.length - 1].focus(); } else if (!e.shiftKey && document.activeElement === f[f.length - 1]) { e.preventDefault(); f[0].focus(); } } }); window.addEventListener('popstate', parseHash); window.X = { unverified: unverified, holderIds: HOLDER_IDS, init: init, esc: esc, g: g, kg: kg, usd: usd, dur: dur, when: when, ago: ago, since: since, spool: spool, printer: printer, print: print, container: container, contents: contents, fullName: fullName, short: short, where: where, gross: gross, value: value, low: low, sw: sw, meter: meter, tareProv: tareProv, remProv: remProv, weightProv: weightProv, durCell: durCell, printSt: printSt, printerSt: printerSt, needs: needs, printsNeeding: printsNeeding, spoolLinks: spoolLinks, openDrawer: openDrawer, D: D, toast: toast, ICON: ICON }; })();