mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-29 09:23:16 -06:00
feat(displays): drag-to-reorder display tiles within a section (#106)
Option A: tile-on-tile (same section) reorders; tile-on-section / cross- section stays group-assign (existing behavior untouched). Ordering is cosmetic (dashboard only — nothing the device/player reads). Backend: - Migration: devices.sort_order column (idempotent ALTER; default 0). - GET /api/devices ordering: sort_order ASC, created_at ASC (was created_at). - POST /api/devices/reorder — ordered id array -> transactional UPDATE sort_order=index, scoped WHERE workspace_id = caller's workspace (forged cross-workspace ids are no-ops). Write-gated (viewer read-only). Mirrors the playlist items reorder. Frontend (the collision): - Card-level dragover/drop: reorder ONLY when target is another card in the SAME section; otherwise no-op so the event bubbles to the section's group-assign handler. stopPropagation on the same-section drop prevents the section handler also firing. Drop indicator (inset box-shadow). Native HTML5 DnD; no library. Validated (headless Chrome, synthetic DnD + a section-level drop spy): - SAME-section reorder: section drop suppressed (sectionDrops=0), POST /devices/reorder fires, NO group call, sort_order persists in DB. - CROSS-section: section drop fires (sectionDrops=1), POST /groups/:id/ devices fires and membership actually changes — group-assign unbroken. - The 0-vs-1 contrast proves stopPropagation disambiguates the shared gesture. - 149 server tests green; migration applies clean on the prod-copy DB. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d64244b5ac
commit
5d24c30ea1
|
|
@ -28,6 +28,7 @@ async function request(url, options = {}) {
|
|||
export const api = {
|
||||
// Devices
|
||||
getDevices: () => request('/devices'),
|
||||
reorderDevices: (order) => request('/devices/reorder', { method: 'POST', body: JSON.stringify({ order }) }),
|
||||
getDevice: (id) => request(`/devices/${id}`),
|
||||
updateDevice: (id, data) => request(`/devices/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
deleteDevice: (id) => request(`/devices/${id}`, { method: 'DELETE' }),
|
||||
|
|
|
|||
|
|
@ -622,11 +622,64 @@ function attachGroupHandlers(groupsWithDevices, allDevices) {
|
|||
});
|
||||
}
|
||||
|
||||
// #106: within-section drag-to-reorder. Tracks the in-flight drag (which device,
|
||||
// and which section it started in) so a CARD-level drop can tell reorder (same
|
||||
// section) from group-assign (different section / section background).
|
||||
let dragDeviceId = null;
|
||||
let dragSectionKey = null;
|
||||
const sectionKeyOf = (el) => {
|
||||
const g = el.closest('.group-section');
|
||||
if (g && g.dataset.groupId) return 'g:' + g.dataset.groupId;
|
||||
if (el.closest('[data-ungrouped="1"]')) return 'ungrouped';
|
||||
return null;
|
||||
};
|
||||
const clearDropIndicators = () => document.querySelectorAll('.device-card').forEach(c => { c.style.boxShadow = ''; });
|
||||
|
||||
document.querySelectorAll('.device-card').forEach(card => {
|
||||
card.addEventListener('dragstart', (e) => {
|
||||
e.dataTransfer.setData('text/device-id', card.dataset.deviceId);
|
||||
e.dataTransfer.setData('text/device-name', card.dataset.deviceName || '');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
dragDeviceId = card.dataset.deviceId; // #106
|
||||
dragSectionKey = sectionKeyOf(card); // #106
|
||||
});
|
||||
card.addEventListener('dragend', () => { dragDeviceId = null; dragSectionKey = null; clearDropIndicators(); });
|
||||
|
||||
// #106 within-section reorder. Engages ONLY when the target is another card in the
|
||||
// SAME section; otherwise it no-ops and the event bubbles to the section handler
|
||||
// (group-assign), leaving the existing behavior untouched.
|
||||
card.addEventListener('dragover', (e) => {
|
||||
if (!dragDeviceId || dragDeviceId === card.dataset.deviceId) return;
|
||||
if (sectionKeyOf(card) !== dragSectionKey) return; // cross-section -> section handles (assign)
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // suppress the section's group-assign dragover/highlight
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
const r = card.getBoundingClientRect();
|
||||
const before = e.clientX < r.left + r.width / 2;
|
||||
card.style.boxShadow = before ? 'inset 3px 0 0 var(--primary)' : 'inset -3px 0 0 var(--primary)';
|
||||
});
|
||||
card.addEventListener('dragleave', () => { card.style.boxShadow = ''; });
|
||||
card.addEventListener('drop', async (e) => {
|
||||
if (!dragDeviceId || dragDeviceId === card.dataset.deviceId) return;
|
||||
if (sectionKeyOf(card) !== dragSectionKey) return; // cross-section -> bubble to section (assign)
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // CRITICAL: stop the section's group-assign drop also firing
|
||||
const r = card.getBoundingClientRect();
|
||||
const before = e.clientX < r.left + r.width / 2;
|
||||
clearDropIndicators();
|
||||
const grid = card.closest('.device-grid');
|
||||
if (!grid) return;
|
||||
const ids = Array.from(grid.querySelectorAll('.device-card')).map(c => c.dataset.deviceId).filter(Boolean);
|
||||
const from = ids.indexOf(dragDeviceId);
|
||||
if (from === -1) return;
|
||||
ids.splice(from, 1);
|
||||
let to = ids.indexOf(card.dataset.deviceId);
|
||||
if (!before) to += 1;
|
||||
ids.splice(to, 0, dragDeviceId);
|
||||
try {
|
||||
await api.reorderDevices(ids);
|
||||
loadDashboard();
|
||||
} catch (err) { showToast(err.message, 'error'); }
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -203,6 +203,9 @@ const migrations = [
|
|||
// #73: zone-binding was reverted (placement belongs to the device, not the playlist - see
|
||||
// the agency-tokens history). Drop the table on DBs where the short-lived migration ran.
|
||||
"DROP TABLE IF EXISTS api_token_target_zones",
|
||||
// #106: cosmetic per-workspace display ordering for the Displays view (drag-to-
|
||||
// reorder). Default 0 -> existing devices fall back to the created_at tiebreak.
|
||||
"ALTER TABLE devices ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0",
|
||||
];
|
||||
// Apply each ALTER idempotently. A "duplicate column name" / "already exists"
|
||||
// error means the column is already present (expected on a migrated DB) - benign.
|
||||
|
|
|
|||
|
|
@ -37,12 +37,34 @@ router.get('/', (req, res) => {
|
|||
ON sc.device_id = latest.device_id AND sc.captured_at = latest.max_at
|
||||
) s ON d.id = s.device_id
|
||||
WHERE d.workspace_id = ?
|
||||
ORDER BY d.created_at ASC
|
||||
ORDER BY d.sort_order ASC, d.created_at ASC
|
||||
LIMIT ? OFFSET ?
|
||||
`).all(req.workspaceId, limit, offset);
|
||||
res.json(devices.map(stripDeviceSecrets));
|
||||
});
|
||||
|
||||
// #106: reorder display tiles (cosmetic, within-section). Writes devices.sort_order
|
||||
// = position in the given id array. Workspace-scoped: the UPDATE matches WHERE
|
||||
// workspace_id = the caller's current workspace, so a forged id from another
|
||||
// workspace is silently a no-op (can't reorder or probe devices you can't see).
|
||||
// Write-gated: workspace_viewer (non-acting) is read-only. Ordering affects ONLY the
|
||||
// dashboard listing — nothing the device/player reads (grouping/pairing/playback
|
||||
// are independent). Mirrors the playlist items reorder.
|
||||
router.post('/reorder', (req, res) => {
|
||||
if (!req.workspaceId) return res.status(403).json({ error: 'No workspace' });
|
||||
if (!req.actingAs && req.workspaceRole === 'workspace_viewer') {
|
||||
return res.status(403).json({ error: 'Read-only access' });
|
||||
}
|
||||
const { order } = req.body;
|
||||
if (!Array.isArray(order)) return res.status(400).json({ error: 'order must be an array of device IDs' });
|
||||
const stmt = db.prepare("UPDATE devices SET sort_order = ?, updated_at = strftime('%s','now') WHERE id = ? AND workspace_id = ?");
|
||||
const tx = db.transaction(() => {
|
||||
order.forEach((id, index) => stmt.run(index, id, req.workspaceId));
|
||||
});
|
||||
tx();
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// List unclaimed provisioning devices (admin only).
|
||||
// #13: read-only, so platform_operator may view the pool too (cross-org staff
|
||||
// troubleshooting). Claiming a device is a separate workspace-scoped mutation.
|
||||
|
|
|
|||
Loading…
Reference in a new issue