refactor(ui): simplify FieldLens Print desktop workflow
Some checks failed
Desktop Release / Bootstrap updater signing key (push) Has been cancelled
Desktop Release / Windows x64 NSIS (push) Has been cancelled
Desktop Release / Linux x64 DEB + AppImage (push) Has been cancelled
Desktop Release / Publish public desktop release (push) Has been cancelled
Some checks failed
Desktop Release / Bootstrap updater signing key (push) Has been cancelled
Desktop Release / Windows x64 NSIS (push) Has been cancelled
Desktop Release / Linux x64 DEB + AppImage (push) Has been cancelled
Desktop Release / Publish public desktop release (push) Has been cancelled
This commit is contained in:
parent
c192e74229
commit
df058afb5e
280
src/main.ts
280
src/main.ts
@ -96,110 +96,165 @@ let virtualPrinterStatus: VirtualPrinterStatus | null = null
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="shell">
|
||||
<header>
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<div class="mark">FL</div>
|
||||
<div><h1>FieldLens Print</h1><p class="subtitle">Digital barcode capture before the label leaves the printer.</p></div>
|
||||
<div>
|
||||
<h1>FieldLens Print</h1>
|
||||
<p class="subtitle">Capture and inspect print jobs before the printer driver renders them.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="status-pill" class="status"><span class="dot"></span><span>Loading</span></div>
|
||||
</header>
|
||||
|
||||
<div class="grid">
|
||||
<section class="card">
|
||||
<div class="card-head"><h2>Capture</h2><span class="muted">RAW + spool routes</span></div>
|
||||
<div class="card-body">
|
||||
<div class="fields">
|
||||
<label>Capture mode
|
||||
<select id="mode">
|
||||
<option value="raw-proxy">RAW 9100 proxy</option>
|
||||
<option value="spool-folder">Spool / virtual-printer drop</option>
|
||||
<option value="both">Both routes</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Listen address
|
||||
<input id="listenAddr" placeholder="127.0.0.1:9100" />
|
||||
</label>
|
||||
<label>Forward to installed printer
|
||||
<select id="forwardPrinter">
|
||||
<option value="">Choose a printer…</option>
|
||||
</select>
|
||||
<span id="forward-printer-help" class="help">Select the physical printer that should receive the original print bytes.</span>
|
||||
</label>
|
||||
<label>RAW/TCP destination
|
||||
<input id="forwardAddr" placeholder="10.0.0.50:9100" />
|
||||
<span class="help">Auto-filled for network printers. Keep as the manual fallback for USB/shared queues.</span>
|
||||
</label>
|
||||
<label>Spool capture folder
|
||||
<input id="spoolDir" placeholder="C:\\Users\\me\\FieldLensPrint\\spool" />
|
||||
<span class="help">Accepts .zpl, .raw, .prn and .spl drops from a virtual/print-to-file route.</span>
|
||||
</label>
|
||||
<main class="grid">
|
||||
<section class="card route-card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<span class="eyebrow">PRINTER ROUTING</span>
|
||||
<h2>Destination</h2>
|
||||
</div>
|
||||
<div class="printer-setup">
|
||||
<button class="icon-btn" id="refresh-printers" title="Refresh printers" aria-label="Refresh printers">↻</button>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<label class="field-label">Physical printer
|
||||
<select id="forwardPrinter">
|
||||
<option value="">Choose a printer…</option>
|
||||
</select>
|
||||
<span id="forward-printer-help" class="help">Choose the printer that should receive production jobs.</span>
|
||||
</label>
|
||||
|
||||
<div class="route-tiles">
|
||||
<div class="route-tile">
|
||||
<div class="route-icon live-route">P</div>
|
||||
<div>
|
||||
<strong>FieldLens Print</strong>
|
||||
<span>Capture + physical print</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="route-tile">
|
||||
<div class="route-icon test-route">T</div>
|
||||
<div>
|
||||
<strong>FieldLens Test Capture</strong>
|
||||
<span>Full pipeline, no paper</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="printer-state">
|
||||
<span class="state-dot"></span>
|
||||
<div>
|
||||
<strong>System printer</strong>
|
||||
<span id="virtual-printer-state" class="muted">Checking whether FieldLens Print is installed…</span>
|
||||
<small class="muted">Linux creates <b>FieldLens Print</b> for real printing and <b>FieldLens Test Capture</b> for a full capture/render test with no physical output.</small>
|
||||
</div>
|
||||
<div class="actions printer-actions">
|
||||
<button class="btn" id="refresh-printers">Refresh printers</button>
|
||||
<button class="btn blue" id="install-printer">Create / repair FieldLens Print</button>
|
||||
<button class="btn stop" id="remove-printer">Remove printer</button>
|
||||
<span id="virtual-printer-state">Checking FieldLens queues…</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn" id="save">Save settings</button>
|
||||
<button class="btn primary" id="start">Start capture</button>
|
||||
<button class="btn stop" id="stop">Stop</button>
|
||||
|
||||
<div class="actions printer-actions">
|
||||
<button class="btn primary" id="install-printer">Create / repair printers</button>
|
||||
<button class="btn ghost danger" id="remove-printer">Remove</button>
|
||||
</div>
|
||||
|
||||
<details class="advanced">
|
||||
<summary>Advanced capture settings</summary>
|
||||
<div class="advanced-body">
|
||||
<div class="fields">
|
||||
<label>Capture mode
|
||||
<select id="mode">
|
||||
<option value="raw-proxy">RAW 9100 proxy</option>
|
||||
<option value="spool-folder">Spool folder</option>
|
||||
<option value="both">Both routes</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Listen address
|
||||
<input id="listenAddr" placeholder="127.0.0.1:9100" />
|
||||
</label>
|
||||
<label>RAW/TCP destination
|
||||
<input id="forwardAddr" placeholder="10.0.0.50:9100" />
|
||||
</label>
|
||||
<label>Spool capture folder
|
||||
<input id="spoolDir" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="actions compact-actions">
|
||||
<button class="btn ghost" id="save">Save settings</button>
|
||||
<button class="btn ghost" id="start">Start capture</button>
|
||||
<button class="btn ghost danger" id="stop">Stop capture</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head"><h2>FieldLens</h2><span class="muted">Device login</span></div>
|
||||
<section class="card activity-card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<span class="eyebrow">ACTIVITY</span>
|
||||
<h2>FieldLens</h2>
|
||||
</div>
|
||||
<span id="job-hint" class="muted">Waiting for capture</span>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="metrics">
|
||||
<div class="metric"><b id="job-count">0</b><span>Jobs</span></div>
|
||||
<div class="metric"><b id="code-count">0</b><span>Codes</span></div>
|
||||
<div class="metric"><b id="pending-count">0</b><span>Pending</span></div>
|
||||
</div>
|
||||
<div class="login-box">
|
||||
<div id="login-state" class="muted">Not signed in.</div>
|
||||
<div id="login-code" class="login-code"></div>
|
||||
<div class="actions">
|
||||
|
||||
<div class="account-row">
|
||||
<div>
|
||||
<strong>FieldLens account</strong>
|
||||
<span id="login-state" class="muted">Not signed in.</span>
|
||||
<div id="login-code" class="login-code"></div>
|
||||
</div>
|
||||
<div class="inline-actions">
|
||||
<button class="btn ghost" id="sync">Sync</button>
|
||||
<button class="btn blue" id="login">Sign in</button>
|
||||
<button class="btn" id="sync">Sync now</button>
|
||||
</div>
|
||||
</div>
|
||||
<details style="margin-top:14px">
|
||||
<summary class="muted">Connection settings</summary>
|
||||
<div class="fields" style="margin-top:12px">
|
||||
<label class="full">FieldLens API<input id="apiBaseUrl" /></label>
|
||||
<label class="full">Keycloak issuer<input id="issuer" /></label>
|
||||
<label>Desktop client<input id="clientId" /></label>
|
||||
<label>Station ID<input id="stationId" placeholder="optional" /></label>
|
||||
|
||||
<details class="advanced">
|
||||
<summary>Connection & updates</summary>
|
||||
<div class="advanced-body">
|
||||
<div class="fields">
|
||||
<label class="full">FieldLens API<input id="apiBaseUrl" /></label>
|
||||
<label class="full">Keycloak issuer<input id="issuer" /></label>
|
||||
<label>Desktop client<input id="clientId" /></label>
|
||||
<label>Station ID<input id="stationId" placeholder="optional" /></label>
|
||||
</div>
|
||||
|
||||
<div class="update-box">
|
||||
<div>
|
||||
<strong>App updates</strong>
|
||||
<span id="version-label" class="muted">Version …</span>
|
||||
<small id="update-state" class="muted">Checking for updates shortly after startup.</small>
|
||||
</div>
|
||||
<div class="inline-actions">
|
||||
<button class="btn ghost" id="check-update">Check</button>
|
||||
<button class="btn blue" id="install-update" hidden>Install</button>
|
||||
</div>
|
||||
<div id="update-progress" class="update-progress" hidden><span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="update-box">
|
||||
<div>
|
||||
<strong>App updates</strong>
|
||||
<span id="version-label" class="muted">Version …</span>
|
||||
<small id="update-state" class="muted">Checking for updates shortly after startup.</small>
|
||||
</div>
|
||||
<div class="actions update-actions">
|
||||
<button class="btn" id="check-update">Check for updates</button>
|
||||
<button class="btn blue" id="install-update" hidden>Download & restart</button>
|
||||
</div>
|
||||
<div id="update-progress" class="update-progress" hidden><span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card" style="grid-column: 1 / -1">
|
||||
<div class="card-head"><h2>Recent print jobs</h2><span id="job-hint" class="muted">Waiting for capture</span></div>
|
||||
<div class="card-body"><div id="jobs" class="jobs"></div></div>
|
||||
<section class="card jobs-card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<span class="eyebrow">RECENT ACTIVITY</span>
|
||||
<h2>Print jobs</h2>
|
||||
</div>
|
||||
<span class="muted">Newest first</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="jobs" class="jobs"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
`
|
||||
|
||||
@ -285,33 +340,74 @@ async function refreshPrinters() {
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function friendlySource(source: string) {
|
||||
if (source === 'cups-original') return 'Live print'
|
||||
if (source === 'cups-test') return 'Test capture'
|
||||
if (source === 'raw-proxy') return 'RAW proxy'
|
||||
if (source === 'spool-folder') return 'Spool'
|
||||
return source
|
||||
}
|
||||
|
||||
function render() {
|
||||
const pill = el<HTMLDivElement>('status-pill')
|
||||
pill.classList.toggle('live', (status?.running ?? false) && !captureError)
|
||||
const captureLive = (status?.running ?? false) && !captureError
|
||||
pill.classList.toggle('live', captureLive)
|
||||
pill.classList.toggle('error-state', Boolean(captureError))
|
||||
pill.querySelector('span:last-child')!.textContent = captureError
|
||||
? 'Capture error'
|
||||
: status?.running
|
||||
? `Capturing · ${status.mode}`
|
||||
: 'Stopped'
|
||||
: captureLive
|
||||
? 'Capture active'
|
||||
: 'Capture stopped'
|
||||
|
||||
el('job-count').textContent = String(status?.jobs ?? jobs.length)
|
||||
el('code-count').textContent = String(jobs.reduce((sum, job) => sum + job.barcodes.length, 0))
|
||||
el('pending-count').textContent = String(status?.pending ?? 0)
|
||||
el('job-hint').textContent = captureError
|
||||
? `Capture unavailable: ${captureError}`
|
||||
: jobs.length
|
||||
? `${jobs.length} retained locally`
|
||||
: status?.running
|
||||
? 'Ready for print jobs'
|
||||
: 'Capture stopped'
|
||||
? 'Capture needs attention'
|
||||
: status?.running
|
||||
? 'Ready'
|
||||
: 'Stopped'
|
||||
|
||||
el('jobs').innerHTML = jobs.length ? jobs.map((job) => `
|
||||
<article class="job">
|
||||
<div class="job-top"><span>${escapeHtml(job.source)} · ${escapeHtml(job.label)}</span><span>${new Date(job.capturedAt).toLocaleTimeString()}</span></div>
|
||||
<strong>${job.barcodes.length ? `${job.barcodes.length} barcode${job.barcodes.length === 1 ? '' : 's'} detected` : '<span class="warn">No supported barcode command detected</span>'}</strong>
|
||||
<div class="codes">${job.barcodes.map((code) => `<span class="code">${escapeHtml(code.format)} · ${escapeHtml(code.value)}</span>`).join('')}</div>
|
||||
<div class="muted small" style="margin-top:7px">${job.bytes.toLocaleString()} bytes · ${job.forwarded ? 'forwarded' : 'not forwarded'}${job.error ? ` · <span class="error">${escapeHtml(job.error)}</span>` : ''}</div>
|
||||
</article>
|
||||
`).join('') : '<div class="muted">No jobs captured yet.</div>'
|
||||
el('jobs').innerHTML = jobs.length ? jobs.map((job) => {
|
||||
const source = friendlySource(job.source)
|
||||
const isTest = job.source === 'cups-test'
|
||||
const routeText = isTest ? 'No paper' : job.forwarded ? 'Printed' : 'Captured'
|
||||
const routeClass = isTest ? 'test' : job.forwarded ? 'success' : 'neutral'
|
||||
const decodeText = job.barcodes.length
|
||||
? `${job.barcodes.length} code${job.barcodes.length === 1 ? '' : 's'} detected`
|
||||
: 'Captured · no barcode decoded yet'
|
||||
|
||||
return `
|
||||
<article class="job">
|
||||
<div class="job-main">
|
||||
<div class="job-title-row">
|
||||
<span class="source-badge">${escapeHtml(source)}</span>
|
||||
<span class="route-badge ${routeClass}">${routeText}</span>
|
||||
<time>${new Date(job.capturedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</time>
|
||||
</div>
|
||||
<strong class="job-result">${decodeText}</strong>
|
||||
<div class="codes">${job.barcodes.map((code) => `<span class="code"><small>${escapeHtml(code.format)}</small>${escapeHtml(code.value)}</span>`).join('')}</div>
|
||||
${job.error ? `<div class="job-error">${escapeHtml(job.error)}</div>` : ''}
|
||||
</div>
|
||||
<div class="job-meta">
|
||||
<span>${formatBytes(job.bytes)}</span>
|
||||
<span title="${escapeHtml(job.label)}">${escapeHtml(job.label)}</span>
|
||||
</div>
|
||||
</article>
|
||||
`
|
||||
}).join('') : `
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">⇢</div>
|
||||
<strong>No print jobs yet</strong>
|
||||
<span>Send a job to FieldLens Print or FieldLens Test Capture.</span>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
|
||||
591
src/styles.css
591
src/styles.css
@ -1,73 +1,534 @@
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
:root {
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: #e5e7eb;
|
||||
background: #0b1220;
|
||||
color: #eef2f7;
|
||||
background: #0b0f15;
|
||||
font-synthesis: none;
|
||||
}
|
||||
body { margin: 0; min-width: 720px; min-height: 100vh; background: radial-gradient(circle at top left, #172554 0, #0b1220 38%, #070b13 100%); }
|
||||
button, input, select { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
.shell { max-width: 1120px; margin: 0 auto; padding: 28px; }
|
||||
header { display: flex; align-items: center; justify-content: space-between; gap: 18px; margin-bottom: 22px; }
|
||||
.brand { display: flex; align-items: center; gap: 13px; }
|
||||
.mark { width: 38px; height: 38px; border-radius: 11px; display: grid; place-items: center; background: linear-gradient(135deg,#22c55e,#0ea5e9); color: #07111b; font-weight: 900; }
|
||||
h1 { margin: 0; font-size: 22px; letter-spacing: -.02em; }
|
||||
.subtitle { margin: 3px 0 0; color: #94a3b8; font-size: 13px; }
|
||||
.status { display: inline-flex; align-items: center; gap: 8px; border: 1px solid #334155; background: #111827cc; border-radius: 999px; padding: 8px 12px; font-size: 12px; color: #cbd5e1; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: #64748b; }
|
||||
.status.live .dot { background: #22c55e; box-shadow: 0 0 0 4px #22c55e22; }
|
||||
.grid { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(320px, .8fr); gap: 18px; }
|
||||
.card { border: 1px solid #263449; border-radius: 18px; background: #111827d9; box-shadow: 0 18px 50px #0005; overflow: hidden; }
|
||||
.card-head { padding: 18px 20px 14px; border-bottom: 1px solid #223047; display: flex; align-items: center; justify-content: space-between; }
|
||||
.card-head h2 { font-size: 14px; margin: 0; }
|
||||
.card-body { padding: 18px 20px 20px; }
|
||||
.fields { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 14px; }
|
||||
label { display: grid; gap: 7px; color: #94a3b8; font-size: 12px; }
|
||||
label.full { grid-column: 1 / -1; }
|
||||
input, select { width: 100%; border: 1px solid #334155; border-radius: 10px; background: #0b1220; color: #e5e7eb; padding: 10px 11px; outline: none; }
|
||||
input:focus, select:focus { border-color: #38bdf8; box-shadow: 0 0 0 3px #38bdf820; }
|
||||
.help { color: #64748b; font-size: 11px; line-height: 1.45; margin-top: 5px; }
|
||||
.actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 18px; }
|
||||
.btn { border: 1px solid #334155; border-radius: 10px; padding: 9px 13px; background: #182235; color: #e5e7eb; }
|
||||
.btn.primary { background: #16a34a; border-color: #22c55e; color: white; }
|
||||
.btn.stop { background: #3f1d24; border-color: #7f1d1d; color: #fecaca; }
|
||||
.btn.blue { background: #0c4a6e; border-color: #0369a1; }
|
||||
.metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 15px; }
|
||||
.metric { border: 1px solid #263449; border-radius: 12px; padding: 12px; background: #0b1220aa; }
|
||||
.metric b { display: block; font-size: 18px; margin-bottom: 3px; }
|
||||
.metric span { color: #64748b; font-size: 10px; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.login-box { border: 1px solid #263449; border-radius: 12px; padding: 13px; background: #0b1220aa; }
|
||||
.login-code { font: 700 18px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .09em; }
|
||||
.muted { color: #64748b; font-size: 12px; }
|
||||
.jobs { display: grid; gap: 10px; max-height: 385px; overflow: auto; }
|
||||
.job { border: 1px solid #263449; border-radius: 12px; padding: 12px; background: #0b1220aa; }
|
||||
.job-top { display: flex; justify-content: space-between; gap: 10px; font-size: 11px; color: #64748b; }
|
||||
.job strong { display: block; margin: 7px 0 4px; font-size: 13px; }
|
||||
.codes { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.code { font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; background: #052e16; border: 1px solid #14532d; color: #bbf7d0; border-radius: 7px; padding: 4px 6px; }
|
||||
.warn { color: #fbbf24; }
|
||||
.error { color: #fca5a5; }
|
||||
.small { font-size: 11px; }
|
||||
@media (max-width: 860px) {
|
||||
body { min-width: 0; }
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
.fields { grid-template-columns: 1fr; }
|
||||
label.full { grid-column: auto; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 720px;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at 12% -10%, rgba(44, 113, 90, .18), transparent 30rem),
|
||||
#0b0f15;
|
||||
}
|
||||
|
||||
.update-box { margin-top: 14px; padding: 13px; border: 1px solid #263449; border-radius: 12px; background: #0b1220aa; }
|
||||
.update-box > div:first-child { display: grid; gap: 3px; }
|
||||
.update-box strong { font-size: 12px; }
|
||||
.update-box small { line-height: 1.45; }
|
||||
.update-actions { margin-top: 12px; }
|
||||
.update-progress { height: 5px; margin-top: 12px; overflow: hidden; border-radius: 999px; background: #1e293b; }
|
||||
.update-progress span { display: block; width: 0; height: 100%; border-radius: inherit; background: #38bdf8; transition: width .16s ease; }
|
||||
.btn:disabled { cursor: wait; opacity: .55; }
|
||||
button, input, select { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
button:disabled { cursor: wait; opacity: .48; }
|
||||
|
||||
.printer-setup { display: grid; gap: 10px; margin-top: 15px; padding: 13px; border: 1px solid #263449; border-radius: 12px; background: #0b1220aa; }
|
||||
.printer-setup > div:first-child { display: grid; gap: 4px; }
|
||||
.printer-setup strong { font-size: 12px; }
|
||||
.printer-setup small { line-height: 1.45; }
|
||||
.printer-actions { margin-top: 4px; }
|
||||
#virtual-printer-state.ready { color: #86efac; }
|
||||
.shell {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.brand { display: flex; align-items: center; gap: 13px; }
|
||||
|
||||
.mark {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #b9f3d5;
|
||||
color: #092016;
|
||||
font-weight: 900;
|
||||
letter-spacing: -.04em;
|
||||
box-shadow: 0 0 0 1px rgba(185, 243, 213, .18);
|
||||
}
|
||||
|
||||
h1 { margin: 0; font-size: 21px; letter-spacing: -.025em; }
|
||||
h2 { margin: 2px 0 0; font-size: 15px; letter-spacing: -.01em; }
|
||||
|
||||
.subtitle {
|
||||
margin: 3px 0 0;
|
||||
color: #8491a3;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: #667386;
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .14em;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1px solid #28313d;
|
||||
background: #111720;
|
||||
border-radius: 999px;
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #99a5b5;
|
||||
}
|
||||
|
||||
.dot,
|
||||
.state-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex: 0 0 auto;
|
||||
background: #647083;
|
||||
}
|
||||
|
||||
.status.live .dot,
|
||||
#virtual-printer-state.ready + .state-dot { background: #65d69a; }
|
||||
|
||||
.status.live {
|
||||
border-color: #254b3b;
|
||||
color: #b9f3d5;
|
||||
background: #0f211a;
|
||||
}
|
||||
|
||||
.status.error-state {
|
||||
color: #ffc7c7;
|
||||
border-color: #5d3131;
|
||||
background: #231414;
|
||||
}
|
||||
|
||||
.status.error-state .dot { background: #ef7f7f; }
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.15fr) minmax(330px, .85fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid #202833;
|
||||
border-radius: 16px;
|
||||
background: #10151d;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 14px 38px rgba(0, 0, 0, .2);
|
||||
}
|
||||
|
||||
.card-head {
|
||||
min-height: 64px;
|
||||
padding: 15px 18px;
|
||||
border-bottom: 1px solid #1e2630;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.card-body { padding: 18px; }
|
||||
|
||||
.jobs-card { grid-column: 1 / -1; }
|
||||
|
||||
.field-label,
|
||||
label {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
color: #a4afbd;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
label.full { grid-column: 1 / -1; }
|
||||
|
||||
input,
|
||||
select {
|
||||
width: 100%;
|
||||
border: 1px solid #2a3441;
|
||||
border-radius: 9px;
|
||||
background: #0b1016;
|
||||
color: #edf2f7;
|
||||
padding: 10px 11px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus {
|
||||
border-color: #4ca77b;
|
||||
box-shadow: 0 0 0 3px rgba(76, 167, 123, .12);
|
||||
}
|
||||
|
||||
.help {
|
||||
color: #667386;
|
||||
font-size: 10.5px;
|
||||
font-weight: 400;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.route-tiles {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 9px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.route-tile {
|
||||
min-height: 62px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid #222c38;
|
||||
border-radius: 11px;
|
||||
padding: 10px;
|
||||
background: #0d131a;
|
||||
}
|
||||
|
||||
.route-tile > div:last-child {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.route-tile strong { font-size: 11.5px; }
|
||||
.route-tile span { color: #667386; font-size: 10px; }
|
||||
|
||||
.route-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.live-route { background: #143326; color: #8be4b4; }
|
||||
.test-route { background: #18273a; color: #9cc7f4; }
|
||||
|
||||
.printer-state,
|
||||
.account-row,
|
||||
.update-box {
|
||||
margin-top: 12px;
|
||||
border: 1px solid #222c38;
|
||||
border-radius: 11px;
|
||||
background: #0d131a;
|
||||
}
|
||||
|
||||
.printer-state {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 9px;
|
||||
padding: 11px;
|
||||
}
|
||||
|
||||
.printer-state > div {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.printer-state strong,
|
||||
.account-row strong,
|
||||
.update-box strong { font-size: 11px; }
|
||||
|
||||
#virtual-printer-state {
|
||||
color: #718094;
|
||||
font-size: 10.5px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
#virtual-printer-state.ready { color: #82dca9; }
|
||||
.printer-state:has(#virtual-printer-state.ready) .state-dot { background: #65d69a; }
|
||||
|
||||
.actions,
|
||||
.inline-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.printer-actions { margin-top: 12px; }
|
||||
|
||||
.btn,
|
||||
.icon-btn {
|
||||
border: 1px solid #2b3643;
|
||||
border-radius: 9px;
|
||||
background: #151d27;
|
||||
color: #dce4ed;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btn { padding: 9px 12px; }
|
||||
.icon-btn { width: 32px; height: 32px; padding: 0; }
|
||||
|
||||
.btn:hover,
|
||||
.icon-btn:hover { background: #1a2430; }
|
||||
|
||||
.btn.primary {
|
||||
background: #b9f3d5;
|
||||
border-color: #b9f3d5;
|
||||
color: #092016;
|
||||
}
|
||||
|
||||
.btn.blue {
|
||||
background: #173552;
|
||||
border-color: #24577f;
|
||||
color: #d2e9ff;
|
||||
}
|
||||
|
||||
.btn.ghost { background: transparent; }
|
||||
.btn.danger { color: #e99b9b; }
|
||||
|
||||
.fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.advanced {
|
||||
margin-top: 14px;
|
||||
border-top: 1px solid #1e2630;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.advanced summary {
|
||||
cursor: pointer;
|
||||
color: #7d8999;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.advanced-body { padding-top: 13px; }
|
||||
.compact-actions { margin-top: 12px; }
|
||||
|
||||
.metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
border: 1px solid #222c38;
|
||||
border-radius: 11px;
|
||||
padding: 12px;
|
||||
background: #0d131a;
|
||||
}
|
||||
|
||||
.metric b {
|
||||
display: block;
|
||||
margin-bottom: 3px;
|
||||
font-size: 21px;
|
||||
letter-spacing: -.04em;
|
||||
}
|
||||
|
||||
.metric span {
|
||||
color: #667386;
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .1em;
|
||||
}
|
||||
|
||||
.account-row {
|
||||
min-height: 60px;
|
||||
padding: 11px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.account-row > div:first-child {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.login-code {
|
||||
color: #b9f3d5;
|
||||
font: 700 16px ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
letter-spacing: .08em;
|
||||
}
|
||||
|
||||
.muted { color: #667386; font-size: 10.5px; }
|
||||
.small { font-size: 10px; }
|
||||
|
||||
.update-box {
|
||||
padding: 11px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.update-box > div:first-child {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.update-box small { line-height: 1.35; }
|
||||
|
||||
.update-progress {
|
||||
grid-column: 1 / -1;
|
||||
height: 4px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: #1a2430;
|
||||
}
|
||||
|
||||
.update-progress span {
|
||||
display: block;
|
||||
width: 0;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: #65d69a;
|
||||
transition: width .16s ease;
|
||||
}
|
||||
|
||||
.jobs {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.job {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(140px, 260px);
|
||||
gap: 14px;
|
||||
border: 1px solid #202a36;
|
||||
border-radius: 11px;
|
||||
padding: 12px;
|
||||
background: #0c1117;
|
||||
}
|
||||
|
||||
.job-main { min-width: 0; }
|
||||
|
||||
.job-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.job-title-row time {
|
||||
margin-left: auto;
|
||||
color: #596679;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.source-badge,
|
||||
.route-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 22px;
|
||||
border-radius: 999px;
|
||||
padding: 0 8px;
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.source-badge {
|
||||
background: #17202b;
|
||||
color: #a9b5c5;
|
||||
}
|
||||
|
||||
.route-badge.success { background: #123225; color: #8ce1b1; }
|
||||
.route-badge.test { background: #172c43; color: #9fcef5; }
|
||||
.route-badge.neutral { background: #2a2519; color: #e3c47d; }
|
||||
|
||||
.job-result {
|
||||
display: block;
|
||||
margin: 9px 0 7px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.codes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.code {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
font: 11px ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
background: #102b20;
|
||||
border: 1px solid #214b39;
|
||||
color: #bff0d3;
|
||||
border-radius: 7px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.code small {
|
||||
color: #68a984;
|
||||
font-size: 8px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.job-meta {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
color: #596679;
|
||||
font-size: 9.5px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.job-meta span:last-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.job-error {
|
||||
margin-top: 7px;
|
||||
color: #f0a0a0;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
min-height: 150px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 5px;
|
||||
color: #667386;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state strong {
|
||||
color: #a9b5c5;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.empty-state span { font-size: 10.5px; }
|
||||
|
||||
.empty-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid #26313e;
|
||||
border-radius: 50%;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
body { min-width: 0; }
|
||||
.shell { padding: 18px; }
|
||||
.topbar { align-items: flex-start; }
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
.jobs-card { grid-column: auto; }
|
||||
.fields,
|
||||
.route-tiles { grid-template-columns: 1fr; }
|
||||
label.full { grid-column: auto; }
|
||||
.job { grid-template-columns: 1fr; }
|
||||
.job-meta { justify-content: flex-start; text-align: left; }
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user