USB kiosk: drive lists in two columns, in the order the labels read

The kiosk was a single column of full-width rows top-aligned on the page, so a
cabinet of a dozen drives meant scrolling a wall panel, and the page itself
scrolled - which takes the "Your drives" heading off screen and leaves somebody
looking at a list with no idea whose drives they are. The lists now sit in a
grid that fills the width and scroll INSIDE themselves against a bounded
height, with the panel centred as one block the way the parts kiosk beside it
already is. Two kiosks on the same floor that sit differently on their screens
read as two different products.

Drive ids sorted as text, which puts USB-10 ahead of USB-3. Compared with a
numeric collator so the list runs in the order the labels on the drives read.
This commit is contained in:
cproudlock
2026-08-27 10:20:03 -04:00
parent 3c9b400db4
commit add310e4e9

View File

@@ -42,8 +42,9 @@
<section v-else-if="step === 'pick'" class="kiosk-step kiosk-wide">
<p class="kiosk-greeting">{{ session.name }}</p>
<div v-if="session.yours.length" class="drive-group">
<div v-if="session.yours.length" class="drive-group drive-group-yours">
<h2>Your drives</h2>
<div class="drive-list">
<button v-for="drive in session.yours" :key="drive.device_id"
class="drive-row" :disabled="busy"
@click="act(drive, 'checkin')">
@@ -53,6 +54,7 @@
</span>
<span class="drive-action return">Return</span>
</button>
</div>
</div>
<div class="drive-group">
@@ -60,6 +62,7 @@
<p v-if="!session.available.length" class="kiosk-hint">
No drives are free right now.
</p>
<div v-else class="drive-list">
<button v-for="drive in session.available" :key="drive.device_id"
class="drive-row" :disabled="busy"
@click="act(drive, 'checkout')">
@@ -70,6 +73,7 @@
</span>
<span class="drive-action take">Take</span>
</button>
</div>
</div>
<!-- Other people's drives are behind a tap, not on the main screen: a
@@ -87,6 +91,7 @@
<p class="kiosk-hint">
Returning one puts it back and records that YOU handed it in.
</p>
<div class="drive-list">
<button v-for="drive in others" :key="drive.device_id"
class="drive-row" :disabled="busy"
@click="act(drive, 'checkin', true)">
@@ -96,6 +101,7 @@
</span>
<span class="drive-action return">Return</span>
</button>
</div>
<button class="btn btn-secondary act-button" @click="step = 'pick'">
Back
</button>
@@ -149,6 +155,14 @@ function focusEntry (event) {
nextTick(() => entryInput.value?.focus())
}
// Drive ids sort as TEXT everywhere else, which puts -10 before -3 and reads as
// a jumble on a wall panel. Compare the numeric runs numerically; falls back to
// plain text for ids that carry no digits.
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
function byDeviceId (a, b) {
return collator.compare(a.device_id || '', b.device_id || '')
}
function messageFrom (err, fallback) {
return err.response?.data?.data?.error?.message || fallback
}
@@ -160,7 +174,10 @@ async function startSession (value) {
busy.value = true
try {
const response = await api.post('/usb/kiosk/session', { badge: scanned })
session.value = response.data.data
const data = response.data.data
data.yours = (data.yours || []).sort(byDeviceId)
data.available = (data.available || []).sort(byDeviceId)
session.value = data
badge.value = scanned
manualBadge.value = ''
step.value = 'pick'
@@ -177,7 +194,7 @@ async function loadOthers () {
busy.value = true
try {
const response = await api.post('/usb/kiosk/others', { badge: badge.value })
others.value = response.data.data || []
others.value = (response.data.data || []).sort(byDeviceId)
step.value = 'others'
} catch (othersError) {
error.value = messageFrom(othersError, 'Could not load those drives')
@@ -211,7 +228,10 @@ async function act (drive, action, forsomeoneelse = false) {
async function refresh () {
try {
const response = await api.post('/usb/kiosk/session', { badge: badge.value })
session.value = response.data.data
const data = response.data.data
data.yours = (data.yours || []).sort(byDeviceId)
data.available = (data.available || []).sort(byDeviceId)
session.value = data
step.value = 'pick'
} catch (refreshError) {
reset()
@@ -232,34 +252,73 @@ function reset () {
</script>
<style scoped>
/* Touch-sized throughout: a wall panel, often operated with gloves on. */
/* Touch-sized throughout: a wall panel, often operated with gloves on.
Laid out to match the parts kiosk beside it - two kiosks on the same floor
that sit differently on their screens read as two different products. */
.usb-kiosk {
min-height: 100vh;
padding: 1.5rem;
padding: 2rem;
background: var(--bg);
color: var(--text);
display: flex;
flex-direction: column;
gap: 1.25rem;
align-items: center;
/* Centered as one block on a wall-mounted display rather than hugging the
top. `safe` so a short screen - or a long drive list - scrolls from the top
instead of clipping the header off the edge, which plain `center` does with
no way to scroll back up. */
justify-content: center;
justify-content: safe center;
gap: 1.5rem;
}
.kiosk-header {
width: 100%;
max-width: 40rem;
display: flex;
justify-content: center;
align-items: center;
justify-content: space-between;
gap: 1rem;
/* Title centered over the panel; Done is pulled out of the flow so it keeps
its top-right corner instead of pushing the title off-center on the steps
that show it. */
position: relative;
}
.kiosk-header .btn {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
}
.kiosk-header h1 { margin: 0; font-size: 2rem; }
.kiosk-step {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
max-width: 34rem;
gap: 1.25rem;
max-width: 40rem;
width: 100%;
margin: 0 auto;
}
.kiosk-wide { max-width: 46rem; align-items: stretch; }
/* The picker holds lists, so its rows stretch - but it keeps the same width as
every other step so the panel does not jump when the step changes.
It is also height-bounded: the lists absorb whatever space is left over and
scroll inside themselves, so the PAGE never scrolls and the heading saying
whose drives these are stays on screen. A fixed max-height cannot do this -
it either clips with room to spare or overflows once both groups are
present, which is exactly what it did. */
.kiosk-wide {
align-items: stretch;
max-height: calc(100vh - 9rem);
min-height: 0;
}
.kiosk-wide .drive-group {
display: flex;
flex-direction: column;
min-height: 0;
}
/* Your own drives size to their content - usually one or two - and the
available list absorbs what is left. Sharing the space evenly clipped a
single row of "Your drives" in half while the other list had room. */
.kiosk-wide .drive-group-yours { flex: 0 0 auto; }
.kiosk-wide .drive-group-yours .drive-list { max-height: 30vh; }
.kiosk-prompt { font-size: 1.5rem; text-align: center; margin: 0; }
.kiosk-greeting { font-size: 1.5rem; margin: 0; text-align: center; }
.kiosk-hint { color: var(--textmuted); text-align: center; margin: 0; }
@@ -276,19 +335,30 @@ function reset () {
.drive-group { width: 100%; }
.drive-group h2 { font-size: 1.2rem; margin: 0 0 0.5rem; }
/* Two columns once there is room, so a cabinet of a dozen drives is one glance
rather than a scroll. The list itself scrolls when it has to - the PAGE must
not, or the person loses the heading telling them whose drives these are. */
.drive-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr));
gap: 0.6rem;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding-right: 0.2rem;
}
.drive-row {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.2rem;
margin-bottom: 0.6rem;
padding: 0.9rem 1rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface, transparent);
border-radius: 0.75rem;
background: var(--bg-card);
color: var(--text);
font-size: 1.2rem;
font-size: 1.05rem;
text-align: left;
cursor: pointer;
}
@@ -307,21 +377,39 @@ function reset () {
.others-button { width: 100%; padding: 0.9rem; margin-top: 0.5rem; }
.entry-panel {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.9rem;
gap: 1rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 1rem;
padding: 1.5rem 2rem;
}
.entry-input {
width: 100%;
padding: 0.8rem 1rem;
font-size: 1.6rem;
width: 16.9rem;
box-sizing: border-box;
font-size: 2.4rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
text-align: center;
padding: 0.5rem 1rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface, transparent);
border-radius: 0.75rem;
background: var(--bg);
color: var(--text);
outline: none;
}
.entry-input:focus { border-color: var(--primary); }
.entry-input::placeholder {
color: var(--text-light);
font-weight: 400;
font-size: 1.4rem;
}
.act-button {
font-size: 1.4rem;
padding: 0.85rem 0;
width: 16.9rem;
border-radius: 0.75rem;
}
.act-button { width: 100%; padding: 1rem; font-size: 1.25rem; }
</style>