3 Commits
Author SHA1 Message Date
overseer f9e0139028 Publish: redirect site root to iteration-3/screens/index.html 2026-09-25 11:10:10 -04:00
Joshua King 5916746ad3 updates 2026-09-25 10:26:24 -04:00
overseer fb8844df8a Merge pull request 'Main' (#1) from main into pages
Reviewed-on: #1
2026-09-25 10:19:23 -04:00
12 changed files with 810 additions and 0 deletions
+76
View File
@@ -0,0 +1,76 @@
<style>
.dash { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(300px, 1fr); gap: var(--space-6); align-items: start; }
.stats .stat { gap: 4px; }
.stats a.stat { border: 1px solid var(--border-hairline); }
.day { font-size: var(--fs-caption); text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); color: var(--text-secondary); font-weight: var(--fw-bold); padding: 14px 16px 6px; }
.feed { display: flex; flex-direction: column; }
.feed-row { all: unset; box-sizing: border-box; cursor: pointer; display: grid; grid-template-columns: 110px minmax(0, 1fr) auto auto; gap: 4px var(--space-4); align-items: center; padding: 10px 16px; border-top: 1px solid var(--border-hairline); font-size: 13px; }
.feed-row:hover { background: var(--accent-wash); }
.feed-row:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: -2px; }
.feed-row .job { font-weight: var(--fw-bold); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.feed-row .r { text-align: right; font-variant-numeric: tabular-nums; }
.side-row { all: unset; box-sizing: border-box; cursor: pointer; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: var(--space-3); align-items: center; padding: 10px 16px; border-top: 1px solid var(--border-hairline); font-size: 13px; width: 100%; }
.side-row:hover { background: var(--accent-wash); }
.side-row:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: -2px; }
.card-h { display: flex; align-items: center; gap: var(--space-3); padding: 14px 16px 10px; }
.card-h h2 { margin: 0; font-size: var(--fs-label); text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); color: var(--text-secondary); }
.card-h a { margin-left: auto; font-size: var(--fs-label); }
.swrow { display: inline-flex; margin-left: 6px; vertical-align: middle; }
.swrow .swatch { margin-left: -6px; width: 18px; height: 18px; }
@media (max-width: 980px) { .dash { grid-template-columns: minmax(0, 1fr); } }
@media (max-width: 620px) { .feed-row { grid-template-columns: minmax(0, 1fr) auto; } .feed-row .when { grid-column: 1 / -1; order: -1; } .feed-row .hide-s { display: none; } .attn { grid-template-columns: 36px minmax(0, 1fr); } .attn .ico { width: 36px; height: 36px; } .attn .btn { grid-column: 1 / -1; justify-content: center; } }
</style>
<script>
(function () {
var x = X;
function attention() {
var lowS = SPOOLS.filter(x.low), tareS = SPOOLS.filter(x.unverified), tareU = tareS.filter(function (s) { return s.tare == null; }).length, need = x.printsNeeding(), down = PRINTERS.filter(function (p) { return !p.connected; });
var withSug = need.filter(function (p) { return p.suggested || (p.filaments || []).some(function (f) { return f.suggested; }); }).length;
var items = [];
down.forEach(function (p) { items.push('<div class="attn err"><span class="ico" aria-hidden="true">⚡</span><div><h3>' + x.esc(p.name) + ' lost its connection</h3><p>Last contact ' + x.ago(p.age) + '. Print capture is paused; <b>' + x.esc(p.job) + '</b> is still open and will be reconciled on reconnect.</p></div><a class="btn btn-sm" href="04-printers.html#printer=' + p.id + '">Open printer</a></div>'); });
if (need.length) items.push('<div class="attn warn"><span class="ico" aria-hidden="true">!</span><div><h3>' + need.length + ' prints need a spool or weight</h3><p>Their filament hasn\'t been deducted and their cost is incomplete.' + (withSug ? ' ' + withSug + ' have a same-color suggestion ready.' : '') + '</p></div><a class="btn btn-sm btn-primary" href="03-prints.html?tab=needs">Review ' + need.length + '</a></div>');
if (lowS.length) items.push('<div class="attn warn"><span class="ico" aria-hidden="true">▁</span><div><h3>' + lowS.length + ' spools under 200 g <span class="swrow">' + lowS.map(function (s) { return x.sw(s.c); }).join('') + '</span></h3><p>' + lowS.filter(function (s) { return s.cont; }).length + ' of them are loaded in a printer right now.</p></div><a class="btn btn-sm" href="02-filament.html?f=low">See low stock</a></div>');
if (tareS.length) items.push('<div class="attn info"><span class="ico" aria-hidden="true">?</span><div><h3>' + tareS.length + ' spools have an unverified empty spool weight</h3><p>' + tareU + ' unknown, so they can\'t be weighed yet. ' + (tareS.length - tareU) + ' use an estimated figure, so their weigh-ins could be off. Weighing an empty spool settles it.</p></div><a class="btn btn-sm" href="02-filament.html?f=unverified">Review empty spool weights</a></div>');
return items.length ? items.join('') : '<div class="attn-done nm-inset">✓ Nothing needs you right now.</div>';
}
function feed() {
var groups = [];
PRINTS.slice(0, 7).forEach(function (p) { var w = x.when(p.start), d = /^(Today|Yesterday)/.test(w) ? w.split(' ')[0] : w.split(' ').slice(0, 2).join(' '); var last = groups[groups.length - 1]; if (!last || last.d !== d) groups.push(last = { d: d, list: [] }); last.list.push(p); });
return groups.map(function (grp) {
return '<div class="day">' + grp.d + '</div>' + grp.list.map(function (p) {
var n = x.needs(p), pr = x.printer(p.printer);
return '<button class="feed-row" type="button" data-print="' + p.id + '"><span class="when">' + x.printSt(p) + '<span class="sub">' + x.when(p.start).split(' ').pop() + '</span></span>' +
'<span style="min-width:0"><span class="job" style="display:block">' + x.esc(p.job) + '</span><span class="sub">' + x.esc(pr.model) + (p.open && !pr.connected ? ' · <span class="prov warn"><i>⚡</i>open across outage</span>' : '') + (n.length ? ' · <span class="prov warn"><i>!</i>needs ' + n.map(function (i) { return i.t; }).join(', ') + '</span>' : '') + '</span></span>' +
'<span class="r hide-s">' + x.durCell(p) + '</span><span class="r">' + (p.grams == null ? '<span class="muted">—</span>' : '<b>' + x.g(p.grams) + '</b><span class="sub">' + x.weightProv(p) + '</span>') + '</span></button>';
}).join('');
}).join('');
}
function printers() {
return PRINTERS.map(function (p) {
return '<a class="side-row" href="04-printers.html#printer=' + p.id + '"><span>' + x.printerSt(p) + '</span><span style="min-width:0"><b style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + x.esc(p.name) + '</b><span class="sub">' + (p.connected ? (p.job ? x.esc(p.job) : 'last: Benchy_x4') : 'last known: printing ' + x.esc(p.job)) + '</span></span><span class="sub">' + (p.connected ? x.ago(p.age) : '<span class="prov warn">' + x.ago(p.age) + '</span>') + '</span></a>';
}).join('');
}
function lows() {
return SPOOLS.filter(x.low).sort(function (a, b) { return a.rem - b.rem; }).map(function (s) {
return '<button class="side-row" type="button" data-spool="' + s.id + '">' + x.sw(s.c) + '<span style="min-width:0"><b>' + x.esc(s.color) + '</b> <span class="sub" style="display:inline">#' + s.id + '</span><span class="sub">' + x.esc(x.fullName(s)) + ' · ' + x.esc(x.where(s).short) + '</span></span><span class="num strong" style="color:var(--status-warning)">' + x.g(s.rem) + '</span></button>';
}).join('');
}
function render() {
var today = PRINTS.filter(function (p) { return /^Today/.test(x.when(p.start)) && p.grams != null; });
var usedG = today.reduce(function (a, p) { return a + p.grams; }, 0), usedC = today.reduce(function (a, p) { return a + (p.cost || 0); }, 0);
return '<header class="page-head"><div><h1>Dashboard</h1><p class="sum">Friday 18 September · <b>' + PRINTERS.length + '</b> printers · <b>' + CANARY.spools + '</b> spools</p></div><button class="btn btn-primary primary-desktop" type="button" data-add-spool>+ Add spool</button></header>' +
'<section aria-labelledby="needs-h" class="stack" style="gap:12px"><h2 class="section" id="needs-h" style="margin:0">Needs you</h2><div class="attn-list">' + attention() + '</div></section>' +
'<section class="stats" aria-label="Stock">' +
'<a class="stat nm-raised tile-link" href="02-filament.html"><span class="stat-label">Spools</span><span class="stat-value">' + CANARY.spools + '</span><span class="stat-foot">' + CANARY.brands + ' brands · 6 materials →</span></a>' +
'<div class="stat nm-raised"><span class="stat-label">Filament left</span><span class="stat-value">' + CANARY.kg.toFixed(2) + ' kg</span><span class="stat-foot">net: filament only, no spools</span></div>' +
'<div class="stat nm-raised"><span class="stat-label">Stock value</span><span class="stat-value">' + x.usd(CANARY.value) + '</span><span class="stat-foot">what\'s left × each $/kg</span></div>' +
'<a class="stat nm-raised tile-link" href="03-prints.html"><span class="stat-label">Used today</span><span class="stat-value">' + x.g(usedG) + '</span><span class="stat-foot">' + x.usd(usedC) + ' · ' + today.length + ' done · 2 running →</span></a>' +
'</section>' +
'<div class="dash"><section class="nm-raised" style="padding:0;overflow:hidden" aria-labelledby="act-h"><div class="card-h"><h2 id="act-h">Recent prints</h2><a href="03-prints.html">All prints →</a></div><div class="feed">' + feed() + '</div></section>' +
'<div class="stack" style="gap:24px"><section class="nm-raised" style="padding:0;overflow:hidden" aria-labelledby="pr-h"><div class="card-h"><h2 id="pr-h">Printers</h2><a href="04-printers.html">Open →</a></div>' + printers() + '</section>' +
'<section class="nm-raised" style="padding:0;overflow:hidden" aria-labelledby="low-h"><div class="card-h"><h2 id="low-h">Running low</h2><a href="02-filament.html?f=low">All →</a></div>' + lows() + '</section></div></div>' +
'<button class="btn btn-primary fab" type="button" data-add-spool aria-label="Add spool">+</button>';
}
X.init({ name: 'dashboard', refresh: function () { document.getElementById('page').innerHTML = render(); } }, render);
})();
</script>
+115
View File
@@ -0,0 +1,115 @@
<style>
.toolbar { padding: var(--space-4); display: flex; flex-direction: column; gap: var(--space-3); }
.toolbar .row1 { display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; }
.searchbox { position: relative; flex: 1; min-width: 220px; }
.searchbox svg { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; color: var(--text-secondary); }
.searchbox .input { padding-left: 36px; }
.ctl { display: inline-flex; gap: 8px; align-items: center; font-size: var(--fs-label); color: var(--text-secondary); }
.ctl .select { min-height: 36px; padding: 6px 10px; width: auto; font-size: 13px; }
.grp td { background: var(--surface-inset); font-size: var(--fs-caption); text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); font-weight: var(--fw-bold); color: var(--text-secondary); padding: 8px 12px; }
.spcell { display: flex; gap: var(--space-3); align-items: center; min-width: 220px; }
.remcell { min-width: 160px; }
.remcell .top { display: flex; justify-content: space-between; gap: 8px; margin-bottom: 5px; }
.rowact { display: flex; gap: 6px; justify-content: flex-end; }
.rowact .btn { min-height: 30px; padding: 4px 10px; }
.weigh-hint { display: flex; gap: var(--space-3); align-items: center; padding: var(--space-3) var(--space-4); border-radius: var(--radius-md); background: var(--accent-wash); border: 1px solid var(--text-accent); font-size: 13px; }
@media (max-width: 1180px) { .wl { display: none; } .rowact .btn { white-space: nowrap; } }
@media (max-width: 760px) { .hide-m { display: none; } .spcell { min-width: 0; } .remcell { min-width: 110px; } .rowact .menu-wrap .btn { padding: 4px 8px; } }
</style>
<script>
(function () {
var x = X;
var q = new URLSearchParams(location.search);
var S = { view: ({ tare: 'unverified' })[q.get('f')] || q.get('f') || 'all', q: q.get('q') || '', mat: '', sort: 'rem', dir: 1, cols: 'stock', group: 'none' };
var VIEWS = [
['all', 'All', function () { return true; }],
['low', 'Low', x.low],
['loaded', 'Loaded', function (s) { return !!s.cont; }],
['storage', 'In storage', function (s) { return !s.cont; }],
['unverified', 'Empty spool weight unverified', x.unverified]
];
function viewFn(k) { for (var i = 0; i < VIEWS.length; i++) if (VIEWS[i][0] === k) return VIEWS[i]; return VIEWS[0]; }
function list() {
var f = viewFn(S.view)[2], qq = S.q.toLowerCase();
var l = SPOOLS.filter(function (s) { return f(s) && (!S.mat || s.mat === S.mat) && (!qq || ('#' + s.id + ' ' + x.fullName(s) + ' ' + s.color + ' ' + x.where(s).text).toLowerCase().indexOf(qq) >= 0); });
var key = { rem: function (s) { return s.rem; }, name: function (s) { return (s.color + s.brand).toLowerCase(); }, value: function (s) { return x.value(s); }, gross: function (s) { return x.gross(s) == null ? -1 : x.gross(s); } }[S.sort];
l.sort(function (a, b) { var A = key(a), B = key(b); return (A < B ? -1 : A > B ? 1 : 0) * S.dir; });
if (S.group !== 'none') { var gk = S.group === 'mat' ? function (s) { return s.mat; } : function (s) { return x.where(s).loaded ? x.where(s).printer.name : 'Storage'; }; l.sort(function (a, b) { return gk(a) < gk(b) ? -1 : gk(a) > gk(b) ? 1 : 0; }); l.gk = gk; }
return l;
}
function th(label, key, cls) { var s = S.sort === key ? (S.dir === 1 ? 'ascending' : 'descending') : 'none'; return '<th class="' + (cls || '') + '" aria-sort="' + s + '"><button class="sort" type="button" data-sort="' + key + '">' + label + '</button></th>'; }
function rowMenu(s) {
return '<div class="menu-wrap"><button class="btn btn-sm btn-icon" type="button" data-menu="rm' + s.id + '" aria-haspopup="menu" aria-expanded="false" aria-label="Actions for ' + x.esc(s.color + ' ' + s.brand) + '">⋯</button><div class="menu" id="rm' + s.id + '" role="menu">' +
'<button role="menuitem" type="button" data-spool="' + s.id + '">Open<span class="hint">↵</span></button><button role="menuitem" type="button" data-spool="' + s.id + '" data-tab-open="weigh">Weigh</button><button role="menuitem" type="button" data-spool="' + s.id + '" data-tab-open="move">' + (s.cont ? 'Move / unload' : 'Load into printer') + '</button><button role="menuitem" type="button" data-spool="' + s.id + '" data-tab-open="edit">Edit</button>' +
(s.label ? '' : '<button role="menuitem" type="button" data-fake="Label PDF generated for #' + s.id + '">Print label</button>') + '<hr><button role="menuitem" type="button" class="danger" data-spool="' + s.id + '" data-then-delete>Delete…</button></div></div>';
}
function rows(l) {
var out = '', last = null, cols = S.cols === 'stock' ? 5 : 5;
l.forEach(function (s) {
if (l.gk) { var gv = l.gk(s); if (gv !== last) { var n = l.filter(function (y) { return l.gk(y) === gv; }), tot = n.reduce(function (a, y) { return a + y.rem; }, 0); out += '<tr class="grp"><td colspan="' + cols + '">' + x.esc(gv) + ' · ' + n.length + ' spools · ' + x.kg(tot) + '</td></tr>'; last = gv; } }
var w = x.where(s), gr = x.gross(s);
out += '<tr class="clickable" tabindex="0" data-row-spool="' + s.id + '" aria-label="' + x.esc(s.color + ', ' + x.fullName(s) + ', ' + s.rem + ' grams') + '">' +
'<td><div class="spcell">' + x.sw(s.c) + '<span style="min-width:0"><span class="strong">' + x.esc(s.color) + '</span> <span class="sub" style="display:inline">#' + s.id + '</span><span class="sub">' + x.esc(x.fullName(s)) + '</span></span></div></td>' +
'<td><div class="remcell"><div class="top"><b class="num"' + (x.low(s) ? ' style="color:var(--status-warning)"' : '') + '>' + x.g(s.rem) + '</b><span class="sub" style="display:inline">of ' + x.g(s.tot) + '</span></div>' + x.meter(s) + '</div></td>' +
(S.cols === 'stock'
? '<td class="hide-m">' + (w.loaded ? '<span class="st run" style="font-weight:500">' + x.esc(w.short) + '</span>' : '<span class="muted">' + x.esc(w.text) + '</span>') + '</td><td class="r hide-m hide-t"><span class="num">' + x.usd(x.value(s)) + '</span><span class="sub">' + x.usd(s.kg) + '/kg</span></td>'
: '<td class="r hide-m">' + (s.tare == null ? '<span class="prov warn"><i>?</i>unknown</span>' : '<span class="num">' + x.g(s.tare) + '</span><span class="sub">' + x.tareProv(s) + '</span>') + '</td><td class="r hide-m">' + (gr == null ? '<span class="muted">unknown</span>' : '<b class="num">' + x.g(gr) + '</b><span class="sub">' + x.remProv(s) + '</span>') + '</td>') +
'<td><div class="rowact">' + (s.tare != null ? '<button class="btn btn-sm hide-m" type="button" data-spool="' + s.id + '" data-tab-open="weigh" aria-label="Weigh ' + x.esc(s.color) + ' #' + s.id + '">⚖<span class="wl"> Weigh</span></button>' : '') + rowMenu(s) + '</div></td></tr>';
});
return out;
}
function render() {
var l = list(), all = SPOOLS.length;
x.D.list = l.map(function (s) { return s.id; });
var chips = [];
if (S.view !== 'all') chips.push(['view', viewFn(S.view)[1]]);
if (S.mat) chips.push(['mat', S.mat]);
if (S.q) chips.push(['q', '“' + S.q + '”']);
var weighable = l.filter(function (s) { return s.tare != null; });
return '<header class="page-head"><div><h1>Filament</h1><p class="sum"><b>' + CANARY.spools + '</b> spools · <b>' + CANARY.kg.toFixed(2) + ' kg</b> net · <b>' + x.usd(CANARY.value) + '</b> · sample page of ' + all + '</p></div>' +
'<div class="actions"><div class="menu-wrap"><button class="btn" type="button" data-menu="pgmenu" aria-haspopup="menu" aria-expanded="false">More ▾</button><div class="menu" id="pgmenu" role="menu"><button role="menuitem" type="button" data-fake="Labels PDF for 4 spools generated">Print ' + SPOOLS.filter(function (s) { return !s.label; }).length + ' missing labels</button><button role="menuitem" type="button" data-soon="Lists">Manage lists (brands, materials…)</button></div></div>' +
'<button class="btn btn-primary primary-desktop" type="button" data-add-spool>+ Add spool</button></div></header>' +
'<section class="toolbar nm-raised" aria-label="Find spools">' +
'<div class="tabs" role="tablist" aria-label="Saved views">' + VIEWS.map(function (v) { var n = SPOOLS.filter(v[2]).length; return '<button role="tab" type="button" data-view="' + v[0] + '" aria-selected="' + (S.view === v[0]) + '">' + v[1] + ' <span class="n' + ((v[0] === 'low' || v[0] === 'unverified') && n ? ' warn' : '') + '">' + n + '</span></button>'; }).join('') + '</div>' +
'<div class="row1"><label class="searchbox">' + X.ICON.search + '<input class="input" id="fq" type="search" value="' + x.esc(S.q) + '" placeholder="Filter by color, brand, #id, location…" aria-label="Filter spools"></label>' +
'<label class="ctl">Material <select class="select" id="fmat"><option value="">Any</option>' + ['PLA', 'PETG', 'ASA', 'TPU', 'PA6', 'ABS'].map(function (m) { return '<option' + (S.mat === m ? ' selected' : '') + '>' + m + '</option>'; }).join('') + '</select></label>' +
'<label class="ctl">Group <select class="select" id="fgroup"><option value="none">None</option><option value="mat"' + (S.group === 'mat' ? ' selected' : '') + '>Material</option><option value="loc"' + (S.group === 'loc' ? ' selected' : '') + '>Location</option></select></label>' +
'<div class="tabs" role="group" aria-label="Columns"><button type="button" data-cols="stock" aria-selected="' + (S.cols === 'stock') + '">Stock</button><button type="button" data-cols="weights" aria-selected="' + (S.cols === 'weights') + '">Weights</button></div></div>' +
(chips.length ? '<div class="fchips" aria-label="Active filters"><span class="sub" style="display:inline">' + l.length + ' of ' + all + ' shown</span>' + chips.map(function (c) { return '<span class="fchip">' + x.esc(c[1]) + '<button type="button" data-unchip="' + c[0] + '" aria-label="Remove filter ' + x.esc(c[1]) + '">✕</button></span>'; }).join('') + '<button class="linkish small" type="button" data-unchip="all">Clear all</button></div>' : '') +
'</section>' +
(weighable.length > 1 && S.view !== 'all' ? '<div class="weigh-hint"><b>⚖</b><span style="flex:1">Weighing these? Weigh all ' + weighable.length + ', lightest first. Each step shows where the spool lives and the result before you save.</span><button class="btn btn-sm btn-primary" type="button" data-weigh-session>Start weighing</button></div>' : '') +
'<section class="nm-raised table-card" aria-label="Spools">' + (l.length ? '<div class="table-scroll"><table class="tbl"><thead><tr>' + th('Spool', 'name') + th('Remaining <abbr title="net filament">net</abbr>', 'rem') +
(S.cols === 'stock' ? '<th class="hide-m">Location</th>' + th('Value', 'value', 'r hide-m hide-t') : '<th class="r hide-m">Empty spool</th>' + th('Gross (scale)', 'gross', 'r hide-m')) + '<th class="r"><span class="sr-only">Actions</span></th></tr></thead><tbody>' + rows(l) + '</tbody></table></div>' +
'<div class="table-foot"><span class="muted">Click a row or press ↵ to open · <span class="kbd">j</span>/<span class="kbd">k</span> step through in the panel</span><span class="muted">1–' + l.length + ' of ' + (S.view === 'all' && !S.q && !S.mat ? CANARY.spools : l.length) + '</span></div>'
: '<div class="empty"><h3>No spools match</h3><p>' + (S.q ? 'Nothing matches “' + x.esc(S.q) + '”' : 'Nothing in this view') + (S.mat ? ' in ' + S.mat : '') + '.</p><button class="btn btn-sm" type="button" data-unchip="all">Clear filters</button></div>') + '</section>' +
'<button class="btn btn-primary fab" type="button" data-add-spool aria-label="Add spool">+</button>';
}
function redraw(keepFocus) {
var a = document.activeElement, id = a && a.id, pos = a && a.selectionStart;
document.getElementById('page').innerHTML = render();
if (keepFocus && id && document.getElementById(id)) { var el = document.getElementById(id); el.focus(); if (pos != null && el.setSelectionRange) el.setSelectionRange(pos, pos); }
var p = new URLSearchParams(); if (S.view !== 'all') p.set('f', S.view); if (S.q) p.set('q', S.q);
history.replaceState(history.state, '', location.pathname + (p.toString() ? '?' + p : '') + location.hash);
if (x.D.kind === 'spool') document.querySelectorAll('[data-row-spool="' + x.D.id + '"]').forEach(function (r) { r.classList.add('is-selected'); });
}
document.addEventListener('click', function (e) {
var t = e.target, a;
if ((a = t.closest('[data-view]'))) { S.view = a.getAttribute('data-view'); return redraw(); }
if ((a = t.closest('[data-cols]'))) { S.cols = a.getAttribute('data-cols'); return redraw(); }
if ((a = t.closest('[data-sort]'))) { var k = a.getAttribute('data-sort'); S.dir = S.sort === k ? -S.dir : 1; S.sort = k; return redraw(); }
if ((a = t.closest('[data-unchip]'))) { var c = a.getAttribute('data-unchip'); if (c === 'view' || c === 'all') S.view = 'all'; if (c === 'mat' || c === 'all') S.mat = ''; if (c === 'q' || c === 'all') S.q = ''; return redraw(); }
if (t.closest('[data-weigh-session]')) { var first = list().filter(function (s) { return s.tare != null; }).sort(function (a, b) { return a.rem - b.rem; }); x.D.list = first.map(function (s) { return s.id; }); x.D.session = true; x.openDrawer('spool', first[0].id, 'weigh'); x.D.session = true; x.D.list = first.map(function (s) { return s.id; }); return; }
if ((a = t.closest('[data-then-delete]'))) { setTimeout(function () { var b = document.querySelector('#drawer [data-delete]'); if (b) b.click(); }, 50); return; }
if (t.closest('button, a, .menu')) return;
if ((a = t.closest('[data-row-spool]'))) { x.D.list = list().map(function (s) { return s.id; }); x.openDrawer('spool', +a.getAttribute('data-row-spool')); }
});
document.addEventListener('input', function (e) {
if (e.target.id === 'fq') { S.q = e.target.value; redraw(true); }
});
document.addEventListener('change', function (e) {
if (e.target.id === 'fmat') { S.mat = e.target.value; redraw(); }
if (e.target.id === 'fgroup') { S.group = e.target.value; redraw(); }
});
X.init({ name: 'filament', refresh: function () { redraw(); } }, render);
})();
</script>
+84
View File
@@ -0,0 +1,84 @@
<style>
.qcard { padding: var(--space-4) var(--space-5); display: grid; grid-template-columns: auto minmax(0, 1fr) minmax(260px, 1.1fr); gap: var(--space-4); align-items: start; }
.qcard.is-done { opacity: .55; }
.qcard .what { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.qcard h3 { margin: 0; font-size: 15px; }
.fixes { display: flex; flex-direction: column; gap: var(--space-3); }
.fix { display: flex; flex-direction: column; gap: 6px; }
.fix .lab { font-size: var(--fs-caption); text-transform: uppercase; letter-spacing: var(--tracking-caps); color: var(--status-warning); font-weight: var(--fw-bold); }
.sug { display: flex; gap: 10px; align-items: center; padding: 8px 10px; border-radius: var(--radius-md); border: 1px dashed var(--text-accent); background: var(--accent-wash); font-size: 13px; }
.batch { display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; padding: var(--space-3) var(--space-4); }
.chk { width: 20px; height: 20px; accent-color: var(--text-accent); }
.filters { display: flex; gap: var(--space-3); flex-wrap: wrap; align-items: center; }
.filters .select, .filters .input { width: auto; min-height: 36px; padding: 6px 10px; font-size: 13px; }
.jobcell { min-width: 200px; }
@media (max-width: 900px) { .qcard { grid-template-columns: auto minmax(0, 1fr); } .qcard .fixes { grid-column: 1 / -1; } }
@media (max-width: 760px) { .hide-m { display: none; } .jobcell { min-width: 0; } }
</style>
<script>
(function () {
var x = X;
var q = new URLSearchParams(location.search);
var S = { tab: q.get('tab') || (x.printsNeeding().length ? 'needs' : 'all'), printer: '', status: '', q: '', sel: {}, done: {} };
function fixUI(p) {
var out = '';
if (p.grams == null) out += '<div class="fix"><span class="lab">! Filament weight</span><span class="row" style="gap:8px"><input class="input num" style="max-width:130px;min-height:36px" placeholder="grams" inputmode="numeric" aria-label="Grams used by ' + x.esc(p.job) + '"><button class="btn btn-sm" type="button" data-done="' + p.id + '" data-msg="Saved weight for ' + x.esc(p.job) + ' · remembered for ' + x.esc(p.key) + '">Save</button></span><span class="sub">From the slicer. Remembered for this gcode path.</span></div>';
var slots = p.filaments ? p.filaments.filter(function (f) { return f.spool == null; }) : (p.spool == null ? [{ suggested: p.suggested, slot: null, grams: p.grams }] : []);
slots.forEach(function (f) {
var s = f.suggested && x.spool(f.suggested);
out += '<div class="fix"><span class="lab">! Spool' + (f.slot != null ? ' for color ' + (f.slot + 1) + ' <span class="swatch swatch-sm" style="vertical-align:-3px"><i style="background:' + f.hex + '"></i></span>' : '') + '</span>' +
(s ? '<div class="sug">' + x.sw(s.c, 'swatch-sm') + '<span style="flex:1;min-width:0"><b>' + x.esc(x.short(s)) + '</b> <span class="sub" style="display:inline">#' + s.id + '</span><span class="sub">Same color, loaded in ' + x.esc(x.where(s).short) + '. Not charged until you confirm.</span></span><button class="btn btn-sm btn-primary" type="button" data-done="' + p.id + '" data-msg="Attributed ' + x.esc(p.job) + ' to ' + x.esc(x.short(s)) + (f.grams ? ' · ' + x.g(f.grams) + ' deducted' : '') + '">Use</button></div>' : '') +
'<select class="select" style="min-height:36px" aria-label="Pick spool"><option>' + (s ? 'Or pick another…' : 'Pick a spool…') + '</option>' + SPOOLS.slice(0, 10).map(function (sp) { return '<option>' + x.g(sp.rem) + ' · ' + x.esc(x.short(sp)) + ' · ' + sp.mat + '</option>'; }).join('') + '</select></div>';
});
return out;
}
function needsTab() {
var l = x.printsNeeding();
if (!l.length) return '<div class="nm-raised empty"><h3>✓ Every print is attributed</h3><p>New prints appear here when they end without a spool or weight.</p></div>';
var nsel = Object.keys(S.sel).filter(function (k) { return S.sel[k]; }).length;
return '<div class="batch nm-inset"><label class="row small" style="gap:8px"><input type="checkbox" class="chk" data-selall ' + (nsel === l.length ? 'checked' : '') + '> Select all ' + l.length + '</label><span class="spacer"></span><span class="small muted">' + nsel + ' selected</span><button class="btn btn-sm" type="button" ' + (nsel ? 'data-fake="Attributed ' + nsel + ' prints to one spool"' : 'disabled') + '>Attribute selected to one spool…</button></div>' +
'<p class="small muted" style="margin:0">Oldest first. Until these have a spool and a weight, nothing is deducted and cost stays incomplete.</p>' +
'<div class="stack" style="gap:12px">' + l.slice().reverse().map(function (p) {
var pr = x.printer(p.printer), done = S.done[p.id];
return '<article class="qcard nm-raised' + (done ? ' is-done' : '') + '"><input type="checkbox" class="chk" data-sel="' + p.id + '" aria-label="Select ' + x.esc(p.job) + '" ' + (S.sel[p.id] ? 'checked' : '') + '>' +
'<div class="what"><h3><button class="linkish" type="button" data-print="' + p.id + '" style="text-decoration:none;color:var(--text-primary)">' + x.esc(p.job) + '</button></h3><span class="sub">' + x.esc(pr.name) + ' · ' + x.when(p.end || p.start) + '</span><span class="row small" style="gap:12px;margin-top:4px">' + x.printSt(p) + '<span>' + x.durCell(p).replace(/<span class="sub[^"]*">/g, ' <span class="sub" style="display:inline">') + '</span>' + (p.grams != null ? '<span><b>' + x.g(p.grams) + '</b> ' + x.weightProv(p) + '</span>' : '') + '</span>' +
(p.filaments ? '<span class="sub">' + p.filaments.length + ' colors · ' + p.filaments.filter(function (f) { return f.spool; }).length + ' attributed</span>' : '') + '</div>' +
'<div class="fixes">' + (done ? '<span class="st ok">Done</span>' : fixUI(p)) + '</div></article>';
}).join('') + '</div>';
}
function allTab(filter) {
var l = PRINTS.filter(function (p) { return filter(p) && (!S.printer || p.printer === +S.printer) && (!S.status || p.status === S.status) && (!S.q || (p.job + ' ' + p.key).toLowerCase().indexOf(S.q.toLowerCase()) >= 0); });
var head = '<div class="filters"><input class="input" id="pq" type="search" placeholder="Job name or path…" value="' + x.esc(S.q) + '" aria-label="Filter prints"><select class="select" id="pp" aria-label="Printer"><option value="">All printers</option>' + PRINTERS.map(function (p) { return '<option value="' + p.id + '"' + (+S.printer === p.id ? ' selected' : '') + '>' + x.esc(p.name) + '</option>'; }).join('') + '</select>' +
'<select class="select" id="ps" aria-label="Status"><option value="">Any status</option>' + ['running', 'finished', 'failed', 'unknown'].map(function (s) { return '<option' + (S.status === s ? ' selected' : '') + '>' + s + '</option>'; }).join('') + '</select><span class="small muted">' + l.length + ' prints</span></div>';
if (!l.length) return head + '<div class="nm-raised empty"><h3>No prints match</h3><p>Change the printer, status or search.</p></div>';
return head + '<section class="nm-raised table-card"><div class="table-scroll"><table class="tbl"><thead><tr><th>Job</th><th>Status</th><th class="r hide-m">Duration</th><th class="r">Filament</th><th class="hide-m">Spool</th><th class="r hide-m hide-t">Cost</th><th class="hide-m hide-t">Use</th></tr></thead><tbody>' +
l.map(function (p) { var pr = x.printer(p.printer), n = x.needs(p); return '<tr class="clickable" tabindex="0" data-row-print="' + p.id + '"><td><div class="jobcell"><span class="strong">' + x.esc(p.job) + '</span><span class="sub">' + x.esc(pr.model) + ' · ' + x.when(p.start) + '</span></div></td><td>' + x.printSt(p) + (n.length ? '<span class="sub prov warn"><i>!</i>needs ' + n.map(function (i) { return i.k; }).join(' + ') + '</span>' : '') + '</td><td class="r hide-m">' + x.durCell(p) + '</td><td class="r">' + (p.grams == null ? '<span class="muted">—</span>' : '<b class="num">' + x.g(p.grams) + '</b><span class="sub">' + x.weightProv(p) + '</span>') + '</td><td class="hide-m">' + x.spoolLinks(p) + '</td><td class="r hide-m hide-t">' + (p.cost == null ? '<span class="muted">pending</span>' : x.usd(p.cost)) + '</td><td class="hide-m hide-t">' + (p.use ? '<span class="small">' + p.use + '</span>' : '<span class="muted small">not recorded</span>') + '</td></tr>'; }).join('') +
'</tbody></table></div><div class="table-foot"><span class="muted">Durations marked ≤ span a connection gap and are upper bounds. Hover for why.</span><span class="muted">' + l.length + ' of ' + PRINTS.length + '</span></div></section>';
}
function render() {
var n = x.printsNeeding().length, run = PRINTS.filter(function (p) { return p.status === 'running'; }).length;
var body = S.tab === 'needs' ? needsTab() : S.tab === 'all' ? allTab(function () { return true; }) : S.tab === 'running' ? allTab(function (p) { return p.status === 'running'; }) : '<div class="nm-raised empty"><h3>No archived prints</h3><p>Archiving hides a print from history. Its grams and cost still count.</p></div>';
return '<header class="page-head"><div><h1>Prints</h1><p class="sum"><b>' + PRINTS.length + '</b> captured (sample) · <b>' + run + '</b> running · <b>' + n + '</b> need attention</p></div></header>' +
'<div class="tabs" role="tablist" aria-label="Print lists">' + [['needs', 'Needs attention', n, 1], ['all', 'All', PRINTS.length], ['running', 'Running', run], ['archived', 'Archived', 0]].map(function (t) { return '<button role="tab" type="button" data-ptab="' + t[0] + '" aria-selected="' + (S.tab === t[0]) + '">' + t[1] + ' <span class="n' + (t[3] && t[2] ? ' warn' : '') + '">' + t[2] + '</span></button>'; }).join('') + '</div>' +
'<div class="stack" style="gap:16px">' + body + '</div>';
}
function redraw(focusId) {
var pos = focusId && document.getElementById(focusId) && document.getElementById(focusId).selectionStart;
document.getElementById('page').innerHTML = render();
if (focusId && document.getElementById(focusId)) { var el = document.getElementById(focusId); el.focus(); if (pos != null) el.setSelectionRange(pos, pos); }
history.replaceState(history.state, '', location.pathname + '?tab=' + S.tab + location.hash);
}
document.addEventListener('click', function (e) {
var t = e.target, a;
if ((a = t.closest('[data-ptab]'))) { S.tab = a.getAttribute('data-ptab'); return redraw(); }
if ((a = t.closest('[data-done]'))) { var id = +a.getAttribute('data-done'), pp = x.print(id); if (/weight/.test(a.getAttribute('data-msg'))) { pp.grams = 18; pp.wsrc = 'typed'; } else if (pp.filaments) { pp.filaments.forEach(function (f) { if (f.spool == null) f.spool = f.suggested || 52; }); } else { pp.spool = pp.suggested || 52; } X.toast(a.getAttribute('data-msg') + ' <a href="#print=' + id + '" data-print="' + id + '">View</a>'); return redraw(); }
if (t.matches('[data-selall]')) { x.printsNeeding().forEach(function (p) { S.sel[p.id] = t.checked; }); return redraw(); }
if (t.matches('[data-sel]')) { S.sel[t.getAttribute('data-sel')] = t.checked; return redraw(); }
if (t.closest('button, a, input, select')) return;
if ((a = t.closest('[data-row-print]'))) x.openDrawer('print', +a.getAttribute('data-row-print'));
});
document.addEventListener('input', function (e) { if (e.target.id === 'pq') { S.q = e.target.value; redraw('pq'); } });
document.addEventListener('change', function (e) { if (e.target.id === 'pp') { S.printer = e.target.value; redraw(); } if (e.target.id === 'ps') { S.status = e.target.value; redraw(); } });
X.init({ name: 'prints', refresh: function () { redraw(); } }, render);
})();
</script>
+84
View File
@@ -0,0 +1,84 @@
<style>
.jump { display: flex; gap: var(--space-2); flex-wrap: wrap; align-items: center; }
.jump a { display: inline-flex; gap: 8px; align-items: center; padding: 6px 12px; border-radius: var(--radius-pill); border: 1px solid var(--border-control); text-decoration: none; color: var(--text-primary); font-size: 13px; background: var(--surface-raised); box-shadow: var(--nm-raised-1); }
.jump a:hover { border-color: var(--text-accent); }
.pgrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr)); gap: var(--space-6); align-items: start; }
.pcard { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-4); scroll-margin-top: 16px; }
.pcard.attn-card { border: 2px solid var(--status-warning); }
.phead { display: flex; gap: var(--space-3); align-items: flex-start; }
.phead h2 { margin: 0; font-size: var(--fs-h3); line-height: var(--lh-snug); }
.now { all: unset; box-sizing: border-box; cursor: pointer; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px var(--space-3); padding: var(--space-3) var(--space-4); border-radius: var(--radius-md); background: var(--surface-inset); box-shadow: var(--nm-inset-1); border: 1px solid var(--border-hairline); width: 100%; font-size: 13px; }
.now:hover { border-color: var(--text-accent); }
.now:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: 2px; }
.now.stale { border: 1px dashed var(--status-warning); }
.cam { aspect-ratio: 16 / 7; border-radius: var(--radius-md); background: radial-gradient(ellipse at 50% 95%, #5a4b3a 0 30%, transparent 31%), linear-gradient(180deg, #1c1d22 0%, #2b2c33 60%, #3a3326 100%); border: 1px solid var(--border-control); position: relative; overflow: hidden; }
.cam::after { content: ""; position: absolute; left: 34%; right: 34%; bottom: 18%; height: 26%; background: repeating-linear-gradient(0deg, #6b8fd6 0 3px, #5a7cc2 3px 6px); border-radius: 4px 4px 0 0; }
.cam .stamp { position: absolute; left: 8px; top: 6px; font-size: var(--fs-caption); color: #f4f4f6; background: rgba(0,0,0,.55); padding: 2px 8px; border-radius: 99px; z-index: 1; }
.cam-off { padding: var(--space-3); border-radius: var(--radius-md); border: 1px dashed var(--border-control); text-align: center; color: var(--text-secondary); font-size: var(--fs-label); }
.holder { padding: var(--space-3) var(--space-4); border-radius: var(--radius-md); background: var(--surface-inset); box-shadow: var(--nm-inset-1); border: 1px solid var(--border-hairline); display: flex; flex-direction: column; gap: 6px; }
.holder-h { display: flex; gap: var(--space-3); align-items: center; font-size: var(--fs-label); }
.fill { display: inline-flex; gap: 3px; }
.fill i { width: 14px; height: 8px; border-radius: 2px; border: 1px solid var(--border-control); }
.fill i.on { background: var(--text-accent); border-color: var(--text-accent); }
.hrow { all: unset; box-sizing: border-box; cursor: pointer; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: var(--space-3); align-items: center; padding: 6px 8px; margin: 0 -8px; border-radius: var(--radius-sm); font-size: 13px; }
.hrow:hover { background: var(--accent-wash); }
.hrow:focus-visible { outline: var(--focus-width) solid var(--focus-ring); }
.lost { padding: var(--space-4); border-radius: var(--radius-md); border: 1px solid var(--status-warning); background: var(--wash-warning); font-size: 13px; display: flex; flex-direction: column; gap: 6px; }
.lost h3 { margin: 0; font-size: 14px; color: var(--status-warning); }
.lost ul { margin: 0; padding-left: 18px; }
details.cfg summary { cursor: pointer; font-size: var(--fs-label); font-weight: var(--fw-bold); }
.future { padding: var(--space-6); border: 2px dashed var(--border-control); border-radius: var(--radius-lg); }
.fgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: var(--space-4); margin-top: var(--space-4); }
.fitem { padding: var(--space-4); border: 1px dashed var(--border-control); border-radius: var(--radius-md); font-size: var(--fs-label); display: flex; flex-direction: column; gap: 6px; }
.fitem b { font-size: 13px; }
body.dev-only [data-feature] { display: none !important; }
body:not(.dev-only) [data-devonly] { display: none !important; }
</style>
<script>
(function () {
var x = X, FEAT = '<span class="prov info" title="Only on the printer-connections feature branch"><i>◈</i>feature</span>';
function lastPrint(pr) { return PRINTS.filter(function (p) { return p.printer === pr.id && p.status !== 'running'; })[0]; }
function holder(c) {
var sp = x.contents(c.id), free = c.cap - sp.length;
return '<div class="holder"><div class="holder-h"><b>' + x.esc(c.label) + '</b><span class="fill" aria-label="' + sp.length + ' of ' + c.cap + ' in use">' + Array.apply(null, Array(c.cap)).map(function (_, i) { return '<i class="' + (i < sp.length ? 'on' : '') + '"></i>'; }).join('') + '</span><span class="muted">' + sp.length + ' / ' + c.cap + '</span><span class="spacer"></span>' +
(free > 0 ? '<button class="btn btn-sm" type="button" data-load-into="' + c.id + '" style="min-height:28px;padding:3px 10px">+ Load</button>' : '<span class="sub">full</span>') + '</div>' +
(sp.length ? sp.map(function (s) { return '<button class="hrow" type="button" data-spool="' + s.id + '">' + x.sw(s.c, 'swatch-sm') + '<span style="min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><b>' + x.esc(s.color) + '</b> <span class="muted">' + x.esc(s.brand + ' ' + s.mat + (s.type ? ' ' + s.type : '')) + '</span></span><span class="num' + (x.low(s) ? '" style="color:var(--status-warning);font-weight:700">⚠ ' : '">') + x.g(s.rem) + '</span></button>'; }).join('') : '<span class="sub">Empty</span>') + '</div>';
}
function card(pr) {
var open = pr.openPrint && x.print(pr.openPrint), last = lastPrint(pr), lostC = !pr.connected;
var conn = lostC
? '<div class="lost" role="status"><h3>⚡ Connection lost</h3><span data-feature>Reconnecting. Last contact <b>' + x.ago(pr.age) + '</b>. ' + FEAT + '</span><span data-devonly>Extrudex\'s MQTT session dropped. When it dropped isn\'t recorded on dev.</span>' +
'<ul class="small"><li>The printer may still be printing. This is Extrudex\'s connection, not the printer\'s power.</li><li><b>Capture is paused.</b> A job that starts and ends during the outage won\'t be recorded.</li><li>On reconnect: if <i>' + x.esc(pr.job) + '</i> is still running, it stays open with <b>+1 gap</b>; otherwise it closes as <b>unknown</b>.</li></ul></div>'
: '<div class="row small" style="gap:8px"><span class="fresh"><span class="dot"></span></span><span data-feature>Ready · last report ' + x.ago(pr.age) + ' ' + FEAT + '</span><span data-devonly>Connected</span></div>';
var now = open
? '<button class="now' + (pr.stale ? ' stale' : '') + '" type="button" data-print="' + open.id + '"><b>' + x.esc(open.job) + '</b><span class="num">' + x.dur(x.since(open.start)) + '</span>' + (pr.stale ? '<span class="sub" style="grid-column:1/-1">open record · started ' + x.when(open.start).split(' ').pop() + ' · watched until the drop</span><span class="sub" data-feature style="grid-column:1/-1"><span class="prov warn" style="white-space:normal">Last known: printing · ' + x.ago(pr.age) + '. Cached from the lost session, not live.</span></span>' : '<span class="sub">started ' + x.when(open.start).split(' ').pop() + ' · weight read from the 3MF when it ends</span><span class="sub">so far</span>') + '</button>'
: (last ? '<button class="now" type="button" data-print="' + last.id + '"><span><span class="sub" style="display:inline">Last · </span><b>' + x.esc(last.job) + '</b></span>' + x.printSt(last) + '<span class="sub">ended ' + x.when(last.end) + '</span><span class="sub">' + x.durCell(last).replace(/<span class="sub[^"]*">[^<]*<\/span>/, '') + '</span></button>' : '');
var cam = pr.connected ? '<div class="cam" role="img" aria-label="Camera snapshot from ' + x.esc(pr.name) + '"><span class="stamp">snapshot 09:41:0' + pr.id + '</span></div>' : '<div class="cam-off">Camera unavailable while disconnected</div>';
return '<article class="pcard nm-raised' + (lostC ? ' attn-card' : '') + '" id="printer-' + pr.id + '" aria-labelledby="ph' + pr.id + '"><div class="phead"><div style="flex:1;min-width:0"><h2 id="ph' + pr.id + '">' + x.esc(pr.name) + '</h2><span class="sub">Bambu Lab · ' + pr.model + '</span></div>' + x.printerSt(pr) +
'<div class="menu-wrap"><button class="btn btn-sm btn-icon" type="button" data-menu="pm' + pr.id + '" aria-haspopup="menu" aria-expanded="false" aria-label="Actions for ' + x.esc(pr.name) + '">⋯</button><div class="menu" id="pm' + pr.id + '" role="menu"><button role="menuitem" type="button" data-pedit="' + pr.id + '">Edit connection</button><button role="menuitem" type="button" data-holders="' + pr.id + '">Manage holders</button><hr><button role="menuitem" type="button" class="danger" data-premove="' + pr.id + '">Remove printer…</button></div></div></div>' +
conn + now + cam + '<div class="stack" style="gap:8px"><div class="row small"><b>Loaded filament</b><span class="sub" style="display:inline">recorded by Extrudex, not read from the printer</span></div>' + pr.containers.map(holder).join('') + '</div>' +
'<details class="cfg"><summary>Connection details</summary><dl class="kv" style="margin-top:10px"><dt>IP</dt><dd>' + x.esc(pr.ip) + '</dd><dt>Serial</dt><dd>' + x.esc(pr.serial) + '</dd><dt>Access code</dt><dd>✓ stored, never shown</dd>' +
'<dt data-feature>Lifecycle</dt><dd data-feature>' + pr.lifecycle + (pr.stale ? ' · <code>stale</code>' : '') + ' ' + FEAT + '</dd><dt data-feature>Session</dt><dd data-feature>#' + pr.session + ' (increments on reconnect)</dd></dl></details></article>';
}
function render() {
var order = PRINTERS.slice().sort(function (a, b) { var r = function (p) { return !p.connected ? 0 : p.state === 'RUNNING' ? 1 : 2; }; return r(a) - r(b); });
var nRun = PRINTERS.filter(function (p) { return p.connected && p.state === 'RUNNING'; }).length, nDown = PRINTERS.filter(function (p) { return !p.connected; }).length;
return '<header class="page-head"><div><h1>Printers</h1><p class="sum"><b>' + PRINTERS.length + '</b> printers · <b>' + nRun + '</b> printing · <b style="color:var(--status-warning)">' + nDown + '</b> disconnected · polled every 10 s, read-only MQTT</p></div>' +
'<div class="actions"><div class="tabs" role="group" aria-label="Data shown"><button type="button" data-ds="dev" aria-selected="' + document.body.classList.contains('dev-only') + '">dev today</button><button type="button" data-ds="feat" aria-selected="' + !document.body.classList.contains('dev-only') + '">+ feature branch</button></div><button class="btn btn-primary primary-desktop" type="button" data-pedit="new">+ Add printer</button></div></header>' +
'<nav class="jump" aria-label="Jump to printer"><span class="small muted">Jump to</span>' + order.map(function (p) { return '<a href="#printer=' + p.id + '">' + x.printerSt(p) + ' ' + x.esc(p.model) + '</a>'; }).join('') + '</nav>' +
'<div class="pgrid">' + order.map(card).join('') + '</div>' +
'<section class="future" aria-labelledby="fut-h"><h2 id="fut-h" style="margin:0;font-size:var(--fs-h3)"><span class="chip chip-neutral chip-dashed">Future, not built</span> Needs data Extrudex doesn\'t store</h2><div class="fgrid">' +
[['Progress % and time left', '<code>mc_percent</code> is parsed but not stored or served. ETA isn\'t read.'], ['Tray position in the AMS', 'Holders store which spools are in them, not which tray. The AMS tray index isn\'t recorded.'], ['Which spool is feeding now', '"Loaded" is recorded; "in use" isn\'t. Hence "not confirmed in use".'], ['Nozzle / bed temperature, layer', 'Not parsed from the report.'], ['AMS remaining % and humidity', 'Not read. <code>remain</code> only means something for RFID spools.'], ['Alert when MQTT drops', 'Only in container logs today. Dev doesn\'t store when it dropped.']].map(function (f) { return '<div class="fitem"><b>' + f[0] + '</b><span class="muted">' + f[1] + '</span></div>'; }).join('') + '</div></section>';
}
function focusPrinter() {
var m = /^#printer=(\d+)/.exec(location.hash); if (!m) return;
var el = document.getElementById('printer-' + m[1]); if (!el) return;
el.scrollIntoView({ block: 'start', behavior: 'smooth' }); el.classList.remove('is-flash'); void el.offsetWidth; el.classList.add('is-flash');
}
document.addEventListener('click', function (e) {
var a = e.target.closest('[data-ds]'); if (a) { document.body.classList.toggle('dev-only', a.getAttribute('data-ds') === 'dev'); document.getElementById('page').innerHTML = render(); }
});
window.addEventListener('hashchange', focusPrinter);
X.init({ name: 'printers', after: function () { setTimeout(focusPrinter, 60); }, refresh: function () { document.getElementById('page').innerHTML = render(); } }, render);
})();
</script>
+76
View File
@@ -0,0 +1,76 @@
<style>
.namebar { padding: var(--space-4) var(--space-5); display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center; }
.part { padding: 6px 12px; border-radius: var(--radius-sm); border: 1px dashed var(--border-control); font-weight: 700; color: var(--text-secondary); }
.part.on { border: 2px solid var(--text-accent); background: var(--accent-wash); color: var(--text-accent); }
.part small { display: block; font-size: 10px; font-weight: 500; text-transform: uppercase; letter-spacing: .08em; }
.ltool { display: flex; gap: var(--space-3); flex-wrap: wrap; align-items: center; padding: var(--space-4); }
.ltool .input { width: auto; flex: 1; min-width: 180px; min-height: 38px; }
.rename { display: flex; gap: 6px; align-items: center; }
.rename .input { min-height: 34px; padding: 5px 10px; max-width: 280px; }
.used a { font-variant-numeric: tabular-nums; }
.addrow td { background: var(--surface-inset); }
@media (max-width: 699px) {
.tbl.stackable thead { display: none; }
.tbl.stackable tr { display: grid; grid-template-columns: 1fr auto; gap: 4px 12px; padding: 12px 14px; border-bottom: 1px solid var(--border-hairline); }
.tbl.stackable td { border: 0; padding: 0; }
.tbl.stackable td.acts { grid-column: 1 / -1; }
.tbl.stackable tr.addrow { grid-template-columns: 1fr; }
}
</style>
<script>
(function () {
var x = X;
var KINDS = [['brands', 'Brands', 0], ['speeds', 'Print speeds', 1], ['materials', 'Materials', 2], ['types', 'Filament types', 3], ['finishes', 'Finishes', 4], ['spool_types', 'Spool types', -1]];
var q = new URLSearchParams(location.search);
var S = { kind: q.get('list') || 'brands', f: '', unused: false, editing: null, conflict: false, addErr: false };
var SRC = { manufacturer: '<span class="prov"><i>◆</i>manufacturer</span>', community: '<span class="prov"><i>◇</i>community</span>', estimated: '<span class="prov warn"><i>≈</i>estimated</span>' };
function kindRow(k) { for (var i = 0; i < KINDS.length; i++) if (KINDS[i][0] === k) return KINDS[i]; }
function namebar() {
var k = kindRow(S.kind), parts = [['Sunlu', 'brand', 1], ['Rapid', 'speed', 0], ['PETG', 'material', 1], ['CF', 'type', 0], ['Silk', 'finish', 1]];
if (k[2] < 0) return '<section class="namebar nm-inset" aria-label="What this list does"><span class="small"><b>Spool types</b> are physical spool designs and what they weigh empty. A spool\'s empty spool weight comes from its type, never its brand, because one brand ships spools that differ by 100 g or more.</span></section>';
return '<section class="namebar nm-inset" aria-label="Where this list appears in a spool name"><span class="small muted" style="margin-right:6px">A spool name is built from</span>' + parts.map(function (p, i) { return '<span class="part' + (i === k[2] ? ' on' : '') + '">' + p[0] + '<small>' + p[1] + (p[2] ? '' : ' · opt') + '</small></span>'; }).join('') + '</section>';
}
function rows() {
var list = LOOKUPS[S.kind].filter(function (r) { return (!S.f || r[0].toLowerCase().indexOf(S.f.toLowerCase()) >= 0) && (!S.unused || !r[1]); });
var st = S.kind === 'spool_types', br = S.kind === 'brands';
var head = '<thead><tr><th>Name</th><th class="r">Used by</th>' + (st ? '<th class="r">Empty weight</th><th>Source</th>' : br ? '<th class="r hide-m">Brand empty spool weight <span class="sub" style="display:inline;text-transform:none">last resort</span></th>' : '') + '<th class="r"><span class="sr-only">Actions</span></th></tr></thead>';
var body = list.map(function (r, i) {
var name = r[0], used = r[1], editing = S.editing === name;
var nameCell = editing ? '<div class="rename"><input class="input" id="rn" value="' + x.esc(name) + '" aria-label="New name for ' + x.esc(name) + '"><button class="btn btn-sm btn-primary" type="button" data-rn-save>Save</button><button class="btn btn-sm" type="button" data-rn-cancel>Cancel</button></div>' +
(S.conflict ? '<p class="field-error" style="margin:6px 0 0">Someone renamed this to “' + x.esc(name) + ' 3D” after you loaded it. Nothing was saved. <button class="linkish" type="button" data-rn-reload>Reload and reapply</button></p>' : '') : '<b>' + x.esc(name) + '</b>';
return '<tr><td>' + nameCell + '</td><td class="r used">' + (used ? '<a href="02-filament.html?q=' + encodeURIComponent(name) + '">' + used + ' spool' + (used > 1 ? 's' : '') + '</a>' : '<span class="muted">unused</span>') + '</td>' +
(st ? '<td class="r num">' + x.g(r[2]) + '</td><td>' + SRC[r[3]] + '</td>' : br ? '<td class="r hide-m">' + (r[2] ? x.g(r[2]) : '<span class="muted">none</span>') + '</td>' : '') +
'<td class="r acts"><div class="row" style="gap:6px;justify-content:flex-end">' + (editing ? '' : '<button class="btn btn-sm" type="button" data-rn="' + x.esc(name) + '">Rename</button>') +
(used ? '<button class="btn btn-sm" type="button" disabled title="In use by ' + used + ' spools. Change those spools first; the database refuses to remove a value that is still used.">Remove</button>' : '<button class="btn btn-sm btn-danger" type="button" data-fake="Removed “' + x.esc(name) + '” from ' + x.esc(kindRow(S.kind)[1]) + '">Remove</button>') + '</div></td></tr>';
}).join('');
var add = '<tr class="addrow"><td colspan="' + (st ? 5 : br ? 4 : 3) + '"><div class="row" style="gap:8px"><input class="input" id="addv" style="max-width:280px;min-height:36px" placeholder="Add a ' + (st ? 'spool type, e.g. eSun — 1 kg cardboard' : 'value') + '" aria-label="New value">' +
(st ? '<input class="input num" style="max-width:110px;min-height:36px" placeholder="Empty g" aria-label="Empty spool weight in grams"><select class="select" style="width:auto;min-height:36px" aria-label="Where the empty spool weight came from"><option>Source…</option><option>manufacturer</option><option>community</option><option>estimated</option></select>' : '') +
'<button class="btn btn-sm btn-primary" type="button" data-add>Add</button></div>' +
(S.addErr ? '<p class="field-error" style="margin:8px 0 0">' + (st ? 'An empty spool weight and its source go together, or neither. 0 g is refused: leave it blank if nobody has weighed one.' : '“petg” looks like <b>PETG</b>, which already exists. Use PETG so spools group together. <span class="sub" style="display:inline">Proposed check: the database compares names case-sensitively today, so it would accept this.</span>') + '</p>' : '') + '</td></tr>';
return '<table class="tbl stackable">' + head + '<tbody>' + (body || '<tr><td colspan="5"><div class="empty"><h3>Nothing matches</h3><p>Try another filter.</p></div></td></tr>') + add + '</tbody></table>';
}
function render() {
var k = kindRow(S.kind);
return '<header class="page-head"><div><h1>Lists</h1><p class="sum">The values a spool\'s name is built from. <b>Operator</b> or admin can change them; values still in use can\'t be removed.</p></div></header>' +
'<div class="tabs" role="tablist" aria-label="Lists">' + KINDS.map(function (kk) { return '<button role="tab" type="button" data-kind="' + kk[0] + '" aria-selected="' + (S.kind === kk[0]) + '">' + kk[1] + ' <span class="n">' + LOOKUPS[kk[0]].length + '</span></button>'; }).join('') + '</div>' +
namebar() +
'<section class="nm-raised table-card"><div class="ltool"><input class="input" id="lf" type="search" value="' + x.esc(S.f) + '" placeholder="Filter ' + k[1].toLowerCase() + '…" aria-label="Filter"><button class="btn btn-sm" type="button" aria-pressed="' + S.unused + '" data-unused>Unused only</button>' +
'<span class="spacer"></span><span class="small muted">Demo</span><button class="btn btn-sm" type="button" data-demo-add>Show add error</button><button class="btn btn-sm" type="button" data-demo-conflict>Show rename conflict</button></div><div class="table-scroll">' + rows() + '</div></section>';
}
function redraw(fid) { var pos = fid && document.getElementById(fid) && document.getElementById(fid).selectionStart; document.getElementById('page').innerHTML = render(); if (fid && document.getElementById(fid)) { var el = document.getElementById(fid); el.focus(); if (pos != null) el.setSelectionRange(pos, pos); } history.replaceState(null, '', location.pathname + '?list=' + S.kind); }
document.addEventListener('click', function (e) {
var t = e.target, a;
if ((a = t.closest('[data-kind]'))) { S.kind = a.getAttribute('data-kind'); S.editing = null; S.conflict = S.addErr = false; return redraw(); }
if (t.closest('[data-unused]')) { S.unused = !S.unused; return redraw(); }
if ((a = t.closest('[data-rn]'))) { S.editing = a.getAttribute('data-rn'); S.conflict = false; redraw('rn'); return; }
if (t.closest('[data-rn-cancel]')) { S.editing = null; S.conflict = false; return redraw(); }
if (t.closest('[data-rn-save]')) { var v = document.getElementById('rn').value; S.editing = null; X.toast('Renamed to <b>' + x.esc(v) + '</b>. Every spool using it now reads the new name.'); return redraw(); }
if (t.closest('[data-rn-reload]')) { S.conflict = false; return redraw('rn'); }
if (t.closest('[data-demo-conflict]')) { S.editing = LOOKUPS[S.kind][0][0]; S.conflict = true; return redraw(); }
if (t.closest('[data-demo-add]')) { S.addErr = true; return redraw('addv'); }
if (t.closest('[data-add]')) { var nv = document.getElementById('addv').value.trim(); if (!nv) { S.addErr = true; return redraw('addv'); } S.addErr = false; X.toast('Added <b>' + x.esc(nv) + '</b> to ' + kindRow(S.kind)[1]); return redraw(); }
});
document.addEventListener('input', function (e) { if (e.target.id === 'lf') { S.f = e.target.value; redraw('lf'); } });
X.init({ name: 'lists' }, render);
})();
</script>
+76
View File
@@ -0,0 +1,76 @@
<style>
.lab { aspect-ratio: 51 / 21; width: 100%; max-width: 340px; background: #fff; color: #111; border-radius: 6px; display: grid; grid-template-columns: 37.25% 1fr; align-items: center; padding: 2% 3% 2% 2%; box-shadow: 0 1px 0 rgba(0,0,0,.2), var(--nm-raised-1); border: 1px solid var(--border-control); font-family: var(--font-mono); overflow: hidden; }
.lab svg { width: 92%; height: auto; display: block; }
.lab .t { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.lab .h { font-weight: 800; font-size: clamp(10px, 3.6cqw, 15px); line-height: 1.1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lab .d { font-size: clamp(8px, 2.6cqw, 11px); line-height: 1.2; }
.lab .id { font-size: clamp(9px, 3cqw, 12px); font-weight: 700; }
.labwrap { container-type: inline-size; }
.lgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); gap: var(--space-4); }
.lcard { padding: var(--space-4); display: flex; flex-direction: column; gap: var(--space-3); }
.lcard.off { opacity: .55; }
.lcard .top { display: flex; gap: var(--space-3); align-items: center; }
.chk { width: 20px; height: 20px; accent-color: var(--text-accent); }
.bar { display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; padding: var(--space-3) var(--space-4); }
.spec { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; font-size: var(--fs-label); margin: 0; }
.spec dt { color: var(--text-secondary); }
.spec dd { margin: 0; }
</style>
<script>
(function () {
var x = X;
var q = new URLSearchParams(location.search);
var S = { tab: q.get('tab') || 'needed', sel: {} };
SPOOLS.filter(function (s) { return !s.label; }).forEach(function (s) { S.sel[s.id] = true; });
/* A deterministic QR-looking pattern (not a real code) with the three finder squares. */
function qr(seed) {
var n = 25, cells = '', r = seed * 9301 + 49297;
function rnd() { r = (r * 9301 + 49297) % 233280; return r / 233280; }
function finder(ox, oy) { return '<rect x="' + ox + '" y="' + oy + '" width="7" height="7" fill="#111"/><rect x="' + (ox + 1) + '" y="' + (oy + 1) + '" width="5" height="5" fill="#fff"/><rect x="' + (ox + 2) + '" y="' + (oy + 2) + '" width="3" height="3" fill="#111"/>'; }
for (var yy = 0; yy < n; yy++) for (var xx = 0; xx < n; xx++) {
var inF = (xx < 8 && yy < 8) || (xx > n - 9 && yy < 8) || (xx < 8 && yy > n - 9);
if (!inF && rnd() > 0.52) cells += '<rect x="' + xx + '" y="' + yy + '" width="1" height="1"/>';
}
return '<svg viewBox="-1 -1 ' + (n + 2) + ' ' + (n + 2) + '" aria-hidden="true" shape-rendering="crispEdges"><rect x="-1" y="-1" width="' + (n + 2) + '" height="' + (n + 2) + '" fill="#fff"/><g fill="#111">' + cells + '</g>' + finder(0, 0) + finder(n - 7, 0) + finder(0, n - 7) + '</svg>';
}
function spoolLabel(s) {
var d = [s.mat, s.type, s.speed, s.fin].filter(Boolean).join(' ');
return '<div class="labwrap"><div class="lab" role="img" aria-label="Label preview: ' + x.esc(s.brand + ' / ' + s.color + ', ' + d + ', #' + s.id) + '">' + qr(s.id) + '<div class="t"><span class="h">' + x.esc(s.brand + ' / ' + s.color) + '</span><span class="d">' + x.esc(d) + '</span><span class="id">#' + s.id + '</span></div></div></div>';
}
function holderLabel(pr, c) {
var kind = { ams: 'AMS', ams_lite: 'AMS Lite', external: 'External' }[c.kind], id = X.holderIds[c.id];
return '<div class="labwrap"><div class="lab" role="img" aria-label="Holder label preview">' + qr(100 + id) + '<div class="t"><span class="h" style="font-size:clamp(9px,3cqw,13px)">Printer: ' + x.esc(pr.name) + '</span><span class="d">Container: ' + x.esc(c.label + ' / ' + kind) + '</span><span class="id">#' + id + '</span></div></div></div>';
}
function needed() {
var l = SPOOLS.filter(function (s) { return !s.label; }), n = l.filter(function (s) { return S.sel[s.id]; }).length;
if (!l.length) return '<div class="nm-raised empty"><h3>✓ Every spool has a label</h3><p>New spools land here until their label is generated.</p></div>';
return '<div class="bar nm-inset"><label class="row small" style="gap:8px"><input type="checkbox" class="chk" data-all ' + (n === l.length ? 'checked' : '') + '> All ' + l.length + '</label><span class="spacer"></span><span class="small muted">One PDF, one 51 × 21 mm page per label</span><button class="btn btn-primary" type="button" ' + (n ? 'data-gen="' + n + '"' : 'disabled') + '>Download ' + n + ' label' + (n === 1 ? '' : 's') + ' (PDF)</button></div>' +
'<div class="lgrid">' + l.map(function (s) { return '<article class="lcard nm-raised' + (S.sel[s.id] ? '' : ' off') + '"><div class="top"><input type="checkbox" class="chk" data-s="' + s.id + '" ' + (S.sel[s.id] ? 'checked' : '') + ' aria-label="Include ' + x.esc(s.color) + ' #' + s.id + '"><span style="flex:1;min-width:0"><b>' + x.esc(s.color) + '</b> <span class="sub" style="display:inline">#' + s.id + '</span><span class="sub">' + x.esc(x.where(s).text) + '</span></span><button class="btn btn-sm" type="button" data-spool="' + s.id + '">Open</button></div>' + spoolLabel(s) + '</article>'; }).join('') + '</div>' +
'<p class="small muted" style="margin:0">A spool counts as labeled once its PDF is generated successfully. Editing the spool later doesn\'t clear that. Reprint any label from the <b>All spools</b> tab.</p>';
}
function all() {
return '<section class="nm-raised table-card"><div class="table-scroll"><table class="tbl"><thead><tr><th>Spool</th><th class="hide-m">Label text</th><th>Status</th><th class="r"><span class="sr-only">Actions</span></th></tr></thead><tbody>' +
SPOOLS.map(function (s) { return '<tr><td><div class="row" style="gap:10px;flex-wrap:nowrap">' + x.sw(s.c, 'swatch-sm') + '<span><b>' + x.esc(s.color) + '</b> <span class="sub" style="display:inline">#' + s.id + '</span></span></div></td><td class="hide-m"><span class="small">' + x.esc(s.brand + ' / ' + s.color) + '</span><span class="sub">' + x.esc([s.mat, s.type, s.speed, s.fin].filter(Boolean).join(' ')) + '</span></td><td>' + (s.label ? '<span class="st ok">Printed</span>' : '<span class="st warn">Not printed</span>') + '</td><td class="r"><button class="btn btn-sm" type="button" data-fake="Label PDF for #' + s.id + ' generated">' + (s.label ? 'Reprint' : 'Print') + '</button></td></tr>'; }).join('') + '</tbody></table></div></section>';
}
function holders() {
return '<p class="small muted" style="margin:0">Stick one on each AMS and external holder. Scanning it with a phone starts a <a href="09-scan-holder.html">holder scan session</a>: every spool label scanned next is loaded, returned or moved there. No mode to pick.</p>' +
PRINTERS.map(function (pr) { return '<section class="stack" style="gap:10px"><h2 class="section" style="margin:0">' + x.esc(pr.name) + '</h2><div class="lgrid">' + pr.containers.map(function (c) { return '<article class="lcard nm-raised"><div class="top"><b style="flex:1">' + x.esc(c.label) + '</b><span class="sub">' + x.contents(c.id).length + ' / ' + c.cap + '</span><button class="btn btn-sm" type="button" data-fake="Holder label #' + X.holderIds[c.id] + ' PDF generated">Print</button></div>' + holderLabel(pr, c) + '</article>'; }).join('') + '</div></section>'; }).join('');
}
function render() {
var need = SPOOLS.filter(function (s) { return !s.label; }).length;
return '<header class="page-head"><div><h1>Labels</h1><p class="sum">51 × 21 mm labels with a QR code. Scanning a spool label opens it; scanning a holder label starts loading into it.</p></div></header>' +
'<div class="tabs" role="tablist" aria-label="Label lists">' + [['needed', 'Needs a label', need, 1], ['all', 'All spools', SPOOLS.length], ['holders', 'Holders', 6]].map(function (t) { return '<button role="tab" type="button" data-ltab="' + t[0] + '" aria-selected="' + (S.tab === t[0]) + '">' + t[1] + ' <span class="n' + (t[3] && t[2] ? ' warn' : '') + '">' + t[2] + '</span></button>'; }).join('') + '</div>' +
(S.tab === 'needed' ? needed() : S.tab === 'all' ? all() : holders()) +
'<details class="nm-inset panel"><summary class="small strong" style="cursor:pointer">Label format</summary><dl class="spec" style="margin-top:10px"><dt>Size</dt><dd>51 × 21 mm, one label per PDF page</dd><dt>QR</dt><dd>19 mm, left; encodes <code>/s/‹id›</code> or <code>/c/‹id›</code></dd><dt>Line 1</dt><dd>Brand / Color (holder: Printer: name)</dd><dt>Line 2</dt><dd>Material, type, speed, finish (holder: Container: label / kind)</dd><dt>Line 3</dt><dd>#id</dd></dl></details>';
}
function redraw() { document.getElementById('page').innerHTML = render(); history.replaceState(null, '', location.pathname + '?tab=' + S.tab); }
document.addEventListener('click', function (e) {
var t = e.target, a;
if ((a = t.closest('[data-ltab]'))) { S.tab = a.getAttribute('data-ltab'); return redraw(); }
if (t.matches('[data-s]')) { S.sel[t.getAttribute('data-s')] = t.checked; return redraw(); }
if (t.matches('[data-all]')) { SPOOLS.filter(function (s) { return !s.label; }).forEach(function (s) { S.sel[s.id] = t.checked; }); return redraw(); }
if ((a = t.closest('[data-gen]'))) { SPOOLS.forEach(function (s) { if (S.sel[s.id]) s.label = true; }); X.toast('Downloaded <b>extrudex-labels.pdf</b> · ' + a.getAttribute('data-gen') + ' labels, now marked printed'); return redraw(); }
});
X.init({ name: 'labels', refresh: redraw }, render);
})();
</script>
+58
View File
@@ -0,0 +1,58 @@
<style>
.roles { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); gap: var(--space-3); }
.role { padding: var(--space-4); display: flex; flex-direction: column; gap: 4px; font-size: var(--fs-label); }
.role b { font-size: 14px; }
.person { padding: var(--space-4) var(--space-5); display: grid; grid-template-columns: 44px minmax(0, 1fr) auto; gap: var(--space-4); align-items: center; }
.avatar { width: 44px; height: 44px; border-radius: 50%; display: grid; place-items: center; font-weight: 800; background: var(--surface-inset); box-shadow: var(--nm-inset-1); border: 1px solid var(--border-control); }
.sessions { grid-column: 2 / -1; display: flex; flex-direction: column; gap: 6px; }
.sess { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px 12px; padding: 8px 12px; border-radius: var(--radius-sm); background: var(--surface-inset); border: 1px solid var(--border-hairline); font-size: var(--fs-label); }
.token-once { padding: var(--space-4); border: 2px solid var(--status-warning); background: var(--wash-warning); border-radius: var(--radius-md); display: flex; flex-direction: column; gap: 8px; }
.token-once code { font-size: 13px; padding: 8px 10px; display: block; overflow-wrap: anywhere; }
.gap-tag { font-size: var(--fs-caption); }
@media (max-width: 699px) { .person { grid-template-columns: 36px minmax(0, 1fr); } .person > .act { grid-column: 1 / -1; } .avatar { width: 36px; height: 36px; } .sessions { grid-column: 1 / -1; } }
</style>
<script>
(function () {
var x = X;
var S = { revoke: null, newToken: null, expanded: { 1: true } };
function rel(iso) { return x.when(iso); }
var ROLE = { reader: ['Reader', 'Sees everything: inventory, prints, printers, camera snapshots, holder labels.'], operator: ['Operator', 'Reader, plus changing inventory: spools, weigh-ins, moves, labels, prints, lists.'], admin: ['Admin', 'Operator, plus printers and holders, access and system settings.'] };
function people() {
return PEOPLE.map(function (p) {
var active = p.sessions.filter(function (s) { return !s.revoked; });
var sess = S.expanded[p.id] ? '<div class="sessions">' + (active.length ? active.map(function (s) { return '<div class="sess"><span><b>' + (s.you ? 'This browser' : 'Session') + '</b> · signed in ' + rel(s.created) + '</span><span class="muted">last seen ' + rel(s.lastSeen) + '</span><span class="muted">idle expiry ' + rel(s.idleExp) + '</span><span class="muted">hard expiry ' + rel(s.maxExp) + '</span></div>'; }).join('') : '<span class="small muted">No local sessions recorded.</span>') + '</div>' : '';
var act = S.revoke === p.id
? '<div class="stack act" style="gap:6px;align-items:flex-end"><span class="small">Sign ' + x.esc(p.name) + ' out of ' + active.length + ' session' + (active.length > 1 ? 's' : '') + '?' + (p.id === 1 ? ' <b>Including this one.</b>' : '') + '</span><div class="row" style="gap:6px"><button class="btn btn-sm" type="button" data-keep>Cancel</button><button class="btn btn-sm btn-danger" type="button" data-do-revoke="' + p.id + '">Revoke</button></div></div>'
: '<div class="row act" style="gap:6px;justify-content:flex-end">' + (active.length ? '<button class="btn btn-sm" type="button" data-expand="' + p.id + '" aria-expanded="' + !!S.expanded[p.id] + '">' + active.length + ' session' + (active.length > 1 ? 's' : '') + ' ' + (S.expanded[p.id] ? '▴' : '▾') + '</button><button class="btn btn-sm" type="button" data-revoke="' + p.id + '">Revoke…</button>' : '<span class="small muted">no sessions</span>') + '</div>';
return '<article class="person nm-raised"><span class="avatar" aria-hidden="true">' + x.esc(p.name[0]) + '</span><div style="min-width:0"><b>' + x.esc(p.name) + '</b>' + (p.id === 1 ? ' <span class="sub" style="display:inline">(you)</span>' : '') + '<span class="sub">' + (p.email ? x.esc(p.email) + ' · ' : '') + '<span class="st ' + (p.role === 'admin' ? 'run' : p.role === 'operator' ? 'ok' : 'idle') + '" style="font-size:11px">' + ROLE[p.role][0] + '</span> · last seen ' + rel(p.lastSeen) + '</span></div>' + act + sess + '</article>';
}).join('');
}
function machines() {
return MACHINES.map(function (m) {
return '<article class="nm-raised" style="padding:0;overflow:hidden"><div class="row" style="padding:14px 16px;gap:12px"><span class="avatar" style="border-radius:12px" aria-hidden="true">⚙</span><span style="flex:1;min-width:0"><b>' + x.esc(m.name) + '</b><span class="sub">machine · <span class="st ' + (m.role === 'operator' ? 'ok' : 'idle') + '" style="font-size:11px">' + ROLE[m.role][0] + '</span></span></span><button class="btn btn-sm" type="button" data-newtoken="' + m.id + '">+ New token</button></div>' +
(S.newToken === m.id ? '<div style="padding:0 16px 16px"><div class="token-once" role="alert"><b>Copy this token now. It won\'t be shown again.</b><code>exd_9f2c…(sample, the full token appears once)</code><span class="small">Only its hash is stored. If it\'s lost, revoke it and make a new one.</span><div class="row" style="gap:8px"><button class="btn btn-sm btn-primary" type="button" data-fake="Token copied to clipboard">Copy</button><button class="btn btn-sm" type="button" data-done-token>I\'ve stored it</button></div></div></div>' : '') +
'<table class="tbl"><thead><tr><th>Token</th><th class="hide-m">Created</th><th>Last used</th><th class="r"><span class="sr-only">Actions</span></th></tr></thead><tbody>' + m.tokens.map(function (t) { return '<tr' + (t.revoked ? ' style="opacity:.6"' : '') + '><td><b>' + x.esc(t.name) + '</b>' + (t.revoked ? '<span class="sub">revoked ' + rel(t.revoked) + '</span>' : '') + '</td><td class="hide-m">' + rel(t.created) + '</td><td>' + (t.lastUsed ? rel(t.lastUsed) : '<span class="muted">never</span>') + '</td><td class="r">' + (t.revoked ? '' : '<button class="btn btn-sm btn-danger" type="button" data-fake="Revoked ' + x.esc(t.name) + '. Requests using it now get 401.">Revoke</button>') + '</td></tr>'; }).join('') + '</tbody></table></article>';
}).join('');
}
function render() {
return '<header class="page-head"><div><h1>Access</h1><p class="sum">Admin only. Who can use Extrudex, and what they can do.</p></div><a class="btn" href="#" data-fake="Opens Authentik in a new tab">Open Authentik ↗</a></header>' +
'<div class="banner" style="border-color:var(--status-info);background:var(--wash-info)"><b style="color:var(--status-info)">i</b><span style="flex:1">People, roles, passwords and account activation are managed in <b>Authentik</b>. This page shows Extrudex\'s local view: roles as Extrudex sees them, and sign-in sessions you can revoke here.</span></div>' +
'<section class="roles" aria-label="Roles">' + ['reader', 'operator', 'admin'].map(function (r) { return '<div class="role nm-inset"><b>' + ROLE[r][0] + '</b><span class="muted">' + ROLE[r][1] + '</span></div>'; }).join('') + '</section>' +
'<section class="stack" style="gap:12px" aria-labelledby="ppl"><h2 class="section" id="ppl" style="margin:0">People · ' + PEOPLE.length + '</h2>' + people() + '</section>' +
'<section class="stack" style="gap:12px" aria-labelledby="mach"><h2 class="section" id="mach" style="margin:0">Machines &amp; API tokens <span class="prov warn gap-tag"><i>⚠</i>API exists · no screen on dev yet</span></h2><p class="small muted" style="margin:0">For integrations like the gateway and health checks. Each machine gets a role; tokens are bearer credentials and can be revoked one at a time.</p>' + machines() +
'<button class="btn" type="button" style="align-self:flex-start" data-fake="New machine form: name + role">+ Add machine</button></section>' +
'<details class="nm-inset panel"><summary class="small strong" style="cursor:pointer">Runbook: changing someone\'s access</summary><ol class="small" style="margin:10px 0 0;padding-left:18px"><li>Change the group in Authentik.</li><li>Existing sessions pick it up when Extrudex next re-checks authorization (each session records when it was last checked). Revoke their sessions here to make it take effect now.</li><li>Removed people keep their name on audit history.</li></ol></details>';
}
function redraw() { document.getElementById('page').innerHTML = render(); }
document.addEventListener('click', function (e) {
var t = e.target, a;
if ((a = t.closest('[data-expand]'))) { var id = +a.getAttribute('data-expand'); S.expanded[id] = !S.expanded[id]; return redraw(); }
if ((a = t.closest('[data-revoke]'))) { S.revoke = +a.getAttribute('data-revoke'); return redraw(); }
if (t.closest('[data-keep]')) { S.revoke = null; return redraw(); }
if ((a = t.closest('[data-do-revoke]'))) { var p = PEOPLE.filter(function (pp) { return pp.id === +a.getAttribute('data-do-revoke'); })[0]; p.sessions.forEach(function (s) { s.revoked = '2026-09-18T09:41'; }); S.revoke = null; X.toast('Revoked ' + x.esc(p.name) + '\'s local sessions. They\'ll need to sign in again.'); return redraw(); }
if ((a = t.closest('[data-newtoken]'))) { S.newToken = +a.getAttribute('data-newtoken'); return redraw(); }
if (t.closest('[data-done-token]')) { S.newToken = null; return redraw(); }
});
X.init({ name: 'access' }, render);
})();
</script>
+53
View File
@@ -0,0 +1,53 @@
<style>
.sysgrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr)); gap: var(--space-5); align-items: start; }
.syscard { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-4); }
.syscard h2 { margin: 0; font-size: var(--fs-h3); display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.afilters { display: flex; gap: var(--space-3); flex-wrap: wrap; align-items: center; padding: var(--space-4); }
.afilters .select, .afilters .input { width: auto; min-height: 36px; padding: 6px 10px; font-size: 13px; }
.act-code { font-size: 12px; }
</style>
<script>
(function () {
var x = X;
var S = { who: '', type: '', sent: false, update: false, installed: false };
var LINK = { spool: function (id) { return '<a href="#spool=' + id + '" data-spool="' + id + '">spool #' + id + '</a>'; }, print: function (id) { return '<a href="#print=' + id + '" data-print="' + id + '">print #' + id + '</a>'; }, printer: function (id) { return '<a href="04-printers.html#printer=' + id + '">printer #' + id + '</a>'; }, spool_type: function (id) { return '<a href="05-lists.html?list=spool_types">spool type #' + id + '</a>'; }, principal: function (id) { return '<a href="07-access.html">person #' + id + '</a>'; } };
var WST = { delivered: ['ok', 'Delivered'], no_low: ['idle', 'Nothing low'], pending: ['warn', 'Retrying'], expired: ['err', 'Expired'] };
function webhook() {
var lowNow = SPOOLS.filter(x.low).length;
return '<section class="syscard nm-raised" aria-labelledby="wh"><h2 id="wh">Low-filament report <span class="st ok" style="font-size:12px">On</span><span class="prov warn" style="font-size:11px"><i>⚠</i>API exists · no screen on dev yet</span></h2>' +
'<dl class="kv"><dt>Sends</dt><dd>' + WEBHOOK.schedule + '</dd><dt>Includes</dt><dd>spools under ' + WEBHOOK.threshold + ' g (' + lowNow + ' right now)</dd><dt>Destination</dt><dd class="muted">' + WEBHOOK.target + '</dd></dl>' +
'<table class="tbl"><thead><tr><th>Week</th><th>Result</th><th class="r">Spools</th><th class="hide-m">Detail</th></tr></thead><tbody>' + WEBHOOK.events.map(function (ev) { var w = WST[ev.state]; return '<tr><td><b>' + ev.period + '</b><span class="sub">' + x.when(ev.observed) + '</span></td><td><span class="st ' + w[0] + '">' + w[1] + '</span>' + (ev.attempts > 1 ? '<span class="sub">' + ev.attempts + ' attempts</span>' : '') + '</td><td class="r">' + (ev.spools || '—') + '</td><td class="hide-m small">' + (ev.lastError ? '<span class="prov warn" style="white-space:normal"><i>!</i>' + x.esc(ev.lastError) + '</span>' : ev.delivered ? 'at ' + x.when(ev.delivered).split(' ').pop() : '<span class="muted">nothing sent</span>') + '</td></tr>'; }).join('') + '</tbody></table>' +
(S.sent ? '<div class="banner" style="border-color:var(--status-success);background:var(--wash-success)"><b style="color:var(--status-success)">✓</b><span>This week\'s report (2026-W38) was already delivered Monday 08:00, so nothing was sent again. A week that\'s still retrying would be retried now.</span></div>' : '') +
'<div class="row"><button class="btn btn-sm" type="button" data-send>Run this week\'s report now</button><span class="sub" style="display:inline">One report per week. Running it again never sends a duplicate.</span></div></section>';
}
function app() {
return '<section class="syscard nm-raised" aria-labelledby="ap"><h2 id="ap">This device</h2>' +
'<dl class="kv"><dt>Version</dt><dd>dev <code>321ce0d</code></dd><dt>Offline use</dt><dd><span class="st ok">Ready</span><span class="sub">App shell is cached; data still needs the server</span></dd><dt>Installed</dt><dd>' + (S.installed ? '<span class="st ok">Yes</span>' : '<span class="st idle">No</span>') + '</dd></dl>' +
(S.update ? '<div class="banner"><b>↻</b><span style="flex:1">An update is ready. Finish any open form, then reload.</span><button class="btn btn-sm btn-primary" type="button" data-fake="Reloading with the update">Reload now</button></div>' : '') +
'<div class="row" style="gap:8px">' + (S.installed ? '' : '<button class="btn btn-sm btn-primary" type="button" data-install>Install Extrudex</button>') + '<button class="btn btn-sm" type="button" data-update>Demo: update ready</button></div>' +
'<p class="sub" style="margin:0">On iPhone or iPad: open in Safari, tap Share, then <b>Add to Home Screen</b>.</p></section>';
}
function audit() {
var who = {}, types = {}; AUDIT.forEach(function (a) { who[a.who] = 1; types[a.type] = 1; });
var l = AUDIT.filter(function (a) { return (!S.who || a.who === S.who) && (!S.type || a.type === S.type); });
return '<section class="nm-raised table-card" aria-labelledby="au"><div class="afilters"><h2 id="au" style="margin:0;font-size:var(--fs-h3);flex:1">Audit log <span class="prov warn" style="font-size:11px"><i>⚠</i>API exists · no screen on dev yet</span></h2>' +
'<select class="select" id="aw" aria-label="Who"><option value="">Anyone</option>' + Object.keys(who).map(function (w) { return '<option' + (S.who === w ? ' selected' : '') + '>' + x.esc(w) + '</option>'; }).join('') + '</select>' +
'<select class="select" id="at" aria-label="Resource"><option value="">Any resource</option>' + Object.keys(types).map(function (w) { return '<option' + (S.type === w ? ' selected' : '') + '>' + w + '</option>'; }).join('') + '</select></div>' +
'<div class="table-scroll"><table class="tbl"><thead><tr><th>When</th><th>Who</th><th>What</th><th class="hide-m">Detail</th></tr></thead><tbody>' + (l.length ? l.map(function (a) { return '<tr><td class="nowrap">' + x.when(a.at) + '</td><td>' + x.esc(a.who) + '</td><td><code class="act-code">' + a.action + '</code><span class="sub">' + (LINK[a.type] ? LINK[a.type](a.rid) : a.type + ' #' + a.rid) + '</span></td><td class="hide-m small">' + x.esc(a.ctx) + '</td></tr>'; }).join('') : '<tr><td colspan="4"><div class="empty"><h3>No events match</h3></div></td></tr>') + '</tbody></table></div>' +
'<div class="table-foot"><span class="muted">Newest first · every write records who, what and when</span><button class="btn btn-sm" type="button" data-fake="Loaded older events (paged by id)">Older →</button></div></section>';
}
function render() {
return '<header class="page-head"><div><h1>System</h1><p class="sum">Admin only. Scheduled reports, the audit trail, and this device.</p></div></header>' +
'<div class="sysgrid">' + webhook() + app() + '</div>' + audit();
}
function redraw() { document.getElementById('page').innerHTML = render(); }
document.addEventListener('click', function (e) {
var t = e.target;
if (t.closest('[data-send]')) { S.sent = true; return redraw(); }
if (t.closest('[data-update]')) { S.update = true; return redraw(); }
if (t.closest('[data-install]')) { S.installed = true; X.toast('Installed. Extrudex opens in its own window now.'); return redraw(); }
});
document.addEventListener('change', function (e) { if (e.target.id === 'aw') { S.who = e.target.value; redraw(); } if (e.target.id === 'at') { S.type = e.target.value; redraw(); } });
X.init({ name: 'system' }, render);
})();
</script>
+79
View File
@@ -0,0 +1,79 @@
<style>
.scanwrap { max-width: 560px; width: 100%; margin: 0 auto; display: flex; flex-direction: column; gap: var(--space-4); }
.hhead { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-3); }
.hhead h1 { margin: 0; font-size: var(--fs-h2); line-height: 1.2; }
.cap { display: flex; gap: 6px; }
.cap i { flex: 1; height: 12px; border-radius: 4px; border: 1px solid var(--border-control); background: var(--surface-inset); }
.cap i.on { background: var(--text-accent); border-color: var(--text-accent); }
.bigscan { min-height: 64px; font-size: 17px; justify-content: center; width: 100%; border-radius: var(--radius-lg); }
.rule { font-size: var(--fs-label); color: var(--text-secondary); display: grid; grid-template-columns: auto 1fr; gap: 4px 10px; }
.rule b { color: var(--text-primary); }
.result { padding: var(--space-4); border-radius: var(--radius-lg); border: 2px solid var(--status-success); background: var(--wash-success); display: grid; grid-template-columns: 44px 1fr; gap: 4px 12px; align-items: center; }
.result.err { border-color: var(--status-error); background: var(--wash-error); }
.result .verb { font-size: var(--fs-caption); text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); font-weight: 800; }
.result.ok .verb { color: var(--status-success); } .result.err .verb { color: var(--status-error); }
.row-sp { all: unset; box-sizing: border-box; cursor: pointer; width: 100%; display: grid; grid-template-columns: auto 1fr auto; gap: 12px; align-items: center; padding: 12px 16px; border-top: 1px solid var(--border-hairline); font-size: 14px; min-height: 56px; }
.row-sp:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: -2px; }
.cam-sheet { position: fixed; inset: 0; z-index: 80; background: #0b0b0d; display: none; flex-direction: column; color: #f4f4f6; }
.cam-sheet.open { display: flex; }
.vf { flex: 1; position: relative; display: grid; place-items: center; background: radial-gradient(ellipse at 50% 55%, #2a2a31 0, #0b0b0d 70%); }
.vf .frame { width: min(64vw, 280px); aspect-ratio: 1; border-radius: 24px; box-shadow: 0 0 0 2000px rgba(0,0,0,.45); border: 3px solid #a99af0; position: relative; }
.vf .hint { position: absolute; bottom: 18%; left: 0; right: 0; text-align: center; font-size: 14px; }
.cam-list { background: #1b1b1e; padding: 12px 16px 24px; display: flex; flex-direction: column; gap: 8px; max-height: 45vh; overflow: auto; }
.cam-list button { all: unset; cursor: pointer; display: flex; gap: 12px; align-items: center; padding: 12px; border-radius: 12px; background: #2c2c30; border: 1px solid #85858f; color: #f4f4f6; font-size: 14px; }
.cam-list button:focus-visible { outline: 2px solid #a99af0; outline-offset: 2px; }
.reader-note { display: none; }
body.as-reader .reader-note { display: flex; }
body.as-reader [data-needs-op] { opacity: .5; pointer-events: none; }
</style>
<script>
(function () {
var x = X;
var ids = X.holderIds, byNum = {}; Object.keys(ids).forEach(function (k) { byNum[ids[k]] = k; });
var q = new URLSearchParams(location.search), cid = byNum[+q.get('c')] || 'p3-lite';
var S = { last: null, log: [] };
function hold() { return x.container(cid); }
function apply(sid) {
var s = x.spool(sid), h = hold(), here = x.contents(cid), before = x.where(s).text;
if (!s) return { err: true, verb: 'Not found', text: 'No spool #' + sid + '. Scan a spool label, or type its number.' };
if (s.cont === cid) { s.cont = null; s.loc = s.loc || 'Storage'; return { verb: 'Returned to storage', s: s, text: 'Was in ' + h.c.label + '. Put it back on its shelf' + (s.loc && s.loc !== 'Storage' ? ' (' + s.loc + ')' : '') + '.' }; }
if (here.length >= h.c.cap) return { err: true, verb: 'Full', s: s, text: h.c.label + ' already holds ' + h.c.cap + '. Scan a spool that\'s in it to return that one first. Nothing changed.' };
var was = s.cont; s.cont = cid; s.v++;
return { verb: was ? 'Moved here' : 'Loaded', s: s, text: (was ? 'Was in ' : 'Was on ') + before + '.' };
}
function resultCard(r) {
if (!r) return '';
return '<div class="result ' + (r.err ? 'err' : 'ok') + '" role="status" aria-live="polite">' + (r.s ? x.sw(r.s.c, '" style="width:44px;height:44px') : '<span class="avatar" style="font-size:22px;text-align:center">?</span>') + '<span><span class="verb">' + (r.err ? '✕ ' : '✓ ') + r.verb + '</span><br>' + (r.s ? '<b>' + x.esc(r.s.color) + '</b> <span class="muted">#' + r.s.id + ' · ' + x.esc(r.s.brand) + '</span>' : '') + '</span><span></span><span class="small">' + x.esc(r.text) + '</span></div>';
}
function render() {
var h = hold(), here = x.contents(cid), free = h.c.cap - here.length;
return '<div class="scanwrap">' +
'<nav class="crumbs" aria-label="Breadcrumb"><a href="04-printers.html#printer=' + h.printer.id + '">' + x.esc(h.printer.name) + '</a> › <span>Holder #' + ids[cid] + '</span></nav>' +
'<section class="hhead nm-raised"><span class="sub">Scan session · holder #' + ids[cid] + '</span><h1>' + x.esc(h.c.label) + ' <span class="muted" style="font-weight:500">· ' + x.esc(h.printer.model) + '</span></h1>' +
'<div class="cap" aria-label="' + here.length + ' of ' + h.c.cap + ' in use">' + Array.apply(null, Array(h.c.cap)).map(function (_, i) { return '<i class="' + (i < here.length ? 'on' : '') + '"></i>'; }).join('') + '</div><span class="small">' + here.length + ' in use · <b>' + free + ' free</b></span></section>' +
'<div class="banner reader-note" role="note"><span>You\'re signed in as a <b>reader</b>. You can see what\'s recorded here; an operator or admin can move spools.</span></div>' +
'<button class="btn btn-primary bigscan" type="button" data-cam data-needs-op>▣ Scan a spool label</button>' +
'<div class="row" style="gap:8px" data-needs-op><input class="input num" id="manual" inputmode="numeric" placeholder="or type spool # (e.g. 49)" aria-label="Spool number" style="flex:1"><button class="btn" type="button" data-manual>Go</button></div>' +
resultCard(S.last) +
'<details class="nm-inset panel" ' + (S.log.length ? '' : 'open') + '><summary class="small strong" style="cursor:pointer">What a scan does</summary><div class="rule" style="margin-top:10px"><b>In storage</b><span>→ loads here (if there\'s room)</span><b>Already here</b><span>→ returns to storage</span><b>In another holder</b><span>→ moves here</span></div><p class="sub" style="margin:8px 0 0">No mode to choose. This holder stays active for this browser tab, so every spool label you scan next lands here until you tap Done.</p></details>' +
'<section class="nm-raised" style="padding:0;overflow:hidden" aria-labelledby="inh"><div class="card-h" style="display:flex;padding:14px 16px 8px"><h2 id="inh" style="margin:0;font-size:var(--fs-label);text-transform:uppercase;letter-spacing:var(--tracking-caps-wide);color:var(--text-secondary)">Recorded in ' + x.esc(h.c.label) + '</h2></div>' +
(here.length ? here.map(function (s) { return '<button class="row-sp" type="button" data-spool="' + s.id + '">' + x.sw(s.c) + '<span style="min-width:0"><b>' + x.esc(s.color) + '</b> <span class="sub" style="display:inline">#' + s.id + '</span><span class="sub">' + x.esc(x.fullName(s)) + '</span></span><span class="num' + (x.low(s) ? '" style="color:var(--status-warning);font-weight:700' : '') + '">' + x.g(s.rem) + '</span></button>'; }).join('') : '<p class="small muted" style="padding:0 16px 16px;margin:0">Empty.</p>') + '</section>' +
(S.log.length ? '<section class="stack" style="gap:6px"><h2 class="section" style="margin:0">This session</h2>' + S.log.map(function (r) { return '<span class="small">' + (r.err ? '<span class="prov warn"><i>✕</i></span>' : '✓') + ' ' + r.verb + (r.s ? ' · ' + x.esc(r.s.color) + ' #' + r.s.id : '') + '</span>'; }).join('') + '</section>' : '') +
'<div class="row" style="justify-content:space-between"><label class="row small" style="gap:6px"><input type="checkbox" data-reader> Demo: view as reader</label><a class="btn" href="04-printers.html#printer=' + h.printer.id + '">Done scanning</a></div></div>' +
'<div class="cam-sheet" id="cam" role="dialog" aria-modal="true" aria-label="Camera"><div class="row" style="padding:14px 16px;justify-content:space-between"><b>Point at a spool label</b><button class="btn btn-sm" type="button" data-cam-close style="background:#2c2c30;color:#f4f4f6">Close</button></div><div class="vf"><div class="frame"></div><span class="hint">Demo: tap a label below to simulate a scan</span></div><div class="cam-list">' +
SPOOLS.filter(function (s) { return [49, 41, 47, 56, 43].indexOf(s.id) >= 0; }).map(function (s) { return '<button type="button" data-scan="' + s.id + '">' + x.sw(s.c) + '<span style="flex:1"><b>#' + s.id + ' ' + x.esc(s.color) + '</b><br><small style="color:#b2b2ba">' + x.esc(x.where(s).text) + '</small></span></button>'; }).join('') + '</div></div>';
}
function redraw() { document.getElementById('page').innerHTML = render(); }
function doScan(id) { var r = apply(id); S.last = r; S.log.unshift(r); var cam = document.getElementById('cam'); if (cam) cam.classList.remove('open'); redraw(); if (navigator.vibrate) try { navigator.vibrate(r.err ? [60, 40, 60] : 30); } catch (e) {} }
document.addEventListener('click', function (e) {
var t = e.target, a;
if (t.closest('[data-cam]')) { document.getElementById('cam').classList.add('open'); var f = document.querySelector('#cam [data-scan]'); if (f) f.focus(); return; }
if (t.closest('[data-cam-close]')) { document.getElementById('cam').classList.remove('open'); return; }
if ((a = t.closest('[data-scan]'))) return doScan(+a.getAttribute('data-scan'));
if (t.closest('[data-manual]')) { var v = +document.getElementById('manual').value.replace(/\D/g, ''); if (v) doScan(v); return; }
if (t.matches('[data-reader]')) { document.body.classList.toggle('as-reader', t.checked); }
});
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') { var c = document.getElementById('cam'); if (c && c.classList.contains('open')) c.classList.remove('open'); } if (e.key === 'Enter' && e.target.id === 'manual') { var v = +e.target.value.replace(/\D/g, ''); if (v) doScan(v); } });
X.init({ name: 'printers', refresh: redraw }, render);
})();
</script>
+47
View File
@@ -0,0 +1,47 @@
<style>
.scanwrap { max-width: 560px; width: 100%; margin: 0 auto; display: flex; flex-direction: column; gap: var(--space-4); }
.hero { padding: var(--space-5); display: grid; grid-template-columns: 64px 1fr; gap: 6px 16px; align-items: center; }
.hero h1 { margin: 0; font-size: var(--fs-h2); line-height: 1.15; }
.hero .meter { grid-column: 1 / -1; height: 12px; margin-top: 8px; }
.big { font-size: 40px; font-weight: 800; line-height: 1; font-variant-numeric: tabular-nums; }
.scale { font-size: 28px !important; min-height: 68px !important; text-align: center; }
.res { display: grid; grid-template-columns: 1fr auto 1fr; gap: 8px; align-items: center; text-align: center; padding: var(--space-4); }
.res .n { font-size: 26px; font-weight: 800; font-variant-numeric: tabular-nums; }
.bigbtn { min-height: 60px; font-size: 16px; justify-content: center; width: 100%; border-radius: var(--radius-lg); }
.pick { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 4px; }
.pick a { flex: none; text-decoration: none; color: inherit; padding: 8px 12px; border-radius: var(--radius-pill); border: 1px solid var(--border-control); font-size: 13px; background: var(--surface-raised); }
.pick a[aria-current] { border: 2px solid var(--text-accent); color: var(--text-accent); font-weight: 700; }
</style>
<script>
(function () {
var x = X;
var q = new URLSearchParams(location.search), sid = +(q.get('s') || 41);
var S = { reading: '', saved: null };
function render() {
var s = x.spool(sid), w = x.where(s);
var demo = '<nav class="pick" aria-label="Demo: pick a scanned spool">' + [41, 44, 47, 49].map(function (i) { return '<a href="?s=' + i + '"' + (i === sid ? ' aria-current="page"' : '') + '>#' + i + ' ' + x.esc(x.spool(i).color) + '</a>'; }).join('') + '</nav>';
var weigh;
if (s.tare == null) {
weigh = '<section class="nm-raised panel stack" style="gap:12px"><h2 class="section" style="margin:0">Weigh it</h2><div class="banner"><b>?</b><span>No empty spool weight is known for this spool, so a scale reading can\'t be turned into filament. It is never assumed to be 0.</span></div><select class="select"><option>Pick its spool type…</option><option>Generic — 1 kg spool (assumed) · 180 g · estimated</option><option>Creality — Hyper cardboard · 176 g · community</option></select><button class="btn bigbtn" type="button" data-fake="Spool type saved. Weigh-in unlocked.">Save spool type</button></section>';
} else {
var v = +S.reading, r = Math.round(v - s.tare), ok = v > 0 && v >= s.tare && r <= s.tot + 1;
weigh = '<section class="nm-raised panel stack" style="gap:12px"><h2 class="section" style="margin:0">Weigh it</h2><label class="stack" style="gap:6px"><span class="small strong">Whole spool on the scale</span><input class="input scale num" id="sc" inputmode="numeric" value="' + x.esc(S.reading) + '" placeholder="grams" autocomplete="off"><span class="sub">minus ' + x.g(s.tare) + ' empty spool (' + x.tareProv(s) + ') = what\'s left</span></label>' +
'<div class="res nm-inset"><span><span class="sub">now</span><br><span class="n">' + x.g(s.rem) + '</span></span><span class="muted" style="font-size:22px">→</span><span><span class="sub">after</span><br><span class="n"' + (v && !ok ? ' style="color:var(--status-error)"' : '') + '>' + (v ? x.g(r) : '—') + '</span></span></div>' +
(v && v < s.tare ? '<p class="field-error" style="margin:0">' + x.g(v) + ' is less than the ' + x.g(s.tare) + ' empty spool. Re-weigh, or correct the empty spool weight.</p>' : v && r > s.tot + 1 ? '<p class="field-error" style="margin:0">That leaves ' + x.g(r) + ', more than a full spool (' + x.g(s.tot) + '). Check the total or the empty spool weight.</p>' : '') +
'<button class="btn btn-primary bigbtn" type="button" data-wsave ' + (ok ? '' : 'disabled') + '>Save weigh-in</button></section>';
}
return '<div class="scanwrap">' + demo +
'<section class="hero nm-raised">' + x.sw(s.c, '" style="width:64px;height:64px') + '<div style="min-width:0"><span class="sub">Scanned spool #' + s.id + '</span><h1>' + x.esc(s.color) + '</h1><span class="sub">' + x.esc(x.fullName(s)) + '</span></div>' +
'<div style="grid-column:1/-1;display:flex;align-items:baseline;gap:10px;margin-top:8px"><span class="big"' + (x.low(s) ? ' style="color:var(--status-warning)"' : '') + '>' + Math.round(s.rem).toLocaleString('en-US') + '<span style="font-size:16px"> g</span></span><span class="muted">of ' + x.g(s.tot) + ' net</span><span class="spacer"></span>' + x.remProv(s) + '</div>' + x.meter(s) + '</section>' +
(S.saved ? '<div class="result ok banner" style="border-color:var(--status-success);background:var(--wash-success)" role="status"><b style="color:var(--status-success)">✓</b><span>Saved: ' + x.g(S.saved[0]) + ' → <b>' + x.g(S.saved[1]) + '</b>. Scan the next label whenever you\'re ready.</span></div>' : '') +
'<div class="findit"><span class="sub">Lives in</span><b>' + x.esc(w.text) + '</b></div>' +
weigh +
'<div class="fr2"><a class="btn bigbtn" href="09-scan-holder.html?c=' + (s.cont ? X.holderIds[s.cont] : 5) + '">' + (s.cont ? 'Open its holder' : 'Load into a holder…') + '</a><a class="btn bigbtn" href="02-filament.html#spool=' + s.id + '">Full details</a></div>' +
'<p class="small muted" style="margin:0">Tip: scan a holder\'s label first to start a <a href="09-scan-holder.html">scan session</a>. Spool labels scanned after that load or move into it instead of opening this page.</p></div>';
}
function redraw(focus) { var pos = focus && document.getElementById('sc') && document.getElementById('sc').selectionStart; document.getElementById('page').innerHTML = render(); if (focus && document.getElementById('sc')) { var el = document.getElementById('sc'); el.focus(); el.setSelectionRange(pos, pos); } }
document.addEventListener('input', function (e) { if (e.target.id === 'sc') { S.reading = e.target.value.replace(/[^\d]/g, ''); S.saved = null; redraw(true); } });
document.addEventListener('click', function (e) { if (e.target.closest('[data-wsave]')) { var s = x.spool(sid), before = s.rem; s.rem = Math.round(+S.reading - s.tare); s.remSrc = 'weighed'; s.weighedAt = '18 Sep 09:41'; S.saved = [before, s.rem]; S.reading = ''; redraw(); } });
X.init({ name: 'filament', refresh: redraw }, render);
})();
</script>
+49
View File
@@ -0,0 +1,49 @@
<style>
.dv-bar { display: flex; flex-wrap: wrap; gap: var(--space-3); align-items: center; }
.dv-row { display: flex; gap: var(--space-6); align-items: flex-start; flex-wrap: wrap; }
.dev { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; }
.dev h2 { margin: 0; font-size: var(--fs-label); text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); color: var(--text-secondary); }
.bezel { border-radius: 36px; padding: 14px; background: linear-gradient(145deg, #3a3a40, #1b1b1e); box-shadow: var(--nm-raised-3); border: 1px solid var(--border-control); }
.bezel.tab { border-radius: 28px; padding: 18px; }
.screen { overflow: hidden; border-radius: 24px; background: var(--surface-page); position: relative; }
.bezel.tab .screen { border-radius: 12px; }
.screen iframe { border: 0; transform-origin: 0 0; position: absolute; left: 0; top: 0; }
.dv-list { display: flex; gap: 6px; flex-wrap: wrap; }
.dv-list button { font: var(--fw-medium) 12px/1 var(--font-body); padding: 8px 12px; border-radius: var(--radius-pill); border: 1px solid var(--border-control); background: var(--surface-raised); color: var(--text-primary); cursor: pointer; }
.dv-list button[aria-pressed="true"] { background: var(--accent-wash); border-color: var(--text-accent); color: var(--text-accent); font-weight: 700; }
.dv-list button[aria-pressed="true"]::before { content: "✓ "; }
.dv-list button:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: 2px; }
.bp { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); gap: var(--space-3); }
.bp div { padding: var(--space-4); font-size: 13px; }
.bp b { display: block; margin-bottom: 4px; }
</style>
<script>
(function () {
var SCREENS = [['01-dashboard.html', 'Dashboard'], ['02-filament.html', 'Filament'], ['02-filament.html?f=low#spool=41&tab=weigh', 'Weigh panel'], ['03-prints.html', 'Prints'], ['04-printers.html', 'Printers'], ['05-lists.html', 'Lists'], ['06-labels.html', 'Labels'], ['07-access.html', 'Access'], ['08-system.html', 'System'], ['09-scan-holder.html', 'Scan: holder'], ['10-scan-spool.html?s=44', 'Scan: spool'], ['02-filament.html?palette=weigh', 'Palette']];
var DEV = [['Phone', 390, 844, 0.62, ''], ['Tablet portrait', 820, 1180, 0.46, 'tab'], ['Tablet landscape', 1180, 820, 0.5, 'tab']];
var q = new URLSearchParams(location.search);
var S = { i: +(q.get('i') || 0), theme: q.get('t') || 'dark' };
function url(u) { return u.replace(/(\?[^#]*)?(#.*)?$/, function (m, qs, h) { return (qs ? qs + '&' : '?') + 'theme=' + S.theme + (h || ''); }); }
function frames() {
return DEV.map(function (d) {
var w = Math.round(d[1] * d[3]), h = Math.round(d[2] * d[3]);
return '<div class="dev"><h2>' + d[0] + ' · ' + d[1] + ' × ' + d[2] + '</h2><div class="bezel ' + d[4] + '"><div class="screen" style="width:' + w + 'px;height:' + h + 'px"><iframe title="' + SCREENS[S.i][1] + ' on ' + d[0] + '" src="' + url(SCREENS[S.i][0]) + '" width="' + d[1] + '" height="' + d[2] + '" style="transform:scale(' + d[3] + ')"></iframe></div></div><a class="small" href="' + url(SCREENS[S.i][0]) + '" target="_blank" rel="noopener">Open full size ↗</a></div>';
}).join('');
}
function render() {
return '<header class="page-head"><div><h1>Devices</h1><p class="sum">Every screen on a phone and on a tablet, portrait and landscape. The frames are live, so you can click inside them.</p></div>' +
'<div class="tabs" role="group" aria-label="Preview theme"><button type="button" data-t="dark" aria-selected="' + (S.theme === 'dark') + '">Dark</button><button type="button" data-t="light" aria-selected="' + (S.theme === 'light') + '">Light</button></div></header>' +
'<div class="dv-list" role="group" aria-label="Screen">' + SCREENS.map(function (s, i) { return '<button type="button" data-i="' + i + '" aria-pressed="' + (i === S.i) + '">' + s[1] + '</button>'; }).join('') + '</div>' +
'<div class="dv-row">' + frames() + '</div>' +
'<section class="bp" aria-label="Breakpoints"><div class="nm-inset"><b>Phone · under 700 px</b>Bottom tab bar (4 + More) with badges, a floating + for the page\'s main action, panels as full-screen sheets, tables drop to 2–3 columns, 44 px touch targets.</div>' +
'<div class="nm-inset"><b>Tablet · 700–1180 px</b>Compact icon rail with labels and badges, panels as a 480 px side sheet over the page, low-priority columns hidden, no keyboard hints on touch.</div>' +
'<div class="nm-inset"><b>Desktop · over 1180 px</b>Full rail with the Manage group, a 520 px panel, all columns, keyboard shortcuts shown.</div></section>';
}
function redraw() { document.getElementById('page').innerHTML = render(); history.replaceState(null, '', location.pathname + '?i=' + S.i + '&t=' + S.theme); }
document.addEventListener('click', function (e) {
var a = e.target.closest('[data-i]'); if (a) { S.i = +a.getAttribute('data-i'); return redraw(); }
a = e.target.closest('[data-t]'); if (a) { S.theme = a.getAttribute('data-t'); return redraw(); }
});
X.init({ name: 'devices' }, render);
})();
</script>
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Extrudex Design v3</title>
<meta http-equiv="refresh" content="0; url=iteration-3/screens/index.html">
<link rel="canonical" href="iteration-3/screens/index.html">
</head>
<body>
<p>Redirecting to the <a href="iteration-3/screens/index.html">iteration 3 screens</a>&hellip;</p>
</body>
</html>