Two operator-visible defects and three doc corrections, closing the tail of the 2026-09-03 run. THE BADGE. The two kiosks read the same physical badges and disagreed. The parts kiosk understood both shapes a scanner emits - the bare SSO and the PayNo wrapper 0<digits>BZ - while the USB kiosk compared the raw string to an SSO, so the wrapper resolved to nobody. An operator whose keyboard-wedge scanner emits it could take a 3D-printed part and could not check out a drive, with the same badge, at two cabinets a few metres apart. The shape now lives in shopdb/utils/badges.py on the contract surface, because a plugin may not import another plugin and because a badge is a property of the site rather than of a cabinet. Both kiosks use it, and both store the DIGITS rather than the string that arrived: one row per spelling makes a custody history that cannot be joined to itself. That closed a subtler hole in the same place. str.isdigit() is true for Arabic-Indic and other decimal forms, and int() parses them, so a badge written in those characters was accepted, matched a REAL employee, and was then stored as an SSO string nothing else matches. ASCII digits only. An empty scan and an unreadable one now get different answers, because they are different problems for the person at the cabinet. THE IMAGES. /api/applications/image/<filename> and /api/models/image/<filename> required a permission, and a browser fetching <img src="..."> sends no Authorization header - so the gate did not protect the file, it guaranteed the picture never appeared. Both are ungated now, like the four sibling image routes (branding logo, map blueprint, employee photo, part picture), and registered in PUBLIC_ENDPOINTS with that reasoning. /api/printers/lookup?fqdn= never matched anything: both arguments were compared against Communication.ipaddress, so a hostname lookup 404d on printers whose hostname was on the record. An FQDN now matches the hostname, with a fallback to the short name. The slide manager gained the per-slide hold time. The PATCH route and the feed have always carried `seconds`; nothing called it, so the one documented per-slide control was curl-only. Docs: API-REFERENCE listed the employee lookups and the whole notifications surface as unauthenticated when they answer 401, and called both kiosks "deliberately open" when they are off by default and refuse anything without the site's device token. The installer download says applications.download, which is what the code requires. api-inventory.json said printers/lookup was jwt-optional. Fourteen tests. The badge rule verified by planting the old per-kiosk behaviour: three fail, one in each kiosk. Scope: product
368 lines
10 KiB
Vue
368 lines
10 KiB
Vue
<template>
|
|
<div>
|
|
<div class="page-header">
|
|
<h1>Slides</h1>
|
|
<router-link to="/settings" class="btn btn-secondary">Back to Settings</router-link>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="tabs">
|
|
<button
|
|
v-for="s in surfaces"
|
|
:key="s.key"
|
|
class="tab"
|
|
:class="{ active: surface === s.key }"
|
|
@click="switchSurface(s.key)"
|
|
>{{ s.label }}</button>
|
|
</div>
|
|
|
|
<div class="toolbar">
|
|
<label class="btn btn-primary upload-btn">
|
|
{{ uploading ? 'Uploading...' : 'Upload Images' }}
|
|
<input type="file" accept="image/*" multiple hidden :disabled="uploading" @change="onUpload" />
|
|
</label>
|
|
<button
|
|
class="btn btn-danger"
|
|
:disabled="!selected.length"
|
|
@click="deleteSelected"
|
|
>Delete Selected ({{ selected.length }})</button>
|
|
<span class="hint">Order top-to-bottom is play order. Images show on the {{ surfaceLabel }}.</span>
|
|
</div>
|
|
|
|
<div v-if="loading" class="muted">Loading...</div>
|
|
<div v-else-if="!slides.length" class="muted empty">No slides yet. Upload some images.</div>
|
|
|
|
<div v-else class="slide-list">
|
|
<div v-for="(slide, idx) in slides" :key="slide.slideid" class="slide-row"
|
|
:class="{ dragging: dragIndex === idx, 'drop-target': dropIndex === idx && dragIndex !== idx }"
|
|
draggable="true"
|
|
@dragstart="onDragStart(idx, $event)"
|
|
@dragover.prevent="dropIndex = idx"
|
|
@dragleave="onDragLeave(idx)"
|
|
@drop.prevent="onDrop(idx)"
|
|
@dragend="onDragEnd">
|
|
<input type="checkbox" class="pick" :value="slide.filename" v-model="selected" />
|
|
<span class="grip" title="Drag to reorder">⋮⋮</span>
|
|
<span class="order-num">{{ idx + 1 }}</span>
|
|
<img :src="withBase(slide.url)" :alt="slide.filename" class="thumb"
|
|
@mouseenter="previewSlide = slide" @mouseleave="previewSlide = null" />
|
|
<span class="fname" :title="slide.filename">{{ slide.filename }}</span>
|
|
<!-- Per-slide hold time. The API and the feed have always carried
|
|
`seconds`, and the manager offered no way to set it, so the one
|
|
documented per-slide control was API-only. 0 means "use the
|
|
default interval", which is what the feed already does. -->
|
|
<label class="seconds" title="Seconds to hold this slide (0 = default)">
|
|
<input type="number" min="0" max="600" :value="slide.seconds"
|
|
@change="setSeconds(slide, $event.target.value)" />
|
|
<span class="seconds-unit">s</span>
|
|
</label>
|
|
<div class="move">
|
|
<button class="btn btn-sm btn-secondary" :disabled="idx === 0" @click="move(idx, -1)" title="Move up">↑</button>
|
|
<button class="btn btn-sm btn-secondary" :disabled="idx === slides.length - 1" @click="move(idx, 1)" title="Move down">↓</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!--
|
|
Hover preview. A 120x68 thumbnail is cropped (object-fit: cover) and far
|
|
too small to read the text on a slide, so picking the right one meant
|
|
opening images by hand. Fixed position rather than inside the row: a
|
|
relatively-positioned parent would clip it, and the list scrolls.
|
|
Pointer-events none so it can never sit between the cursor and a button.
|
|
-->
|
|
<div v-if="previewSlide" class="slide-preview">
|
|
<img :src="withBase(previewSlide.url)" :alt="previewSlide.filename" />
|
|
<div class="slide-preview-name">{{ previewSlide.filename }}</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { slidesApi } from '@/api'
|
|
import { withBase } from '@/utils/basePath'
|
|
import { useToast } from '@/composables/toast'
|
|
import { apiError } from '@/utils/apiError'
|
|
|
|
// Slide currently hovered, shown enlarged. Null when the cursor is elsewhere.
|
|
const previewSlide = ref(null)
|
|
|
|
// Drag reordering. dragIndex is the row being carried, dropIndex the row it is
|
|
// currently over. Both null when no drag is in progress.
|
|
const dragIndex = ref(null)
|
|
const dropIndex = ref(null)
|
|
|
|
function onDragStart(idx, event) {
|
|
dragIndex.value = idx
|
|
// The hover preview would otherwise sit over the list for the whole drag.
|
|
previewSlide.value = null
|
|
if (event.dataTransfer) {
|
|
event.dataTransfer.effectAllowed = 'move'
|
|
// Firefox starts no drag at all unless some data is set.
|
|
event.dataTransfer.setData('text/plain', String(idx))
|
|
}
|
|
}
|
|
|
|
function onDragLeave(idx) {
|
|
// Only clear when leaving the row that is actually marked, or the row being
|
|
// entered next would clear its own highlight.
|
|
if (dropIndex.value === idx) dropIndex.value = null
|
|
}
|
|
|
|
function onDragEnd() {
|
|
dragIndex.value = null
|
|
dropIndex.value = null
|
|
}
|
|
|
|
async function onDrop(target) {
|
|
const from = dragIndex.value
|
|
onDragEnd()
|
|
if (from === null || from === target) return
|
|
await reorderTo(from, target)
|
|
}
|
|
|
|
// Shared by the drag handler and the up/down buttons, so both persist the same
|
|
// way and a failure recovers the same way.
|
|
async function reorderTo(from, to) {
|
|
const arr = slides.value.slice()
|
|
const [item] = arr.splice(from, 1)
|
|
arr.splice(to, 0, item)
|
|
slides.value = arr
|
|
try {
|
|
await slidesApi.reorder(surface.value, arr.map(s => s.filename))
|
|
} catch (err) {
|
|
console.error('Reorder failed:', err)
|
|
// The list on screen no longer matches the server; reload rather than
|
|
// leave an order that looks saved and is not.
|
|
await load()
|
|
}
|
|
}
|
|
|
|
const surfaces = [
|
|
{ key: 'lobby', label: 'Lobby Display' },
|
|
{ key: 'shopfloor', label: 'Shopfloor Screensaver' }
|
|
]
|
|
const surface = ref('lobby')
|
|
const toast = useToast()
|
|
const slides = ref([])
|
|
const selected = ref([])
|
|
const loading = ref(true)
|
|
const uploading = ref(false)
|
|
|
|
const surfaceLabel = computed(() => surfaces.find(s => s.key === surface.value)?.label || surface.value)
|
|
|
|
async function load() {
|
|
loading.value = true
|
|
selected.value = []
|
|
try {
|
|
const response = await slidesApi.list(surface.value)
|
|
slides.value = response.data.data || []
|
|
} catch (err) {
|
|
console.error('Error loading slides:', err)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function switchSurface(key) {
|
|
if (key === surface.value) return
|
|
surface.value = key
|
|
load()
|
|
}
|
|
|
|
async function onUpload(event) {
|
|
const files = Array.from(event.target.files || [])
|
|
if (!files.length) return
|
|
uploading.value = true
|
|
try {
|
|
const formData = new FormData()
|
|
files.forEach(f => formData.append('files', f))
|
|
await slidesApi.upload(surface.value, formData)
|
|
await load()
|
|
} catch (err) {
|
|
console.error('Upload failed:', err)
|
|
} finally {
|
|
uploading.value = false
|
|
event.target.value = ''
|
|
}
|
|
}
|
|
|
|
async function setSeconds(slide, raw) {
|
|
const seconds = Math.max(0, Math.min(600, Number(raw) || 0))
|
|
if (seconds === slide.seconds) return
|
|
try {
|
|
await slidesApi.update(surface.value, slide.slideid, { seconds })
|
|
slide.seconds = seconds
|
|
toast.success(seconds
|
|
? `${slide.filename} holds for ${seconds}s`
|
|
: `${slide.filename} uses the default interval`)
|
|
} catch (err) {
|
|
console.error('Error setting slide seconds:', err)
|
|
toast.error(apiError(err, 'Could not set the hold time'))
|
|
await load()
|
|
}
|
|
}
|
|
|
|
async function move(idx, delta) {
|
|
const target = idx + delta
|
|
if (target < 0 || target >= slides.value.length) return
|
|
await reorderTo(idx, target)
|
|
}
|
|
|
|
async function deleteSelected() {
|
|
if (!selected.value.length) return
|
|
if (!confirm(`Delete ${selected.value.length} slide(s)?`)) return
|
|
try {
|
|
await slidesApi.remove(surface.value, selected.value)
|
|
await load()
|
|
} catch (err) {
|
|
console.error('Delete failed:', err)
|
|
}
|
|
}
|
|
|
|
onMounted(load)
|
|
</script>
|
|
|
|
<style scoped>
|
|
.seconds {
|
|
align-items: center;
|
|
display: inline-flex;
|
|
gap: 0.25rem;
|
|
}
|
|
.seconds input {
|
|
width: 4.5rem;
|
|
}
|
|
.seconds-unit {
|
|
color: var(--text-light);
|
|
}
|
|
.tabs {
|
|
display: flex;
|
|
gap: 4px;
|
|
border-bottom: 1px solid var(--border);
|
|
margin-bottom: 16px;
|
|
}
|
|
.tab {
|
|
padding: 10px 18px;
|
|
border: none;
|
|
background: none;
|
|
color: var(--text-light);
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
border-bottom: 3px solid transparent;
|
|
}
|
|
.tab.active {
|
|
color: var(--text);
|
|
border-bottom-color: var(--primary);
|
|
}
|
|
.toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
margin-bottom: 18px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.upload-btn { position: relative; cursor: pointer; }
|
|
.hint { color: var(--text-light); font-size: 0.85rem; }
|
|
.muted { color: var(--text-light); }
|
|
.empty { padding: 30px 0; text-align: center; }
|
|
|
|
.slide-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
.slide-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 8px 12px;
|
|
background: var(--bg);
|
|
}
|
|
.slide-row .pick { flex-shrink: 0; }
|
|
.order-num {
|
|
width: 1.6rem;
|
|
flex-shrink: 0;
|
|
text-align: center;
|
|
font-weight: 700;
|
|
color: var(--text-light);
|
|
}
|
|
.grip {
|
|
cursor: grab;
|
|
color: var(--text-light);
|
|
letter-spacing: -3px;
|
|
user-select: none;
|
|
padding: 0 0.25rem;
|
|
}
|
|
|
|
.slide-row {
|
|
transition: background 0.12s, opacity 0.12s;
|
|
}
|
|
|
|
.slide-row.dragging {
|
|
opacity: 0.4;
|
|
}
|
|
|
|
/* A line on the edge being dropped against, rather than moving rows around
|
|
under the cursor - that reads as the list fighting the drag. */
|
|
.slide-row.drop-target {
|
|
box-shadow: inset 0 2px 0 0 var(--primary);
|
|
}
|
|
|
|
.thumb {
|
|
width: 120px;
|
|
height: 68px;
|
|
flex-shrink: 0;
|
|
object-fit: cover;
|
|
border-radius: 4px;
|
|
background: #000;
|
|
cursor: zoom-in;
|
|
}
|
|
|
|
.slide-preview {
|
|
position: fixed;
|
|
top: 50%;
|
|
right: 2rem;
|
|
transform: translateY(-50%);
|
|
z-index: 1000;
|
|
padding: 0.5rem;
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
|
pointer-events: none;
|
|
}
|
|
|
|
.slide-preview img {
|
|
display: block;
|
|
/* Bounded by the viewport, not the image: a 3300x2550 slide would otherwise
|
|
fill the screen. object-fit contain keeps the whole slide visible, since
|
|
the point is reading it rather than filling the box. */
|
|
max-width: min(46vw, 900px);
|
|
max-height: 76vh;
|
|
object-fit: contain;
|
|
background: #000;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.slide-preview-name {
|
|
margin-top: 0.4rem;
|
|
font-size: 0.85rem;
|
|
color: var(--text-light);
|
|
text-align: center;
|
|
word-break: break-all;
|
|
}
|
|
.fname {
|
|
flex: 1;
|
|
min-width: 0;
|
|
font-size: 0.82rem;
|
|
font-family: monospace;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.move { display: flex; gap: 4px; flex-shrink: 0; }
|
|
</style>
|