mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-29 09:23:16 -06:00
commit
d2feb2a3c5
|
|
@ -28,6 +28,7 @@ async function request(url, options = {}) {
|
||||||
export const api = {
|
export const api = {
|
||||||
// Devices
|
// Devices
|
||||||
getDevices: () => request('/devices'),
|
getDevices: () => request('/devices'),
|
||||||
|
reorderDevices: (order) => request('/devices/reorder', { method: 'POST', body: JSON.stringify({ order }) }),
|
||||||
getDevice: (id) => request(`/devices/${id}`),
|
getDevice: (id) => request(`/devices/${id}`),
|
||||||
updateDevice: (id, data) => request(`/devices/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
updateDevice: (id, data) => request(`/devices/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
deleteDevice: (id) => request(`/devices/${id}`, { method: 'DELETE' }),
|
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 => {
|
document.querySelectorAll('.device-card').forEach(card => {
|
||||||
card.addEventListener('dragstart', (e) => {
|
card.addEventListener('dragstart', (e) => {
|
||||||
e.dataTransfer.setData('text/device-id', card.dataset.deviceId);
|
e.dataTransfer.setData('text/device-id', card.dataset.deviceId);
|
||||||
e.dataTransfer.setData('text/device-name', card.dataset.deviceName || '');
|
e.dataTransfer.setData('text/device-name', card.dataset.deviceName || '');
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
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
|
// #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.
|
// the agency-tokens history). Drop the table on DBs where the short-lived migration ran.
|
||||||
"DROP TABLE IF EXISTS api_token_target_zones",
|
"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"
|
// Apply each ALTER idempotently. A "duplicate column name" / "already exists"
|
||||||
// error means the column is already present (expected on a migrated DB) - benign.
|
// 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
|
ON sc.device_id = latest.device_id AND sc.captured_at = latest.max_at
|
||||||
) s ON d.id = s.device_id
|
) s ON d.id = s.device_id
|
||||||
WHERE d.workspace_id = ?
|
WHERE d.workspace_id = ?
|
||||||
ORDER BY d.created_at ASC
|
ORDER BY d.sort_order ASC, d.created_at ASC
|
||||||
LIMIT ? OFFSET ?
|
LIMIT ? OFFSET ?
|
||||||
`).all(req.workspaceId, limit, offset);
|
`).all(req.workspaceId, limit, offset);
|
||||||
res.json(devices.map(stripDeviceSecrets));
|
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).
|
// List unclaimed provisioning devices (admin only).
|
||||||
// #13: read-only, so platform_operator may view the pool too (cross-org staff
|
// #13: read-only, so platform_operator may view the pool too (cross-org staff
|
||||||
// troubleshooting). Claiming a device is a separate workspace-scoped mutation.
|
// troubleshooting). Claiming a device is a separate workspace-scoped mutation.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue