554 lines
75 KiB
JavaScript
554 lines
75 KiB
JavaScript
/* 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 '<span class="swatch ' + (cls || '') + '" aria-hidden="true">' + colors.map(function (h) { return '<i style="background:' + h + '"></i>'; }).join('') + '</span>'; }
|
|||
|
|
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 '<div class="meter' + (low(s) ? ' is-low' : '') + '" role="meter" aria-valuemin="0" aria-valuemax="' + s.tot + '" aria-valuenow="' + s.rem + '" aria-label="' + esc(s.color) + ' remaining"><span style="width:' + pct + '%"></span><i class="tick" style="left:' + tick + '%" title="200 g low-stock line"></i></div>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* provenance: quiet icon + words */
|
|||
|
|
var TSRC = { spool: ['⚖', 'weighed bare', ''], manufacturer: ['◆', 'manufacturer', ''], community: ['◇', 'community', ''], estimated: ['≈', 'estimated', 'warn'] };
|
|||
|
|
function tareProv(s) {
|
|||
|
|
if (s.tare == null) return '<span class="prov warn"><i>?</i>empty spool weight unknown</span>';
|
|||
|
|
var t = TSRC[s.tsrc]; return '<span class="prov ' + t[2] + '" title="' + esc(s.tsrc === 'spool' ? 'This reel was weighed empty' : 'From spool type: ' + s.stype) + '"><i>' + t[0] + '</i>' + t[1] + '</span>';
|
|||
|
|
}
|
|||
|
|
function remProv(s) { return s.remSrc === 'weighed' ? '<span class="prov"><i>⚖</i>weighed' + (s.weighedAt ? ' ' + esc(s.weighedAt) : '') + '</span>' : '<span class="prov"><i>✎</i>typed</span>'; }
|
|||
|
|
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 '<span class="prov ' + w[2] + '" title="' + esc(w[3]) + '"><i>' + w[0] + '</i>' + w[1] + '</span>'; }
|
|||
|
|
function durCell(p) {
|
|||
|
|
if (p.status === 'running') return '<span class="num">' + dur(since(p.start)) + '</span><span class="sub">so far</span>';
|
|||
|
|
if (p.status === 'unknown') return '<span class="muted" title="Nobody saw this print end. The only measurable figure is the outage, which is not how long anything printed.">no duration</span><span class="sub">end not seen</span>';
|
|||
|
|
if (p.gaps) return '<span class="num strong" style="color:var(--status-warning)" title="The connection dropped ' + (p.gaps === 1 ? 'once' : p.gaps + ' times') + ' during this print. That time is inside the figure and nobody watched it, so it is an upper bound.">≤ ' + dur(p.dur) + '</span><span class="sub prov warn">upper bound · ' + p.gaps + ' gap' + (p.gaps > 1 ? 's' : '') + '</span>';
|
|||
|
|
if (p.gaps == null) return '<span class="num">' + dur(p.dur) + '</span><span class="sub" title="Predates gap counting: nobody asked whether the connection dropped.">gaps not counted</span>';
|
|||
|
|
return '<span class="num">' + dur(p.dur) + '</span>';
|
|||
|
|
}
|
|||
|
|
var PST = { running: ['run', 'Running'], finished: ['ok', 'Finished'], failed: ['err', 'Failed'], unknown: ['warn', 'Unknown'] };
|
|||
|
|
function printSt(p) { var s = PST[p.status]; return '<span class="st ' + s[0] + '">' + s[1] + '</span>'; }
|
|||
|
|
function printerSt(pr) {
|
|||
|
|
if (!pr.connected) return '<span class="st warn">' + (pr.lifecycle === 'reconnecting' ? 'Reconnecting' : 'Offline') + '</span>';
|
|||
|
|
if (pr.state === 'RUNNING') return '<span class="st run">Printing</span>';
|
|||
|
|
if (pr.state === 'PAUSE') return '<span class="st warn">Paused</span>';
|
|||
|
|
if (pr.state === 'FAILED') return '<span class="st err">Failed</span>';
|
|||
|
|
return '<span class="st idle">Idle</span>';
|
|||
|
|
}
|
|||
|
|
/* 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 ? '<a href="#spool=' + s.id + '" data-spool="' + s.id + '">' + sw(s.c, 'swatch-sm') + ' ' + esc(short(s)) + '</a>' : '<span class="prov warn"><i>!</i>color ' + (f.slot + 1) + ' unattributed</span>'; }).join('<br>');
|
|||
|
|
var s = p.spool && spool(p.spool);
|
|||
|
|
return s ? '<a href="#spool=' + s.id + '" data-spool="' + s.id + '" class="nowrap">' + sw(s.c, 'swatch-sm') + ' ' + esc(short(s)) + '</a>' : (p.status === 'running' ? '<span class="muted">—</span>' : '<span class="prov warn"><i>!</i>no spool</span>');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── shell ───────────────────────────────────────────────── */
|
|||
|
|
var ICON = {
|
|||
|
|
dashboard: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="3" y="3" width="7" height="9" rx="2"/><rect x="14" y="3" width="7" height="5" rx="2"/><rect x="14" y="12" width="7" height="9" rx="2"/><rect x="3" y="16" width="7" height="5" rx="2"/></svg>',
|
|||
|
|
filament: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3"/><path d="M12 3v6M12 15v6"/></svg>',
|
|||
|
|
prints: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M5 20h14M7 16l3-9h4l3 9M9 12h6"/></svg>',
|
|||
|
|
printers: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 17h8M9 7h6v5H9z"/></svg>',
|
|||
|
|
lists: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01"/></svg>',
|
|||
|
|
labels: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M3 12V4h8l10 10-8 8z"/><circle cx="7.5" cy="7.5" r="1.5"/></svg>',
|
|||
|
|
access: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="9" cy="8" r="4"/><path d="M2 21a7 7 0 0 1 14 0M17 11l2 2 4-4"/></svg>',
|
|||
|
|
system: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M4 6h16M4 12h10M4 18h7"/><circle cx="18" cy="16" r="3"/></svg>',
|
|||
|
|
more: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></svg>',
|
|||
|
|
search: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-4-4"/></svg>',
|
|||
|
|
sun: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>'
|
|||
|
|
};
|
|||
|
|
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 ? '<span class="count count-warn" title="' + unv.length + ' spools with an unverified empty spool weight: ' + unk + ' unknown, ' + (unv.length - unk) + ' estimated">' + unv.length + '</span>' : '',
|
|||
|
|
prints: need ? '<span class="count count-warn" title="' + need + ' prints need a spool or weight">' + need + '</span>' : '',
|
|||
|
|
printers: down ? '<span class="count count-err" title="' + down + ' printer not connected">' + down + '</span>' : ''
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
function shell(page) {
|
|||
|
|
var b = badges();
|
|||
|
|
var nav = PAGES.map(function (p) { return '<a href="' + p[2] + '"' + (p[0] === page ? ' aria-current="page"' : '') + '>' + ICON[p[0]] + '<span>' + p[1] + '</span>' + (b[p[0]] || '') + '</a>'; }).join('');
|
|||
|
|
var unl = SPOOLS.filter(function (s) { return !s.label; }).length;
|
|||
|
|
var rail = '<nav class="rail nm-raised" aria-label="Main">' +
|
|||
|
|
'<a class="wordmark" href="01-dashboard.html" style="text-decoration:none;color:inherit"><span class="wordmark-mark" aria-hidden="true">X</span>Extrudex</a>' +
|
|||
|
|
'<div class="nav nav-group">' + nav + '</div>' +
|
|||
|
|
'<div class="nav nav-group"><span class="nav-label">Manage</span>' +
|
|||
|
|
MANAGE.map(function (m) { return '<a href="' + m[2] + '"' + (m[0] === page ? ' aria-current="page"' : '') + '>' + ICON[m[0]] + '<span>' + m[1] + '</span>' + (m[0] === 'labels' ? '<span class="count" style="color:var(--text-secondary)" title="' + unl + ' spools without a printed label">' + unl + '</span>' : m[3] ? '<span class="sub" style="margin-left:auto">admin</span>' : '') + '</a>'; }).join('') + '</div>' +
|
|||
|
|
'<div class="rail-foot"><div class="seg" role="group" aria-label="Color theme"><button type="button" data-set-theme="light">Light</button><button type="button" data-set-theme="dark">Dark</button></div>' +
|
|||
|
|
'<span class="small muted">Signed in · operator</span></div></nav>';
|
|||
|
|
var inManage = MANAGE.some(function (m) { return m[0] === page; });
|
|||
|
|
var tabbar = '<nav class="tabbar nm-raised" aria-label="Main">' + PAGES.map(function (p) { return '<a href="' + p[2] + '"' + (p[0] === page ? ' aria-current="page"' : '') + '>' + ICON[p[0]] + (b[p[0]] || '') + '<span>' + p[1] + '</span></a>'; }).join('') +
|
|||
|
|
'<a href="#" data-menu="moremenu" aria-haspopup="menu" aria-expanded="false"' + (inManage ? ' aria-current="page"' : '') + '>' + ICON.more + '<span>More</span></a></nav>' +
|
|||
|
|
'<div class="menu more-sheet nm-dialog" id="moremenu" role="menu" aria-label="More">' + MANAGE.map(function (m) { return '<a role="menuitem" href="' + m[2] + '">' + ICON[m[0]] + ' ' + m[1] + (m[3] ? '<span class="hint">admin</span>' : '') + '</a>'; }).join('') + '<hr><button role="menuitem" type="button" data-toggle-theme>' + ICON.sun + ' Switch light / dark</button></div>';
|
|||
|
|
var top = '<div class="topbar"><button class="search-trigger" type="button" data-palette>' + ICON.search + '<span style="flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"><span class="long">Search spools, prints, printers…</span><span class="short">Search…</span></span><span class="kbd long">⌘K</span></button>' +
|
|||
|
|
'<span class="fresh" id="fresh" role="status"><span class="dot" aria-hidden="true"></span><span id="fresh-t">Live · updated 3s ago</span></span>' +
|
|||
|
|
'<button class="btn btn-icon btn-sm" type="button" data-toggle-theme aria-label="Switch color theme">' + ICON.sun + '</button></div>';
|
|||
|
|
var mock = '<div class="mock"><button type="button" data-menu="mockmenu" aria-expanded="false">Mockup<span class="long"> · v3 iteration 3</span></button><div class="menu" id="mockmenu" role="dialog" aria-label="About this mockup">' +
|
|||
|
|
'<p><b>Sample data</b>, based on the real inventory. Nothing is wired to the API.</p>' +
|
|||
|
|
'<p><span class="prov info"><i>◈</i>feature</span> fields exist only on the printer-connections branch. <span class="prov warn"><i>⚠</i>API gap</span> means stored but not served.</p><hr>' +
|
|||
|
|
'<button type="button" data-demo="loading">Replay first load (skeletons)</button>' +
|
|||
|
|
'<button type="button" data-demo="offline">Simulate polling failure</button>' +
|
|||
|
|
'<button type="button" data-demo="online">Back to live</button><hr>' +
|
|||
|
|
'<a href="index.html">Flow map of all screens →</a></div></div>';
|
|||
|
|
var drawer = '<div class="drawer-scrim" data-close-drawer></div><aside class="drawer nm-dialog" id="drawer" role="dialog" aria-modal="true" aria-labelledby="drawer-title" hidden></aside>';
|
|||
|
|
var pal = '<div class="pal-scrim" id="pal" role="presentation"><div class="pal nm-dialog" role="dialog" aria-modal="true" aria-label="Search and commands"><div class="pal-input">' + ICON.search +
|
|||
|
|
'<input id="pal-q" role="combobox" aria-expanded="true" aria-controls="pal-list" aria-autocomplete="list" placeholder="Type a spool, #id, job, printer or action…" autocomplete="off"><span class="kbd">esc</span></div>' +
|
|||
|
|
'<div class="pal-list" id="pal-list" role="listbox"></div><div class="pal-foot"><span><span class="kbd">↑</span> <span class="kbd">↓</span> move</span><span><span class="kbd">↵</span> open</span><span><span class="kbd">/</span> or <span class="kbd">⌘K</span> anywhere</span></div></div></div>';
|
|||
|
|
return { rail: rail, tabbar: tabbar, top: top, rest: mock + drawer + pal + '<div class="toasts" id="toasts" aria-live="polite"></div>' };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── 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 = '<span>' + html + '</span><button class="btn btn-sm btn-icon" type="button" aria-label="Dismiss">✕</button>';
|
|||
|
|
$('#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) ? '<div class="drawer-nav"><button class="btn btn-sm btn-icon" type="button" data-step="-1" aria-label="Previous spool in list" ' + (i === 0 ? 'disabled' : '') + '>↑</button><button class="btn btn-sm btn-icon" type="button" data-step="1" aria-label="Next spool in list" ' + (i === list.length - 1 ? 'disabled' : '') + '>↓</button></div>' : '';
|
|||
|
|
return '<div class="drawer-head">' + icon + '<div style="flex:1;min-width:0"><h2 id="drawer-title">' + title + '</h2><span class="sub">' + sub + '</span>' + (extra || '') + '</div>' + nav + '<button class="btn btn-sm btn-icon" type="button" data-close-drawer aria-label="Close panel (Esc)">✕</button></div>';
|
|||
|
|
}
|
|||
|
|
function tabs(list) {
|
|||
|
|
return '<div class="drawer-tabs"><div class="tabs" role="tablist" aria-label="Spool panel">' + list.map(function (t) { return '<button type="button" role="tab" data-tab="' + t[0] + '" aria-selected="' + (D.tab === t[0]) + '">' + t[1] + '</button>'; }).join('') + '</div></div>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function spoolDrawer(s) {
|
|||
|
|
var w = where(s);
|
|||
|
|
var h = head(sw(s.c, '" style="width:44px;height:44px'), esc(s.color) + ' <span class="muted" style="font-weight:400">#' + s.id + '</span>', 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 = '<div class="nm-inset panel"><div class="row" style="align-items:baseline"><span style="font-size:34px;font-weight:800;line-height:1" class="num' + (low(s) ? '" style="color:var(--status-warning)' : '') + '">' + Math.round(s.rem).toLocaleString('en-US') + '<span style="font-size:15px"> g</span></span><span class="muted">of ' + g(s.tot) + ' net</span><span class="spacer"></span>' + remProv(s) + '</div>' +
|
|||
|
|
'<div style="margin-top:12px">' + meter(s) + '</div>' + (low(s) ? '<p class="small" style="margin:10px 0 0;color:var(--status-warning)"><b>⚠ Below the 200 g line.</b> Reorder or finish it on a small job.</p>' : '') + '</div>' +
|
|||
|
|
'<div class="panel nm-raised-sm"><h3>On the scale</h3>' + (gr == null
|
|||
|
|
? '<p class="small" style="margin:0">Gross is <b>unknown</b> because no empty spool weight is known. It is never treated as 0.</p><div class="row" style="margin-top:12px"><button class="btn btn-sm" type="button" data-tab="weigh">Set empty spool weight</button></div>'
|
|||
|
|
: '<dl class="kv"><dt>Empty spool</dt><dd>' + g(s.tare) + ' ' + tareProv(s) + '</dd><dt>+ Filament (net)</dt><dd>' + g(s.rem) + '</dd><dt class="strong" style="color:var(--text-primary)">= Gross, on a scale</dt><dd class="strong">' + g(gr) + '</dd></dl>') + '</div>' +
|
|||
|
|
'<dl class="kv"><dt>Location</dt><dd>' + (w.loaded ? '<a href="04-printers.html#printer=' + w.printer.id + '">' + esc(w.text) + '</a>' : esc(w.text)) + ' · <button class="btn btn-sm" type="button" data-tab="move" style="min-height:26px;padding:2px 8px">Move</button></dd>' +
|
|||
|
|
'<dt>Value left</dt><dd>' + usd(value(s)) + ' <span class="sub" style="display:inline">at ' + usd(s.kg) + '/kg</span></dd>' +
|
|||
|
|
'<dt>Spool type</dt><dd>' + (s.stype ? esc(s.stype) : '<span class="muted">none chosen</span>') + '</dd>' +
|
|||
|
|
'<dt>Label</dt><dd>' + (s.label ? 'printed' : '<span class="prov warn"><i>!</i>not printed</span> <button class="btn btn-sm" type="button" data-fake="Label PDF generated for #' + s.id + '" style="min-height:26px;padding:2px 8px">Print label</button>') + '</dd>' +
|
|||
|
|
'<dt>Record</dt><dd class="muted">version ' + s.v + '</dd></dl>' +
|
|||
|
|
'<div><h3 class="section" style="margin-bottom:8px">Prints from this spool <span class="muted">· ' + used.length + '</span></h3>' + (used.length ? '<div class="stack" style="gap:6px">' + used.map(function (p) { var f = (p.filaments || []).filter(function (f) { return f.spool === s.id; })[0]; return '<a class="row nm-raised-sm" style="padding:8px 12px;text-decoration:none;color:inherit;gap:10px" href="#print=' + p.id + '" data-print="' + p.id + '">' + printSt(p) + '<span class="strong" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis">' + esc(p.job) + '</span><span class="num">' + g(f ? f.grams : p.grams) + '</span><span class="sub" style="display:inline">' + when(p.start) + '</span></a>'; }).join('') + '</div>' : '<p class="small muted" style="margin:0">No prints drew from this spool yet.</p>') + '</div>' +
|
|||
|
|
'<p class="small muted" style="margin:0"><span class="prov warn"><i>⚠</i>API gap</span> Location history is stored in <code>spool_location_event</code> but there is no endpoint to read it.</p>';
|
|||
|
|
foot = '<button class="btn btn-danger btn-sm" type="button" data-delete="' + s.id + '" style="margin-right:auto">Delete…</button><button class="btn" type="button" data-tab="edit">Edit</button><button class="btn btn-primary" type="button" data-tab="weigh" ' + (s.tare == null ? 'title="Needs an empty spool weight first"' : '') + '>Weigh</button>';
|
|||
|
|
}
|
|||
|
|
if (D.tab === 'weigh') {
|
|||
|
|
if (s.tare == null) {
|
|||
|
|
body = '<div class="banner"><b>?</b><span>This spool has <b>no empty spool weight</b>, so a scale reading can\'t be turned into filament. Set one first. It is never assumed to be 0.</span></div>' +
|
|||
|
|
'<label class="field stack" style="gap:6px"><span class="strong small">Pick a spool type</span><select class="select" autofocus><option>Choose…</option><option>Generic — 1 kg spool (assumed) · 180 g · estimated</option><option>Creality — Hyper cardboard · 176 g · community</option><option>Creality — legacy plastic · 138 g · community</option></select><span class="sub">Fastest. The figure\'s trust level is shown next to it.</span></label>' +
|
|||
|
|
'<div class="row"><hr style="flex:1;border:0;border-top:1px solid var(--border-hairline)"><span class="sub">or, more accurate</span><hr style="flex:1;border:0;border-top:1px solid var(--border-hairline)"></div>' +
|
|||
|
|
'<label class="field stack" style="gap:6px"><span class="strong small">Weigh the empty spool</span><input class="input num" inputmode="numeric" placeholder="e.g. 210 g"><span class="sub">Only possible once the spool is used up, or from an identical empty.</span></label>';
|
|||
|
|
foot = '<button class="btn" type="button" data-tab="overview">Cancel</button><button class="btn btn-primary" type="button" data-fake="Empty spool weight saved for #' + s.id + '. Weigh-in unlocked.">Save empty spool weight</button>';
|
|||
|
|
} else {
|
|||
|
|
var wl = where(s), li = D.list ? D.list.indexOf(s.id) : -1;
|
|||
|
|
body = (D.session && li >= 0 ? '<div class="row small" style="gap:10px"><span class="st run">Weigh session</span><span class="muted">spool ' + (li + 1) + ' of ' + D.list.length + ' · lightest first</span></div>' : '') +
|
|||
|
|
'<div class="findit"><span class="sub">Find it</span><b>' + esc(wl.text) + '</b>' + sw(s.c, 'swatch-sm') + '<span class="muted small">' + esc(s.color) + ' · #' + s.id + '</span></div>' +
|
|||
|
|
'<div class="panel nm-inset"><label class="stack" style="gap:8px"><span class="strong">Whole spool on the scale</span><input class="input num" id="scale" inputmode="numeric" autofocus placeholder="full reads ' + g(s.tare + s.tot) + '" style="font-size:22px;min-height:56px"><span class="sub">Remaining = reading − ' + g(s.tare) + ' empty spool (' + tareProv(s) + ')</span></label></div>' +
|
|||
|
|
'<div class="panel nm-raised-sm" id="weigh-out" aria-live="polite"><h3>Result</h3><div class="row"><span class="muted">Now</span><b class="num">' + g(s.rem) + '</b><span class="muted">→</span><b class="num" id="w-after">—</b><span class="spacer"></span><span id="w-diff" class="num"></span></div><p class="small muted" id="w-note" style="margin:8px 0 0">Type the reading and the result appears here before you save.</p></div>';
|
|||
|
|
foot = '<button class="btn" type="button" data-tab="overview">Cancel</button><button class="btn btn-primary" type="button" id="w-save" disabled>Save weigh-in</button>' + (D.list && D.list.indexOf(s.id) < D.list.length - 1 ? '<button class="btn" type="button" id="w-next" disabled>Save & weigh next ↓</button>' : '');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (D.tab === 'move') {
|
|||
|
|
var dests = PRINTERS.map(function (pr) {
|
|||
|
|
return '<div class="stack" style="gap:8px"><div class="row"><b class="small">' + esc(pr.name) + '</b>' + (pr.connected ? '' : '<span class="prov warn"><i>⚡</i>disconnected (loading still recorded)</span>') + '</div><div class="row" style="gap:8px;align-items:stretch">' +
|
|||
|
|
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 '<button type="button" class="slot" style="min-height:64px;flex:1" data-dest="' + c.id + '" ' + (full ? 'disabled title="Full: unload a spool first"' : '') + ' aria-pressed="' + here + '"><span class="sn">' + esc(c.label) + '</span><span class="g">' + n + ' / ' + c.cap + (here ? ' · here now' : full ? ' · full' : '') + '</span></button>'; }).join('') + '</div></div>';
|
|||
|
|
}).join('');
|
|||
|
|
body = '<p class="small muted" style="margin:0">Where is the spool going? Full holders are disabled. Extrudex records which holder a spool is in, not its tray position.</p>' +
|
|||
|
|
'<div class="stack" style="gap:8px"><b class="small">Storage</b><div class="row" style="gap:8px;align-items:stretch"><button type="button" class="slot" style="min-height:64px;flex:1" data-dest="storage" aria-pressed="' + !s.cont + '"><span class="sn">Shelf / storage</span><span class="g">' + (s.cont ? 'unload' : 'here now') + '</span></button></div>' +
|
|||
|
|
'<label class="stack" style="gap:6px"><span class="sub">Where in storage (optional)</span><input class="input" value="' + esc(s.loc || '') + '" placeholder="Shelf A2, Dry box 1…"></label></div>' + dests;
|
|||
|
|
foot = '<button class="btn" type="button" data-tab="overview">Cancel</button><button class="btn btn-primary" type="button" id="m-save" disabled>Move spool</button>';
|
|||
|
|
}
|
|||
|
|
if (D.tab === 'edit') { return editDrawer(s); }
|
|||
|
|
return h + t + '<div class="drawer-body">' + body + '</div><div class="drawer-foot">' + foot + '</div>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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('<span class="swatch" style="width:44px;height:44px"><i style="background:#888"></i></span>', 'Add spool', 'New spools carry no version') : head(sw(s.c, '" style="width:44px;height:44px'), 'Edit ' + esc(s.color) + ' <span class="muted" style="font-weight:400">#' + s.id + '</span>', '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 '<label class="stack" style="gap:6px"><span class="small strong">' + label + ' ' + (req ? '<span class="sub" style="display:inline;color:var(--text-accent)">required</span>' : '<span class="sub" style="display:inline">optional</span>') + '</span><select class="select"><option>' + (val || 'None') + '</option>' + (opts || '') + '</select></label>'; }
|
|||
|
|
var demo = '<div class="row small" style="gap:8px"><span class="muted">Demo state</span><div class="tabs" role="group" aria-label="Demo state">' + [['normal', 'Normal'], ['errors', '400 errors'], ['conflict', '409 conflict']].map(function (x) { return '<button type="button" data-state="' + x[0] + '" aria-selected="' + (st === x[0]) + '">' + x[1] + '</button>'; }).join('') + '</div></div>';
|
|||
|
|
var conflict = st === 'conflict' ? '<div class="nm-inset" role="alert" style="padding:16px;border:2px solid var(--status-warning);background:var(--wash-warning);border-radius:var(--radius-lg)"><b style="color:var(--status-warning)">⚠ This spool changed since you opened it. Nothing was saved.</b><p class="small" style="margin:8px 0">Someone weighed it at 09:39 (version ' + (s.v - 1) + ' → ' + s.v + '). Saving would have put the old remaining weight back.</p>' +
|
|||
|
|
'<table class="tbl" style="font-size:12px"><thead><tr><th>Field</th><th class="r">You opened</th><th class="r">Now</th><th class="r">Your edit</th></tr></thead><tbody><tr><td>Remaining</td><td class="r">860 g</td><td class="r strong" style="color:var(--status-warning)">' + g(s.rem) + ' ⚖</td><td class="r muted">—</td></tr><tr><td>Location</td><td class="r">Shelf A3</td><td class="r">Shelf A3</td><td class="r strong accent">' + esc(where(s).short) + '</td></tr></tbody></table>' +
|
|||
|
|
'<div class="row" style="margin-top:12px"><button class="btn btn-primary btn-sm" type="button" data-fake="Reloaded version ' + s.v + ' and reapplied your location change. Review, then save.">Reload & reapply my change</button><button class="btn btn-sm" type="button" data-state="normal">Reload, discard mine</button></div></div>' : '';
|
|||
|
|
var errsum = st === 'errors' ? '<div role="alert" style="padding:14px 16px;border:2px solid var(--status-error);background:var(--wash-error);border-radius:var(--radius-lg)"><b style="color:var(--status-error)">⚠ 2 fields need fixing. Nothing was saved.</b><ul class="small" style="margin:6px 0 0;padding-left:18px"><li><a href="#e-rem">Remaining is more than the spool holds</a></li><li><a href="#e-name">Color name is empty</a></li></ul></div>' : '';
|
|||
|
|
var name = '<div class="nm-inset" style="padding:12px 14px;border-radius:var(--radius-md)"><span class="sub">Reads as</span><div class="strong" style="font-size:17px">' + esc(fullName(s)) + '</div><span class="sub">brand · <i>speed</i> · material · <i>type</i> · finish (italic = optional)</span></div>';
|
|||
|
|
var body = demo + conflict + errsum + name +
|
|||
|
|
'<div class="fr2">' + sel('Brand', s.brand, 1) + sel('Print speed', s.speed, 0, '<option>Rapid</option><option>High Speed</option>') + sel('Material', s.mat, 1) + sel('Filament type', s.type, 0, '<option>CF</option><option>+ 2.0</option>') + sel('Finish', s.fin, 1) + '</div>' +
|
|||
|
|
'<div class="fr2"><div class="stack' + (st === 'errors' ? ' field has-error' : '') + '" id="e-name" style="gap:6px"><label class="small strong" for="f-name">Color name <span class="sub" style="display:inline;color:var(--text-accent)">required</span></label><input class="input" id="f-name" value="' + (st === 'errors' ? '' : esc(s.color)) + '" ' + (st === 'errors' ? 'aria-invalid="true" aria-describedby="e-name-m"' : '') + ' placeholder="e.g. Charcoal">' + (st === 'errors' ? '<p class="field-error" id="e-name-m" style="margin:0">Give the color a name, even "Unknown". <span class="sub" style="display:inline">server: <code>color_name is required</code></span></p>' : '') + '</div>' +
|
|||
|
|
'<label class="stack" style="gap:6px"><span class="small strong">Colors <span class="sub" style="display:inline">1–3</span></span><span class="row" style="gap:8px">' + sw(s.c) + '<input class="input" value="' + s.c[0] + '" style="max-width:110px"><button class="btn btn-sm" type="button">+ color</button></span></label></div>' +
|
|||
|
|
'<div class="fr2"><label class="stack" style="gap:6px"><span class="small strong">Total <span class="sub" style="display:inline">net, as sold</span></span><input class="input num" value="' + g(s.tot) + '"></label>' +
|
|||
|
|
'<div class="stack' + (st === 'errors' ? ' field has-error' : '') + '" id="e-rem" style="gap:6px"><label class="small strong" for="f-rem">Remaining <span class="sub" style="display:inline">net</span></label><input class="input num" id="f-rem" value="' + (st === 'errors' ? '1,040 g' : g(s.rem)) + '" ' + (st === 'errors' ? 'aria-invalid="true" aria-describedby="e-rem-m"' : '') + '>' + (st === 'errors' ? '<p class="field-error" id="e-rem-m" style="margin:0">1,040 g is more than this spool holds (' + g(s.tot) + '). <span class="sub" style="display:inline">server: <code>remaining_grams cannot exceed total_grams</code></span></p>' : '<span class="sub">To measure it, use the <b>Weigh</b> tab instead.</span>') + '</div></div>' +
|
|||
|
|
'<div class="fr2"><label class="stack" style="gap:6px"><span class="small strong">Spool type <span class="sub" style="display:inline">supplies the empty spool weight</span></span><select class="select"><option>' + esc(s.stype || 'None chosen') + '</option></select></label>' +
|
|||
|
|
'<label class="stack" style="gap:6px"><span class="small strong">Empty spool weight</span><input class="input num" value="' + (s.tare == null ? '' : g(s.tare)) + '" placeholder="unknown"><span class="sub">' + (s.tare == null ? '<span class="prov warn"><i>?</i>unknown, never 0</span>' : tareProv(s)) + '</span></label></div>' +
|
|||
|
|
'<div class="fr2"><label class="stack" style="gap:6px"><span class="small strong">Cost per kg</span><input class="input num" value="' + usd(s.kg) + '"></label><label class="stack" style="gap:6px"><span class="small strong">Storage location <span class="sub" style="display:inline">optional</span></span><input class="input" value="' + esc(s.loc || '') + '"></label></div>' +
|
|||
|
|
'<div class="banner" id="discard-bar" hidden><span style="flex:1">Discard unsaved changes?</span><button class="btn btn-sm" type="button" data-discard>Discard</button><button class="btn btn-sm" type="button" data-keep>Keep editing</button></div>';
|
|||
|
|
var foot = (st === 'conflict' ? '<span class="small muted" style="margin-right:auto">Save is off until you choose a version.</span>' : '') + '<button class="btn" type="button" data-' + (isNew ? 'close-drawer' : 'tab="overview"') + '>Cancel</button><button class="btn btn-primary" type="button" ' + (st === 'conflict' ? 'disabled' : 'data-save') + '>' + (isNew ? 'Add spool' : 'Save changes') + '</button>';
|
|||
|
|
return h + t + '<div class="drawer-body">' + body + '</div><div class="drawer-foot">' + foot + '</div>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function printDrawer(p) {
|
|||
|
|
var pr = printer(p.printer), n = needs(p);
|
|||
|
|
var h = head('<span class="wordmark-mark" style="width:44px;height:44px;background:var(--surface-inset);color:var(--text-primary)">' + ICON.prints + '</span>', esc(p.job), printSt(p) + ' · ' + esc(pr.name) + ' · print #' + p.id);
|
|||
|
|
var fix = '';
|
|||
|
|
if (n.length) {
|
|||
|
|
fix = '<div class="nm-inset panel" style="border:1px solid var(--status-warning)"><h3 style="color:var(--status-warning)">Needs ' + n.map(function (x) { return x.t; }).join(' + ') + '</h3>' + fixers(p) + '</div>';
|
|||
|
|
}
|
|||
|
|
var body = fix + '<dl class="kv"><dt>Printer</dt><dd><a href="04-printers.html#printer=' + pr.id + '">' + esc(pr.name) + '</a></dd><dt>Started</dt><dd>' + when(p.start) + '</dd><dt>Ended</dt><dd>' + (p.end ? when(p.end) : p.status === 'running' ? '<span class="muted">still running</span>' : '—') + '</dd>' +
|
|||
|
|
'<dt>Duration</dt><dd>' + durCell(p) + '</dd>' +
|
|||
|
|
'<dt>Job key</dt><dd><code>' + esc(p.key) + '</code><span class="sub">The identity. Two jobs can share a name, never a path.</span></dd>' +
|
|||
|
|
'<dt>Filament</dt><dd>' + (p.grams == null ? '<span class="muted">not known yet</span>' : '<b class="num">' + g(p.grams) + '</b> ' + weightProv(p) + (p.status === 'failed' ? '<span class="sub">Failed prints record the full job weight. Correct by hand if needed.</span>' : '')) + '</dd>' +
|
|||
|
|
'<dt>Spool</dt><dd>' + spoolLinks(p) + changeSpool(p) + '</dd><dt>Cost</dt><dd>' + (p.cost == null ? '<span class="muted">pending: needs weight and spool</span>' : usd(p.cost) + (p.filaments && p.filaments.some(function (f) { return f.spool == null; }) ? ' <span class="sub" style="display:inline">so far</span>' : '')) + '</dd>' +
|
|||
|
|
'<dt>Use</dt><dd><div class="tabs" role="group" aria-label="Use type"><button type="button" aria-selected="' + (p.use === 'business') + '">Business</button><button type="button" aria-selected="' + (p.use === 'personal') + '">Personal</button></div>' + (p.use == null ? '<span class="sub">Not recorded</span>' : '') + '</dd></dl>' +
|
|||
|
|
(p.filaments ? '<div><h3 class="section">Colors</h3><table class="tbl"><thead><tr><th>#</th><th>Color</th><th>Spool</th><th class="r">Grams</th><th class="r">Cost</th></tr></thead><tbody>' + p.filaments.map(function (f) { var s = f.spool && spool(f.spool); return '<tr><td>' + (f.slot + 1) + '</td><td><span class="swatch swatch-sm"><i style="background:' + f.hex + '"></i></span> <code>' + f.hex + '</code></td><td>' + (s ? '<a href="#spool=' + s.id + '" data-spool="' + s.id + '">' + esc(short(s)) + '</a>' : '<span class="prov warn"><i>!</i>unattributed</span>') + '</td><td class="r">' + g(f.grams) + '</td><td class="r">' + usd(f.cost) + '</td></tr>'; }).join('') + '</tbody></table></div>' : '');
|
|||
|
|
var foot = p.status === 'running' ? '<span class="small muted">Name and weight are captured when the print ends.</span>' : '<button class="btn btn-sm" type="button" data-fake="Archived ' + esc(p.job) + '. Accounting unchanged." style="margin-right:auto">Archive</button><button class="btn" type="button" data-close-drawer>Done</button>';
|
|||
|
|
return h + '<div class="drawer-body">' + body + '</div><div class="drawer-foot">' + foot + '</div>';
|
|||
|
|
}
|
|||
|
|
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 '<div class="banner err" role="alert" style="margin-top:8px;font-size:12px"><span><b>Can\'t move this one yet.</b> It was attributed to spool #' + p.spool + ' before Extrudex tracked deductions, so nothing records how much came off. Check that spool\'s remaining grams, then re-attribute this print to <b>the same spool</b> to reconcile it. After that it can move.</span></div>';
|
|||
|
|
return attributed.map(function (f) {
|
|||
|
|
var from = spool(f.spool);
|
|||
|
|
return '<details class="chg"><summary>Change spool' + (f.slot != null ? ' for color ' + (f.slot + 1) : '') + '</summary><div class="stack" style="gap:8px;margin-top:8px"><select class="select" style="min-height:36px" aria-label="New spool">' + SPOOLS.filter(function (x) { return x.id !== f.spool; }).slice(0, 10).map(function (x) { return '<option>' + g(x.rem) + ' · ' + esc(short(x)) + ' · ' + x.mat + ' · #' + x.id + '</option>'; }).join('') + '</select>' +
|
|||
|
|
'<span class="sub">' + (f.grams != null ? '<b>' + g(f.grams) + ' goes back</b> 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.') + '</span>' +
|
|||
|
|
'<button class="btn btn-sm" type="button" data-fake="Moved ' + esc(p.job) + (f.grams != null ? ': ' + g(f.grams) + ' returned to ' + esc(short(from)) : '') + '">Move to this spool</button></div></details>';
|
|||
|
|
}).join('');
|
|||
|
|
}
|
|||
|
|
function fixers(p) {
|
|||
|
|
var out = '';
|
|||
|
|
if (p.grams == null) out += '<label class="stack" style="gap:6px;margin-bottom:12px"><span class="small strong">Filament used</span><span class="row" style="gap:8px"><input class="input num" style="max-width:140px" placeholder="grams" inputmode="numeric" autofocus><button class="btn btn-sm" type="button" data-fake="Saved ' + esc(p.job) + ' weight · remembered for ' + esc(p.key) + '">Save weight</button></span><span class="sub">From the slicer. Remembered for <code>' + esc(p.key) + '</code> so later runs fill in by themselves.</span></label>';
|
|||
|
|
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 += '<div class="stack" style="gap:6px;margin-bottom:8px"><span class="small strong">Spool' + (f.slot != null ? ' for color ' + (f.slot + 1) + ' <span class="swatch swatch-sm"><i style="background:' + f.hex + '"></i></span>' : '') + '</span>' +
|
|||
|
|
(sug ? '<div class="row nm-raised-sm" style="padding:8px 12px;gap:10px">' + sw(sug.c, 'swatch-sm') + '<span style="flex:1"><b>' + esc(short(sug)) + '</b><span class="sub">Suggested: same color, loaded in ' + esc(where(sug).short) + '. Not charged until you confirm.</span></span><button class="btn btn-sm btn-primary" type="button" data-fake="Attributed to ' + esc(short(sug)) + ' · ' + g(f.grams || p.grams) + ' deducted">Use this</button></div>' : '') +
|
|||
|
|
'<select class="select"><option>' + (sug ? 'Or pick another spool…' : 'Pick a spool…') + '</option>' + SPOOLS.slice(0, 8).map(function (s) { return '<option>' + g(s.rem) + ' · ' + esc(short(s)) + ' · ' + esc(s.mat) + '</option>'; }).join('') + '</select></div>';
|
|||
|
|
});
|
|||
|
|
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('<span class="wordmark-mark" style="width:44px;height:44px;background:var(--surface-inset);color:var(--text-primary)">' + ICON.printers + '</span>', 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 '<div class="stack' + (err ? ' field has-error' : '') + '" style="gap:6px"><label class="small strong">' + label + (hint ? ' <span class="sub" style="display:inline">' + hint + '</span>' : '') + '</label>' + input + (err ? '<p class="field-error" style="margin:0">' + err + '</p>' : '') + '</div>'; }
|
|||
|
|
var body = '<div class="row small" style="gap:8px"><span class="muted">Demo state</span><div class="tabs" role="group" aria-label="Demo state">' + [['normal', 'Normal'], ['errors', '400 errors']].map(function (x) { return '<button type="button" data-state="' + x[0] + '" aria-selected="' + (st === x[0]) + '">' + x[1] + '</button>'; }).join('') + '</div></div>' +
|
|||
|
|
'<div class="fr2">' + f('Brand', '', '<select class="select" id="pe-brand"><option value="bambu"' + (brand === 'bambu' ? ' selected' : '') + '>Bambu Lab</option><option value="elegoo"' + (brand === 'elegoo' ? ' selected' : '') + '>Elegoo</option></select>') +
|
|||
|
|
f('Model', '', '<select class="select">' + MODELS[brand].map(function (m) { return '<option' + (pr && pr.model === m ? ' selected' : '') + '>' + m + '</option>'; }).join('') + '</select>') + '</div>' +
|
|||
|
|
f('Name', 'shown everywhere; must be unique', '<input class="input" value="' + (pr ? esc(pr.name) : '') + '" placeholder="X1C — Bench">', st === 'errors' && !pr ? 'A printer called “P1S - Prints Charming” already exists. Names must be unique.' : '') +
|
|||
|
|
'<div class="fr2">' + f('IP address', 'same network as Extrudex', '<input class="input" value="' + (st === 'errors' ? '10.60.1.300' : pr ? esc(pr.ip) : '') + '" placeholder="10.60.1.10" inputmode="decimal">', st === 'errors' ? '10.60.1.300 isn\'t a valid IPv4 address.' : '') +
|
|||
|
|
f('Serial', 'on the printer\'s label', '<input class="input" value="' + (pr ? esc(pr.serial) : '') + '">') + '</div>' +
|
|||
|
|
(brand === 'bambu' ? f('Access code', pr ? 'leave blank to keep the stored code' : 'from the printer screen: Settings → LAN', '<input class="input" type="password" placeholder="' + (pr ? 'unchanged' : '8 characters') + '" autocomplete="off">') + '<p class="sub" style="margin:0">Stored, never shown again. Read-only MQTT needs only IP, serial and access code, no Developer Mode.</p>'
|
|||
|
|
: '<p class="small muted" style="margin:0">Elegoo Centauri uses unauthenticated SDCP, so there is no access code.</p>') +
|
|||
|
|
(pr ? '' : '<div class="nm-inset panel small"><b>After you add it</b><ul style="margin:6px 0 0;padding-left:18px"><li>An <b>External</b> holder is created automatically.</li><li>Add its AMS units under <b>Manage holders</b>.</li><li>Capture starts on the first status report.</li></ul></div>');
|
|||
|
|
return h + '<div class="drawer-body">' + body + '</div><div class="drawer-foot"><button class="btn" type="button" data-close-drawer>Cancel</button><button class="btn btn-primary" type="button" ' + (st === 'errors' ? 'disabled' : 'data-fake="' + (pr ? 'Saved ' + esc(pr.name) + '. The monitor reconnects with the new details.' : 'Added printer. Waiting for its first status report.') + '"') + '>' + (pr ? 'Save' : 'Add printer') + '</button></div>';
|
|||
|
|
}
|
|||
|
|
function holdersDrawer(id) {
|
|||
|
|
var pr = printer(+id);
|
|||
|
|
var h = head('<span class="wordmark-mark" style="width:44px;height:44px;background:var(--surface-inset);color:var(--text-primary)">' + ICON.printers + '</span>', '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 '<div class="holder"><div class="holder-h"><b>' + esc(c.label) + '</b><span class="muted">' + { ams: 'AMS', ams_lite: 'AMS Lite', external: 'External' }[c.kind] + ' · holds ' + c.cap + ' · #' + HOLDER_IDS[c.id] + '</span><span class="spacer"></span><span class="small">' + n + ' loaded</span></div>' +
|
|||
|
|
'<div class="row" style="gap:8px"><button class="btn btn-sm" type="button" data-fake="Holder label PDF #' + HOLDER_IDS[c.id] + ' generated">Print label</button>' +
|
|||
|
|
(ext ? '<span class="sub">Every printer keeps exactly one external holder.</span>' : n ? '<button class="btn btn-sm" type="button" disabled title="Unload its spools first">Remove</button><span class="sub">Unload its ' + n + ' spool' + (n > 1 ? 's' : '') + ' first.</span>' : '<button class="btn btn-sm btn-danger" type="button" data-fake="Removed ' + esc(c.label) + '">Remove</button>') + '</div></div>';
|
|||
|
|
}).join('') +
|
|||
|
|
'<div class="panel nm-raised-sm stack" style="gap:10px"><h3 style="margin:0">Add an AMS unit</h3><div class="fr2"><label class="stack" style="gap:6px"><span class="small strong">Type</span><select class="select"><option>AMS (4 spools)</option><option>AMS Lite (4 spools)</option></select></label><label class="stack" style="gap:6px"><span class="small strong">Label</span><input class="input" value="AMS 2"><span class="sub">Unique on this printer</span></label></div><button class="btn btn-sm" type="button" data-fake="Added AMS 2 to ' + esc(pr.model) + '" style="align-self:flex-start">Add holder</button></div>' +
|
|||
|
|
'<p class="small muted" style="margin:0">Capacity comes from the type, so a holder can\'t disagree with its hardware. Tray positions aren\'t recorded.</p>';
|
|||
|
|
return h + '<div class="drawer-body">' + body + '</div><div class="drawer-foot"><button class="btn" type="button" data-close-drawer>Done</button></div>';
|
|||
|
|
}
|
|||
|
|
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('<span class="wordmark-mark" style="width:44px;height:44px;background:var(--wash-error);color:var(--status-error);border-color:var(--status-error)">!</span>', 'Remove ' + esc(pr.name) + '?', esc(pr.model) + ' · ' + esc(pr.ip));
|
|||
|
|
var body = '<ul class="small" style="margin:0;padding-left:18px;display:flex;flex-direction:column;gap:6px"><li>Capture stops for this printer.</li><li><b>Print history is kept</b>, including cost records.</li>' + (open ? '<li><b>' + esc(open.job) + '</b> is open, so it will be <b>closed as unknown</b> with no duration, because nobody will watch it end.</li>' : '') + '</ul>' +
|
|||
|
|
(loaded ? '<div class="banner err" role="alert"><span><b>Can\'t remove yet:</b> ' + loaded + ' spool' + (loaded > 1 ? 's are' : ' is') + ' still loaded in its holders. Unload them first, so they don\'t lose their place. <a href="04-printers.html#printer=' + pr.id + '" data-close-drawer>Show holders</a></span></div>' : '') +
|
|||
|
|
'<label class="stack" style="gap:6px"><span class="small strong">Type <i>' + esc(pr.name) + '</i> to confirm</span><input class="input" id="rm-name" autocomplete="off" ' + (loaded ? 'disabled' : '') + '></label>';
|
|||
|
|
return h + '<div class="drawer-body">' + body + '</div><div class="drawer-foot"><button class="btn" type="button" data-close-drawer autofocus>Keep printer</button><button class="btn btn-danger" type="button" id="rm-go" disabled data-name="' + esc(pr.name) + '">Remove printer</button></div>';
|
|||
|
|
}
|
|||
|
|
function loadDrawer(cid) {
|
|||
|
|
var c = container(cid), inIt = contents(cid);
|
|||
|
|
var h = head('<span class="wordmark-mark" style="width:44px;height:44px;background:var(--surface-inset);color:var(--text-primary)">' + ICON.printers + '</span>', '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 = '<label class="stack" style="gap:6px"><span class="small strong">Find a spool in storage</span><input class="input" id="load-q" placeholder="Brand, color, material…" autofocus></label><div class="stack" style="gap:6px" id="load-list">' +
|
|||
|
|
cands.map(function (s) { return '<button type="button" class="slot" style="min-height:0;flex-direction:row;align-items:center;gap:12px;width:100%" data-load="' + s.id + '" aria-pressed="false" data-hay="' + esc((fullName(s) + ' ' + s.color + ' ' + (s.loc || '')).toLowerCase()) + '">' + sw(s.c) + '<span style="flex:1;min-width:0"><span class="nm" style="display:block">' + esc(s.color) + ' · ' + esc(s.brand) + '</span><span class="sub">' + esc(fullName(s)) + ' · ' + esc(s.loc || 'Storage') + '</span></span><span class="g num' + (low(s) ? '" style="color:var(--status-warning)' : '') + '">' + g(s.rem) + '</span></button>'; }).join('') + '</div>' +
|
|||
|
|
'<p class="small muted" style="margin:0">Scanning the holder\'s QR label does the same thing from a phone (existing <code>/c/:id</code> scan session).</p>';
|
|||
|
|
return h + '<div class="drawer-body">' + body + '</div><div class="drawer-foot"><button class="btn" type="button" data-close-drawer>Cancel</button><button class="btn btn-primary" type="button" id="l-save" disabled>Load spool</button></div>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── 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 <b>' + esc(s1.color + ' · ' + s1.brand) + '</b> 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 <b>' + esc(p1.job) + '</b> 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 += '<div class="pal-sec">' + esc(it.sec) + '</div>'; last = it.sec; }
|
|||
|
|
html += '<div class="pal-item" role="option" id="pi' + i + '" data-i="' + i + '" aria-selected="' + (i === P.sel) + '">' + (it.html || '<span style="width:18px;text-align:center" aria-hidden="true">' + it.ico + '</span>') + '<span>' + esc(it.t) + '</span><span class="meta">' + esc(it.m || '') + '</span></div>';
|
|||
|
|
});
|
|||
|
|
$('#pal-list').innerHTML = html || '<div class="empty"><h3>No matches</h3><p>Try a color, brand, <code>#47</code>, a job name or a printer.</p></div>';
|
|||
|
|
$('#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 <span class="long">· retrying</span>' : 'Live <span class="long">· updated ' + fresh.n + 's ago</span>';
|
|||
|
|
}
|
|||
|
|
function demo(kind) {
|
|||
|
|
var main = $('#page');
|
|||
|
|
if (kind === 'loading') {
|
|||
|
|
var keep = main.innerHTML;
|
|||
|
|
main.innerHTML = '<div class="stack" style="gap:24px" aria-busy="true" aria-label="Loading"><div class="skel" style="width:40%;height:28px"></div><div class="stats">' + [1, 2, 3, 4].map(function () { return '<div class="stat nm-raised"><div class="skel" style="width:50%"></div><div class="skel" style="width:70%;height:24px;margin-top:10px"></div></div>'; }).join('') + '</div><div class="nm-raised card stack">' + [1, 2, 3, 4, 5].map(function () { return '<div class="skel" style="height:18px"></div>'; }).join('') + '</div></div>';
|
|||
|
|
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 = '<b>⚠</b><span style="flex:1">Can\'t reach Extrudex. You are looking at data from <b>09:41:07</b>, and it is not updating. Retrying every 10 s. Saving is paused.</span><button class="btn btn-sm" type="button" data-demo="online">Retry now</button>'; 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', '<a class="btn btn-sm" href="#page" style="position:absolute;left:-9999px;top:8px" onfocus="this.style.left=\'8px\'" onblur="this.style.left=\'-9999px\'">Skip to content</a><div class="shell">' + s.rail + '<main id="main">' + s.top + '<div id="page"></div></main></div>' + 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('<b>' + a.getAttribute('data-soon') + '</b> 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 <b>' + esc(t.getAttribute('data-name')) + '</b>. 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 <b>' + esc(ss.color + ' · ' + ss.brand) + '</b> into ' + esc(cc2.printer.model + ' · ' + cc2.c.label) + ' <a href="#spool=' + sid + '" data-spool="' + sid + '">View</a>'); 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 <b>' + esc(sp.color) + '</b> 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 <b>' + esc(ws.color + ' · ' + ws.brand) + '</b>: ' + 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 <b>#57</b>. <a href="#">Print its label</a>' : '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 = '<div class="stack" style="gap:8px;width:100%"><b>Delete ' + esc(ds.color + ' · ' + fullName(ds)) + '?</b><span class="small">Its print history stays. Prints keep their grams and cost and only lose the link to this spool. This can\'t be undone.</span><div class="row" style="justify-content:flex-end"><button class="btn" type="button" data-tab="overview" autofocus>Keep spool</button><button class="btn btn-danger" type="button" data-fake="Spool #' + ds.id + ' deleted. 0 prints lost their cost.">Delete spool</button></div></div>'; $('#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 ? '<span class="prov">' + (r - s.rem > 0 ? '+' : '') + (r - s.rem).toLocaleString('en-US') + ' g</span>' : '';
|
|||
|
|
$('#w-note').innerHTML = !v ? 'Type the reading and the result appears here before you save.' : v < s.tare ? '<span style="color:var(--status-error)">⚠ ' + g(v) + ' is less than the ' + g(s.tare) + ' empty spool. One of them is wrong. Nothing will be saved.</span>' : r > s.tot + 1 ? '<span style="color:var(--status-error)">⚠ That leaves ' + g(r) + ', more than the spool holds full (' + g(s.tot) + '). Check the total or the empty spool weight.</span>' : '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 };
|
|||
|
|
})();
|