A cabinet of thirty drives was one long column to read through. The free list now filters from a search box and lays out in as many columns as the panel is wide - four on a wall display, two on a tablet, one on a phone - while the steps that hold a single control stay narrow, because stretching one input across a 24 inch screen reads as a broken page. A drive is found by its id, by what is written on it, or by where it lives. The words can arrive in any order, and "locker" is part of what is searched, so "locker engineer" finds the engineering locker - that is how people say it out loud. Filtering is client-side: every free drive is already on the screen, and a round trip per keystroke on a wall panel would be worse than no filter at all. The matcher lives in its own module with tests rather than inside the view. The box takes focus as the picker opens so somebody can simply start typing, and it is cleared when the kiosk resets - the next person at the cabinet must not inherit a filter and conclude there are no drives left. Also: both this view and the guides blocks coloured muted text with a `--textmuted` variable this app has never defined. The browser dropped the declaration, so every hint, subtitle and locker line was rendering at full strength. The variable is `--text-light`.
489 lines
16 KiB
Vue
489 lines
16 KiB
Vue
<template>
|
|
<div class="usb-kiosk" @click="focusEntry">
|
|
<header class="kiosk-header">
|
|
<h1>USB Drives</h1>
|
|
<button v-if="step !== 'badge'" class="btn btn-secondary" @click="reset">
|
|
Done
|
|
</button>
|
|
</header>
|
|
|
|
<div v-if="disabled" class="kiosk-step">
|
|
<p class="kiosk-prompt">This kiosk is turned off.</p>
|
|
<p class="kiosk-hint">An administrator enables it in Settings under USB.</p>
|
|
</div>
|
|
|
|
<template v-else>
|
|
<div v-if="error" class="kiosk-error">{{ error }}</div>
|
|
|
|
<!--
|
|
Badge FIRST. The drives carry no barcode, so there is nothing to present
|
|
and the screen has to offer a list - which means the list has to cost
|
|
something. A badge that resolves to a real person is the same bar taking
|
|
a drive already has to clear.
|
|
-->
|
|
<section v-if="step === 'badge'" class="kiosk-step">
|
|
<p class="kiosk-prompt">Enter your SSO to start</p>
|
|
<div class="entry-panel">
|
|
<input ref="entryInput" v-model="manualBadge" class="entry-input"
|
|
inputmode="none" autocomplete="off" placeholder="SSO"
|
|
@keydown.enter.prevent="startSession(manualBadge)" />
|
|
<TouchKeypad @digit="manualBadge += $event"
|
|
@clear="manualBadge = ''"
|
|
@backspace="manualBadge = manualBadge.slice(0, -1)" />
|
|
<button class="btn btn-primary act-button"
|
|
:disabled="!manualBadge || busy"
|
|
@click="startSession(manualBadge)">
|
|
{{ busy ? 'Checking...' : 'Next' }}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- the picker -->
|
|
<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 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')">
|
|
<span class="drive-name">
|
|
{{ drive.device_id }}
|
|
<small v-if="drive.device_desc">{{ drive.device_desc }}</small>
|
|
</span>
|
|
<span class="drive-action return">Return</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="drive-group">
|
|
<div class="drive-group-head">
|
|
<h2>Available</h2>
|
|
<!-- Typed, not tapped: this panel has a keyboard, and a cabinet of
|
|
thirty drives is faster to narrow than to read. -->
|
|
<div v-if="session.available.length" class="drive-filter">
|
|
<input ref="filterInput" v-model="drivefilter"
|
|
class="drive-filter-input"
|
|
autocomplete="off" spellcheck="false"
|
|
placeholder="Find a drive, a locker..." />
|
|
<button v-if="drivefilter" class="drive-filter-clear"
|
|
title="Clear" @click="drivefilter = ''">Clear</button>
|
|
</div>
|
|
</div>
|
|
<p v-if="!session.available.length" class="kiosk-hint">
|
|
No drives are free right now.
|
|
</p>
|
|
<p v-else-if="!availabledrives.length" class="kiosk-hint">
|
|
No free drive matches "{{ drivefilter }}".
|
|
</p>
|
|
<div v-else class="drive-list">
|
|
<button v-for="drive in availabledrives" :key="drive.device_id"
|
|
class="drive-row" :disabled="busy"
|
|
@click="act(drive, 'checkout')">
|
|
<span class="drive-name">
|
|
{{ drive.device_id }}
|
|
<small v-if="drive.device_desc">{{ drive.device_desc }}</small>
|
|
<small v-if="drive.locker_location">Locker {{ drive.locker_location }}</small>
|
|
</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
|
|
passer-by should not read the whole cabinet's holders off a wall. -->
|
|
<button v-if="session.otherscount" class="btn btn-secondary others-button"
|
|
:disabled="busy" @click="loadOthers">
|
|
Returning a drive for somebody else?
|
|
({{ session.otherscount }} out with others)
|
|
</button>
|
|
</section>
|
|
|
|
<!-- somebody else's drives -->
|
|
<section v-else-if="step === 'others'" class="kiosk-step kiosk-wide">
|
|
<h2>Checked out to somebody else</h2>
|
|
<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)">
|
|
<span class="drive-name">
|
|
{{ drive.device_id }}
|
|
<small>{{ drive.holdername || 'somebody else' }}</small>
|
|
</span>
|
|
<span class="drive-action return">Return</span>
|
|
</button>
|
|
</div>
|
|
<button class="btn btn-secondary act-button" @click="step = 'pick'">
|
|
Back
|
|
</button>
|
|
</section>
|
|
|
|
<!-- done -->
|
|
<section v-else-if="step === 'done'" class="kiosk-step">
|
|
<p class="kiosk-done">{{ doneMessage }}</p>
|
|
<button class="btn btn-primary act-button" @click="reset">Finish</button>
|
|
</section>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
|
import api, { settingsApi } from '@/api'
|
|
import TouchKeypad from '@/components/TouchKeypad.vue'
|
|
import { filterdrives } from '../drivefilter'
|
|
|
|
const step = ref('badge')
|
|
const badge = ref('')
|
|
const session = ref({ name: '', yours: [], available: [], otherscount: 0 })
|
|
const others = ref([])
|
|
const error = ref('')
|
|
const doneMessage = ref('')
|
|
const busy = ref(false)
|
|
const disabled = ref(false)
|
|
const manualBadge = ref('')
|
|
const entryInput = ref(null)
|
|
let resetTimer = null
|
|
|
|
onMounted(async () => {
|
|
// The kiosk ships off. Reading the flag turns "everything 404s" into a screen
|
|
// that says so - a kiosk merely switched off must not look broken.
|
|
try {
|
|
const response = await settingsApi.get('usb_kiosk_enabled')
|
|
const value = String(response.data?.data?.value ?? '').toLowerCase()
|
|
disabled.value = !(value === 'true' || value === '1')
|
|
} catch (err) {
|
|
// An unreachable setting is not proof it is off; let the API decide.
|
|
disabled.value = false
|
|
}
|
|
focusEntry()
|
|
})
|
|
onBeforeUnmount(() => clearTimeout(resetTimer))
|
|
|
|
function focusEntry (event) {
|
|
const tag = event?.target?.tagName
|
|
if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA'
|
|
|| tag === 'BUTTON' || tag === 'A') return
|
|
nextTick(() => entryInput.value?.focus())
|
|
}
|
|
|
|
// Narrowing the free drives. The matcher lives in its own module so it can be
|
|
// tested without mounting a kiosk.
|
|
const drivefilter = ref('')
|
|
const filterInput = ref(null)
|
|
|
|
// Focused as the picker opens, so somebody can simply start typing. The badge
|
|
// step owns the keyboard until then.
|
|
function focusFilter () {
|
|
nextTick(() => filterInput.value?.focus())
|
|
}
|
|
|
|
const availabledrives = computed(
|
|
() => filterdrives(session.value.available, drivefilter.value))
|
|
|
|
// 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
|
|
}
|
|
|
|
async function startSession (value) {
|
|
const scanned = (value || '').trim()
|
|
if (!scanned || busy.value) return
|
|
error.value = ''
|
|
busy.value = true
|
|
try {
|
|
const response = await api.post('/usb/kiosk/session', { badge: scanned })
|
|
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'
|
|
focusFilter()
|
|
} catch (sessionError) {
|
|
error.value = messageFrom(sessionError, 'Could not look that badge up')
|
|
} finally {
|
|
busy.value = false
|
|
focusEntry()
|
|
}
|
|
}
|
|
|
|
async function loadOthers () {
|
|
error.value = ''
|
|
busy.value = true
|
|
try {
|
|
const response = await api.post('/usb/kiosk/others', { badge: badge.value })
|
|
others.value = (response.data.data || []).sort(byDeviceId)
|
|
step.value = 'others'
|
|
} catch (othersError) {
|
|
error.value = messageFrom(othersError, 'Could not load those drives')
|
|
} finally {
|
|
busy.value = false
|
|
}
|
|
}
|
|
|
|
async function act (drive, action, forsomeoneelse = false) {
|
|
if (busy.value) return
|
|
error.value = ''
|
|
busy.value = true
|
|
try {
|
|
const body = { device_id: drive.device_id, badge: badge.value }
|
|
if (forsomeoneelse) body.forsomeoneelse = true
|
|
const response = await api.post(`/usb/kiosk/${action}`, body)
|
|
doneMessage.value = response.data?.message
|
|
|| (action === 'checkin' ? 'Drive returned' : 'Drive checked out')
|
|
step.value = 'done'
|
|
// Clear the screen for the next person rather than leaving a name up.
|
|
resetTimer = setTimeout(reset, 8000)
|
|
} catch (actError) {
|
|
error.value = messageFrom(actError, 'That did not go through')
|
|
// The list is now out of date either way - somebody else may have taken it.
|
|
await refresh()
|
|
} finally {
|
|
busy.value = false
|
|
}
|
|
}
|
|
|
|
async function refresh () {
|
|
try {
|
|
const response = await api.post('/usb/kiosk/session', { badge: badge.value })
|
|
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()
|
|
}
|
|
}
|
|
|
|
function reset () {
|
|
clearTimeout(resetTimer)
|
|
step.value = 'badge'
|
|
badge.value = ''
|
|
session.value = { name: '', yours: [], available: [], otherscount: 0 }
|
|
others.value = []
|
|
error.value = ''
|
|
doneMessage.value = ''
|
|
manualBadge.value = ''
|
|
// The next person at the cabinet must not inherit somebody else's filter and
|
|
// conclude there are no drives left.
|
|
drivefilter.value = ''
|
|
focusEntry()
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* 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: 2rem;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
display: flex;
|
|
flex-direction: column;
|
|
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: 72rem;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
/* 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 {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 1.25rem;
|
|
max-width: 40rem;
|
|
width: 100%;
|
|
}
|
|
/* The picker holds lists, so its rows stretch, and it is WIDER than the steps
|
|
that hold a single control - a cabinet of thirty drives is three or four
|
|
columns of one glance rather than a scroll. The steps that are just a keypad
|
|
or a message stay narrow, because stretching one input across a 24 inch panel
|
|
reads as a broken page.
|
|
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-width: 72rem;
|
|
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(--text-light); text-align: center; margin: 0; }
|
|
.kiosk-error {
|
|
padding: 0.9rem 1.1rem;
|
|
border: 1px solid var(--danger, #b3261e);
|
|
border-left-width: 5px;
|
|
border-radius: 4px;
|
|
color: var(--danger, #b3261e);
|
|
font-size: 1.1rem;
|
|
text-align: center;
|
|
}
|
|
.kiosk-done { font-size: 1.6rem; text-align: center; margin: 2rem 0; }
|
|
|
|
.drive-group { width: 100%; }
|
|
.drive-group h2 { font-size: 1.2rem; margin: 0 0 0.5rem; }
|
|
/* Heading and filter on one line: the filter belongs to the list under it, and
|
|
a row of its own would cost height the lists need. */
|
|
.drive-group-head {
|
|
display: flex;
|
|
align-items: baseline;
|
|
justify-content: space-between;
|
|
gap: 1rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.drive-filter { display: flex; align-items: center; gap: 0.5rem; }
|
|
.drive-filter-input {
|
|
/* Touch-sized like everything else here, and wide enough for a locker name
|
|
rather than just a drive number. */
|
|
min-width: 16rem;
|
|
padding: 0.6rem 0.8rem;
|
|
font-size: 1.1rem;
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
background: var(--bg-card-solid);
|
|
color: var(--text);
|
|
}
|
|
.drive-filter-input:focus {
|
|
outline: none;
|
|
border-color: var(--primary);
|
|
}
|
|
.drive-filter-clear {
|
|
padding: 0.6rem 0.9rem;
|
|
font-size: 1rem;
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
background: transparent;
|
|
color: var(--text);
|
|
cursor: pointer;
|
|
}
|
|
/* As many columns as the panel has room for - four on a wall display, one on a
|
|
small screen - 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(16rem, 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: 0.9rem 1rem;
|
|
border: 1px solid var(--border);
|
|
border-radius: 0.75rem;
|
|
background: var(--bg-card);
|
|
color: var(--text);
|
|
font-size: 1.05rem;
|
|
text-align: left;
|
|
cursor: pointer;
|
|
}
|
|
.drive-row:disabled { opacity: 0.5; }
|
|
.drive-name { display: flex; flex-direction: column; gap: 0.15rem; }
|
|
.drive-name small { color: var(--text-light); font-size: 0.85rem; }
|
|
.drive-action {
|
|
flex: 0 0 auto;
|
|
padding: 0.5rem 1.1rem;
|
|
border-radius: 4px;
|
|
font-weight: 600;
|
|
font-size: 1rem;
|
|
}
|
|
.take { background: var(--primary, #2f6fed); color: #fff; }
|
|
.return { background: var(--warning, #b26a00); color: #fff; }
|
|
.others-button { width: 100%; padding: 0.9rem; margin-top: 0.5rem; }
|
|
|
|
.entry-panel {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border);
|
|
border-radius: 1rem;
|
|
padding: 1.5rem 2rem;
|
|
}
|
|
.entry-input {
|
|
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: 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;
|
|
}
|
|
</style>
|