Reorder slides by dragging

Reordering meant clicking the up arrow repeatedly - moving a slide from the
bottom of a long playlist to the top was a dozen clicks and a page of
re-rendering. Rows are now draggable, with a grip so it looks it.

Applies to BOTH surfaces: the manager already switches between Lobby Display and
Shopfloor Screensaver, so one change covers the lobby TV and the EventSaver
playlist.

The drop target is shown as a line on the row being dropped against rather than
by shuffling rows under the cursor, which reads as the list fighting the drag.
The hover preview is dismissed when a drag starts, or it would sit over the list
for the whole gesture.

Drag and the up/down buttons now share reorderTo(), so both persist through the
same call and both recover the same way: a failed save reloads from the server
rather than leaving an order on screen that looks saved and is not.

dataTransfer.setData is set because Firefox starts no drag at all without it.
This commit is contained in:
cproudlock
2026-08-06 10:11:09 -04:00
parent 8fd2b3bcd2
commit 1aeb3bd1d4

View File

@@ -33,8 +33,16 @@
<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">
<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">&#8942;&#8942;</span>
<span class="order-num">{{ idx + 1 }}</span>
<img :src="withBase(slide.url)" :alt="slide.filename" class="thumb"
@mouseenter="previewSlide = slide" @mouseleave="previewSlide = null" />
@@ -69,6 +77,57 @@ import { withBase } from '@/utils/basePath'
// 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' }
@@ -120,16 +179,7 @@ async function onUpload(event) {
async function move(idx, delta) {
const target = idx + delta
if (target < 0 || target >= slides.value.length) return
const arr = slides.value.slice()
const [item] = arr.splice(idx, 1)
arr.splice(target, 0, item)
slides.value = arr
try {
await slidesApi.reorder(surface.value, arr.map(s => s.filename))
} catch (err) {
console.error('Reorder failed:', err)
await load()
}
await reorderTo(idx, target)
}
async function deleteSelected() {
@@ -200,6 +250,28 @@ onMounted(load)
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;