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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user