Add the Extrudex Design System v3 bundle
Tokens (light + dark, neumorphism, 54/54 WCAG AA contrast pairs), the self-hosted JetBrains Mono fonts, and interactive mockups of every screen across three iterations. Iteration 3 is final: phone, tablet and desktop tiers, device preview, and "empty spool weight" terminology. The build scripts regenerate the screens byte-identically from source. Implementation is tracked in Maestro feature UI Refresh - V3.0 (224b72a1-8d84-4970-97ea-5dafa4d8ea3b). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9ba0d409c8
commit
d9ed1898b9
@@ -0,0 +1,142 @@
|
||||
# Iteration 3: the remaining screens, plus tablet and phone
|
||||
|
||||
2026-09-18. Built on iteration 2 (left unchanged in `../iteration-2/`). Same v3
|
||||
tokens, same shared panel/palette/toast system. **Start at
|
||||
`screens/index.html`** (flow map, 14 guided flows) or **`screens/devices.html`**
|
||||
(every screen in live phone, tablet-portrait and tablet-landscape frames).
|
||||
|
||||
---
|
||||
|
||||
## 1. Your answers, applied
|
||||
|
||||
| Question | Your answer | What changed |
|
||||
|---|---|---|
|
||||
| One-click suggestion | Fine if accurate; fixable by hand | Kept one click. Every attributed print now has **Change spool** in its panel, and it states the real effect: *"46 g goes back to Elegoo Black #42 and comes off the spool you pick; cost is re-rated."* (That's what `SetPrintSpool` does on `dev`.) Rows attributed before deductions were tracked get the real refusal: reconcile to the same spool first (see print **Test_cube**). |
|
||||
| Weigh-session order | By weight, with location visible | The session always runs **lightest first**, whatever the table sort. Each step opens with a **"Find it: Shelf C"** strip and "spool 2 of 4 · lightest first". |
|
||||
| Landing page | Dashboard | Unchanged. |
|
||||
| Filament badge | Yes: it flags an assumed weight that could be off | **Filament [7]** badge = empty spool weight **unverified**: 3 unknown + 4 estimated (Generic 180 g, Refill 35 g). Its tooltip splits the two. The saved view is renamed **Empty spool weight unverified**, and the Dashboard item reads "7 spools have an unverified empty spool weight: 3 can't be weighed yet, 4 could be off." |
|
||||
| Palette commands | Makes sense | `load ‹spool› into ‹holder›` and `attribute ‹print› to ‹spool›`. They're typed in stages: the list narrows to spools or prints, then to holders or spools. Full holders show "full · 4/4" and refuse. Two new actions in the empty palette prefill them. |
|
||||
|
||||
> **Check my reading of 4a.** I took "identifies an assumed weight" to include
|
||||
> *estimated* empty spool weights, not just unknown ones. If you meant unknown only, it's a
|
||||
> one-line change and the badge drops to 3.
|
||||
|
||||
---
|
||||
|
||||
## 1b. Terminology: "empty spool weight", not "tare" (changed 2026-09-18)
|
||||
|
||||
"Tare" isn't how the 3D printing community talks, so every user-facing string
|
||||
now says **empty spool weight**. The short form **empty spool** is used only
|
||||
where space is tight: column headers, and the scale equation "262 g empty spool
|
||||
+ 812 g filament = 1,074 g". Examples:
|
||||
|
||||
| Was | Now |
|
||||
|---|---|
|
||||
| Tare unverified (view) | Empty spool weight unverified |
|
||||
| Empty spool (tare) | Empty spool / Empty spool weight |
|
||||
| tare unknown | empty spool weight unknown |
|
||||
| Set a tare · Save tare | Set empty spool weight · Save empty spool weight |
|
||||
| Remaining = reading − 140 g tare | Remaining = reading − 140 g empty spool |
|
||||
| Brand tare | Brand empty spool weight |
|
||||
|
||||
**Not changed:** code and API names (`tare_grams`, `tare_source`,
|
||||
`effective_tare_grams`, `tare_origin`). Renaming those would be an API break for
|
||||
no user benefit. CTXD's GLOSSARY.MD still defines "Tare"; I haven't edited CTXD
|
||||
(read-only for this task). You may want to add "empty spool weight" there as
|
||||
the user-facing term.
|
||||
|
||||
Iterations 1 and 2 still say "tare"; they're kept as snapshots.
|
||||
|
||||
---
|
||||
|
||||
## 2. New screens
|
||||
|
||||
| Screen | Who | What it's for | Built from on `dev` |
|
||||
|---|---|---|---|
|
||||
| **05 Lists** | operator+ | The 5 name-part lists + spool types. A name bar shows which part you're editing ("Sunlu **Rapid** PETG CF Silk"). "Used by N spools" links to Filament filtered. Remove is disabled with the reason when in use. Spool types show empty spool weight + trust level; adding one enforces "weight and source together, never 0 g". Demos: rename conflict (409), add error. | `ManageLookups.tsx`, `lookup` store rules |
|
||||
| **06 Labels** | operator+ | Queue of spools without a label (preselected) → one PDF. Previews at the **real 51 × 21 mm layout** (19 mm QR · Brand / Color · taxonomy · #id). Tabs: all spools (reprint), holders (per-printer holder labels that start scan sessions). A format reference is included. | `labels.go`, `/api/spool-labels/unprinted`, `/api/filament-containers/{id}/label` |
|
||||
| **07 Access** | admin | Authentik owns people and roles; Extrudex shows its local view. Role cards (reader / operator / admin, from the route policy). People with sessions (idle and hard expiry) and **Revoke…** with an inline confirm. **Machines & API tokens**: new token shown **once** (`exd_…`), per-token revoke. | `AccessAdministration.tsx`; machines/tokens have **API but no screen** on `dev` |
|
||||
| **08 System** | admin | **Low-filament report**: schedule, threshold, weekly delivery history (retries, errors), **Run this week's now**. **Audit log**: who/what/when with links to the thing changed, filters, paging. **This device**: install, update-ready, offline readiness. | `webhook.go`, `audit.go`, `PWAControls.tsx`; webhook and audit have **API but no screen** on `dev` |
|
||||
| **09 Scan: holder** (`/c/:id`) | phone-first | Scan a holder label → a session for this tab. Each spool scanned **loads** (from storage), **returns** (already here) or **moves here** (from another holder). No mode picker. Full → refused, nothing changes. Result card + haptic, a session log, "Done scanning". Camera sheet (demo) + type-a-number fallback. Reader view shows contents only. | `ScanSession.tsx`, `ScannedSpoolRoute` |
|
||||
| **10 Scan: spool** (`/s/:id`) | phone-first | A spool label scanned with no holder session active: big remaining, **Lives in**, one-handed weigh-in (68 px input, before → after, the same two refusals), then open its holder or full details. Empty spool weight unknown → pick a spool type first. | `ScanWeighSpool.tsx` |
|
||||
| **Printer panels** (on Printers) | admin | **Add / edit printer** (brand filters models from `printerCatalog.ts`; access code write-only; Elegoo has none), **Manage holders** (add AMS/AMS Lite, the external holder can't be removed, loaded holders can't be removed, print holder label), **Remove printer** (type the exact name). | `PrinterForm.tsx`, `ManagePrinterContainers.tsx`, `ConfirmRemovePrinter.tsx` |
|
||||
| **Devices** | — | Every screen in 3 live frames, dark/light. | — |
|
||||
|
||||
The nav now reaches everything: **Manage** (Lists, Labels, Access, System) on
|
||||
the rail, and a **More** tab on phones.
|
||||
|
||||
---
|
||||
|
||||
## 3. Things I checked against the code (and corrected in the design)
|
||||
|
||||
| Claim I almost made | What `dev` actually does | Design now says |
|
||||
|---|---|---|
|
||||
| "Run the report again resends it" | A delivered week returns its existing result; only a retrying week is retried (`runPeriod`) | "Already delivered Monday 08:00; nothing was sent again. Running it never sends a duplicate." |
|
||||
| "Remove printer, and history is kept" | Also **refused while any holder still has spools**, and an open print is **settled as unknown** | Both shown in the remove panel; confirm disabled while loaded |
|
||||
| Re-attributing a charged print is blocked | Allowed: grams are **returned** to the old spool and taken from the new one, *except* rows from before tracking (`ErrUnreconciledAttribution`) | "Change spool" with the exact effect, and the real refusal |
|
||||
| Scanning a spool label always opens it | Only with no holder session active; otherwise it's loaded/moved into the active holder | Both routes designed; each page explains the other |
|
||||
| Sample token `ctxd_…` | Extrudex tokens are `exd_…` | Fixed |
|
||||
|
||||
**Two findings for you** (not design changes):
|
||||
1. **List names are case-sensitive in the schema** (`UNIQUE`, no `COLLATE
|
||||
NOCASE`), so "petg" can be added beside "PETG", which is the very problem
|
||||
the lists exist to prevent. The Lists screen shows a *proposed*
|
||||
near-duplicate warning, labeled as a proposal.
|
||||
2. **Audit log, low-filament report and machine tokens have APIs but no
|
||||
screens on `dev`.** They're designed here and tagged "API exists · no screen
|
||||
on dev yet".
|
||||
|
||||
---
|
||||
|
||||
## 4. Responsive specification
|
||||
|
||||
| | Phone < 700 px | Tablet 700–1180 px | Desktop > 1180 px |
|
||||
|---|---|---|---|
|
||||
| Navigation | Bottom bar: Dashboard · Filament · Prints · Printers · **More** (sheet with Manage + theme), badges on icons | **Compact rail** (88 px): icon + label, badges top-right, Manage group | Full rail with labels, Manage group, theme switch |
|
||||
| Search | "Search…" pill, no shortcut hint | Full placeholder; shortcut hint hidden on touch | Full placeholder + ⌘K |
|
||||
| Primary action | Floating **+** (56 px) above the bar | Header button | Header button |
|
||||
| Panel | Full-screen sheet | 480 px side sheet over the page | 520 px side sheet |
|
||||
| Tables | Low-priority columns dropped (`hide-m`); Lists become stacked rows | Tablet-only drops (`hide-t`): Value, Cost, Use; Weigh becomes icon-only | All columns |
|
||||
| Touch | 44 px+ targets; menu items 12 px padding; `pointer: coarse` rules | Same `pointer: coarse` rules | Mouse sizes, keyboard hints |
|
||||
| Scan pages | Designed for this: 64 px scan button, 68 px scale input, haptic on result | Centered 560 px column | Centered 560 px column |
|
||||
| Dashboard | One column; attention buttons full-width | One column below 980 px, two above | Two columns |
|
||||
| Printers | One card per row | 2 per row (portrait 1, landscape 2) | 3 per row |
|
||||
|
||||
---
|
||||
|
||||
## 5. New open questions
|
||||
|
||||
1. **Empty spool weight badge scope.** Unknown + estimated (as built), or unknown only? See §1.
|
||||
2. **Near-duplicate list names.** Add a case-insensitive uniqueness check (a
|
||||
schema change plus migration), or only warn in the UI?
|
||||
3. **Holder scan session end.** `dev` keeps the active holder for the browser
|
||||
session. Is an explicit **Done scanning** (as designed) wanted, and should it
|
||||
also time out after a few minutes idle?
|
||||
4. **Machine tokens, audit log, low-filament report.** Worth building screens
|
||||
for all three now, or keep them API-only until something needs them?
|
||||
5. **Label printing.** Always PDF download, or is there a specific label printer
|
||||
you'd want a direct-print path for?
|
||||
|
||||
## 6. More implementation tasks (titles only, smallest first)
|
||||
|
||||
1. Rename the "Tare unknown" view to "Empty spool weight unverified" and include estimated weights
|
||||
2. Add a Filament nav badge for unverified empty spool weights
|
||||
3. Always run weigh sessions lightest first
|
||||
4. Show the spool's location at the top of the weigh step
|
||||
5. Add "Change spool" to attributed prints, stating the returned grams
|
||||
6. Show the unreconciled-attribution refusal with its fix
|
||||
7. Add a case-insensitive near-duplicate warning to Lists
|
||||
8. Show "Used by N spools" with a link on every list value
|
||||
9. Add label previews at 51 × 21 mm proportions
|
||||
10. Add holder labels to the Labels screen
|
||||
11. Add the "load … into …" palette command
|
||||
12. Add the "attribute … to …" palette command
|
||||
13. Add a More tab and sheet to the phone navigation
|
||||
14. Add the compact tablet rail (700–1180 px)
|
||||
15. Add a Manage holders panel with the removal rules
|
||||
16. Add "Remove printer" with name confirmation and the loaded-holders refusal
|
||||
17. Add a "Done scanning" control to holder scan sessions
|
||||
18. Add the phone weigh-from-label layout
|
||||
19. Add an audit log screen over `GET /api/admin/audit`
|
||||
20. Add a low-filament report screen over the webhook endpoints
|
||||
21. Add a machine and API token screen with show-once tokens
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,553 @@
|
||||
/* Iteration-2 shared behaviour: shell, navigation badges, command palette,
|
||||
* drawers (spool / print / load), toasts, freshness and demo states.
|
||||
* Plain browser JS, no dependencies, works offline. */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
/* ── helpers ─────────────────────────────────────────────── */
|
||||
var $ = function (s, r) { return (r || document).querySelector(s); };
|
||||
var $$ = function (s, r) { return Array.prototype.slice.call((r || document).querySelectorAll(s)); };
|
||||
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }
|
||||
function g(n) { return n == null ? '—' : Math.round(n).toLocaleString('en-US') + ' g'; }
|
||||
function kg(n) { return (n / 1000).toFixed(2) + ' kg'; }
|
||||
function usd(n) { return n == null ? '—' : '$' + n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); }
|
||||
function dur(s) { if (s == null) return '—'; if (s < 60) return '<1m'; var h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60); return h ? h + 'h ' + String(m).padStart(2, '0') + 'm' : m + 'm'; }
|
||||
function when(iso) {
|
||||
if (!iso) return '—';
|
||||
var d = new Date(iso), t = d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
|
||||
var days = Math.round((new Date(NOW.toDateString()) - new Date(d.toDateString())) / 864e5);
|
||||
if (days === 0) return 'Today ' + t;
|
||||
if (days === 1) return 'Yesterday ' + t;
|
||||
return d.getDate() + ' ' + d.toLocaleString('en-GB', { month: 'short' }) + ' ' + t;
|
||||
}
|
||||
function ago(sec) { if (sec < 60) return sec + 's ago'; var m = Math.floor(sec / 60); if (m < 60) return m + 'm ago'; var h = Math.floor(m / 60); return h < 24 ? h + 'h ago' : Math.floor(h / 24) + 'd ago'; }
|
||||
function since(iso) { return Math.max(0, Math.round((NOW - new Date(iso)) / 1000)); }
|
||||
|
||||
var byId = function (arr, id) { for (var i = 0; i < arr.length; i++) if (arr[i].id === id) return arr[i]; return null; };
|
||||
function spool(id) { return byId(SPOOLS, id); }
|
||||
function printer(id) { return byId(PRINTERS, id); }
|
||||
function print(id) { return byId(PRINTS, id); }
|
||||
function container(cid) {
|
||||
for (var i = 0; i < PRINTERS.length; i++) for (var j = 0; j < PRINTERS[i].containers.length; j++)
|
||||
if (PRINTERS[i].containers[j].id === cid) return { printer: PRINTERS[i], c: PRINTERS[i].containers[j] };
|
||||
return null;
|
||||
}
|
||||
function contents(cid) { return SPOOLS.filter(function (s) { return s.cont === cid; }); }
|
||||
function fullName(s) { return [s.brand, s.speed, s.mat, s.type, s.fin].filter(Boolean).join(' '); }
|
||||
function short(s) { return s.brand + ' ' + s.color; }
|
||||
function where(s) {
|
||||
if (!s.cont) return { loaded: false, text: s.loc || 'Storage', short: s.loc || 'Storage' };
|
||||
var c = container(s.cont);
|
||||
return { loaded: true, text: c.printer.name + ' · ' + c.c.label, short: c.printer.model + ' · ' + c.c.label, printer: c.printer };
|
||||
}
|
||||
/* Unknown, or an estimated figure nobody weighed: the weigh-in would rest on a guess. */
|
||||
function unverified(s) { return s.tare == null || s.tsrc === 'estimated'; }
|
||||
function gross(s) { return s.tare == null ? null : s.tare + s.rem; }
|
||||
function value(s) { return s.rem / 1000 * s.kg; }
|
||||
function low(s) { return s.rem < LOW; }
|
||||
function sw(colors, cls) { return '<span class="swatch ' + (cls || '') + '" aria-hidden="true">' + colors.map(function (h) { return '<i style="background:' + h + '"></i>'; }).join('') + '</span>'; }
|
||||
function meter(s) {
|
||||
var pct = Math.max(0, Math.min(100, s.rem / s.tot * 100)), tick = Math.min(100, LOW / s.tot * 100);
|
||||
return '<div class="meter' + (low(s) ? ' is-low' : '') + '" role="meter" aria-valuemin="0" aria-valuemax="' + s.tot + '" aria-valuenow="' + s.rem + '" aria-label="' + esc(s.color) + ' remaining"><span style="width:' + pct + '%"></span><i class="tick" style="left:' + tick + '%" title="200 g low-stock line"></i></div>';
|
||||
}
|
||||
|
||||
/* provenance: quiet icon + words */
|
||||
var TSRC = { spool: ['⚖', 'weighed bare', ''], manufacturer: ['◆', 'manufacturer', ''], community: ['◇', 'community', ''], estimated: ['≈', 'estimated', 'warn'] };
|
||||
function tareProv(s) {
|
||||
if (s.tare == null) return '<span class="prov warn"><i>?</i>empty spool weight unknown</span>';
|
||||
var t = TSRC[s.tsrc]; return '<span class="prov ' + t[2] + '" title="' + esc(s.tsrc === 'spool' ? 'This reel was weighed empty' : 'From spool type: ' + s.stype) + '"><i>' + t[0] + '</i>' + t[1] + '</span>';
|
||||
}
|
||||
function remProv(s) { return s.remSrc === 'weighed' ? '<span class="prov"><i>⚖</i>weighed' + (s.weighedAt ? ' ' + esc(s.weighedAt) : '') + '</span>' : '<span class="prov"><i>✎</i>typed</span>'; }
|
||||
var WSRC = { typed: ['✎', 'typed', '', 'A measurement someone entered. Survives an identity correction.'], sliced: ['▦', 'sliced', 'info', "Read from this run's 3MF over FTPS."], recalled: ['⎘', 'recalled', 'info', 'Copied from the weight remembered for this gcode path. No longer written.'], unknown: ['?', 'source unknown', 'warn', 'Written before the source was recorded. Neither correction path will move it.'] };
|
||||
function weightProv(p) { if (!p.wsrc) return ''; var w = WSRC[p.wsrc]; return '<span class="prov ' + w[2] + '" title="' + esc(w[3]) + '"><i>' + w[0] + '</i>' + w[1] + '</span>'; }
|
||||
function durCell(p) {
|
||||
if (p.status === 'running') return '<span class="num">' + dur(since(p.start)) + '</span><span class="sub">so far</span>';
|
||||
if (p.status === 'unknown') return '<span class="muted" title="Nobody saw this print end. The only measurable figure is the outage, which is not how long anything printed.">no duration</span><span class="sub">end not seen</span>';
|
||||
if (p.gaps) return '<span class="num strong" style="color:var(--status-warning)" title="The connection dropped ' + (p.gaps === 1 ? 'once' : p.gaps + ' times') + ' during this print. That time is inside the figure and nobody watched it, so it is an upper bound.">≤ ' + dur(p.dur) + '</span><span class="sub prov warn">upper bound · ' + p.gaps + ' gap' + (p.gaps > 1 ? 's' : '') + '</span>';
|
||||
if (p.gaps == null) return '<span class="num">' + dur(p.dur) + '</span><span class="sub" title="Predates gap counting: nobody asked whether the connection dropped.">gaps not counted</span>';
|
||||
return '<span class="num">' + dur(p.dur) + '</span>';
|
||||
}
|
||||
var PST = { running: ['run', 'Running'], finished: ['ok', 'Finished'], failed: ['err', 'Failed'], unknown: ['warn', 'Unknown'] };
|
||||
function printSt(p) { var s = PST[p.status]; return '<span class="st ' + s[0] + '">' + s[1] + '</span>'; }
|
||||
function printerSt(pr) {
|
||||
if (!pr.connected) return '<span class="st warn">' + (pr.lifecycle === 'reconnecting' ? 'Reconnecting' : 'Offline') + '</span>';
|
||||
if (pr.state === 'RUNNING') return '<span class="st run">Printing</span>';
|
||||
if (pr.state === 'PAUSE') return '<span class="st warn">Paused</span>';
|
||||
if (pr.state === 'FAILED') return '<span class="st err">Failed</span>';
|
||||
return '<span class="st idle">Idle</span>';
|
||||
}
|
||||
/* what a print still needs; empty while running */
|
||||
function needs(p) {
|
||||
if (p.status === 'running') return [];
|
||||
var n = [];
|
||||
if (p.grams == null) n.push({ k: 'weight', t: 'filament weight' });
|
||||
if (p.filaments) p.filaments.forEach(function (f) { if (f.spool == null) n.push({ k: 'spool', t: 'spool for color ' + (f.slot + 1), f: f }); });
|
||||
else if (p.spool == null) n.push({ k: 'spool', t: 'spool' });
|
||||
return n;
|
||||
}
|
||||
function printsNeeding() { return PRINTS.filter(function (p) { return needs(p).length && !p.archived; }); }
|
||||
function spoolLinks(p) {
|
||||
if (p.filaments) return p.filaments.map(function (f) { var s = f.spool && spool(f.spool); return s ? '<a href="#spool=' + s.id + '" data-spool="' + s.id + '">' + sw(s.c, 'swatch-sm') + ' ' + esc(short(s)) + '</a>' : '<span class="prov warn"><i>!</i>color ' + (f.slot + 1) + ' unattributed</span>'; }).join('<br>');
|
||||
var s = p.spool && spool(p.spool);
|
||||
return s ? '<a href="#spool=' + s.id + '" data-spool="' + s.id + '" class="nowrap">' + sw(s.c, 'swatch-sm') + ' ' + esc(short(s)) + '</a>' : (p.status === 'running' ? '<span class="muted">—</span>' : '<span class="prov warn"><i>!</i>no spool</span>');
|
||||
}
|
||||
|
||||
/* ── shell ───────────────────────────────────────────────── */
|
||||
var ICON = {
|
||||
dashboard: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="3" y="3" width="7" height="9" rx="2"/><rect x="14" y="3" width="7" height="5" rx="2"/><rect x="14" y="12" width="7" height="9" rx="2"/><rect x="3" y="16" width="7" height="5" rx="2"/></svg>',
|
||||
filament: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3"/><path d="M12 3v6M12 15v6"/></svg>',
|
||||
prints: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M5 20h14M7 16l3-9h4l3 9M9 12h6"/></svg>',
|
||||
printers: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 17h8M9 7h6v5H9z"/></svg>',
|
||||
lists: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01"/></svg>',
|
||||
labels: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M3 12V4h8l10 10-8 8z"/><circle cx="7.5" cy="7.5" r="1.5"/></svg>',
|
||||
access: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="9" cy="8" r="4"/><path d="M2 21a7 7 0 0 1 14 0M17 11l2 2 4-4"/></svg>',
|
||||
system: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M4 6h16M4 12h10M4 18h7"/><circle cx="18" cy="16" r="3"/></svg>',
|
||||
more: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></svg>',
|
||||
search: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-4-4"/></svg>',
|
||||
sun: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>'
|
||||
};
|
||||
var MANAGE = [['lists', 'Lists', '05-lists.html', 0], ['labels', 'Labels', '06-labels.html', 0], ['access', 'Access', '07-access.html', 1], ['system', 'System', '08-system.html', 1]];
|
||||
var PAGES = [['dashboard', 'Dashboard', '01-dashboard.html'], ['filament', 'Filament', '02-filament.html'], ['prints', 'Prints', '03-prints.html'], ['printers', 'Printers', '04-printers.html']];
|
||||
function badges() {
|
||||
var need = printsNeeding().length, down = PRINTERS.filter(function (p) { return !p.connected; }).length;
|
||||
var unv = SPOOLS.filter(unverified), unk = unv.filter(function (s) { return s.tare == null; }).length;
|
||||
return {
|
||||
filament: unv.length ? '<span class="count count-warn" title="' + unv.length + ' spools with an unverified empty spool weight: ' + unk + ' unknown, ' + (unv.length - unk) + ' estimated">' + unv.length + '</span>' : '',
|
||||
prints: need ? '<span class="count count-warn" title="' + need + ' prints need a spool or weight">' + need + '</span>' : '',
|
||||
printers: down ? '<span class="count count-err" title="' + down + ' printer not connected">' + down + '</span>' : ''
|
||||
};
|
||||
}
|
||||
function shell(page) {
|
||||
var b = badges();
|
||||
var nav = PAGES.map(function (p) { return '<a href="' + p[2] + '"' + (p[0] === page ? ' aria-current="page"' : '') + '>' + ICON[p[0]] + '<span>' + p[1] + '</span>' + (b[p[0]] || '') + '</a>'; }).join('');
|
||||
var unl = SPOOLS.filter(function (s) { return !s.label; }).length;
|
||||
var rail = '<nav class="rail nm-raised" aria-label="Main">' +
|
||||
'<a class="wordmark" href="01-dashboard.html" style="text-decoration:none;color:inherit"><span class="wordmark-mark" aria-hidden="true">X</span>Extrudex</a>' +
|
||||
'<div class="nav nav-group">' + nav + '</div>' +
|
||||
'<div class="nav nav-group"><span class="nav-label">Manage</span>' +
|
||||
MANAGE.map(function (m) { return '<a href="' + m[2] + '"' + (m[0] === page ? ' aria-current="page"' : '') + '>' + ICON[m[0]] + '<span>' + m[1] + '</span>' + (m[0] === 'labels' ? '<span class="count" style="color:var(--text-secondary)" title="' + unl + ' spools without a printed label">' + unl + '</span>' : m[3] ? '<span class="sub" style="margin-left:auto">admin</span>' : '') + '</a>'; }).join('') + '</div>' +
|
||||
'<div class="rail-foot"><div class="seg" role="group" aria-label="Color theme"><button type="button" data-set-theme="light">Light</button><button type="button" data-set-theme="dark">Dark</button></div>' +
|
||||
'<span class="small muted">Signed in · operator</span></div></nav>';
|
||||
var inManage = MANAGE.some(function (m) { return m[0] === page; });
|
||||
var tabbar = '<nav class="tabbar nm-raised" aria-label="Main">' + PAGES.map(function (p) { return '<a href="' + p[2] + '"' + (p[0] === page ? ' aria-current="page"' : '') + '>' + ICON[p[0]] + (b[p[0]] || '') + '<span>' + p[1] + '</span></a>'; }).join('') +
|
||||
'<a href="#" data-menu="moremenu" aria-haspopup="menu" aria-expanded="false"' + (inManage ? ' aria-current="page"' : '') + '>' + ICON.more + '<span>More</span></a></nav>' +
|
||||
'<div class="menu more-sheet nm-dialog" id="moremenu" role="menu" aria-label="More">' + MANAGE.map(function (m) { return '<a role="menuitem" href="' + m[2] + '">' + ICON[m[0]] + ' ' + m[1] + (m[3] ? '<span class="hint">admin</span>' : '') + '</a>'; }).join('') + '<hr><button role="menuitem" type="button" data-toggle-theme>' + ICON.sun + ' Switch light / dark</button></div>';
|
||||
var top = '<div class="topbar"><button class="search-trigger" type="button" data-palette>' + ICON.search + '<span style="flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"><span class="long">Search spools, prints, printers…</span><span class="short">Search…</span></span><span class="kbd long">⌘K</span></button>' +
|
||||
'<span class="fresh" id="fresh" role="status"><span class="dot" aria-hidden="true"></span><span id="fresh-t">Live · updated 3s ago</span></span>' +
|
||||
'<button class="btn btn-icon btn-sm" type="button" data-toggle-theme aria-label="Switch color theme">' + ICON.sun + '</button></div>';
|
||||
var mock = '<div class="mock"><button type="button" data-menu="mockmenu" aria-expanded="false">Mockup<span class="long"> · v3 iteration 3</span></button><div class="menu" id="mockmenu" role="dialog" aria-label="About this mockup">' +
|
||||
'<p><b>Sample data</b>, based on the real inventory. Nothing is wired to the API.</p>' +
|
||||
'<p><span class="prov info"><i>◈</i>feature</span> fields exist only on the printer-connections branch. <span class="prov warn"><i>⚠</i>API gap</span> means stored but not served.</p><hr>' +
|
||||
'<button type="button" data-demo="loading">Replay first load (skeletons)</button>' +
|
||||
'<button type="button" data-demo="offline">Simulate polling failure</button>' +
|
||||
'<button type="button" data-demo="online">Back to live</button><hr>' +
|
||||
'<a href="index.html">Flow map of all screens →</a></div></div>';
|
||||
var drawer = '<div class="drawer-scrim" data-close-drawer></div><aside class="drawer nm-dialog" id="drawer" role="dialog" aria-modal="true" aria-labelledby="drawer-title" hidden></aside>';
|
||||
var pal = '<div class="pal-scrim" id="pal" role="presentation"><div class="pal nm-dialog" role="dialog" aria-modal="true" aria-label="Search and commands"><div class="pal-input">' + ICON.search +
|
||||
'<input id="pal-q" role="combobox" aria-expanded="true" aria-controls="pal-list" aria-autocomplete="list" placeholder="Type a spool, #id, job, printer or action…" autocomplete="off"><span class="kbd">esc</span></div>' +
|
||||
'<div class="pal-list" id="pal-list" role="listbox"></div><div class="pal-foot"><span><span class="kbd">↑</span> <span class="kbd">↓</span> move</span><span><span class="kbd">↵</span> open</span><span><span class="kbd">/</span> or <span class="kbd">⌘K</span> anywhere</span></div></div></div>';
|
||||
return { rail: rail, tabbar: tabbar, top: top, rest: mock + drawer + pal + '<div class="toasts" id="toasts" aria-live="polite"></div>' };
|
||||
}
|
||||
|
||||
/* ── theme ───────────────────────────────────────────────── */
|
||||
function setTheme(v) {
|
||||
document.documentElement.setAttribute('data-theme', v);
|
||||
$$('[data-set-theme]').forEach(function (b) { b.setAttribute('aria-pressed', String(b.getAttribute('data-set-theme') === v)); });
|
||||
try { localStorage.setItem('extrudex-v3-theme', v); } catch (e) {}
|
||||
}
|
||||
|
||||
/* ── toasts ──────────────────────────────────────────────── */
|
||||
function updateBadges() {
|
||||
var b = badges();
|
||||
$$('.nav a, .tabbar a').forEach(function (a) {
|
||||
var key = { '02-filament.html': 'filament', '03-prints.html': 'prints', '04-printers.html': 'printers' }[a.getAttribute('href')]; if (!key) return;
|
||||
var old = a.querySelector('.count'); if (old) old.parentNode.removeChild(old);
|
||||
if (b[key]) a.querySelector('span').insertAdjacentHTML(a.closest('.tabbar') ? 'beforebegin' : 'afterend', b[key]);
|
||||
});
|
||||
}
|
||||
function toast(html, kind) {
|
||||
setTimeout(updateBadges, 0);
|
||||
var t = document.createElement('div'); t.className = 'toast ' + (kind || ''); t.setAttribute('role', 'status');
|
||||
t.innerHTML = '<span>' + html + '</span><button class="btn btn-sm btn-icon" type="button" aria-label="Dismiss">✕</button>';
|
||||
$('#toasts').appendChild(t);
|
||||
var kill = function () { if (t.parentNode) t.parentNode.removeChild(t); };
|
||||
t.querySelector('button').onclick = kill; setTimeout(kill, 6000);
|
||||
}
|
||||
|
||||
/* ── drawer ──────────────────────────────────────────────── */
|
||||
var D = { kind: null, id: null, tab: 'overview', state: 'normal', list: null, lastFocus: null, dirty: false };
|
||||
function openDrawer(kind, id, tab, push) {
|
||||
D.kind = kind; D.id = id; D.tab = tab || 'overview'; D.state = 'normal'; D.dirty = false;
|
||||
if (!document.body.classList.contains('drawer-open')) D.lastFocus = document.activeElement;
|
||||
renderDrawer();
|
||||
var el = $('#drawer'); el.hidden = false;
|
||||
requestAnimationFrame(function () { document.body.classList.add('drawer-open'); var f = el.querySelector('[autofocus]') || el.querySelector('h2'); if (f) { f.setAttribute('tabindex', f.tagName === 'H2' ? '-1' : f.getAttribute('tabindex')); f.focus(); } });
|
||||
var h = '#' + kind + '=' + id + (D.tab !== 'overview' ? '&tab=' + D.tab : '');
|
||||
if (push !== false && location.hash !== h) history.pushState({ d: 1 }, '', h);
|
||||
$$('[data-row-spool],[data-row-print]').forEach(function (r) { r.classList.toggle('is-selected', (kind === 'spool' && +r.getAttribute('data-row-spool') === id) || (kind === 'print' && +r.getAttribute('data-row-print') === id)); });
|
||||
}
|
||||
function closeDrawer(push) {
|
||||
if (D.dirty && !confirmDiscard()) return;
|
||||
document.body.classList.remove('drawer-open'); D.kind = null; D.session = false; D.brand = null;
|
||||
setTimeout(function () { if (!D.kind) $('#drawer').hidden = true; }, 240);
|
||||
if (push !== false && location.hash) history.pushState({}, '', location.pathname + location.search);
|
||||
$$('.is-selected').forEach(function (r) { r.classList.remove('is-selected'); });
|
||||
if (D.lastFocus && D.lastFocus.focus) D.lastFocus.focus();
|
||||
}
|
||||
function confirmDiscard() {
|
||||
var bar = $('#discard-bar'); if (bar) { bar.hidden = false; bar.querySelector('button').focus(); return false; }
|
||||
return true;
|
||||
}
|
||||
function renderDrawer() {
|
||||
var el = $('#drawer');
|
||||
if (D.kind === 'spool') el.innerHTML = spoolDrawer(spool(D.id));
|
||||
else if (D.kind === 'new') el.innerHTML = editDrawer(null);
|
||||
else if (D.kind === 'print') el.innerHTML = printDrawer(print(D.id));
|
||||
else if (D.kind === 'load') el.innerHTML = loadDrawer(D.id);
|
||||
else if (D.kind === 'pedit') el.innerHTML = peditDrawer(D.id);
|
||||
else if (D.kind === 'holders') el.innerHTML = holdersDrawer(D.id);
|
||||
else if (D.kind === 'premove') el.innerHTML = premoveDrawer(D.id);
|
||||
if (window.PAGE && PAGE.onDrawer) PAGE.onDrawer();
|
||||
}
|
||||
function head(icon, title, sub, extra) {
|
||||
var list = D.list || [], i = list.indexOf(D.id);
|
||||
var nav = (D.kind === 'spool' && i >= 0 && list.length > 1) ? '<div class="drawer-nav"><button class="btn btn-sm btn-icon" type="button" data-step="-1" aria-label="Previous spool in list" ' + (i === 0 ? 'disabled' : '') + '>↑</button><button class="btn btn-sm btn-icon" type="button" data-step="1" aria-label="Next spool in list" ' + (i === list.length - 1 ? 'disabled' : '') + '>↓</button></div>' : '';
|
||||
return '<div class="drawer-head">' + icon + '<div style="flex:1;min-width:0"><h2 id="drawer-title">' + title + '</h2><span class="sub">' + sub + '</span>' + (extra || '') + '</div>' + nav + '<button class="btn btn-sm btn-icon" type="button" data-close-drawer aria-label="Close panel (Esc)">✕</button></div>';
|
||||
}
|
||||
function tabs(list) {
|
||||
return '<div class="drawer-tabs"><div class="tabs" role="tablist" aria-label="Spool panel">' + list.map(function (t) { return '<button type="button" role="tab" data-tab="' + t[0] + '" aria-selected="' + (D.tab === t[0]) + '">' + t[1] + '</button>'; }).join('') + '</div></div>';
|
||||
}
|
||||
|
||||
function spoolDrawer(s) {
|
||||
var w = where(s);
|
||||
var h = head(sw(s.c, '" style="width:44px;height:44px'), esc(s.color) + ' <span class="muted" style="font-weight:400">#' + s.id + '</span>', esc(fullName(s)) + ' · ' + esc(w.text));
|
||||
var t = tabs([['overview', 'Overview'], ['weigh', 'Weigh'], ['move', w.loaded ? 'Move' : 'Load / move'], ['edit', 'Edit']]);
|
||||
var body = '', foot = '';
|
||||
if (D.tab === 'overview') {
|
||||
var used = PRINTS.filter(function (p) { return p.spool === s.id || (p.filaments || []).some(function (f) { return f.spool === s.id; }); });
|
||||
var gr = gross(s);
|
||||
body = '<div class="nm-inset panel"><div class="row" style="align-items:baseline"><span style="font-size:34px;font-weight:800;line-height:1" class="num' + (low(s) ? '" style="color:var(--status-warning)' : '') + '">' + Math.round(s.rem).toLocaleString('en-US') + '<span style="font-size:15px"> g</span></span><span class="muted">of ' + g(s.tot) + ' net</span><span class="spacer"></span>' + remProv(s) + '</div>' +
|
||||
'<div style="margin-top:12px">' + meter(s) + '</div>' + (low(s) ? '<p class="small" style="margin:10px 0 0;color:var(--status-warning)"><b>⚠ Below the 200 g line.</b> Reorder or finish it on a small job.</p>' : '') + '</div>' +
|
||||
'<div class="panel nm-raised-sm"><h3>On the scale</h3>' + (gr == null
|
||||
? '<p class="small" style="margin:0">Gross is <b>unknown</b> because no empty spool weight is known. It is never treated as 0.</p><div class="row" style="margin-top:12px"><button class="btn btn-sm" type="button" data-tab="weigh">Set empty spool weight</button></div>'
|
||||
: '<dl class="kv"><dt>Empty spool</dt><dd>' + g(s.tare) + ' ' + tareProv(s) + '</dd><dt>+ Filament (net)</dt><dd>' + g(s.rem) + '</dd><dt class="strong" style="color:var(--text-primary)">= Gross, on a scale</dt><dd class="strong">' + g(gr) + '</dd></dl>') + '</div>' +
|
||||
'<dl class="kv"><dt>Location</dt><dd>' + (w.loaded ? '<a href="04-printers.html#printer=' + w.printer.id + '">' + esc(w.text) + '</a>' : esc(w.text)) + ' · <button class="btn btn-sm" type="button" data-tab="move" style="min-height:26px;padding:2px 8px">Move</button></dd>' +
|
||||
'<dt>Value left</dt><dd>' + usd(value(s)) + ' <span class="sub" style="display:inline">at ' + usd(s.kg) + '/kg</span></dd>' +
|
||||
'<dt>Spool type</dt><dd>' + (s.stype ? esc(s.stype) : '<span class="muted">none chosen</span>') + '</dd>' +
|
||||
'<dt>Label</dt><dd>' + (s.label ? 'printed' : '<span class="prov warn"><i>!</i>not printed</span> <button class="btn btn-sm" type="button" data-fake="Label PDF generated for #' + s.id + '" style="min-height:26px;padding:2px 8px">Print label</button>') + '</dd>' +
|
||||
'<dt>Record</dt><dd class="muted">version ' + s.v + '</dd></dl>' +
|
||||
'<div><h3 class="section" style="margin-bottom:8px">Prints from this spool <span class="muted">· ' + used.length + '</span></h3>' + (used.length ? '<div class="stack" style="gap:6px">' + used.map(function (p) { var f = (p.filaments || []).filter(function (f) { return f.spool === s.id; })[0]; return '<a class="row nm-raised-sm" style="padding:8px 12px;text-decoration:none;color:inherit;gap:10px" href="#print=' + p.id + '" data-print="' + p.id + '">' + printSt(p) + '<span class="strong" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis">' + esc(p.job) + '</span><span class="num">' + g(f ? f.grams : p.grams) + '</span><span class="sub" style="display:inline">' + when(p.start) + '</span></a>'; }).join('') + '</div>' : '<p class="small muted" style="margin:0">No prints drew from this spool yet.</p>') + '</div>' +
|
||||
'<p class="small muted" style="margin:0"><span class="prov warn"><i>⚠</i>API gap</span> Location history is stored in <code>spool_location_event</code> but there is no endpoint to read it.</p>';
|
||||
foot = '<button class="btn btn-danger btn-sm" type="button" data-delete="' + s.id + '" style="margin-right:auto">Delete…</button><button class="btn" type="button" data-tab="edit">Edit</button><button class="btn btn-primary" type="button" data-tab="weigh" ' + (s.tare == null ? 'title="Needs an empty spool weight first"' : '') + '>Weigh</button>';
|
||||
}
|
||||
if (D.tab === 'weigh') {
|
||||
if (s.tare == null) {
|
||||
body = '<div class="banner"><b>?</b><span>This spool has <b>no empty spool weight</b>, so a scale reading can\'t be turned into filament. Set one first. It is never assumed to be 0.</span></div>' +
|
||||
'<label class="field stack" style="gap:6px"><span class="strong small">Pick a spool type</span><select class="select" autofocus><option>Choose…</option><option>Generic — 1 kg spool (assumed) · 180 g · estimated</option><option>Creality — Hyper cardboard · 176 g · community</option><option>Creality — legacy plastic · 138 g · community</option></select><span class="sub">Fastest. The figure\'s trust level is shown next to it.</span></label>' +
|
||||
'<div class="row"><hr style="flex:1;border:0;border-top:1px solid var(--border-hairline)"><span class="sub">or, more accurate</span><hr style="flex:1;border:0;border-top:1px solid var(--border-hairline)"></div>' +
|
||||
'<label class="field stack" style="gap:6px"><span class="strong small">Weigh the empty spool</span><input class="input num" inputmode="numeric" placeholder="e.g. 210 g"><span class="sub">Only possible once the spool is used up, or from an identical empty.</span></label>';
|
||||
foot = '<button class="btn" type="button" data-tab="overview">Cancel</button><button class="btn btn-primary" type="button" data-fake="Empty spool weight saved for #' + s.id + '. Weigh-in unlocked.">Save empty spool weight</button>';
|
||||
} else {
|
||||
var wl = where(s), li = D.list ? D.list.indexOf(s.id) : -1;
|
||||
body = (D.session && li >= 0 ? '<div class="row small" style="gap:10px"><span class="st run">Weigh session</span><span class="muted">spool ' + (li + 1) + ' of ' + D.list.length + ' · lightest first</span></div>' : '') +
|
||||
'<div class="findit"><span class="sub">Find it</span><b>' + esc(wl.text) + '</b>' + sw(s.c, 'swatch-sm') + '<span class="muted small">' + esc(s.color) + ' · #' + s.id + '</span></div>' +
|
||||
'<div class="panel nm-inset"><label class="stack" style="gap:8px"><span class="strong">Whole spool on the scale</span><input class="input num" id="scale" inputmode="numeric" autofocus placeholder="full reads ' + g(s.tare + s.tot) + '" style="font-size:22px;min-height:56px"><span class="sub">Remaining = reading − ' + g(s.tare) + ' empty spool (' + tareProv(s) + ')</span></label></div>' +
|
||||
'<div class="panel nm-raised-sm" id="weigh-out" aria-live="polite"><h3>Result</h3><div class="row"><span class="muted">Now</span><b class="num">' + g(s.rem) + '</b><span class="muted">→</span><b class="num" id="w-after">—</b><span class="spacer"></span><span id="w-diff" class="num"></span></div><p class="small muted" id="w-note" style="margin:8px 0 0">Type the reading and the result appears here before you save.</p></div>';
|
||||
foot = '<button class="btn" type="button" data-tab="overview">Cancel</button><button class="btn btn-primary" type="button" id="w-save" disabled>Save weigh-in</button>' + (D.list && D.list.indexOf(s.id) < D.list.length - 1 ? '<button class="btn" type="button" id="w-next" disabled>Save & weigh next ↓</button>' : '');
|
||||
}
|
||||
}
|
||||
if (D.tab === 'move') {
|
||||
var dests = PRINTERS.map(function (pr) {
|
||||
return '<div class="stack" style="gap:8px"><div class="row"><b class="small">' + esc(pr.name) + '</b>' + (pr.connected ? '' : '<span class="prov warn"><i>⚡</i>disconnected (loading still recorded)</span>') + '</div><div class="row" style="gap:8px;align-items:stretch">' +
|
||||
pr.containers.map(function (c) { var n = contents(c.id).length, full = n >= c.cap && s.cont !== c.id, here = s.cont === c.id; return '<button type="button" class="slot" style="min-height:64px;flex:1" data-dest="' + c.id + '" ' + (full ? 'disabled title="Full: unload a spool first"' : '') + ' aria-pressed="' + here + '"><span class="sn">' + esc(c.label) + '</span><span class="g">' + n + ' / ' + c.cap + (here ? ' · here now' : full ? ' · full' : '') + '</span></button>'; }).join('') + '</div></div>';
|
||||
}).join('');
|
||||
body = '<p class="small muted" style="margin:0">Where is the spool going? Full holders are disabled. Extrudex records which holder a spool is in, not its tray position.</p>' +
|
||||
'<div class="stack" style="gap:8px"><b class="small">Storage</b><div class="row" style="gap:8px;align-items:stretch"><button type="button" class="slot" style="min-height:64px;flex:1" data-dest="storage" aria-pressed="' + !s.cont + '"><span class="sn">Shelf / storage</span><span class="g">' + (s.cont ? 'unload' : 'here now') + '</span></button></div>' +
|
||||
'<label class="stack" style="gap:6px"><span class="sub">Where in storage (optional)</span><input class="input" value="' + esc(s.loc || '') + '" placeholder="Shelf A2, Dry box 1…"></label></div>' + dests;
|
||||
foot = '<button class="btn" type="button" data-tab="overview">Cancel</button><button class="btn btn-primary" type="button" id="m-save" disabled>Move spool</button>';
|
||||
}
|
||||
if (D.tab === 'edit') { return editDrawer(s); }
|
||||
return h + t + '<div class="drawer-body">' + body + '</div><div class="drawer-foot">' + foot + '</div>';
|
||||
}
|
||||
|
||||
function editDrawer(s) {
|
||||
var isNew = !s; s = s || { id: null, c: ['#888888'], color: '', brand: 'Sunlu', speed: null, mat: 'PLA', type: null, fin: 'Basic', rem: 1000, tot: 1000, tare: 140, tsrc: 'community', stype: 'Sunlu — 1 kg plastic', kg: 0, loc: '', v: null };
|
||||
var st = D.state;
|
||||
var h = isNew ? head('<span class="swatch" style="width:44px;height:44px"><i style="background:#888"></i></span>', 'Add spool', 'New spools carry no version') : head(sw(s.c, '" style="width:44px;height:44px'), 'Edit ' + esc(s.color) + ' <span class="muted" style="font-weight:400">#' + s.id + '</span>', 'Opened 09:32 at version ' + (st === 'conflict' ? s.v - 1 : s.v));
|
||||
var t = isNew ? '' : tabs([['overview', 'Overview'], ['weigh', 'Weigh'], ['move', where(s).loaded ? 'Move' : 'Load / move'], ['edit', 'Edit']]);
|
||||
function sel(label, val, req, opts) { return '<label class="stack" style="gap:6px"><span class="small strong">' + label + ' ' + (req ? '<span class="sub" style="display:inline;color:var(--text-accent)">required</span>' : '<span class="sub" style="display:inline">optional</span>') + '</span><select class="select"><option>' + (val || 'None') + '</option>' + (opts || '') + '</select></label>'; }
|
||||
var demo = '<div class="row small" style="gap:8px"><span class="muted">Demo state</span><div class="tabs" role="group" aria-label="Demo state">' + [['normal', 'Normal'], ['errors', '400 errors'], ['conflict', '409 conflict']].map(function (x) { return '<button type="button" data-state="' + x[0] + '" aria-selected="' + (st === x[0]) + '">' + x[1] + '</button>'; }).join('') + '</div></div>';
|
||||
var conflict = st === 'conflict' ? '<div class="nm-inset" role="alert" style="padding:16px;border:2px solid var(--status-warning);background:var(--wash-warning);border-radius:var(--radius-lg)"><b style="color:var(--status-warning)">⚠ This spool changed since you opened it. Nothing was saved.</b><p class="small" style="margin:8px 0">Someone weighed it at 09:39 (version ' + (s.v - 1) + ' → ' + s.v + '). Saving would have put the old remaining weight back.</p>' +
|
||||
'<table class="tbl" style="font-size:12px"><thead><tr><th>Field</th><th class="r">You opened</th><th class="r">Now</th><th class="r">Your edit</th></tr></thead><tbody><tr><td>Remaining</td><td class="r">860 g</td><td class="r strong" style="color:var(--status-warning)">' + g(s.rem) + ' ⚖</td><td class="r muted">—</td></tr><tr><td>Location</td><td class="r">Shelf A3</td><td class="r">Shelf A3</td><td class="r strong accent">' + esc(where(s).short) + '</td></tr></tbody></table>' +
|
||||
'<div class="row" style="margin-top:12px"><button class="btn btn-primary btn-sm" type="button" data-fake="Reloaded version ' + s.v + ' and reapplied your location change. Review, then save.">Reload & reapply my change</button><button class="btn btn-sm" type="button" data-state="normal">Reload, discard mine</button></div></div>' : '';
|
||||
var errsum = st === 'errors' ? '<div role="alert" style="padding:14px 16px;border:2px solid var(--status-error);background:var(--wash-error);border-radius:var(--radius-lg)"><b style="color:var(--status-error)">⚠ 2 fields need fixing. Nothing was saved.</b><ul class="small" style="margin:6px 0 0;padding-left:18px"><li><a href="#e-rem">Remaining is more than the spool holds</a></li><li><a href="#e-name">Color name is empty</a></li></ul></div>' : '';
|
||||
var name = '<div class="nm-inset" style="padding:12px 14px;border-radius:var(--radius-md)"><span class="sub">Reads as</span><div class="strong" style="font-size:17px">' + esc(fullName(s)) + '</div><span class="sub">brand · <i>speed</i> · material · <i>type</i> · finish (italic = optional)</span></div>';
|
||||
var body = demo + conflict + errsum + name +
|
||||
'<div class="fr2">' + sel('Brand', s.brand, 1) + sel('Print speed', s.speed, 0, '<option>Rapid</option><option>High Speed</option>') + sel('Material', s.mat, 1) + sel('Filament type', s.type, 0, '<option>CF</option><option>+ 2.0</option>') + sel('Finish', s.fin, 1) + '</div>' +
|
||||
'<div class="fr2"><div class="stack' + (st === 'errors' ? ' field has-error' : '') + '" id="e-name" style="gap:6px"><label class="small strong" for="f-name">Color name <span class="sub" style="display:inline;color:var(--text-accent)">required</span></label><input class="input" id="f-name" value="' + (st === 'errors' ? '' : esc(s.color)) + '" ' + (st === 'errors' ? 'aria-invalid="true" aria-describedby="e-name-m"' : '') + ' placeholder="e.g. Charcoal">' + (st === 'errors' ? '<p class="field-error" id="e-name-m" style="margin:0">Give the color a name, even "Unknown". <span class="sub" style="display:inline">server: <code>color_name is required</code></span></p>' : '') + '</div>' +
|
||||
'<label class="stack" style="gap:6px"><span class="small strong">Colors <span class="sub" style="display:inline">1–3</span></span><span class="row" style="gap:8px">' + sw(s.c) + '<input class="input" value="' + s.c[0] + '" style="max-width:110px"><button class="btn btn-sm" type="button">+ color</button></span></label></div>' +
|
||||
'<div class="fr2"><label class="stack" style="gap:6px"><span class="small strong">Total <span class="sub" style="display:inline">net, as sold</span></span><input class="input num" value="' + g(s.tot) + '"></label>' +
|
||||
'<div class="stack' + (st === 'errors' ? ' field has-error' : '') + '" id="e-rem" style="gap:6px"><label class="small strong" for="f-rem">Remaining <span class="sub" style="display:inline">net</span></label><input class="input num" id="f-rem" value="' + (st === 'errors' ? '1,040 g' : g(s.rem)) + '" ' + (st === 'errors' ? 'aria-invalid="true" aria-describedby="e-rem-m"' : '') + '>' + (st === 'errors' ? '<p class="field-error" id="e-rem-m" style="margin:0">1,040 g is more than this spool holds (' + g(s.tot) + '). <span class="sub" style="display:inline">server: <code>remaining_grams cannot exceed total_grams</code></span></p>' : '<span class="sub">To measure it, use the <b>Weigh</b> tab instead.</span>') + '</div></div>' +
|
||||
'<div class="fr2"><label class="stack" style="gap:6px"><span class="small strong">Spool type <span class="sub" style="display:inline">supplies the empty spool weight</span></span><select class="select"><option>' + esc(s.stype || 'None chosen') + '</option></select></label>' +
|
||||
'<label class="stack" style="gap:6px"><span class="small strong">Empty spool weight</span><input class="input num" value="' + (s.tare == null ? '' : g(s.tare)) + '" placeholder="unknown"><span class="sub">' + (s.tare == null ? '<span class="prov warn"><i>?</i>unknown, never 0</span>' : tareProv(s)) + '</span></label></div>' +
|
||||
'<div class="fr2"><label class="stack" style="gap:6px"><span class="small strong">Cost per kg</span><input class="input num" value="' + usd(s.kg) + '"></label><label class="stack" style="gap:6px"><span class="small strong">Storage location <span class="sub" style="display:inline">optional</span></span><input class="input" value="' + esc(s.loc || '') + '"></label></div>' +
|
||||
'<div class="banner" id="discard-bar" hidden><span style="flex:1">Discard unsaved changes?</span><button class="btn btn-sm" type="button" data-discard>Discard</button><button class="btn btn-sm" type="button" data-keep>Keep editing</button></div>';
|
||||
var foot = (st === 'conflict' ? '<span class="small muted" style="margin-right:auto">Save is off until you choose a version.</span>' : '') + '<button class="btn" type="button" data-' + (isNew ? 'close-drawer' : 'tab="overview"') + '>Cancel</button><button class="btn btn-primary" type="button" ' + (st === 'conflict' ? 'disabled' : 'data-save') + '>' + (isNew ? 'Add spool' : 'Save changes') + '</button>';
|
||||
return h + t + '<div class="drawer-body">' + body + '</div><div class="drawer-foot">' + foot + '</div>';
|
||||
}
|
||||
|
||||
function printDrawer(p) {
|
||||
var pr = printer(p.printer), n = needs(p);
|
||||
var h = head('<span class="wordmark-mark" style="width:44px;height:44px;background:var(--surface-inset);color:var(--text-primary)">' + ICON.prints + '</span>', esc(p.job), printSt(p) + ' · ' + esc(pr.name) + ' · print #' + p.id);
|
||||
var fix = '';
|
||||
if (n.length) {
|
||||
fix = '<div class="nm-inset panel" style="border:1px solid var(--status-warning)"><h3 style="color:var(--status-warning)">Needs ' + n.map(function (x) { return x.t; }).join(' + ') + '</h3>' + fixers(p) + '</div>';
|
||||
}
|
||||
var body = fix + '<dl class="kv"><dt>Printer</dt><dd><a href="04-printers.html#printer=' + pr.id + '">' + esc(pr.name) + '</a></dd><dt>Started</dt><dd>' + when(p.start) + '</dd><dt>Ended</dt><dd>' + (p.end ? when(p.end) : p.status === 'running' ? '<span class="muted">still running</span>' : '—') + '</dd>' +
|
||||
'<dt>Duration</dt><dd>' + durCell(p) + '</dd>' +
|
||||
'<dt>Job key</dt><dd><code>' + esc(p.key) + '</code><span class="sub">The identity. Two jobs can share a name, never a path.</span></dd>' +
|
||||
'<dt>Filament</dt><dd>' + (p.grams == null ? '<span class="muted">not known yet</span>' : '<b class="num">' + g(p.grams) + '</b> ' + weightProv(p) + (p.status === 'failed' ? '<span class="sub">Failed prints record the full job weight. Correct by hand if needed.</span>' : '')) + '</dd>' +
|
||||
'<dt>Spool</dt><dd>' + spoolLinks(p) + changeSpool(p) + '</dd><dt>Cost</dt><dd>' + (p.cost == null ? '<span class="muted">pending: needs weight and spool</span>' : usd(p.cost) + (p.filaments && p.filaments.some(function (f) { return f.spool == null; }) ? ' <span class="sub" style="display:inline">so far</span>' : '')) + '</dd>' +
|
||||
'<dt>Use</dt><dd><div class="tabs" role="group" aria-label="Use type"><button type="button" aria-selected="' + (p.use === 'business') + '">Business</button><button type="button" aria-selected="' + (p.use === 'personal') + '">Personal</button></div>' + (p.use == null ? '<span class="sub">Not recorded</span>' : '') + '</dd></dl>' +
|
||||
(p.filaments ? '<div><h3 class="section">Colors</h3><table class="tbl"><thead><tr><th>#</th><th>Color</th><th>Spool</th><th class="r">Grams</th><th class="r">Cost</th></tr></thead><tbody>' + p.filaments.map(function (f) { var s = f.spool && spool(f.spool); return '<tr><td>' + (f.slot + 1) + '</td><td><span class="swatch swatch-sm"><i style="background:' + f.hex + '"></i></span> <code>' + f.hex + '</code></td><td>' + (s ? '<a href="#spool=' + s.id + '" data-spool="' + s.id + '">' + esc(short(s)) + '</a>' : '<span class="prov warn"><i>!</i>unattributed</span>') + '</td><td class="r">' + g(f.grams) + '</td><td class="r">' + usd(f.cost) + '</td></tr>'; }).join('') + '</tbody></table></div>' : '');
|
||||
var foot = p.status === 'running' ? '<span class="small muted">Name and weight are captured when the print ends.</span>' : '<button class="btn btn-sm" type="button" data-fake="Archived ' + esc(p.job) + '. Accounting unchanged." style="margin-right:auto">Archive</button><button class="btn" type="button" data-close-drawer>Done</button>';
|
||||
return h + '<div class="drawer-body">' + body + '</div><div class="drawer-foot">' + foot + '</div>';
|
||||
}
|
||||
function changeSpool(p) {
|
||||
var attributed = p.filaments ? p.filaments.filter(function (f) { return f.spool; }) : (p.spool ? [{ spool: p.spool, grams: p.grams, slot: null }] : []);
|
||||
if (!attributed.length || p.status === 'running') return '';
|
||||
if (p.unreconciled) return '<div class="banner err" role="alert" style="margin-top:8px;font-size:12px"><span><b>Can\'t move this one yet.</b> It was attributed to spool #' + p.spool + ' before Extrudex tracked deductions, so nothing records how much came off. Check that spool\'s remaining grams, then re-attribute this print to <b>the same spool</b> to reconcile it. After that it can move.</span></div>';
|
||||
return attributed.map(function (f) {
|
||||
var from = spool(f.spool);
|
||||
return '<details class="chg"><summary>Change spool' + (f.slot != null ? ' for color ' + (f.slot + 1) : '') + '</summary><div class="stack" style="gap:8px;margin-top:8px"><select class="select" style="min-height:36px" aria-label="New spool">' + SPOOLS.filter(function (x) { return x.id !== f.spool; }).slice(0, 10).map(function (x) { return '<option>' + g(x.rem) + ' · ' + esc(short(x)) + ' · ' + x.mat + ' · #' + x.id + '</option>'; }).join('') + '</select>' +
|
||||
'<span class="sub">' + (f.grams != null ? '<b>' + g(f.grams) + ' goes back</b> to ' + esc(short(from)) + ' #' + from.id + ' and comes off the spool you pick. Cost is re-rated at that spool\'s $/kg.' : 'No weight yet, so nothing moves until one is entered.') + '</span>' +
|
||||
'<button class="btn btn-sm" type="button" data-fake="Moved ' + esc(p.job) + (f.grams != null ? ': ' + g(f.grams) + ' returned to ' + esc(short(from)) : '') + '">Move to this spool</button></div></details>';
|
||||
}).join('');
|
||||
}
|
||||
function fixers(p) {
|
||||
var out = '';
|
||||
if (p.grams == null) out += '<label class="stack" style="gap:6px;margin-bottom:12px"><span class="small strong">Filament used</span><span class="row" style="gap:8px"><input class="input num" style="max-width:140px" placeholder="grams" inputmode="numeric" autofocus><button class="btn btn-sm" type="button" data-fake="Saved ' + esc(p.job) + ' weight · remembered for ' + esc(p.key) + '">Save weight</button></span><span class="sub">From the slicer. Remembered for <code>' + esc(p.key) + '</code> so later runs fill in by themselves.</span></label>';
|
||||
var slots = p.filaments ? p.filaments.filter(function (f) { return f.spool == null; }) : (p.spool == null ? [{ suggested: p.suggested, slot: null }] : []);
|
||||
slots.forEach(function (f) {
|
||||
var sug = f.suggested && spool(f.suggested);
|
||||
out += '<div class="stack" style="gap:6px;margin-bottom:8px"><span class="small strong">Spool' + (f.slot != null ? ' for color ' + (f.slot + 1) + ' <span class="swatch swatch-sm"><i style="background:' + f.hex + '"></i></span>' : '') + '</span>' +
|
||||
(sug ? '<div class="row nm-raised-sm" style="padding:8px 12px;gap:10px">' + sw(sug.c, 'swatch-sm') + '<span style="flex:1"><b>' + esc(short(sug)) + '</b><span class="sub">Suggested: same color, loaded in ' + esc(where(sug).short) + '. Not charged until you confirm.</span></span><button class="btn btn-sm btn-primary" type="button" data-fake="Attributed to ' + esc(short(sug)) + ' · ' + g(f.grams || p.grams) + ' deducted">Use this</button></div>' : '') +
|
||||
'<select class="select"><option>' + (sug ? 'Or pick another spool…' : 'Pick a spool…') + '</option>' + SPOOLS.slice(0, 8).map(function (s) { return '<option>' + g(s.rem) + ' · ' + esc(short(s)) + ' · ' + esc(s.mat) + '</option>'; }).join('') + '</select></div>';
|
||||
});
|
||||
return out;
|
||||
}
|
||||
var MODELS = { bambu: ['A1', 'A1 mini', 'P1P', 'P1S', 'X1 Carbon', 'X1E', 'H2D', 'H2C', 'H2S'], elegoo: ['Centauri', 'Centauri Carbon', 'OrangeStorm Giga'] };
|
||||
function peditDrawer(id) {
|
||||
var pr = id === 'new' ? null : printer(+id), st = D.state;
|
||||
var h = head('<span class="wordmark-mark" style="width:44px;height:44px;background:var(--surface-inset);color:var(--text-primary)">' + ICON.printers + '</span>', pr ? 'Edit ' + esc(pr.name) : 'Add printer', pr ? 'Connection details · admin' : 'Extrudex connects read-only; nothing is sent to the printer');
|
||||
var brand = pr ? 'bambu' : (D.brand || 'bambu');
|
||||
function f(label, hint, input, err) { return '<div class="stack' + (err ? ' field has-error' : '') + '" style="gap:6px"><label class="small strong">' + label + (hint ? ' <span class="sub" style="display:inline">' + hint + '</span>' : '') + '</label>' + input + (err ? '<p class="field-error" style="margin:0">' + err + '</p>' : '') + '</div>'; }
|
||||
var body = '<div class="row small" style="gap:8px"><span class="muted">Demo state</span><div class="tabs" role="group" aria-label="Demo state">' + [['normal', 'Normal'], ['errors', '400 errors']].map(function (x) { return '<button type="button" data-state="' + x[0] + '" aria-selected="' + (st === x[0]) + '">' + x[1] + '</button>'; }).join('') + '</div></div>' +
|
||||
'<div class="fr2">' + f('Brand', '', '<select class="select" id="pe-brand"><option value="bambu"' + (brand === 'bambu' ? ' selected' : '') + '>Bambu Lab</option><option value="elegoo"' + (brand === 'elegoo' ? ' selected' : '') + '>Elegoo</option></select>') +
|
||||
f('Model', '', '<select class="select">' + MODELS[brand].map(function (m) { return '<option' + (pr && pr.model === m ? ' selected' : '') + '>' + m + '</option>'; }).join('') + '</select>') + '</div>' +
|
||||
f('Name', 'shown everywhere; must be unique', '<input class="input" value="' + (pr ? esc(pr.name) : '') + '" placeholder="X1C — Bench">', st === 'errors' && !pr ? 'A printer called “P1S - Prints Charming” already exists. Names must be unique.' : '') +
|
||||
'<div class="fr2">' + f('IP address', 'same network as Extrudex', '<input class="input" value="' + (st === 'errors' ? '10.60.1.300' : pr ? esc(pr.ip) : '') + '" placeholder="10.60.1.10" inputmode="decimal">', st === 'errors' ? '10.60.1.300 isn\'t a valid IPv4 address.' : '') +
|
||||
f('Serial', 'on the printer\'s label', '<input class="input" value="' + (pr ? esc(pr.serial) : '') + '">') + '</div>' +
|
||||
(brand === 'bambu' ? f('Access code', pr ? 'leave blank to keep the stored code' : 'from the printer screen: Settings → LAN', '<input class="input" type="password" placeholder="' + (pr ? 'unchanged' : '8 characters') + '" autocomplete="off">') + '<p class="sub" style="margin:0">Stored, never shown again. Read-only MQTT needs only IP, serial and access code, no Developer Mode.</p>'
|
||||
: '<p class="small muted" style="margin:0">Elegoo Centauri uses unauthenticated SDCP, so there is no access code.</p>') +
|
||||
(pr ? '' : '<div class="nm-inset panel small"><b>After you add it</b><ul style="margin:6px 0 0;padding-left:18px"><li>An <b>External</b> holder is created automatically.</li><li>Add its AMS units under <b>Manage holders</b>.</li><li>Capture starts on the first status report.</li></ul></div>');
|
||||
return h + '<div class="drawer-body">' + body + '</div><div class="drawer-foot"><button class="btn" type="button" data-close-drawer>Cancel</button><button class="btn btn-primary" type="button" ' + (st === 'errors' ? 'disabled' : 'data-fake="' + (pr ? 'Saved ' + esc(pr.name) + '. The monitor reconnects with the new details.' : 'Added printer. Waiting for its first status report.') + '"') + '>' + (pr ? 'Save' : 'Add printer') + '</button></div>';
|
||||
}
|
||||
function holdersDrawer(id) {
|
||||
var pr = printer(+id);
|
||||
var h = head('<span class="wordmark-mark" style="width:44px;height:44px;background:var(--surface-inset);color:var(--text-primary)">' + ICON.printers + '</span>', 'Holders · ' + esc(pr.model), esc(pr.name) + ' · where filament can be loaded');
|
||||
var body = pr.containers.map(function (c) {
|
||||
var n = contents(c.id).length, ext = c.kind === 'external';
|
||||
return '<div class="holder"><div class="holder-h"><b>' + esc(c.label) + '</b><span class="muted">' + { ams: 'AMS', ams_lite: 'AMS Lite', external: 'External' }[c.kind] + ' · holds ' + c.cap + ' · #' + HOLDER_IDS[c.id] + '</span><span class="spacer"></span><span class="small">' + n + ' loaded</span></div>' +
|
||||
'<div class="row" style="gap:8px"><button class="btn btn-sm" type="button" data-fake="Holder label PDF #' + HOLDER_IDS[c.id] + ' generated">Print label</button>' +
|
||||
(ext ? '<span class="sub">Every printer keeps exactly one external holder.</span>' : n ? '<button class="btn btn-sm" type="button" disabled title="Unload its spools first">Remove</button><span class="sub">Unload its ' + n + ' spool' + (n > 1 ? 's' : '') + ' first.</span>' : '<button class="btn btn-sm btn-danger" type="button" data-fake="Removed ' + esc(c.label) + '">Remove</button>') + '</div></div>';
|
||||
}).join('') +
|
||||
'<div class="panel nm-raised-sm stack" style="gap:10px"><h3 style="margin:0">Add an AMS unit</h3><div class="fr2"><label class="stack" style="gap:6px"><span class="small strong">Type</span><select class="select"><option>AMS (4 spools)</option><option>AMS Lite (4 spools)</option></select></label><label class="stack" style="gap:6px"><span class="small strong">Label</span><input class="input" value="AMS 2"><span class="sub">Unique on this printer</span></label></div><button class="btn btn-sm" type="button" data-fake="Added AMS 2 to ' + esc(pr.model) + '" style="align-self:flex-start">Add holder</button></div>' +
|
||||
'<p class="small muted" style="margin:0">Capacity comes from the type, so a holder can\'t disagree with its hardware. Tray positions aren\'t recorded.</p>';
|
||||
return h + '<div class="drawer-body">' + body + '</div><div class="drawer-foot"><button class="btn" type="button" data-close-drawer>Done</button></div>';
|
||||
}
|
||||
function premoveDrawer(id) {
|
||||
var pr = printer(+id), loaded = pr.containers.reduce(function (a, c) { return a + contents(c.id).length; }, 0), open = pr.openPrint && print(pr.openPrint);
|
||||
var h = head('<span class="wordmark-mark" style="width:44px;height:44px;background:var(--wash-error);color:var(--status-error);border-color:var(--status-error)">!</span>', 'Remove ' + esc(pr.name) + '?', esc(pr.model) + ' · ' + esc(pr.ip));
|
||||
var body = '<ul class="small" style="margin:0;padding-left:18px;display:flex;flex-direction:column;gap:6px"><li>Capture stops for this printer.</li><li><b>Print history is kept</b>, including cost records.</li>' + (open ? '<li><b>' + esc(open.job) + '</b> is open, so it will be <b>closed as unknown</b> with no duration, because nobody will watch it end.</li>' : '') + '</ul>' +
|
||||
(loaded ? '<div class="banner err" role="alert"><span><b>Can\'t remove yet:</b> ' + loaded + ' spool' + (loaded > 1 ? 's are' : ' is') + ' still loaded in its holders. Unload them first, so they don\'t lose their place. <a href="04-printers.html#printer=' + pr.id + '" data-close-drawer>Show holders</a></span></div>' : '') +
|
||||
'<label class="stack" style="gap:6px"><span class="small strong">Type <i>' + esc(pr.name) + '</i> to confirm</span><input class="input" id="rm-name" autocomplete="off" ' + (loaded ? 'disabled' : '') + '></label>';
|
||||
return h + '<div class="drawer-body">' + body + '</div><div class="drawer-foot"><button class="btn" type="button" data-close-drawer autofocus>Keep printer</button><button class="btn btn-danger" type="button" id="rm-go" disabled data-name="' + esc(pr.name) + '">Remove printer</button></div>';
|
||||
}
|
||||
function loadDrawer(cid) {
|
||||
var c = container(cid), inIt = contents(cid);
|
||||
var h = head('<span class="wordmark-mark" style="width:44px;height:44px;background:var(--surface-inset);color:var(--text-primary)">' + ICON.printers + '</span>', 'Load into ' + esc(c.c.label), esc(c.printer.name) + ' · ' + inIt.length + ' of ' + c.c.cap + ' in use');
|
||||
var cands = SPOOLS.filter(function (s) { return !s.cont; });
|
||||
var body = '<label class="stack" style="gap:6px"><span class="small strong">Find a spool in storage</span><input class="input" id="load-q" placeholder="Brand, color, material…" autofocus></label><div class="stack" style="gap:6px" id="load-list">' +
|
||||
cands.map(function (s) { return '<button type="button" class="slot" style="min-height:0;flex-direction:row;align-items:center;gap:12px;width:100%" data-load="' + s.id + '" aria-pressed="false" data-hay="' + esc((fullName(s) + ' ' + s.color + ' ' + (s.loc || '')).toLowerCase()) + '">' + sw(s.c) + '<span style="flex:1;min-width:0"><span class="nm" style="display:block">' + esc(s.color) + ' · ' + esc(s.brand) + '</span><span class="sub">' + esc(fullName(s)) + ' · ' + esc(s.loc || 'Storage') + '</span></span><span class="g num' + (low(s) ? '" style="color:var(--status-warning)' : '') + '">' + g(s.rem) + '</span></button>'; }).join('') + '</div>' +
|
||||
'<p class="small muted" style="margin:0">Scanning the holder\'s QR label does the same thing from a phone (existing <code>/c/:id</code> scan session).</p>';
|
||||
return h + '<div class="drawer-body">' + body + '</div><div class="drawer-foot"><button class="btn" type="button" data-close-drawer>Cancel</button><button class="btn btn-primary" type="button" id="l-save" disabled>Load spool</button></div>';
|
||||
}
|
||||
|
||||
/* ── palette ─────────────────────────────────────────────── */
|
||||
var P = { items: [], sel: 0 };
|
||||
function palItems(q) {
|
||||
q = q.replace(/^\s+/, '').toLowerCase(); if (!/^(load|attribute)\s/.test(q)) q = q.trim(); else q = q.replace(/\s+$/, function (m) { return /\s(into|to)\s+$/.test(q) ? ' ' : ''; });
|
||||
var acts = [
|
||||
{ sec: 'Actions', ico: '+', t: 'Add spool', m: 'new', run: function () { openDrawer('new', 0, 'edit'); } },
|
||||
{ sec: 'Actions', ico: '⚖', t: 'Weigh a spool…', m: 'type its name', run: function () { $('#pal-q').value = 'weigh '; palRender(); }, keep: 1 },
|
||||
{ sec: 'Actions', ico: '⇥', t: 'Load a spool into a holder…', m: 'load ‹spool› into ‹holder›', run: function () { $('#pal-q').value = 'load '; palRender(); }, keep: 1 },
|
||||
{ sec: 'Actions', ico: '⇄', t: 'Attribute a print to a spool…', m: 'attribute ‹print› to ‹spool›', run: function () { $('#pal-q').value = 'attribute '; palRender(); }, keep: 1 },
|
||||
{ sec: 'Actions', ico: '!', t: 'Prints needing attention', m: printsNeeding().length + ' open', href: '03-prints.html?tab=needs' },
|
||||
{ sec: 'Actions', ico: '◐', t: 'Switch color theme', m: 'light / dark', run: function () { setTheme(document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark'); } }
|
||||
].concat(PAGES.map(function (p) { return { sec: 'Go to', ico: '→', t: p[1], m: 'page', href: p[2] }; }));
|
||||
var lm = /^load(?:\s|$)/.test(q) && /^load\s*(.*?)(?:\s+into\b\s*(.*))?$/.exec(q), am = /^attribute(?:\s|$)/.test(q) && /^attribute\s*(.*?)(?:\s+to\b\s*(.*))?$/.exec(q);
|
||||
if (lm) return loadCmd(lm[1] || '', lm[2]);
|
||||
if (am) return attrCmd(am[1] || '', am[2]);
|
||||
var weigh = /^weigh\s*/.test(q); if (weigh) q = q.replace(/^weigh\s*/, '');
|
||||
var sp = SPOOLS.map(function (s) { return { sec: weigh ? 'Weigh which spool?' : 'Spools', html: sw(s.c, 'swatch-sm'), t: s.color + ' · ' + fullName(s), m: '#' + s.id + ' · ' + g(s.rem) + ' · ' + where(s).short, hay: ('#' + s.id + ' ' + s.id + ' ' + fullName(s) + ' ' + s.color + ' ' + where(s).text).toLowerCase(), run: function () { go('spool', s.id, weigh ? 'weigh' : 'overview'); } }; });
|
||||
var pr = PRINTS.map(function (p) { return { sec: 'Prints', ico: '▤', t: p.job, m: PST[p.status][1] + ' · ' + when(p.start), hay: (p.job + ' ' + p.key + ' ' + printer(p.printer).name).toLowerCase(), run: function () { go('print', p.id); } }; });
|
||||
var pt = PRINTERS.map(function (p) { return { sec: 'Printers', ico: '▣', t: p.name, m: p.connected ? (p.state === 'RUNNING' ? 'printing' : 'idle') : 'reconnecting', hay: p.name.toLowerCase(), href: '04-printers.html#printer=' + p.id }; });
|
||||
if (weigh) return sp.filter(function (i) { return !q || i.hay.indexOf(q) >= 0; }).slice(0, 12);
|
||||
if (!q) return acts.concat(sp.filter(function (i, k) { return k < 3; }).map(function (i) { i.sec = 'Recent spools'; return i; }));
|
||||
var match = function (i) { return (i.hay || i.t.toLowerCase()).indexOf(q) >= 0; };
|
||||
return acts.filter(match).concat(sp.filter(match).slice(0, 8), pr.filter(match).slice(0, 5), pt.filter(match));
|
||||
}
|
||||
function holders() { var out = []; PRINTERS.forEach(function (pr) { pr.containers.forEach(function (c) { out.push({ id: c.id, pr: pr, c: c, n: contents(c.id).length, hay: (pr.name + ' ' + pr.model + ' ' + c.label + ' ' + c.kind).toLowerCase() }); }); }); return out; }
|
||||
function spoolHay(s) { return ('#' + s.id + ' ' + s.id + ' ' + fullName(s) + ' ' + s.color + ' ' + where(s).text).toLowerCase(); }
|
||||
function loadCmd(sq, hq) {
|
||||
var sp = SPOOLS.filter(function (s) { return !sq || spoolHay(s).indexOf(sq) >= 0; }).slice(0, 6);
|
||||
if (hq == null) return sp.map(function (s) { return { sec: 'Load which spool? (then type “ into …”)', html: sw(s.c, 'swatch-sm'), t: s.color + ' · ' + fullName(s), m: '#' + s.id + ' · ' + where(s).short, keep: 1, run: function () { $('#pal-q').value = 'load #' + s.id + ' into '; palRender(); } }; });
|
||||
var s1 = sp[0]; if (!s1) return [];
|
||||
return holders().filter(function (h) { return !hq || h.hay.indexOf(hq) >= 0; }).map(function (h) {
|
||||
var full = h.n >= h.c.cap && s1.cont !== h.id;
|
||||
return { sec: 'Load ' + s1.color + ' · ' + s1.brand + ' (#' + s1.id + ') into…', ico: full ? '✕' : '⇥', t: h.pr.model + ' · ' + h.c.label, m: full ? 'full · ' + h.n + '/' + h.c.cap : h.n + '/' + h.c.cap + ' in use', run: function () { if (full) return toast(h.c.label + ' on ' + h.pr.model + ' is full. Unload a spool first.', 'warn'); s1.cont = h.id; s1.v++; toast('Loaded <b>' + esc(s1.color + ' · ' + s1.brand) + '</b> into ' + esc(h.pr.model + ' · ' + h.c.label)); if (PAGE.refresh) PAGE.refresh(); } };
|
||||
});
|
||||
}
|
||||
function attrCmd(pq, sq) {
|
||||
var pr = printsNeeding().filter(function (p) { return !pq || (p.job + ' ' + p.key).toLowerCase().indexOf(pq) >= 0; });
|
||||
if (sq == null) return pr.map(function (p) { return { sec: 'Attribute which print? (then type “ to …”)', ico: '▤', t: p.job, m: needs(p).map(function (n) { return n.t; }).join(', '), keep: 1, run: function () { $('#pal-q').value = 'attribute ' + p.job.toLowerCase() + ' to '; palRender(); } }; });
|
||||
var p1 = pr[0]; if (!p1) return [];
|
||||
return SPOOLS.filter(function (s) { return !sq || spoolHay(s).indexOf(sq) >= 0; }).slice(0, 8).map(function (s) {
|
||||
return { sec: 'Attribute ' + p1.job + ' to…', html: sw(s.c, 'swatch-sm'), t: s.color + ' · ' + fullName(s), m: '#' + s.id + ' · ' + g(s.rem), run: function () { if (p1.filaments) p1.filaments.forEach(function (f) { if (f.spool == null) f.spool = s.id; }); else p1.spool = s.id; toast('Attributed <b>' + esc(p1.job) + '</b> to ' + esc(short(s)) + (p1.grams != null ? ' · ' + g(p1.grams) + ' deducted' : '')); if (PAGE.refresh) PAGE.refresh(); } };
|
||||
});
|
||||
}
|
||||
function go(kind, id, tab) {
|
||||
var pageFor = { spool: null, print: null };
|
||||
if (window.PAGE && PAGE.name) openDrawer(kind, id, tab);
|
||||
}
|
||||
function palRender() {
|
||||
P.items = palItems($('#pal-q').value); if (P.sel >= P.items.length) P.sel = 0;
|
||||
var last = '', html = '';
|
||||
P.items.forEach(function (it, i) {
|
||||
if (it.sec !== last) { html += '<div class="pal-sec">' + esc(it.sec) + '</div>'; last = it.sec; }
|
||||
html += '<div class="pal-item" role="option" id="pi' + i + '" data-i="' + i + '" aria-selected="' + (i === P.sel) + '">' + (it.html || '<span style="width:18px;text-align:center" aria-hidden="true">' + it.ico + '</span>') + '<span>' + esc(it.t) + '</span><span class="meta">' + esc(it.m || '') + '</span></div>';
|
||||
});
|
||||
$('#pal-list').innerHTML = html || '<div class="empty"><h3>No matches</h3><p>Try a color, brand, <code>#47</code>, a job name or a printer.</p></div>';
|
||||
$('#pal-q').setAttribute('aria-activedescendant', P.items.length ? 'pi' + P.sel : '');
|
||||
var cur = $('#pi' + P.sel); if (cur) cur.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
function palOpen(prefill) { $('#pal').classList.add('is-open'); $('#pal-q').value = prefill || ''; P.sel = 0; palRender(); $('#pal-q').focus(); }
|
||||
function palClose() { $('#pal').classList.remove('is-open'); }
|
||||
function palRun(i) { var it = P.items[i]; if (!it) return; if (!it.keep) palClose(); if (it.href) location.href = it.href; else it.run(); }
|
||||
|
||||
/* ── freshness + demos ───────────────────────────────────── */
|
||||
var fresh = { n: 3, stale: false };
|
||||
function tick() {
|
||||
fresh.n = fresh.stale ? fresh.n + 1 : (fresh.n >= 10 ? 0 : fresh.n + 1);
|
||||
var f = $('#fresh'); if (!f) return;
|
||||
f.classList.toggle('is-stale', fresh.stale);
|
||||
$('#fresh-t').innerHTML = fresh.stale ? 'Not updating · data from 09:41:07 <span class="long">· retrying</span>' : 'Live <span class="long">· updated ' + fresh.n + 's ago</span>';
|
||||
}
|
||||
function demo(kind) {
|
||||
var main = $('#page');
|
||||
if (kind === 'loading') {
|
||||
var keep = main.innerHTML;
|
||||
main.innerHTML = '<div class="stack" style="gap:24px" aria-busy="true" aria-label="Loading"><div class="skel" style="width:40%;height:28px"></div><div class="stats">' + [1, 2, 3, 4].map(function () { return '<div class="stat nm-raised"><div class="skel" style="width:50%"></div><div class="skel" style="width:70%;height:24px;margin-top:10px"></div></div>'; }).join('') + '</div><div class="nm-raised card stack">' + [1, 2, 3, 4, 5].map(function () { return '<div class="skel" style="height:18px"></div>'; }).join('') + '</div></div>';
|
||||
setTimeout(function () { main.innerHTML = keep; if (PAGE.after) PAGE.after(); }, 1600);
|
||||
}
|
||||
if (kind === 'offline') { fresh.stale = true; fresh.n = 0; tick(); var b = $('#offline-banner'); if (!b) { b = document.createElement('div'); b.id = 'offline-banner'; b.className = 'banner'; b.setAttribute('role', 'alert'); b.innerHTML = '<b>⚠</b><span style="flex:1">Can\'t reach Extrudex. You are looking at data from <b>09:41:07</b>, and it is not updating. Retrying every 10 s. Saving is paused.</span><button class="btn btn-sm" type="button" data-demo="online">Retry now</button>'; main.parentNode.insertBefore(b, main); } }
|
||||
if (kind === 'online') { fresh.stale = false; fresh.n = 0; tick(); var ob = $('#offline-banner'); if (ob) ob.parentNode.removeChild(ob); }
|
||||
}
|
||||
|
||||
/* ── wiring ──────────────────────────────────────────────── */
|
||||
function parseHash() {
|
||||
var m = /^#(spool|print|load|pedit|holders|premove)=([\w-]+)(?:&tab=(\w+))?(?:&state=(\w+))?/.exec(location.hash);
|
||||
if (m) { var id = (m[1] === 'load' || m[2] === 'new') ? m[2] : +m[2]; openDrawer(m[1], id, m[3], false); if (m[4]) { D.state = m[4]; renderDrawer(); } }
|
||||
else if (D.kind) closeDrawer(false);
|
||||
}
|
||||
function init(page, render) {
|
||||
var s = shell(page.name);
|
||||
document.body.insertAdjacentHTML('afterbegin', '<a class="btn btn-sm" href="#page" style="position:absolute;left:-9999px;top:8px" onfocus="this.style.left=\'8px\'" onblur="this.style.left=\'-9999px\'">Skip to content</a><div class="shell">' + s.rail + '<main id="main">' + s.top + '<div id="page"></div></main></div>' + s.tabbar + s.rest);
|
||||
window.PAGE = page;
|
||||
$('#page').innerHTML = render();
|
||||
if (page.after) page.after();
|
||||
setTheme(document.documentElement.getAttribute('data-theme'));
|
||||
setInterval(tick, 1000);
|
||||
parseHash();
|
||||
var pq = /[?&]palette=([^&]*)/.exec(location.search); if (pq) palOpen(decodeURIComponent(pq[1]).replace(/\+/g, ' '));
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
var t = e.target, a;
|
||||
if ((a = t.closest('[data-set-theme]'))) return setTheme(a.getAttribute('data-set-theme'));
|
||||
if (t.closest('[data-toggle-theme]')) return setTheme(document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark');
|
||||
if (t.closest('[data-palette]')) return palOpen();
|
||||
if (t.id === 'pal') return palClose();
|
||||
if ((a = t.closest('.pal-item'))) return palRun(+a.getAttribute('data-i'));
|
||||
if ((a = t.closest('[data-menu]'))) { e.preventDefault(); var m = $('#' + a.getAttribute('data-menu')), open = !m.classList.contains('is-open'); $$('.menu.is-open').forEach(function (x) { x.classList.remove('is-open'); }); m.classList.toggle('is-open', open); a.setAttribute('aria-expanded', String(open)); if (open) { var f = m.querySelector('button,a'); if (f) f.focus(); } e.stopPropagation(); return; }
|
||||
if (!t.closest('.menu')) $$('.menu.is-open').forEach(function (x) { x.classList.remove('is-open'); });
|
||||
if ((a = t.closest('[data-demo]'))) { $$('.menu.is-open').forEach(function (x) { x.classList.remove('is-open'); }); return demo(a.getAttribute('data-demo')); }
|
||||
if ((a = t.closest('[data-soon]'))) { e.preventDefault(); return toast('<b>' + a.getAttribute('data-soon') + '</b> keeps its current screen on dev. It isn\'t redesigned in this iteration.'); }
|
||||
if ((a = t.closest('[data-spool]'))) { e.preventDefault(); D.list = null; return openDrawer('spool', +a.getAttribute('data-spool'), a.getAttribute('data-tab-open') || 'overview'); }
|
||||
if ((a = t.closest('[data-print]'))) { e.preventDefault(); return openDrawer('print', +a.getAttribute('data-print')); }
|
||||
if ((a = t.closest('[data-load-into]'))) { e.preventDefault(); return openDrawer('load', a.getAttribute('data-load-into')); }
|
||||
if ((a = t.closest('[data-pedit]'))) { e.preventDefault(); var pv = a.getAttribute('data-pedit'); return openDrawer('pedit', pv === 'new' ? 'new' : +pv); }
|
||||
if ((a = t.closest('[data-holders]'))) { e.preventDefault(); return openDrawer('holders', +a.getAttribute('data-holders')); }
|
||||
if ((a = t.closest('[data-premove]'))) { e.preventDefault(); return openDrawer('premove', +a.getAttribute('data-premove')); }
|
||||
if (t.id === 'rm-go') { closeDrawer(); return toast('Removed <b>' + esc(t.getAttribute('data-name')) + '</b>. Its print history is kept.'); }
|
||||
if ((a = t.closest('[data-add-spool]'))) { e.preventDefault(); return openDrawer('new', 0, 'edit'); }
|
||||
if (t.closest('[data-close-drawer]')) return closeDrawer();
|
||||
if ((a = t.closest('#drawer [data-tab]'))) { D.tab = a.getAttribute('data-tab'); D.state = 'normal'; renderDrawer(); history.replaceState({ d: 1 }, '', '#' + D.kind + '=' + D.id + (D.tab !== 'overview' ? '&tab=' + D.tab : '')); var f = $('#drawer [autofocus]'); if (f) f.focus(); return; }
|
||||
if ((a = t.closest('[data-state]'))) { D.state = a.getAttribute('data-state'); return renderDrawer(); }
|
||||
if ((a = t.closest('[data-step]'))) { var i = D.list.indexOf(D.id) + +a.getAttribute('data-step'); if (D.list[i] != null) openDrawer('spool', D.list[i], D.tab, false); history.replaceState({ d: 1 }, '', '#spool=' + D.id + (D.tab !== 'overview' ? '&tab=' + D.tab : '')); return; }
|
||||
if ((a = t.closest('[data-dest]'))) { $$('[data-dest]').forEach(function (b) { b.setAttribute('aria-pressed', String(b === a)); }); $('#m-save').disabled = false; var dc = a.getAttribute('data-dest'), cc = dc !== 'storage' && container(dc); $('#m-save').textContent = cc ? 'Load into ' + cc.printer.model + ' · ' + cc.c.label : 'Move to storage'; return; }
|
||||
if ((a = t.closest('[data-load]'))) { $$('[data-load]').forEach(function (b) { b.setAttribute('aria-pressed', String(b === a)); }); var ls = spool(+a.getAttribute('data-load')); $('#l-save').disabled = false; $('#l-save').textContent = 'Load ' + ls.color + ' · ' + ls.brand; $('#l-save').setAttribute('data-sid', ls.id); return; }
|
||||
if (t.id === 'l-save') { var sid = +t.getAttribute('data-sid'), ss = spool(sid), cc2 = container(D.id); ss.cont = D.id; closeDrawer(); toast('Loaded <b>' + esc(ss.color + ' · ' + ss.brand) + '</b> into ' + esc(cc2.printer.model + ' · ' + cc2.c.label) + ' <a href="#spool=' + sid + '" data-spool="' + sid + '">View</a>'); if (PAGE.refresh) PAGE.refresh(); return; }
|
||||
if (t.id === 'm-save') { var dest = $('[data-dest][aria-pressed="true"]').getAttribute('data-dest'), sp = spool(D.id); sp.cont = dest === 'storage' ? null : dest; sp.v++; D.tab = 'overview'; renderDrawer(); toast('Moved <b>' + esc(sp.color) + '</b> to ' + esc(where(sp).text)); if (PAGE.refresh) PAGE.refresh(); return; }
|
||||
if (t.id === 'w-save' || t.id === 'w-next') { var ws = spool(D.id), before = ws.rem, nv = Math.round(+$('#scale').value.replace(/[^\d.]/g, '') - ws.tare); ws.rem = nv; ws.remSrc = 'weighed'; ws.weighedAt = '18 Sep 09:41'; ws.v++; toast('Weighed <b>' + esc(ws.color + ' · ' + ws.brand) + '</b>: ' + g(before) + ' → ' + g(nv)); if (PAGE.refresh) PAGE.refresh(); if (t.id === 'w-next') { var nx = D.list[D.list.indexOf(D.id) + 1]; openDrawer('spool', nx, 'weigh', false); history.replaceState({ d: 1 }, '', '#spool=' + nx + '&tab=weigh'); } else { D.tab = 'overview'; renderDrawer(); } return; }
|
||||
if (t.closest('[data-save]')) { var sv = D.kind === 'new'; closeDrawer(); D.dirty = false; return toast(sv ? 'Added spool <b>#57</b>. <a href="#">Print its label</a>' : 'Saved changes'); }
|
||||
if (t.closest('[data-discard]')) { D.dirty = false; return closeDrawer(); }
|
||||
if (t.closest('[data-keep]')) { $('#discard-bar').hidden = true; return; }
|
||||
if ((a = t.closest('[data-fake]'))) { toast(a.getAttribute('data-fake')); return; }
|
||||
if ((a = t.closest('[data-delete]'))) { var ds = spool(+a.getAttribute('data-delete')); $('#drawer .drawer-foot').innerHTML = '<div class="stack" style="gap:8px;width:100%"><b>Delete ' + esc(ds.color + ' · ' + fullName(ds)) + '?</b><span class="small">Its print history stays. Prints keep their grams and cost and only lose the link to this spool. This can\'t be undone.</span><div class="row" style="justify-content:flex-end"><button class="btn" type="button" data-tab="overview" autofocus>Keep spool</button><button class="btn btn-danger" type="button" data-fake="Spool #' + ds.id + ' deleted. 0 prints lost their cost.">Delete spool</button></div></div>'; $('#drawer .drawer-foot [autofocus]').focus(); return; }
|
||||
});
|
||||
document.addEventListener('input', function (e) {
|
||||
if (e.target.id === 'pal-q') { P.sel = 0; return palRender(); }
|
||||
if (e.target.closest('#drawer .drawer-body') && D.tab === 'edit') D.dirty = true;
|
||||
if (e.target.id === 'scale') {
|
||||
var s = spool(D.id), v = +e.target.value.replace(/[^\d.]/g, ''), r = Math.round(v - s.tare);
|
||||
var ok = v > 0 && v >= s.tare && r <= s.tot + 1;
|
||||
$('#w-after').textContent = v ? g(r) : '—';
|
||||
$('#w-diff').innerHTML = v && ok ? '<span class="prov">' + (r - s.rem > 0 ? '+' : '') + (r - s.rem).toLocaleString('en-US') + ' g</span>' : '';
|
||||
$('#w-note').innerHTML = !v ? 'Type the reading and the result appears here before you save.' : v < s.tare ? '<span style="color:var(--status-error)">⚠ ' + g(v) + ' is less than the ' + g(s.tare) + ' empty spool. One of them is wrong. Nothing will be saved.</span>' : r > s.tot + 1 ? '<span style="color:var(--status-error)">⚠ That leaves ' + g(r) + ', more than the spool holds full (' + g(s.tot) + '). Check the total or the empty spool weight.</span>' : 'Saved with the empty spool weight it used, so correcting that weight later re-bases this reading.';
|
||||
$('#w-save').disabled = !ok; if ($('#w-next')) $('#w-next').disabled = !ok;
|
||||
}
|
||||
if (e.target.id === 'rm-name') { $('#rm-go').disabled = e.target.value !== $('#rm-go').getAttribute('data-name'); }
|
||||
if (e.target.id === 'load-q') { var q = e.target.value.toLowerCase(); $$('[data-load]').forEach(function (b) { b.hidden = q && b.getAttribute('data-hay').indexOf(q) < 0; }); }
|
||||
});
|
||||
document.addEventListener('change', function (e) { if (e.target.id === 'pe-brand') { D.brand = e.target.value; renderDrawer(); } });
|
||||
document.addEventListener('keydown', function (e) {
|
||||
var typing = /INPUT|SELECT|TEXTAREA/.test(document.activeElement.tagName);
|
||||
if ((e.key === 'k' && (e.metaKey || e.ctrlKey)) || (e.key === '/' && !typing)) { e.preventDefault(); return palOpen(); }
|
||||
if ($('#pal').classList.contains('is-open')) {
|
||||
if (e.key === 'Escape') { e.preventDefault(); return palClose(); }
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); P.sel = Math.min(P.items.length - 1, P.sel + 1); return palRender(); }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); P.sel = Math.max(0, P.sel - 1); return palRender(); }
|
||||
if (e.key === 'Enter') { e.preventDefault(); return palRun(P.sel); }
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') { if ($('.menu.is-open')) { $$('.menu.is-open').forEach(function (x) { x.classList.remove('is-open'); }); return; } if (D.kind) return closeDrawer(); }
|
||||
if (D.kind === 'spool' && !typing && D.list && (e.key === 'j' || e.key === 'k')) { var b = $('[data-step="' + (e.key === 'j' ? 1 : -1) + '"]'); if (b && !b.disabled) b.click(); }
|
||||
if (e.key === 'Enter' && document.activeElement.matches('tr[data-row-spool],tr[data-row-print]')) document.activeElement.click();
|
||||
if (D.kind && e.key === 'Tab') { var dr = $('#drawer'), f = $$('button:not([disabled]),a[href],input,select,[tabindex="0"]', dr).filter(function (x) { return x.offsetParent; }); if (!f.length) return; if (e.shiftKey && document.activeElement === f[0]) { e.preventDefault(); f[f.length - 1].focus(); } else if (!e.shiftKey && document.activeElement === f[f.length - 1]) { e.preventDefault(); f[0].focus(); } }
|
||||
});
|
||||
window.addEventListener('popstate', parseHash);
|
||||
|
||||
window.X = { unverified: unverified, holderIds: HOLDER_IDS, init: init, esc: esc, g: g, kg: kg, usd: usd, dur: dur, when: when, ago: ago, since: since, spool: spool, printer: printer, print: print, container: container, contents: contents, fullName: fullName, short: short, where: where, gross: gross, value: value, low: low, sw: sw, meter: meter, tareProv: tareProv, remProv: remProv, weightProv: weightProv, durCell: durCell, printSt: printSt, printerSt: printerSt, needs: needs, printsNeeding: printsNeeding, spoolLinks: spoolLinks, openDrawer: openDrawer, D: D, toast: toast, ICON: ICON };
|
||||
})();
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Iteration 2: assemble self-contained pages.
|
||||
|
||||
Each screen = v3 tokens (fonts inlined as base64) + iteration-1 app.css
|
||||
utilities + components.css + page CSS, then data.js + app.js + page script.
|
||||
Run: python3 build.py (writes ../screens/*.html)
|
||||
"""
|
||||
import base64, pathlib, re
|
||||
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
ROOT = HERE.parent.parent # ~/Design/extrudex-v3
|
||||
OUT = HERE.parent / 'screens'
|
||||
OUT.mkdir(exist_ok=True)
|
||||
|
||||
tokens = (ROOT / 'design-system-v3.css').read_text()
|
||||
tokens = re.sub(r"url\('\./fonts/([^']+)'\)",
|
||||
lambda m: "url('data:font/woff2;base64," + base64.b64encode((ROOT / 'fonts' / m.group(1)).read_bytes()).decode() + "')",
|
||||
tokens)
|
||||
base_css = (ROOT / 'source' / 'app.css').read_text()
|
||||
comp_css = (HERE / 'components.css').read_text()
|
||||
data_js = (HERE / 'data.js').read_text()
|
||||
app_js = (HERE / 'app.js').read_text()
|
||||
|
||||
THEME = """<script>
|
||||
(function () { var t = null; try { t = localStorage.getItem('extrudex-v3-theme'); } catch (e) {}
|
||||
var q = /[?&]theme=(light|dark)/.exec(location.search); if (q) t = q[1];
|
||||
if (t !== 'light' && t !== 'dark') t = window.matchMedia && matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', t); })();
|
||||
</script>"""
|
||||
|
||||
def build(slug, title, desc):
|
||||
src = (HERE / 'pages' / f'{slug}.html').read_text()
|
||||
css = ''.join(re.findall(r'<style>(.*?)</style>', src, re.S))
|
||||
js = ''.join(re.findall(r'<script>(.*?)</script>', src, re.S))
|
||||
html = f"""<!doctype html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{title}</title>
|
||||
<meta name="description" content="{desc}">
|
||||
{THEME}
|
||||
<style>
|
||||
{tokens}
|
||||
{base_css}
|
||||
{comp_css}
|
||||
{css}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
{data_js}
|
||||
{app_js}
|
||||
</script>
|
||||
<script>
|
||||
{js}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
(OUT / f'{slug}.html').write_text(html)
|
||||
print(slug, len(html) // 1024, 'KB')
|
||||
|
||||
PAGES = [
|
||||
('index', 'Extrudex Flow Map', 'How the iteration-2 screens connect, with guided flows.'),
|
||||
('01-dashboard', 'Extrudex Dashboard', 'Attention-first dashboard, iteration 3.'),
|
||||
('02-filament', 'Extrudex Filament', 'Filament inventory with drawer, filters and weigh session, iteration 3.'),
|
||||
('03-prints', 'Extrudex Prints', 'Print history and attribution queue, iteration 3.'),
|
||||
('04-printers', 'Extrudex Printers', 'Printers with holders, load flow, admin panels and connection state.'),
|
||||
('05-lists', 'Extrudex Lists', 'Taxonomy lists the spool name is built from.'),
|
||||
('06-labels', 'Extrudex Labels', 'Spool and holder QR labels.'),
|
||||
('07-access', 'Extrudex Access', 'People, sessions, machines and API tokens.'),
|
||||
('08-system', 'Extrudex System', 'Low-filament report, audit log and this device.'),
|
||||
('09-scan-holder', 'Extrudex Holder Scan', 'Phone-first holder scan session.'),
|
||||
('10-scan-spool', 'Extrudex Spool Scan', 'Phone-first scanned spool and weigh-in.'),
|
||||
('devices', 'Extrudex Devices', 'Every screen in phone and tablet frames.'),
|
||||
]
|
||||
for p in PAGES:
|
||||
if (HERE / 'pages' / f'{p[0]}.html').exists():
|
||||
build(*p)
|
||||
@@ -0,0 +1,275 @@
|
||||
/* ── Design System v3.1 components (iteration 2) ─────────────────────────
|
||||
* Built only from design-system-v3.css tokens. No new colors. */
|
||||
|
||||
/* Shell */
|
||||
.shell { display: grid; grid-template-columns: 224px minmax(0, 1fr); gap: var(--space-6); max-width: 1480px; margin: 0 auto; padding: var(--space-5) var(--space-6); min-height: 100vh; }
|
||||
.rail { position: sticky; top: var(--space-5); align-self: start; padding: var(--space-4); display: flex; flex-direction: column; gap: var(--space-4); max-height: calc(100vh - 40px); }
|
||||
.wordmark { font-weight: var(--fw-display); font-size: var(--fs-h3); display: flex; align-items: center; gap: var(--space-2); padding: 4px 6px; }
|
||||
.wordmark-mark { width: 30px; height: 30px; border-radius: 10px; background: var(--accent-fill); color: var(--text-on-accent); display: grid; place-items: center; font-size: 15px; border: 1px solid var(--border-control); box-shadow: var(--nm-raised-1); }
|
||||
.nav-group { display: flex; flex-direction: column; gap: 4px; }
|
||||
.nav-label { font-size: var(--fs-caption); text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); color: var(--text-secondary); font-weight: var(--fw-bold); padding: 8px 12px 2px; }
|
||||
.nav a { display: flex; align-items: center; gap: var(--space-3); padding: 9px 12px; border-radius: var(--radius-md); color: var(--text-primary); text-decoration: none; border: 1px solid transparent; font-weight: var(--fw-medium); position: relative; }
|
||||
.nav a:hover { border-color: var(--border-control); }
|
||||
.nav a:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: 2px; }
|
||||
.nav a[aria-current="page"] { background: var(--surface-inset); box-shadow: var(--nm-inset-1); border-color: var(--text-accent); color: var(--text-accent); font-weight: var(--fw-bold); }
|
||||
.nav svg { width: 18px; height: 18px; flex: none; }
|
||||
.nav .count { margin-left: auto; min-width: 22px; height: 20px; padding: 0 6px; border-radius: var(--radius-pill); font-size: var(--fs-caption); font-weight: var(--fw-bold); display: inline-grid; place-items: center; border: 1px solid currentColor; }
|
||||
.count-warn { color: var(--status-warning); background: var(--wash-warning); }
|
||||
.count-err { color: var(--status-error); background: var(--wash-error); }
|
||||
.rail-foot { margin-top: auto; display: flex; flex-direction: column; gap: var(--space-3); border-top: 1px solid var(--border-hairline); padding-top: var(--space-3); }
|
||||
|
||||
/* Top bar: search, freshness, context */
|
||||
.topbar { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; }
|
||||
.search-trigger { flex: 1; min-width: 220px; max-width: 560px; display: flex; align-items: center; gap: var(--space-3); padding: 9px 14px; border-radius: var(--radius-pill); background: var(--surface-inset); box-shadow: var(--nm-inset-1); border: 1px solid var(--border-control); color: var(--text-secondary); font: inherit; cursor: pointer; text-align: left; }
|
||||
.search-trigger:hover { border-color: var(--text-accent); }
|
||||
.search-trigger:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: var(--focus-offset); }
|
||||
.search-trigger svg { width: 16px; height: 16px; }
|
||||
.kbd { font: var(--fw-bold) 10px/1 var(--font-mono); padding: 3px 6px; border-radius: 5px; border: 1px solid var(--border-control); color: var(--text-secondary); background: var(--surface-raised); box-shadow: 0 1px 0 var(--border-strong); }
|
||||
.fresh { display: inline-flex; align-items: center; gap: 8px; font-size: var(--fs-label); color: var(--text-secondary); }
|
||||
.fresh .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--status-success); box-shadow: 0 0 0 3px var(--wash-success); }
|
||||
.fresh.is-stale .dot { background: var(--status-warning); box-shadow: 0 0 0 3px var(--wash-warning); }
|
||||
.fresh.is-stale { color: var(--status-warning); }
|
||||
|
||||
/* Breadcrumb */
|
||||
.crumbs { display: flex; gap: 6px; align-items: center; font-size: var(--fs-label); color: var(--text-secondary); }
|
||||
.crumbs a { color: var(--text-secondary); text-decoration: none; }
|
||||
.crumbs a:hover { color: var(--text-accent); text-decoration: underline; }
|
||||
|
||||
/* Page header: title, one-line summary, ONE primary action, overflow for the rest */
|
||||
.page-head { display: flex; flex-wrap: wrap; gap: var(--space-4); align-items: flex-end; justify-content: space-between; }
|
||||
.page-head h1 { margin: 0; font-size: var(--fs-h1); line-height: var(--lh-tight); font-weight: var(--fw-display); }
|
||||
.page-head .sum { margin: 6px 0 0; color: var(--text-secondary); font-size: 13px; }
|
||||
.page-head .sum b { color: var(--text-primary); }
|
||||
|
||||
/* Overflow / row menu */
|
||||
.menu-wrap { position: relative; display: inline-block; }
|
||||
.menu { position: absolute; right: 0; top: calc(100% + 6px); z-index: 30; min-width: 220px; padding: 6px; display: none; flex-direction: column; gap: 2px; background: var(--surface-high); border: 1px solid var(--border-strong); border-radius: var(--radius-md); box-shadow: var(--nm-raised-3); }
|
||||
.menu.is-open { display: flex; }
|
||||
.menu button, .menu a { all: unset; box-sizing: border-box; display: flex; gap: 10px; align-items: center; padding: 8px 10px; border-radius: var(--radius-sm); font-size: 13px; cursor: pointer; color: var(--text-primary); }
|
||||
.menu button:hover, .menu a:hover, .menu button:focus-visible, .menu a:focus-visible { background: var(--accent-wash); outline: 1px solid var(--text-accent); }
|
||||
.menu .danger { color: var(--status-error); }
|
||||
.menu hr { border: 0; border-top: 1px solid var(--border-hairline); margin: 4px 0; width: 100%; }
|
||||
.menu .hint { margin-left: auto; font-size: var(--fs-caption); color: var(--text-secondary); }
|
||||
|
||||
/* Tabs (segmented, with counts) */
|
||||
.tabs { display: flex; gap: 4px; padding: 4px; border-radius: var(--radius-pill); background: var(--surface-inset); box-shadow: var(--nm-inset-1); border: 1px solid var(--border-control); width: max-content; max-width: 100%; overflow-x: auto; }
|
||||
.tabs button { font: var(--fw-medium) 13px/1 var(--font-body); color: var(--text-secondary); background: transparent; border: 1px solid transparent; border-radius: var(--radius-pill); padding: 8px 14px; cursor: pointer; display: inline-flex; gap: 8px; align-items: center; white-space: nowrap; }
|
||||
.tabs button[aria-selected="true"] { background: var(--surface-raised); color: var(--text-primary); font-weight: var(--fw-bold); box-shadow: var(--nm-raised-1); border-color: var(--border-control); }
|
||||
.tabs button:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: 2px; }
|
||||
.tabs .n { font-size: var(--fs-caption); padding: 1px 7px; border-radius: var(--radius-pill); border: 1px solid currentColor; }
|
||||
.tabs .n.warn { color: var(--status-warning); }
|
||||
|
||||
/* Filter chips (removable) */
|
||||
.fchips { display: flex; gap: var(--space-2); flex-wrap: wrap; align-items: center; }
|
||||
.fchip { display: inline-flex; gap: 6px; align-items: center; padding: 4px 6px 4px 10px; border-radius: var(--radius-pill); border: 1px solid var(--text-accent); background: var(--accent-wash); color: var(--text-accent); font-size: var(--fs-label); font-weight: var(--fw-bold); }
|
||||
.fchip button { all: unset; cursor: pointer; width: 18px; height: 18px; display: grid; place-items: center; border-radius: 50%; }
|
||||
.fchip button:hover, .fchip button:focus-visible { background: var(--text-accent); color: var(--text-on-accent); }
|
||||
.fadd { font-size: var(--fs-label); }
|
||||
|
||||
/* Quiet provenance: icon + lowercase text, never a chip */
|
||||
.prov { display: inline-flex; gap: 4px; align-items: center; font-size: var(--fs-caption); color: var(--text-secondary); white-space: nowrap; }
|
||||
.prov i { font-style: normal; width: 14px; text-align: center; }
|
||||
.prov.warn { color: var(--status-warning); font-weight: var(--fw-bold); }
|
||||
.prov.info { color: var(--status-info); }
|
||||
|
||||
/* Status: small filled label + glyph. Reserved for state, not provenance */
|
||||
.st { display: inline-flex; gap: 6px; align-items: center; font-size: var(--fs-label); font-weight: var(--fw-bold); white-space: nowrap; }
|
||||
.st::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: currentColor; flex: none; }
|
||||
.st.run { color: var(--text-accent); } .st.ok { color: var(--status-success); } .st.err { color: var(--status-error); } .st.warn { color: var(--status-warning); } .st.idle { color: var(--text-secondary); }
|
||||
.st.warn::before { border-radius: 2px; transform: rotate(45deg); }
|
||||
.st.err::before { border-radius: 1px; }
|
||||
.st.idle::before { background: transparent; border: 2px solid currentColor; width: 6px; height: 6px; }
|
||||
|
||||
/* Data tables (v3.1): quieter, row = one line where possible */
|
||||
.tbl { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.tbl th { text-align: left; font-size: var(--fs-caption); text-transform: uppercase; letter-spacing: var(--tracking-caps); color: var(--text-secondary); font-weight: var(--fw-bold); padding: 10px 12px; border-bottom: 1px solid var(--border-strong); white-space: nowrap; position: sticky; top: 0; background: var(--surface-raised); z-index: 1; }
|
||||
.tbl th button.sort { all: unset; cursor: pointer; display: inline-flex; gap: 4px; align-items: center; }
|
||||
.tbl th button.sort:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: 2px; border-radius: 3px; }
|
||||
.tbl th[aria-sort="ascending"] button.sort::after { content: "▲"; font-size: 9px; color: var(--text-accent); }
|
||||
.tbl th[aria-sort="descending"] button.sort::after { content: "▼"; font-size: 9px; color: var(--text-accent); }
|
||||
.tbl th[aria-sort="none"] button.sort::after { content: "↕"; font-size: 10px; opacity: .6; }
|
||||
.tbl td { padding: 10px 12px; border-bottom: 1px solid var(--border-hairline); vertical-align: middle; }
|
||||
.tbl tr:last-child td { border-bottom: 0; }
|
||||
.tbl .r { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.tbl tbody tr.clickable { cursor: pointer; }
|
||||
.tbl tbody tr.clickable:hover td { background: var(--accent-wash); }
|
||||
.tbl tbody tr.is-selected td { background: var(--accent-wash); box-shadow: inset 0 1px 0 var(--text-accent), inset 0 -1px 0 var(--text-accent); }
|
||||
.tbl tbody tr.is-selected td:first-child { box-shadow: inset 3px 0 0 var(--text-accent), inset 0 1px 0 var(--text-accent), inset 0 -1px 0 var(--text-accent); }
|
||||
.tbl tbody tr:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: -2px; }
|
||||
.sub { display: block; font-size: var(--fs-caption); color: var(--text-secondary); font-weight: var(--fw-regular); line-height: var(--lh-snug); }
|
||||
.strong { font-weight: var(--fw-bold); }
|
||||
.num { font-variant-numeric: tabular-nums; }
|
||||
.table-card { padding: 0; overflow: hidden; }
|
||||
.table-scroll { overflow: auto; max-height: 70vh; }
|
||||
.table-foot { display: flex; justify-content: space-between; align-items: center; gap: var(--space-3); padding: var(--space-3) var(--space-4); border-top: 1px solid var(--border-hairline); flex-wrap: wrap; font-size: var(--fs-label); }
|
||||
|
||||
/* Meter: remaining bar with the 200 g threshold tick */
|
||||
.meter { position: relative; height: 8px; border-radius: var(--radius-pill); background: var(--surface-inset); box-shadow: var(--nm-inset-1); border: 1px solid var(--border-hairline); overflow: visible; min-width: 90px; }
|
||||
.meter > span { display: block; height: 100%; border-radius: var(--radius-pill); background: var(--text-accent); }
|
||||
.meter.is-low > span { background: var(--status-warning); }
|
||||
.meter .tick { position: absolute; top: -3px; bottom: -3px; width: 2px; background: var(--text-secondary); border-radius: 1px; }
|
||||
|
||||
/* Drawer (right sheet; full-screen on phones) */
|
||||
.drawer-scrim { position: fixed; inset: 0; background: rgba(10, 10, 14, .5); opacity: 0; pointer-events: none; transition: opacity var(--dur-base) var(--ease-out); z-index: 40; }
|
||||
:root[data-theme="light"] .drawer-scrim { background: rgba(60, 55, 80, .28); }
|
||||
.drawer { position: fixed; top: 12px; right: 12px; bottom: 12px; width: min(520px, calc(100vw - 24px)); z-index: 41; transform: translateX(calc(100% + 24px)); transition: transform 220ms var(--ease-out); display: flex; flex-direction: column; overflow: hidden; }
|
||||
body.drawer-open .drawer { transform: none; }
|
||||
body.drawer-open .drawer-scrim { opacity: 1; pointer-events: auto; }
|
||||
.drawer-head { padding: var(--space-5) var(--space-5) var(--space-3); display: flex; gap: var(--space-3); align-items: flex-start; border-bottom: 1px solid var(--border-hairline); }
|
||||
.drawer-head h2 { margin: 0; font-size: var(--fs-h3); line-height: var(--lh-snug); }
|
||||
.drawer-tabs { padding: var(--space-3) var(--space-5) 0; }
|
||||
.drawer-body { flex: 1; overflow: auto; padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-5); }
|
||||
.drawer-foot { padding: var(--space-4) var(--space-5); border-top: 1px solid var(--border-hairline); display: flex; gap: var(--space-3); justify-content: flex-end; align-items: center; flex-wrap: wrap; }
|
||||
.drawer-nav { display: flex; gap: 4px; }
|
||||
@media (prefers-reduced-motion: reduce) { .drawer, .drawer-scrim { transition: none; } }
|
||||
|
||||
/* Key-value list */
|
||||
.kv { display: grid; grid-template-columns: minmax(110px, auto) 1fr; gap: 8px var(--space-4); margin: 0; font-size: 13px; }
|
||||
.kv dt { color: var(--text-secondary); }
|
||||
.kv dd { margin: 0; font-variant-numeric: tabular-nums; }
|
||||
.panel { padding: var(--space-4); }
|
||||
.panel h3 { margin: 0 0 var(--space-3); font-size: var(--fs-label); text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); color: var(--text-secondary); }
|
||||
|
||||
/* Command palette */
|
||||
.pal-scrim { position: fixed; inset: 0; background: rgba(10, 10, 14, .55); z-index: 60; display: none; align-items: flex-start; justify-content: center; padding-top: 12vh; }
|
||||
:root[data-theme="light"] .pal-scrim { background: rgba(60, 55, 80, .3); }
|
||||
.pal-scrim.is-open { display: flex; }
|
||||
.pal { width: min(640px, calc(100vw - 32px)); max-height: 70vh; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.pal-input { display: flex; gap: var(--space-3); align-items: center; padding: var(--space-4); border-bottom: 1px solid var(--border-hairline); }
|
||||
.pal-input input { all: unset; flex: 1; font: var(--fw-medium) 16px/1.4 var(--font-body); color: var(--text-primary); }
|
||||
.pal-input input::placeholder { color: var(--text-secondary); }
|
||||
.pal-list { overflow: auto; padding: 6px; }
|
||||
.pal-sec { font-size: var(--fs-caption); text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); color: var(--text-secondary); font-weight: var(--fw-bold); padding: 10px 10px 4px; }
|
||||
.pal-item { display: flex; gap: var(--space-3); align-items: center; padding: 9px 10px; border-radius: var(--radius-sm); cursor: pointer; font-size: 13px; border: 1px solid transparent; }
|
||||
.pal-item[aria-selected="true"] { background: var(--accent-wash); border-color: var(--text-accent); }
|
||||
.pal-item .meta { margin-left: auto; font-size: var(--fs-caption); color: var(--text-secondary); white-space: nowrap; }
|
||||
.pal-foot { padding: 8px var(--space-4); border-top: 1px solid var(--border-hairline); display: flex; gap: var(--space-4); font-size: var(--fs-caption); color: var(--text-secondary); flex-wrap: wrap; }
|
||||
|
||||
/* Toasts */
|
||||
.toasts { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); z-index: 70; display: flex; flex-direction: column; gap: 8px; align-items: center; pointer-events: none; }
|
||||
.toast { pointer-events: auto; display: flex; gap: var(--space-3); align-items: center; padding: 10px 12px 10px 16px; background: var(--surface-high); border: 1px solid var(--border-strong); border-left: 4px solid var(--status-success); border-radius: var(--radius-md); box-shadow: var(--nm-raised-3); font-size: 13px; max-width: min(560px, calc(100vw - 32px)); }
|
||||
.toast.warn { border-left-color: var(--status-warning); }
|
||||
|
||||
/* Attention list (dashboard) */
|
||||
.attn-list { display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.attn { display: grid; grid-template-columns: 44px minmax(0, 1fr) auto; gap: var(--space-4); align-items: center; padding: var(--space-4); border-radius: var(--radius-md); background: var(--surface-raised); border: 1px solid var(--border-hairline); box-shadow: var(--nm-raised-2); }
|
||||
.attn .ico { width: 44px; height: 44px; border-radius: 12px; display: grid; place-items: center; font-size: 20px; font-weight: var(--fw-bold); background: var(--surface-inset); box-shadow: var(--nm-inset-1); border: 1px solid var(--border-hairline); }
|
||||
.attn.warn .ico { color: var(--status-warning); border-color: var(--status-warning); }
|
||||
.attn.err .ico { color: var(--status-error); border-color: var(--status-error); }
|
||||
.attn.info .ico { color: var(--status-info); }
|
||||
.attn h3 { margin: 0; font-size: 14px; }
|
||||
.attn p { margin: 2px 0 0; font-size: var(--fs-label); color: var(--text-secondary); }
|
||||
.attn-done { padding: var(--space-5); text-align: center; color: var(--text-secondary); }
|
||||
|
||||
/* Skeleton + empty + error states */
|
||||
.skel { border-radius: 6px; background: linear-gradient(90deg, var(--surface-inset) 0%, var(--border-hairline) 50%, var(--surface-inset) 100%); background-size: 200% 100%; animation: skel 1.4s linear infinite; height: 12px; }
|
||||
@keyframes skel { to { background-position: -200% 0; } }
|
||||
@media (prefers-reduced-motion: reduce) { .skel { animation: none; } }
|
||||
.empty { padding: var(--space-8) var(--space-5); text-align: center; display: flex; flex-direction: column; gap: var(--space-3); align-items: center; }
|
||||
.empty h3 { margin: 0; font-size: 15px; }
|
||||
.empty p { margin: 0; color: var(--text-secondary); font-size: 13px; max-width: 420px; }
|
||||
.banner { display: flex; gap: var(--space-3); align-items: center; padding: var(--space-3) var(--space-4); border-radius: var(--radius-md); font-size: 13px; border: 1px solid var(--status-warning); background: var(--wash-warning); }
|
||||
.banner.err { border-color: var(--status-error); background: var(--wash-error); }
|
||||
|
||||
/* Slot grid (printers + move flow) */
|
||||
.slotgrid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
|
||||
.slotgrid.one { grid-template-columns: minmax(0, 1fr); max-width: 25%; }
|
||||
.slot { all: unset; box-sizing: border-box; cursor: pointer; display: flex; flex-direction: column; gap: 6px; align-items: flex-start; padding: 10px; min-height: 92px; border-radius: var(--radius-md); border: 1px solid var(--border-control); background: var(--surface-raised); box-shadow: var(--nm-raised-1); font-size: var(--fs-caption); position: relative; min-width: 0; }
|
||||
.slot:hover { border-color: var(--text-accent); }
|
||||
.slot:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: 2px; }
|
||||
.slot .sn { color: var(--text-secondary); font-weight: var(--fw-bold); }
|
||||
.slot .nm { font-weight: var(--fw-bold); font-size: 12px; line-height: var(--lh-snug); overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||
.slot .g { margin-top: auto; font-variant-numeric: tabular-nums; font-size: 12px; }
|
||||
.slot.empty { border-style: dashed; box-shadow: none; background: transparent; color: var(--text-secondary); justify-content: center; align-items: center; text-align: center; }
|
||||
.slot.is-low { border-color: var(--status-warning); }
|
||||
.slot[aria-pressed="true"] { background: var(--accent-wash); border: 2px solid var(--text-accent); box-shadow: var(--nm-inset-1); }
|
||||
.slot[aria-pressed="true"]::after { content: "✓"; position: absolute; top: 6px; right: 8px; color: var(--text-accent); font-weight: var(--fw-bold); font-size: 13px; }
|
||||
.slot[disabled] { cursor: not-allowed; opacity: .75; }
|
||||
|
||||
/* Mockup pill (replaces the iteration-1 banner) */
|
||||
.mock { position: fixed; left: 16px; bottom: 16px; z-index: 35; }
|
||||
.mock > button { font: var(--fw-bold) var(--fs-caption)/1 var(--font-body); text-transform: uppercase; letter-spacing: var(--tracking-caps); padding: 8px 12px; border-radius: var(--radius-pill); border: 1px solid var(--text-accent); background: var(--surface-high); color: var(--text-accent); box-shadow: var(--nm-raised-2); cursor: pointer; }
|
||||
.mock .menu { left: 0; right: auto; top: auto; bottom: calc(100% + 8px); width: 330px; padding: var(--space-4); gap: var(--space-2); font-size: var(--fs-label); }
|
||||
.mock .menu p { margin: 0; line-height: var(--lh-snug); }
|
||||
|
||||
/* Mobile bottom tab bar + FAB */
|
||||
.tabbar { display: none; }
|
||||
.fab { display: none; }
|
||||
@media (max-width: 699px) {
|
||||
.shell { grid-template-columns: minmax(0, 1fr); padding: var(--space-3) var(--space-4) 96px; gap: var(--space-4); }
|
||||
.rail { display: none; }
|
||||
.tabbar { display: grid; grid-template-columns: repeat(4, 1fr); position: fixed; left: 8px; right: 8px; bottom: 8px; z-index: 36; padding: 6px; border-radius: var(--radius-xl); }
|
||||
.tabbar a { display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 6px 0; border-radius: var(--radius-lg); color: var(--text-secondary); text-decoration: none; font-size: 11px; font-weight: var(--fw-medium); position: relative; border: 1px solid transparent; }
|
||||
.tabbar a[aria-current="page"] { color: var(--text-accent); background: var(--surface-inset); box-shadow: var(--nm-inset-1); border-color: var(--text-accent); font-weight: var(--fw-bold); }
|
||||
.tabbar svg { width: 20px; height: 20px; }
|
||||
.tabbar .count { position: absolute; top: 2px; right: calc(50% - 22px); min-width: 16px; height: 16px; font-size: 9px; padding: 0 4px; border-radius: 99px; display: grid; place-items: center; border: 1px solid currentColor; }
|
||||
.fab { display: grid; place-items: center; position: fixed; right: 16px; bottom: 88px; z-index: 36; width: 56px; height: 56px; border-radius: 18px; font-size: 26px; padding: 0; }
|
||||
.mock { bottom: 88px; }
|
||||
.topbar .fresh span.long { display: none; }
|
||||
.page-head .primary-desktop { display: none; }
|
||||
.drawer { top: 0; right: 0; bottom: 0; width: 100vw; border-radius: 0; }
|
||||
.slotgrid.one { max-width: 50%; }
|
||||
}
|
||||
@media (max-width: 520px) { .slotgrid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
|
||||
/* Form grid + small utilities */
|
||||
.fr2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px 16px; }
|
||||
.nowrap { white-space: nowrap; }
|
||||
.drawer-body a { color: var(--text-accent); }
|
||||
.linkish { all: unset; cursor: pointer; color: var(--text-accent); text-decoration: underline; }
|
||||
.linkish:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: 2px; }
|
||||
.tile-link { text-decoration: none; color: inherit; }
|
||||
.tile-link:hover { border-color: var(--text-accent); }
|
||||
.tile-link:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: var(--focus-offset); }
|
||||
.is-flash { animation: flash 1.6s var(--ease-out) 1; }
|
||||
@keyframes flash { 0%, 40% { outline: 3px solid var(--focus-ring); outline-offset: 4px; } 100% { outline: 3px solid transparent; outline-offset: 4px; } }
|
||||
@media (prefers-reduced-motion: reduce) { .is-flash { animation: none; outline: 3px solid var(--focus-ring); } }
|
||||
#page { display: flex; flex-direction: column; gap: var(--space-6); min-width: 0; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
||||
#drawer-title:focus { outline: none; }
|
||||
#drawer-title:focus-visible { outline: none; text-decoration: underline; text-decoration-color: var(--focus-ring); text-underline-offset: 4px; }
|
||||
.short { display: none; }
|
||||
@media (max-width: 699px) {
|
||||
.search-trigger .long, .search-trigger .kbd.long, .mock .long { display: none; }
|
||||
.search-trigger .short { display: inline; }
|
||||
.search-trigger { min-width: 0; }
|
||||
.remcell .top { flex-wrap: nowrap; white-space: nowrap; }
|
||||
.remcell .top .sub { display: none !important; }
|
||||
}
|
||||
.pal-input svg { width: 18px; height: 18px; flex: none; color: var(--text-secondary); }
|
||||
|
||||
/* ── Tablet tier (700–1180 px): compact rail, side-sheet panel ───────────── */
|
||||
@media (min-width: 700px) and (max-width: 1180px) {
|
||||
.shell { grid-template-columns: 88px minmax(0, 1fr); gap: var(--space-5); padding: var(--space-4) var(--space-5); }
|
||||
.rail { padding: var(--space-3) 8px; gap: var(--space-3); align-items: stretch; }
|
||||
.wordmark { justify-content: center; font-size: 0; padding: 4px 0; }
|
||||
.wordmark-mark { width: 40px; height: 40px; font-size: 18px; }
|
||||
.nav-label { font-size: 9px; padding: 6px 0 0; text-align: center; letter-spacing: .06em; }
|
||||
.nav a { flex-direction: column; gap: 4px; padding: 10px 2px 8px; font-size: 11px; text-align: center; justify-content: center; }
|
||||
.nav a svg { width: 22px; height: 22px; }
|
||||
.nav a > span:not(.count) { line-height: 1.1; }
|
||||
.nav a .sub { display: none; }
|
||||
.nav .count { position: absolute; top: 4px; right: 10px; margin: 0; min-width: 18px; height: 18px; font-size: 10px; padding: 0 4px; }
|
||||
.rail-foot .seg, .rail-foot .small { display: none; }
|
||||
.rail-foot { border-top: 0; padding-top: 0; }
|
||||
.drawer { width: min(480px, calc(100vw - 96px)); }
|
||||
.hide-t { display: none !important; }
|
||||
.page-head h1 { font-size: var(--fs-h2); }
|
||||
}
|
||||
/* Tablet touch targets: at least 44 px for primary interactive rows */
|
||||
@media (pointer: coarse) {
|
||||
.btn-sm { min-height: 40px; }
|
||||
.tbl td { padding-top: 12px; padding-bottom: 12px; }
|
||||
.menu button, .menu a { padding: 12px 12px; }
|
||||
.kbd { display: none; }
|
||||
}
|
||||
.more-sheet { position: fixed; left: 8px; right: 8px; bottom: 84px; top: auto; z-index: 37; padding: 8px; }
|
||||
.more-sheet a svg, .more-sheet button svg { width: 18px; height: 18px; }
|
||||
.findit { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; padding: 12px 14px; border-radius: var(--radius-md); border: 2px solid var(--text-accent); background: var(--accent-wash); }
|
||||
.findit b { font-size: 17px; }
|
||||
.findit .sub { width: 100%; text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); font-weight: 700; }
|
||||
details.chg { margin-top: 8px; }
|
||||
details.chg summary { cursor: pointer; font-size: var(--fs-label); color: var(--text-accent); font-weight: 700; }
|
||||
.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: 8px; }
|
||||
.holder-h { display: flex; gap: var(--space-3); align-items: center; font-size: var(--fs-label); flex-wrap: wrap; }
|
||||
.tabbar { grid-template-columns: repeat(5, 1fr) !important; }
|
||||
@@ -0,0 +1,96 @@
|
||||
/* Sample data for the iteration-2 mockups. One model shared by every page, so a
|
||||
* spool, print or printer is the same object wherever it appears. Values are
|
||||
* based on the real inventory (import/filament_review.tsv, seed.sql spool
|
||||
* types) and the 2026-08-12 production canary. Nothing here is live. */
|
||||
var NOW = new Date('2026-09-18T09:41:07');
|
||||
|
||||
var CANARY = { spools: 105, brands: 18, kg: 95.72, value: 1721.18 };
|
||||
|
||||
/* tsrc: where this spool's tare came from.
|
||||
* 'spool' weighed for this reel (tare_origin=spool)
|
||||
* 'manufacturer' | 'community' | 'estimated' via the chosen spool type
|
||||
* null no tare known */
|
||||
var SPOOLS = [
|
||||
{ id: 40, c: ['#1f3fbf'], color: 'Klein Blue', brand: 'Sunlu', speed: null, mat: 'PLA', type: '+ 2.0', fin: 'Basic', rem: 64, tot: 250, tare: 60, tsrc: 'community', stype: 'Sunlu — 250 g mini', remSrc: 'typed', kg: 25.20, cont: null, loc: 'Shelf B2', label: true, v: 3 },
|
||||
{ id: 41, c: ['#b87333'], color: 'Copper', brand: 'Sunlu', speed: null, mat: 'PLA', type: '+', fin: 'Silk', rem: 97, tot: 1000, tare: 140, tsrc: 'community', stype: 'Sunlu — 1 kg plastic', remSrc: 'typed', kg: 19.99, cont: 'p1-ams1', label: true, v: 7 },
|
||||
{ id: 42, c: ['#141414'], color: 'Black', brand: 'Elegoo', speed: 'Rapid', mat: 'PETG', type: null, fin: 'Basic', rem: 142, tot: 1000, tare: 154, tsrc: 'manufacturer', stype: 'Elegoo — 1 kg cardboard', remSrc: 'typed', kg: 11.74, cont: 'p2-ams1', label: true, v: 9 },
|
||||
{ id: 43, c: ['#ece8df'], color: 'Rock White', brand: 'Overture', speed: null, mat: 'PLA', type: null, fin: 'Marble', rem: 171, tot: 1000, tare: 155, tsrc: 'community', stype: 'Overture — cardboard, PLA lines', remSrc: 'typed', kg: 17.99, cont: null, loc: 'Shelf C', label: true, v: 4 },
|
||||
{ id: 44, c: ['#161616', '#c0232f'], color: 'Black Red', brand: 'TECSONAR', speed: null, mat: 'PLA', type: null, fin: 'Matte', rem: 188, tot: 1000, tare: null, tsrc: null, stype: null, remSrc: 'typed', kg: 15.99, cont: null, loc: 'Shelf C', label: false, v: 2 },
|
||||
{ id: 45, c: ['#101010'], color: 'Black', brand: 'JAYO', speed: 'Rapid', mat: 'PETG', type: null, fin: 'Matte', rem: 655, tot: 1000, tare: null, tsrc: null, stype: null, remSrc: 'typed', kg: 12.72, cont: 'p1-ams1', label: true, v: 5 },
|
||||
{ id: 46, c: ['#8a8f7a', '#e9e4d8'], color: 'Marble', brand: 'iSANMATE', speed: null, mat: 'ASA', type: null, fin: 'Marble', rem: 430, tot: 1000, tare: 180, tsrc: 'estimated', stype: 'Generic — 1 kg spool (assumed)', remSrc: 'weighed', kg: 25.99, cont: null, loc: 'Dry box 1', label: true, v: 6 },
|
||||
{ id: 47, c: ['#1b1b1b'], color: 'Black', brand: 'Sunlu', speed: null, mat: 'PETG', type: 'CF', fin: 'Matte', rem: 812, tot: 1000, tare: 262, tsrc: 'spool', stype: 'Sunlu — 1 kg plastic', remSrc: 'weighed', weighedAt: '18 Sep 09:39', kg: 22.79, cont: 'p3-lite', label: true, v: 5 },
|
||||
{ id: 48, c: ['#b8864b'], color: 'Natural Wood', brand: 'Sunlu', speed: null, mat: 'PLA', type: null, fin: 'Wood', rem: 1000, tot: 1000, tare: 35, tsrc: 'estimated', stype: 'Refill — bare cardboard core', remSrc: 'typed', kg: 16.50, cont: null, loc: 'Shelf A3', label: false, v: 1 },
|
||||
{ id: 49, c: ['#a3402c'], color: 'Brick Red', brand: 'Sunlu', speed: 'High Speed', mat: 'PLA', type: null, fin: 'Marble', rem: 1000, tot: 1000, tare: 140, tsrc: 'community', stype: 'Sunlu — 1 kg plastic', remSrc: 'typed', kg: 17.50, cont: null, loc: 'Shelf A2', label: true, v: 1 },
|
||||
{ id: 50, c: ['#e8453c', '#f2c230', '#2a8fd6'], color: 'Gradient Rainbow', brand: 'AMOLEN', speed: null, mat: 'PETG', type: null, fin: 'Gradient', rem: 1000, tot: 1000, tare: 180, tsrc: 'estimated', stype: 'Generic — 1 kg spool (assumed)', remSrc: 'typed', kg: 23.99, cont: null, loc: 'Shelf D', label: false, v: 1 },
|
||||
{ id: 51, c: ['#1d4fa8'], color: 'Blue', brand: 'Elegoo', speed: 'Rapid', mat: 'PETG', type: null, fin: 'Basic', rem: 1000, tot: 1000, tare: 154, tsrc: 'manufacturer', stype: 'Elegoo — 1 kg cardboard', remSrc: 'typed', kg: 17.99, cont: 'p2-ams1', label: true, v: 2 },
|
||||
{ id: 52, c: ['#141414'], color: 'Black', brand: 'Sunlu', speed: null, mat: 'PLA', type: '+ 2.0', fin: 'Basic', rem: 873, tot: 1000, tare: 140, tsrc: 'community', stype: 'Sunlu — 1 kg plastic', remSrc: 'typed', kg: 11.47, cont: 'p1-ams1', label: true, v: 11 },
|
||||
{ id: 53, c: ['#b9bcc0'], color: 'Mist Gray', brand: 'Overture', speed: null, mat: 'PLA', type: null, fin: 'Marble', rem: 920, tot: 1000, tare: 155, tsrc: 'community', stype: 'Overture — cardboard, PLA lines', remSrc: 'typed', kg: 16.99, cont: 'p1-ams1', label: true, v: 3 },
|
||||
{ id: 54, c: ['#f4f4f0'], color: 'White', brand: 'Elegoo', speed: null, mat: 'PLA', type: null, fin: 'Matte', rem: 569, tot: 1000, tare: 154, tsrc: 'manufacturer', stype: 'Elegoo — 1 kg cardboard', remSrc: 'typed', kg: 14.99, cont: 'p2-ams1', label: true, v: 6 },
|
||||
{ id: 55, c: ['#e8453c', '#f2c230', '#2a8fd6'], color: 'Red Yellow Blue', brand: 'AMOLEN', speed: null, mat: 'PLA', type: null, fin: 'Matte', rem: 532, tot: 1000, tare: 180, tsrc: 'estimated', stype: 'Generic — 1 kg spool (assumed)', remSrc: 'typed', kg: 19.99, cont: 'p3-lite', label: true, v: 4 },
|
||||
{ id: 56, c: ['#6e1a2a'], color: 'Transparent Wine Red', brand: 'Creality', speed: null, mat: 'PETG', type: null, fin: 'Translucent', rem: 1000, tot: 1000, tare: null, tsrc: null, stype: null, remSrc: 'typed', kg: 17.99, cont: null, loc: 'Shelf D', label: false, v: 1 }
|
||||
];
|
||||
|
||||
var PRINTERS = [
|
||||
{ id: 1, name: 'P1S - Prints Charming', model: 'P1S', ip: '10.60.1.242', serial: '01P09A••••••4821', state: 'RUNNING', connected: true, lifecycle: 'ready', stale: false, age: 2, session: 3, camera: 'snapshot', job: 'Gridfinity_bins_3x2', openPrint: 212,
|
||||
containers: [{ id: 'p1-ams1', kind: 'ams', label: 'AMS 1', cap: 4 }, { id: 'p1-ext', kind: 'external', label: 'External', cap: 1 }] },
|
||||
{ id: 2, name: 'H2C - Constipation Orion', model: 'H2C', ip: '10.60.1.202', serial: '31B••••••••0093', state: 'FINISH', connected: true, lifecycle: 'ready', stale: false, age: 4, session: 2, camera: 'snapshot', job: '', openPrint: null,
|
||||
containers: [{ id: 'p2-ams1', kind: 'ams', label: 'AMS 1', cap: 4 }, { id: 'p2-ext', kind: 'external', label: 'External', cap: 1 }] },
|
||||
{ id: 3, name: 'A1 - Jordyns Layer Slayer', model: 'A1', ip: '10.60.1.x (sample)', serial: '039••••••••7710', state: 'RUNNING', connected: false, lifecycle: 'reconnecting', stale: true, age: 366, session: 14, camera: 'snapshot', job: 'Coaster_set', openPrint: 213,
|
||||
containers: [{ id: 'p3-lite', kind: 'ams_lite', label: 'AMS Lite', cap: 4 }, { id: 'p3-ext', kind: 'external', label: 'External', cap: 1 }] }
|
||||
];
|
||||
|
||||
/* weight source: typed | sliced | recalled | unknown | null (not known yet)
|
||||
* gaps: null = before counting, 0 = asked and none */
|
||||
var PRINTS = [
|
||||
{ id: 213, job: 'Coaster_set', key: '/coasters/plate_1.gcode', printer: 3, status: 'running', start: '2026-09-18T08:55', end: null, dur: null, gaps: 0, grams: null, wsrc: null, cost: null, spool: null, use: 'personal', open: true },
|
||||
{ id: 212, job: 'Gridfinity_bins_3x2', key: '/gridfinity/bins_3x2.gcode', printer: 1, status: 'running', start: '2026-09-18T08:37', end: null, dur: null, gaps: 0, grams: null, wsrc: null, cost: null, spool: null, use: 'business' },
|
||||
{ id: 211, job: 'Benchy_x4', key: '/benchy-batch/plate_1.gcode', printer: 2, status: 'finished', start: '2026-09-18T02:15', end: '2026-09-18T05:27', dur: 11520, gaps: 1, grams: 46, wsrc: 'sliced', cost: 0.54, spool: 42, use: 'business' },
|
||||
{ id: 210, job: 'Sign_CubeCraft', key: '/signs/cubecraft.gcode', printer: 2, status: 'finished', start: '2026-09-17T21:48', end: '2026-09-17T23:53', dur: 7500, gaps: 0, grams: 64, wsrc: 'sliced', cost: 0.61, spool: null, use: 'business',
|
||||
filaments: [{ slot: 0, spool: 54, grams: 41, hex: '#f4f4f0', cost: 0.61 }, { slot: 1, spool: null, suggested: 51, grams: 23, hex: '#1d4fa8', cost: null }] },
|
||||
{ id: 209, job: 'Cable_clips', key: '/misc/cable_clips.gcode', printer: 3, status: 'unknown', start: '2026-09-17T19:30', end: '2026-09-17T21:02', dur: null, gaps: 1, grams: 12, wsrc: 'typed', cost: 0.27, spool: 47, use: 'personal' },
|
||||
{ id: 208, job: 'Lid_v3', key: '/enclosure/lid_v3.gcode', printer: 1, status: 'failed', start: '2026-09-17T16:02', end: '2026-09-17T16:49', dur: 2820, gaps: 0, grams: 118, wsrc: 'typed', cost: 1.35, spool: 52, use: 'business' },
|
||||
{ id: 207, job: 'Planter_small', key: '/garden/planter_small.gcode', printer: 3, status: 'finished', start: '2026-09-16T10:20', end: '2026-09-16T16:00', dur: 20400, gaps: 0, grams: 212, wsrc: 'recalled', cost: 4.24, spool: 55, use: 'personal' },
|
||||
{ id: 206, job: 'Bracket_M3', key: '/brackets/m3.gcode', printer: 1, status: 'finished', start: '2026-09-16T08:05', end: '2026-09-16T09:17', dur: 4320, gaps: 0, grams: null, wsrc: null, cost: null, spool: null, use: null },
|
||||
{ id: 205, job: 'Filament_clip', key: '/misc/filament_clip.gcode', printer: 1, status: 'finished', start: '2026-09-15T14:40', end: '2026-09-15T15:18', dur: 2280, gaps: 0, grams: 7, wsrc: 'sliced', cost: null, spool: null, suggested: 41, use: 'personal' },
|
||||
{ id: 201, job: 'Test_cube', key: '/calibration/test_cube.gcode', printer: 1, status: 'finished', start: '2026-08-07T14:11', end: '2026-08-07T14:42', dur: 1860, gaps: null, grams: 9, wsrc: 'unknown', cost: 0.10, spool: 52, use: null, unreconciled: true }
|
||||
];
|
||||
|
||||
var LOW = 200;
|
||||
|
||||
/* ── Iteration 3 additions ───────────────────────────────────────────── */
|
||||
/* Lookup lists (Lists screen). `used` = spools referencing the value in the
|
||||
* real 105-spool inventory where known from import/filament_review.tsv. Brand
|
||||
* tares are the last-resort guess the form labels as such. */
|
||||
var LOOKUPS = {
|
||||
brands: [['Sunlu', 35, null], ['Elegoo', 27, null], ['Overture', 7, null], ['TECSONAR', 6, null], ['Polymaker', 4, null], ['Inland', 4, null], ['ZIRO', 3, null], ['iSANMATE', 3, null], ['Sovol', 2, null], ['JAYO', 2, null], ['GratKit', 2, null], ['Creality', 2, null], ['AMOLEN', 2, null], ['TINMORRY', 1, null], ['Kingroon', 1, null], ['Geeetech', 1, null], ['Anycubic', 1, null], ['3DimsFila', 1, null], ['Prusament', 0, null], ['eSun', 0, null], ['Hatchbox', 0, null], ['Bambu Lab', 0, null]],
|
||||
materials: [['PLA', 55], ['PETG', 34], ['ASA', 10], ['TPU', 3], ['PA6', 2], ['ABS', 1]],
|
||||
types: [['Standard', 0], ['CF', 3], ['GF', 4], ['+', 6], ['+ 2.0', 14], ['HF', 0], ['95A', 3]],
|
||||
speeds: [['Standard', 0], ['Rapid', 18], ['High Speed', 9]],
|
||||
finishes: [['Basic', 44], ['Matte', 17], ['Silk', 6], ['Sparkle', 3], ['Translucent', 4], ['Metallic', 0], ['Wood', 5], ['Marble', 12], ['Rock', 1], ['Gradient', 9], ['Glow', 0]],
|
||||
spool_types: [['Generic — 1 kg spool (assumed)', 33, 180, 'estimated'], ['Sunlu — 1 kg plastic', 23, 140, 'community'], ['Elegoo — 1 kg cardboard', 20, 154, 'manufacturer'], ['Sunlu — 250 g mini', 8, 60, 'community'], ['Overture — cardboard, PLA lines', 6, 155, 'community'], ['Refill — bare cardboard core', 4, 35, 'estimated'], ['Inland — PLA Basics cardboard', 4, 143, 'community'], ['Polymaker — 0.5 kg cardboard', 3, 190, 'manufacturer'], ['Polymaker — 1 kg cardboard', 1, 140, 'manufacturer'], ['Overture — cardboard, PETG / ASA', 1, 132, 'community'], ['Bambu Lab — reusable', 0, 250, 'manufacturer'], ['Bambu Lab — cardboard', 0, 196, 'estimated'], ['Hatchbox — 1 kg', 0, 225, 'manufacturer'], ['Creality — Hyper cardboard', 0, 176, 'community']]
|
||||
};
|
||||
|
||||
/* Holder ids as the database numbers them (used on holder labels, /c/:id). */
|
||||
var HOLDER_IDS = { 'p1-ams1': 1, 'p1-ext': 2, 'p2-ams1': 3, 'p2-ext': 4, 'p3-lite': 5, 'p3-ext': 6 };
|
||||
|
||||
/* Access (humans come from Authentik; Extrudex shows its local projection). */
|
||||
var PEOPLE = [
|
||||
{ id: 1, name: 'Joshua King', email: 'owner@cubecraft (sample)', role: 'admin', lastSeen: '2026-09-18T09:40', sessions: [{ created: '2026-09-18T07:02', lastSeen: '2026-09-18T09:40', idleExp: '2026-09-18T17:40', maxExp: '2026-09-25T07:02', revoked: null, you: true }, { created: '2026-09-15T20:11', lastSeen: '2026-09-17T22:03', idleExp: '2026-09-18T06:03', maxExp: '2026-09-22T20:11', revoked: null }] },
|
||||
{ id: 2, name: 'Jordyn', email: 'jordyn@cubecraft (sample)', role: 'operator', lastSeen: '2026-09-17T19:28', sessions: [{ created: '2026-09-17T18:50', lastSeen: '2026-09-17T19:28', idleExp: '2026-09-18T03:28', maxExp: '2026-09-24T18:50', revoked: null }] },
|
||||
{ id: 3, name: 'Guest tablet', email: null, role: 'reader', lastSeen: '2026-09-09T12:00', sessions: [] }
|
||||
];
|
||||
var MACHINES = [
|
||||
{ id: 10, name: 'hermes-gateway', role: 'operator', tokens: [{ id: 21, name: 'gateway 2026-09', created: '2026-09-01T10:00', lastUsed: '2026-09-18T09:39', revoked: null }, { id: 17, name: 'gateway 2026-08', created: '2026-08-03T10:00', lastUsed: '2026-08-31T23:58', revoked: '2026-09-01T10:02' }] },
|
||||
{ id: 11, name: 'uptime-kuma', role: 'reader', tokens: [{ id: 22, name: 'health probe', created: '2026-09-05T14:30', lastUsed: '2026-09-18T09:41', revoked: null }] }
|
||||
];
|
||||
var AUDIT = [
|
||||
{ id: 9031, who: 'Joshua King', action: 'spool.weighed', type: 'spool', rid: 47, ctx: '860 g → 812 g · tare 262 g (spool)', at: '2026-09-18T09:39' },
|
||||
{ id: 9030, who: 'hermes-gateway', action: 'print.spool_set', type: 'print', rid: 211, ctx: 'spool #42 · 46 g deducted at $11.74/kg', at: '2026-09-18T05:28' },
|
||||
{ id: 9029, who: 'Jordyn', action: 'spool.moved', type: 'spool', rid: 55, ctx: 'Shelf D → A1 · AMS Lite', at: '2026-09-17T19:26' },
|
||||
{ id: 9028, who: 'Jordyn', action: 'print.use_type_set', type: 'print', rid: 209, ctx: 'personal', at: '2026-09-17T19:25' },
|
||||
{ id: 9027, who: 'Joshua King', action: 'lookup.updated', type: 'spool_type', rid: 4, ctx: 'Sunlu — 250 g mini · tare 60 g · community', at: '2026-09-17T08:12' },
|
||||
{ id: 9026, who: 'Joshua King', action: 'printer.updated', type: 'printer', rid: 3, ctx: 'ip changed · access code unchanged', at: '2026-09-16T21:40' },
|
||||
{ id: 9025, who: 'Joshua King', action: 'session.revoked', type: 'principal', rid: 3, ctx: '1 local session', at: '2026-09-09T12:05' }
|
||||
];
|
||||
var WEBHOOK = { enabled: true, schedule: 'Mondays 08:00 America/New_York', threshold: 200, target: 'configured (URL kept in a root-only file, never shown)',
|
||||
events: [{ period: '2026-W38', observed: '2026-09-14T08:00', state: 'delivered', attempts: 1, spools: 4, delivered: '2026-09-14T08:00' }, { period: '2026-W37', observed: '2026-09-07T08:00', state: 'delivered', attempts: 3, spools: 3, delivered: '2026-09-07T08:21', lastError: 'HTTP 502 from receiver (retried)' }, { period: '2026-W36', observed: '2026-08-31T08:00', state: 'no_low', attempts: 0, spools: 0 }] };
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 & 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,79 @@
|
||||
<style>
|
||||
.map { padding: var(--space-5); }
|
||||
.map svg { width: 100%; height: auto; display: block; }
|
||||
.map .node rect { fill: var(--surface-raised); stroke: var(--border-control); stroke-width: 1.5; }
|
||||
.map .node.hub rect { fill: var(--accent-wash); stroke: var(--text-accent); stroke-width: 2; }
|
||||
.map .node.page rect { stroke: var(--text-primary); }
|
||||
.map text { fill: var(--text-primary); font-family: var(--font-mono); font-size: 13px; }
|
||||
.map text.s { fill: var(--text-secondary); font-size: 11px; }
|
||||
.map .e { stroke: var(--text-secondary); stroke-width: 1.5; fill: none; }
|
||||
.map .e.hot { stroke: var(--text-accent); stroke-width: 2; }
|
||||
.map .lbl { fill: var(--text-secondary); font-size: 10.5px; }
|
||||
.map a:focus-visible rect { stroke: var(--focus-ring); stroke-width: 3; }
|
||||
.flows { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 330px), 1fr)); gap: var(--space-4); }
|
||||
.flow { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.flow h3 { margin: 0; font-size: 15px; }
|
||||
.flow ol { margin: 0; padding-left: 20px; font-size: 13px; color: var(--text-secondary); display: flex; flex-direction: column; gap: 4px; }
|
||||
.flow ol b { color: var(--text-primary); }
|
||||
.flow .btn { align-self: flex-start; }
|
||||
.compare { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); gap: var(--space-4); }
|
||||
.compare > div { padding: var(--space-4); font-size: 13px; }
|
||||
.compare h3 { margin: 0 0 8px; font-size: var(--fs-label); text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); color: var(--text-secondary); }
|
||||
.compare ul { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 4px; }
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
function node(x, y, w, h, t, s, cls, href) {
|
||||
var inner = '<g class="node ' + cls + '"><rect x="' + x + '" y="' + y + '" width="' + w + '" height="' + h + '" rx="14"/><text x="' + (x + 16) + '" y="' + (y + 26) + '" font-weight="700">' + t + '</text>' + s.map(function (l, i) { return '<text class="s" x="' + (x + 16) + '" y="' + (y + 46 + i * 16) + '">' + l + '</text>'; }).join('') + '</g>';
|
||||
return href ? '<a href="' + href + '">' + inner + '</a>' : inner;
|
||||
}
|
||||
function edge(d, hot, lx, ly, label) { return '<path class="e' + (hot ? ' hot' : '') + '" d="' + d + '" marker-end="url(#ar' + (hot ? 'h' : '') + ')"/>' + (label ? '<text class="lbl" x="' + lx + '" y="' + ly + '">' + label + '</text>' : ''); }
|
||||
function svg() {
|
||||
return '<svg viewBox="0 -50 1100 600" role="img" aria-labelledby="map-t map-d"><title id="map-t">Extrudex iteration 2 flow map</title><desc id="map-d">Four pages share one navigation, a command palette and a detail panel. The Dashboard links into each page\'s filtered views; spools, prints and holders open in the panel from any page.</desc>' +
|
||||
'<defs><marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 10 5 0 10z" fill="var(--text-secondary)"/></marker><marker id="arh" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 10 5 0 10z" fill="var(--text-accent)"/></marker></defs>' +
|
||||
node(30, 30, 230, 110, 'Dashboard', ['“Needs you” queue', 'stock · recent prints', 'printers · running low'], 'page', '01-dashboard.html') +
|
||||
node(310, 30, 230, 110, 'Filament', ['saved views + counts', 'Stock / Weights columns', 'weigh session'], 'page', '02-filament.html') +
|
||||
node(590, 30, 230, 110, 'Prints', ['Needs attention queue', 'All · Running · Archived', 'batch attribute'], 'page', '03-prints.html') +
|
||||
node(870, 30, 200, 110, 'Printers', ['urgency order', 'holders + Load', 'connection state'], 'page', '04-printers.html') +
|
||||
node(250, 300, 600, 130, 'Detail panel (#spool= · #print= · #load=)', ['Spool: Overview · Weigh · Move · Edit (+ 409 / 400 states)', 'Print: what it needs, fixed in place · provenance explained', 'Load: pick a stored spool for a holder with space', 'Opens over any page · Esc / back closes · ↑↓ / j k step through the list'], 'hub', '02-filament.html#spool=47') +
|
||||
node(30, 450, 300, 90, '⌘K / “/” palette', ['spools · prints · printers', '“weigh copper” jumps straight in'], 'hub', null) +
|
||||
node(770, 450, 300, 90, 'Toasts + badges', ['every save confirms what changed', 'nav counts update immediately'], '', null) +
|
||||
edge('M262 85 L306 85', 1, 262, 76, 'views') +
|
||||
edge('M180 30 C180 -18 705 -18 705 26', 1, 395, -8, 'Review N prints') +
|
||||
edge('M110 30 C110 -38 970 -38 970 26', 1, 520, -30, 'Open printer') +
|
||||
edge('M145 140 C145 230 330 250 400 298', 1, 150, 225, 'spool / print rows') +
|
||||
edge('M425 140 L470 298', 1, 452, 230, 'row / ↵') +
|
||||
edge('M705 140 L640 298', 1, 680, 230, 'row / fix') +
|
||||
edge('M970 140 C970 230 860 270 800 298', 1, 900, 235, 'spool · + Load') +
|
||||
edge('M180 450 C180 400 240 380 248 380', 0, 120, 420, 'open anything') +
|
||||
edge('M850 380 C900 400 920 420 920 448', 0, 930, 415, 'after save') +
|
||||
'</svg>';
|
||||
}
|
||||
var FLOWS = [
|
||||
['Morning check (≈1 min)', ['<b>Dashboard</b> opens on “Needs you”', 'Review 3 → <b>Use</b> the same-color suggestion', 'Badge on <b>Prints</b> drops as each one clears'], '01-dashboard.html', 'Start on the Dashboard'],
|
||||
['Weigh the low spools', ['<b>Filament → Low</b> view', '<b>Start weighing</b>: type the scale reading', 'See before → after, then <b>Save & weigh next</b>'], '02-filament.html?f=low', 'Open low stock'],
|
||||
['Load a spool into a printer', ['<b>Printers</b> → A1 AMS Lite → <b>+ Load</b>', 'Filter stored spools, pick one', 'Toast confirms; the holder fill updates'], '04-printers.html#printer=3', 'Open A1'],
|
||||
['Find anything', ['Press <b>/</b> or <b>⌘K</b> on any page', 'Type <i>copper</i>, <i>#47</i> or <i>weigh black</i>', '↵ opens the panel right there'], '02-filament.html', 'Try it on Filament'],
|
||||
['Someone changed it first (409)', ['Spool #47 → <b>Edit</b>', 'Demo state: <b>409 conflict</b>', 'Side-by-side; reapply your change, or discard it'], '02-filament.html#spool=47&tab=edit', 'Open the editor'],
|
||||
['A spool with no empty spool weight', ['<b>Empty spool weight unverified</b> view (7)', 'Weigh tab explains why it can\'t weigh yet', 'Pick a spool type, or weigh the empty spool'], '02-filament.html?f=tare#spool=44&tab=weigh', 'Open #44'],
|
||||
['A printer drops off', ['<b>Printers</b>: A1 sits first with a warning border', 'Toggle <b>dev today / + feature branch</b>', 'What happens on reconnect is spelled out'], '04-printers.html#printer=3', 'Open Printers'],
|
||||
['Label new spools', ['<b>Labels</b> → Needs a label (4)', 'Previews at real 51 × 21 mm proportions', '<b>Download</b>: they\'re marked printed on success'], '06-labels.html', 'Open Labels'],
|
||||
['Load spools at the printer (phone)', ['Scan the <b>AMS Lite</b> holder label', 'Scan spool labels: each loads, returns or moves', 'A full holder says so; nothing changes'], '09-scan-holder.html?c=5', 'Open scan session'],
|
||||
['Weigh from a label (phone)', ['Scan a spool label with no session active', 'See where it lives; type the scale reading', 'Before → after, then <b>Save</b>'], '10-scan-spool.html?s=41', 'Open #41'],
|
||||
['Fix a wrong attribution', ['Print <b>Benchy_x4</b> → <b>Change spool</b>', 'See: 46 g goes back to Elegoo Black', 'Pick the right spool; cost re-rates'], '03-prints.html?tab=all#print=211', 'Open Benchy_x4'],
|
||||
['Add or remove a printer', ['<b>Printers → + Add printer</b>: brand filters models', 'Remove asks for the exact name', 'Refused while its holders still hold spools'], '04-printers.html#premove=3', 'Try removing A1'],
|
||||
['Palette commands', ['<b>/</b> then <i>load copper into p1s ext</i>', 'or <i>attribute bracket to black</i>', '↵ does it; a toast confirms'], '02-filament.html?palette=load%20', 'Open palette'],
|
||||
['When the data is not live', ['Mockup pill (bottom left) → <b>Simulate polling failure</b>', 'Header turns amber, banner says the data\'s age', '<b>Replay first load</b> shows skeletons'], '01-dashboard.html', 'Open Dashboard']
|
||||
];
|
||||
function render() {
|
||||
return '<header class="page-head"><div><h1>Flow map</h1><p class="sum">Iteration 3 · every screen, how they connect, and <b>' + FLOWS.length + ' flows</b> to click through</p></div><div class="actions"><a class="btn" href="../../iteration-2/screens/index.html">← Iteration 2</a><a class="btn btn-primary" href="devices.html">Devices →</a></div></header>' +
|
||||
'<section class="map nm-raised" aria-label="Flow diagram">' + svg() + '</section>' +
|
||||
'<section class="stack" style="gap:12px" aria-labelledby="sc-h"><h2 class="section" id="sc-h" style="margin:0">All screens · <a href="devices.html">see them on phone and tablet →</a></h2><div class="flows">' +
|
||||
[['01-dashboard.html', 'Dashboard', 'Needs you · stock · recent prints'], ['02-filament.html', 'Filament', 'views · weigh session · panel'], ['03-prints.html', 'Prints', 'attention queue · history'], ['04-printers.html', 'Printers', 'holders · load · admin panels'], ['05-lists.html', 'Lists', 'the 5 name parts + spool types'], ['06-labels.html', 'Labels', 'spool + holder QR labels'], ['07-access.html', 'Access', 'people · sessions · API tokens'], ['08-system.html', 'System', 'weekly report · audit · device'], ['09-scan-holder.html', 'Scan: holder', 'phone · load/return/move'], ['10-scan-spool.html', 'Scan: spool', 'phone · weigh from a label']].map(function (s2) { return '<a class="flow nm-raised" style="text-decoration:none;color:inherit;padding:14px 16px" href="' + s2[0] + '"><h3>' + s2[1] + ' →</h3><span class="small muted">' + s2[2] + '</span></a>'; }).join('') + '</div></section>' +
|
||||
'<section aria-labelledby="fl-h" class="stack" style="gap:12px"><h2 class="section" id="fl-h" style="margin:0">Try these flows</h2><div class="flows">' + FLOWS.map(function (f) { return '<article class="flow nm-raised"><h3>' + f[0] + '</h3><ol>' + f[1].map(function (s) { return '<li>' + s + '</li>'; }).join('') + '</ol><a class="btn btn-sm" href="' + f[2] + '">' + f[3] + ' →</a></article>'; }).join('') + '</div></section>' +
|
||||
'<section class="compare" aria-label="What changed"><div class="nm-inset"><h3>Iteration 1</h3><ul><li>“Prints” and “All prints” were dead ends</li><li>Edit left the list for a separate page</li><li>A spool in an AMS slot or low-stock list went nowhere</li><li>Every data point was a loud chip, so a legend was needed</li><li>No freshness, loading or error state</li><li>Invented “slot 2” positions the database doesn\'t store</li></ul></div>' +
|
||||
'<div class="nm-raised"><h3>Iteration 2</h3><ul><li>Every spool, print and holder opens one shared panel, deep-linkable</li><li>Dashboard leads with what needs doing, each item one click from the fix</li><li>Chips only for state; provenance is quiet text with a tooltip</li><li>Palette, badges, toasts, j/k stepping, Esc/back to close</li><li>“Live · updated 3s ago”, with an honest offline state</li><li>Holders show fill (“3 / 4”), not tray positions</li></ul></div></section>';
|
||||
}
|
||||
X.init({ name: 'map' }, render);
|
||||
})();
|
||||
</script>
|
||||
Reference in New Issue
Block a user