add mobile putaway web server with camera/upload capture
This commit is contained in:
+136
@@ -0,0 +1,136 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="theme-color" content="#0b0f17">
|
||||
<title>Spool Putaway</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||
html, body { margin: 0; height: 100%; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
background: #0b0f17; color: #e5e7eb; -webkit-text-size-adjust: 100%;
|
||||
}
|
||||
.wrap {
|
||||
max-width: 560px; margin: 0 auto;
|
||||
padding: max(16px, env(safe-area-inset-top)) 16px calc(16px + env(safe-area-inset-bottom));
|
||||
min-height: 100dvh; display: flex; flex-direction: column; gap: 16px;
|
||||
}
|
||||
#result { flex: 1; display: flex; flex-direction: column; gap: 16px; justify-content: center; }
|
||||
#buttons { display: flex; flex-direction: column; gap: 14px; }
|
||||
.btn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.4rem; font-weight: 650; padding: 28px; border-radius: 18px;
|
||||
border: none; background: #2563eb; color: #fff; cursor: pointer; width: 100%;
|
||||
}
|
||||
.btn.secondary { background: #1f2937; color: #e5e7eb; }
|
||||
.btn:active { filter: brightness(.9); }
|
||||
input[type=file] { position: absolute; width: 1px; height: 1px; opacity: 0; }
|
||||
|
||||
.busy { display: flex; flex-direction: column; align-items: center; gap: 18px; }
|
||||
.spinner { width: 64px; height: 64px; border: 6px solid #1f2937; border-top-color: #2563eb; border-radius: 50%; animation: spin 1s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.busyText { color: #9ca3af; font-size: 1.1rem; }
|
||||
.timer { color: #4b5563; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.card { background: #111827; border: 1px solid #1f2937; border-radius: 18px; padding: 18px; }
|
||||
.hero { text-align: center; padding: 24px 18px; }
|
||||
.hero .label { color: #9ca3af; font-size: 1rem; letter-spacing: .03em; }
|
||||
.hero .loc { font-size: 6rem; font-weight: 800; line-height: 1; margin: 6px 0; }
|
||||
.hero.ok .loc { color: #34d399; }
|
||||
.hero.warn .loc { color: #fbbf24; font-size: 2.4rem; }
|
||||
.status { font-size: 1.25rem; font-weight: 650; }
|
||||
.ok { color: #34d399; } .warn { color: #fbbf24; } .err { color: #f87171; } .muted { color: #9ca3af; }
|
||||
.rows { margin-top: 10px; display: flex; justify-content: space-between; align-items: baseline; font-size: 1.05rem; }
|
||||
.rows .k { color: #9ca3af; }
|
||||
.rows .v { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.big { font-size: 1.6rem; font-weight: 750; }
|
||||
.arrow { color: #6b7280; }
|
||||
.note { font-size: .95rem; color: #9ca3af; }
|
||||
a { color: #60a5fa; }
|
||||
.errcard { border-color: #7f1d1d; background: #1b1113; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div id="result"></div>
|
||||
<div id="buttons">
|
||||
<label class="btn">Photo<input type="file" accept="image/*" id="photo"></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const result = document.getElementById('result');
|
||||
const buttons = document.getElementById('buttons');
|
||||
const photo = document.getElementById('photo');
|
||||
let tick = null;
|
||||
|
||||
function upload(file) {
|
||||
buttons.style.display = 'none';
|
||||
result.innerHTML = '<div class="busy"><div class="spinner"></div><div class="busyText">Processing…</div><div class="timer" id="timer">0s</div></div>';
|
||||
photo.value = '';
|
||||
const t0 = Date.now();
|
||||
const timer = document.getElementById('timer');
|
||||
tick = setInterval(() => { timer.textContent = Math.round((Date.now() - t0) / 1000) + 's'; }, 250);
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('photo', file);
|
||||
fetch('/process', { method: 'POST', body: fd })
|
||||
.then(r => r.json())
|
||||
.then(render)
|
||||
.catch(() => render({ error: 'network error — try again' }))
|
||||
.finally(() => { clearInterval(tick); buttons.style.display = 'flex'; });
|
||||
}
|
||||
|
||||
photo.addEventListener('change', e => e.target.files[0] && upload(e.target.files[0]));
|
||||
|
||||
const g = n => Math.round(n) + ' g';
|
||||
|
||||
function render(res) {
|
||||
if (res.error) {
|
||||
result.innerHTML = '<div class="card errcard"><div class="status err">Couldn\'t process</div><p class="note">' + escapeHtml(res.error) + '</p></div>';
|
||||
return;
|
||||
}
|
||||
const hasLoc = res.location && res.location.length;
|
||||
const nw = res.new_weight || {}, pw = res.previous_weight || {};
|
||||
|
||||
let html = '<div class="card hero ' + (hasLoc ? 'ok' : 'warn') + '">' +
|
||||
'<div class="label">Put it away at</div>' +
|
||||
'<div class="loc">' + (hasLoc ? escapeHtml(res.location) : 'no location') + '</div></div>';
|
||||
|
||||
let status, rows = '';
|
||||
if (res.previous_weight === undefined) {
|
||||
status = '<div class="status warn">spooldb unavailable</div>';
|
||||
if (nw.total != null) rows += row('On the scale', g(nw.total), true);
|
||||
} else if (res.updated) {
|
||||
status = '<div class="status ok">Updated</div>';
|
||||
rows += '<div class="rows"><span class="k">Filament</span><span class="v big">' +
|
||||
g(pw.filament) + ' <span class="arrow">→</span> ' + g(nw.filament) + '</span></div>';
|
||||
} else {
|
||||
status = '<div class="status muted">Already up to date</div>';
|
||||
rows += row('Filament remaining', g(nw.filament), true);
|
||||
}
|
||||
if (nw.total != null) rows += row('On the scale', g(nw.total));
|
||||
html += '<div class="card">' + status + rows + '</div>';
|
||||
|
||||
if (typeof res.confidence === 'number' && res.confidence < 0.6) {
|
||||
html += '<div class="card"><div class="status warn">Low confidence</div>' +
|
||||
'<p class="note">Double-check the scale. Votes: ' + (res.vote_weights || []).join(', ') + ' g</p></div>';
|
||||
}
|
||||
if (res.url) html += '<p class="note" style="text-align:center"><a href="' + res.url + '" target="_blank">' + escapeHtml(res.spool_id || 'spool') + '</a></p>';
|
||||
|
||||
result.innerHTML = html;
|
||||
}
|
||||
|
||||
function row(k, v, big) {
|
||||
return '<div class="rows"><span class="k">' + k + '</span><span class="v ' + (big ? 'big' : '') + '">' + v + '</span></div>';
|
||||
}
|
||||
function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user