From 436a3be7f6f03c50311a0dd1cf5d91ffcf5320f3 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Mon, 13 Apr 2026 20:52:29 -0500 Subject: [PATCH] Phase 3: playlist publish/draft state with auto-publish from device detail Schema: add status and published_snapshot columns to playlists table. Migration snapshots all existing playlists as published (idempotent via schema_migrations). Devices always receive the published_snapshot, not live playlist_items. Edits from device-detail/groups auto-publish immediately (display updates instantly). Edits from playlist detail page go to draft (requires explicit publish). POST /playlists/:id/publish snapshots and pushes to all devices. POST /playlists/:id/discard reverts playlist_items from published snapshot. Content deletion scrubs references from all published snapshots. Frontend: draft badge in playlist list, prominent yellow banner with publish/discard buttons on playlist detail and device detail pages. Co-Authored-By: Claude Opus 4.6 --- frontend/js/api.js | 2 + frontend/js/views/device-detail.js | 46 ++++++++++++++ frontend/js/views/playlists.js | 70 +++++++++++++++++++++- server/db/database.js | 46 ++++++++++++++ server/db/schema.sql | 2 + server/routes/assignments.js | 57 ++++++++++-------- server/routes/content.js | 39 ++++++++++-- server/routes/device-groups.js | 31 +++++++++- server/routes/devices.js | 11 +++- server/routes/playlists.js | 96 ++++++++++++++++++++++++++++-- server/ws/deviceSocket.js | 17 ++---- 11 files changed, 361 insertions(+), 56 deletions(-) diff --git a/frontend/js/api.js b/frontend/js/api.js index 7d21838..e7f9772 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -115,6 +115,8 @@ export const api = { deletePlaylistItem: (id, itemId) => request(`/playlists/${id}/items/${itemId}`, { method: 'DELETE' }), reorderPlaylistItems: (id, order) => request(`/playlists/${id}/items/reorder`, { method: 'POST', body: JSON.stringify({ order }) }), assignPlaylistToDevice: (playlistId, device_id) => request(`/playlists/${playlistId}/assign`, { method: 'POST', body: JSON.stringify({ device_id }) }), + publishPlaylist: (id) => request(`/playlists/${id}/publish`, { method: 'POST' }), + discardPlaylistDraft: (id) => request(`/playlists/${id}/discard`, { method: 'POST' }), // Device Groups - Playlist groupAssignPlaylist: (groupId, playlist_id) => request(`/groups/${groupId}/assign-playlist`, { method: 'POST', body: JSON.stringify({ playlist_id }) }), diff --git a/frontend/js/views/device-detail.js b/frontend/js/views/device-detail.js index 20d36b7..4629a08 100644 --- a/frontend/js/views/device-detail.js +++ b/frontend/js/views/device-detail.js @@ -159,6 +159,21 @@ async function loadDevice(deviceId, activeTab = null) {
+ ${device.playlist_status === 'draft' ? ` +
+
+ +
+
Unpublished changes
+
${device.playlist_has_published ? 'Devices are still showing the last published version.' : 'This playlist has never been published. Devices will show nothing until you publish.'}
+
+
+
+ ${device.playlist_has_published ? '' : ''} + +
+
+ ` : ''}
@@ -568,6 +583,37 @@ async function setupActions(device) { } }); + // Publish / Discard from device detail + const devicePublishBtn = document.getElementById('devicePublishBtn'); + if (devicePublishBtn && device.playlist_id) { + devicePublishBtn.addEventListener('click', async () => { + try { + devicePublishBtn.disabled = true; + devicePublishBtn.textContent = 'Publishing...'; + await api.publishPlaylist(device.playlist_id); + showToast('Playlist published — devices updated'); + loadDevice(device.id, 'playlist'); + } catch (err) { + devicePublishBtn.disabled = false; + devicePublishBtn.textContent = 'Publish'; + showToast(err.message, 'error'); + } + }); + } + const deviceDiscardBtn = document.getElementById('deviceDiscardDraftBtn'); + if (deviceDiscardBtn && device.playlist_id) { + deviceDiscardBtn.addEventListener('click', async () => { + if (!confirm('Discard all unpublished changes and revert to the last published version?')) return; + try { + await api.discardPlaylistDraft(device.playlist_id); + showToast('Draft changes discarded'); + loadDevice(device.id, 'playlist'); + } catch (err) { + showToast(err.message, 'error'); + } + }); + } + // Populate playlist picker const playlistPicker = document.getElementById('playlistPicker'); if (playlistPicker) { diff --git a/frontend/js/views/playlists.js b/frontend/js/views/playlists.js index 92badb6..332e7b8 100644 --- a/frontend/js/views/playlists.js +++ b/frontend/js/views/playlists.js @@ -99,6 +99,7 @@ async function loadPlaylists() {
${esc(p.name)}
${p.is_auto_generated ? 'auto' : ''} + ${p.status === 'draft' ? 'draft' : ''}
${p.item_count} item${p.item_count !== 1 ? 's' : ''}
@@ -175,7 +176,26 @@ async function renderDetail(container, playlistId) { } function renderDetailContent(container, playlist) { + const isDraft = playlist.status === 'draft'; + const hasPublished = !!playlist.published_snapshot; + container.innerHTML = ` + ${isDraft ? ` +
+
+ +
+
Unpublished changes
+
${hasPublished ? 'Devices are still showing the last published version.' : 'This playlist has never been published. Devices will show nothing until you publish.'}
+
+
+
+ ${hasPublished ? '' : ''} + +
+
+ ` : ''} +