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.
329 lines
9.1 KiB
Vue
329 lines
9.1 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>
|
|
<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'
|
|
|
|
// 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 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 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>
|
|
.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>
|