From e6ebf2a380f99e59440cbdcdd035df942b952093 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Mon, 15 Jun 2026 14:36:19 -0500 Subject: [PATCH] feat(playlists): duplicate + replace playlist items in place (#105) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Duplicate and Replace per-item actions, both leaning on the normalized playlist_items schema (only content_id/widget_id/zone_id/sort_order/ duration_sec; type-specific fields are JOINed at snapshot time). - Replace: extend PUT /:id/items/:itemId to accept a content_id/widget_id swap. Clean FK swap across ANY content type (image<->video<->youtube<-> widget) — sets one, nulls the other, preserving zone_id/duration/ sort_order/schedule rows. Only acts when content_id|widget_id is present, so partial PUTs are unaffected. Workspace-validated; markDraft. - Duplicate: new POST /:id/items/:itemId/duplicate — copies the row + its schedule blocks (new ids) in one transaction, appended (sort_order MAX+1). markDraft. - Frontend: Replace + Duplicate icon buttons per item; Replace reuses the add-item picker in a replaceItemId mode (PUT instead of POST). i18n x6. Validated end-to-end against the live API: duplicate (incl. schedule copy with distinct ids), replace same-type and cross-type both directions, preservation of duration/schedule/zone, and validation (both->400, missing->404). 149 server tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/js/api.js | 1 + frontend/js/i18n/de.js | 7 ++++ frontend/js/i18n/en.js | 7 ++++ frontend/js/i18n/es.js | 7 ++++ frontend/js/i18n/fr.js | 7 ++++ frontend/js/i18n/it.js | 7 ++++ frontend/js/i18n/pt.js | 7 ++++ frontend/js/views/playlists.js | 55 +++++++++++++++++++++++++-- server/routes/playlists.js | 68 ++++++++++++++++++++++++++++++++++ 9 files changed, 162 insertions(+), 4 deletions(-) diff --git a/frontend/js/api.js b/frontend/js/api.js index 790bc9e..a74bd32 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -145,6 +145,7 @@ export const api = { addPlaylistItem: (id, data) => request(`/playlists/${id}/items`, { method: 'POST', body: JSON.stringify(data) }), updatePlaylistItem: (id, itemId, data) => request(`/playlists/${id}/items/${itemId}`, { method: 'PUT', body: JSON.stringify(data) }), deletePlaylistItem: (id, itemId) => request(`/playlists/${id}/items/${itemId}`, { method: 'DELETE' }), + duplicatePlaylistItem: (id, itemId) => request(`/playlists/${id}/items/${itemId}/duplicate`, { method: 'POST' }), reorderPlaylistItems: (id, order) => request(`/playlists/${id}/items/reorder`, { method: 'POST', body: JSON.stringify({ order }) }), // #74/#75 per-item schedule blocks getItemSchedules: (id, itemId) => request(`/playlists/${id}/items/${itemId}/schedules`), diff --git a/frontend/js/i18n/de.js b/frontend/js/i18n/de.js index d41d4ee..b2e5c13 100644 --- a/frontend/js/i18n/de.js +++ b/frontend/js/i18n/de.js @@ -729,6 +729,13 @@ export default { 'playlist.no_content_found': 'Kein Inhalt gefunden', 'playlist.no_widgets_found': 'Keine Widgets gefunden', 'playlist.add_btn': 'Hinzufügen', + 'playlist.replace_item': 'Inhalt ersetzen', + 'playlist.duplicate_item': 'Element duplizieren', + 'playlist.replace_modal_title': 'Inhalt ersetzen', + 'playlist.replace_btn': 'Ersetzen', + 'playlist.replacing': 'Wird ersetzt…', + 'playlist.toast.item_duplicated': 'Element dupliziert', + 'playlist.toast.item_replaced': 'Inhalt ersetzt', 'playlist.adding': 'Wird hinzugefügt...', 'playlist.added': 'Hinzugefügt', diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 871364d..23e4ed5 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -813,6 +813,13 @@ export default { 'playlist.no_content_found': 'No content found', 'playlist.no_widgets_found': 'No widgets found', 'playlist.add_btn': 'Add', + 'playlist.replace_item': 'Replace content', + 'playlist.duplicate_item': 'Duplicate item', + 'playlist.replace_modal_title': 'Replace content', + 'playlist.replace_btn': 'Replace', + 'playlist.replacing': 'Replacing…', + 'playlist.toast.item_duplicated': 'Item duplicated', + 'playlist.toast.item_replaced': 'Content replaced', 'playlist.adding': 'Adding...', 'playlist.added': 'Added', diff --git a/frontend/js/i18n/es.js b/frontend/js/i18n/es.js index 93c68bd..f5704cf 100644 --- a/frontend/js/i18n/es.js +++ b/frontend/js/i18n/es.js @@ -728,6 +728,13 @@ export default { 'playlist.no_content_found': 'No se encontró contenido', 'playlist.no_widgets_found': 'No se encontraron widgets', 'playlist.add_btn': 'Agregar', + 'playlist.replace_item': 'Reemplazar contenido', + 'playlist.duplicate_item': 'Duplicar elemento', + 'playlist.replace_modal_title': 'Reemplazar contenido', + 'playlist.replace_btn': 'Reemplazar', + 'playlist.replacing': 'Reemplazando…', + 'playlist.toast.item_duplicated': 'Elemento duplicado', + 'playlist.toast.item_replaced': 'Contenido reemplazado', 'playlist.adding': 'Agregando...', 'playlist.added': 'Agregado', diff --git a/frontend/js/i18n/fr.js b/frontend/js/i18n/fr.js index 232d511..d498774 100644 --- a/frontend/js/i18n/fr.js +++ b/frontend/js/i18n/fr.js @@ -729,6 +729,13 @@ export default { 'playlist.no_content_found': 'Aucun contenu trouvé', 'playlist.no_widgets_found': 'Aucun widget trouvé', 'playlist.add_btn': 'Ajouter', + 'playlist.replace_item': 'Remplacer le contenu', + 'playlist.duplicate_item': 'Dupliquer l’élément', + 'playlist.replace_modal_title': 'Remplacer le contenu', + 'playlist.replace_btn': 'Remplacer', + 'playlist.replacing': 'Remplacement…', + 'playlist.toast.item_duplicated': 'Élément dupliqué', + 'playlist.toast.item_replaced': 'Contenu remplacé', 'playlist.adding': 'Ajout...', 'playlist.added': 'Ajouté', diff --git a/frontend/js/i18n/it.js b/frontend/js/i18n/it.js index 00de295..8efe521 100644 --- a/frontend/js/i18n/it.js +++ b/frontend/js/i18n/it.js @@ -724,6 +724,13 @@ export default { 'playlist.no_content_found': 'Nessun contenuto trovato', 'playlist.no_widgets_found': 'Nessun widget trovato', 'playlist.add_btn': 'Aggiungi', + 'playlist.replace_item': 'Sostituisci contenuto', + 'playlist.duplicate_item': 'Duplica elemento', + 'playlist.replace_modal_title': 'Sostituisci contenuto', + 'playlist.replace_btn': 'Sostituisci', + 'playlist.replacing': 'Sostituzione…', + 'playlist.toast.item_duplicated': 'Elemento duplicato', + 'playlist.toast.item_replaced': 'Contenuto sostituito', 'playlist.adding': 'Aggiunta in corso...', 'playlist.added': 'Aggiunto', diff --git a/frontend/js/i18n/pt.js b/frontend/js/i18n/pt.js index ddbbe2a..acc1358 100644 --- a/frontend/js/i18n/pt.js +++ b/frontend/js/i18n/pt.js @@ -729,6 +729,13 @@ export default { 'playlist.no_content_found': 'Nenhum conteúdo encontrado', 'playlist.no_widgets_found': 'Nenhum widget encontrado', 'playlist.add_btn': 'Adicionar', + 'playlist.replace_item': 'Substituir conteúdo', + 'playlist.duplicate_item': 'Duplicar item', + 'playlist.replace_modal_title': 'Substituir conteúdo', + 'playlist.replace_btn': 'Substituir', + 'playlist.replacing': 'Substituindo…', + 'playlist.toast.item_duplicated': 'Item duplicado', + 'playlist.toast.item_replaced': 'Conteúdo substituído', 'playlist.adding': 'Adicionando...', 'playlist.added': 'Adicionado', diff --git a/frontend/js/views/playlists.js b/frontend/js/views/playlists.js index b040213..5586444 100644 --- a/frontend/js/views/playlists.js +++ b/frontend/js/views/playlists.js @@ -343,6 +343,12 @@ function renderItems(items) { + + @@ -393,6 +399,32 @@ function renderItems(items) { }); }); + // #105 duplicate: server copies the row + its schedule blocks, appended at the end. + itemsEl.querySelectorAll('.item-duplicate').forEach(btn => { + btn.addEventListener('click', async (e) => { + const itemId = e.currentTarget.dataset.itemId; + try { + e.currentTarget.disabled = true; + await api.duplicatePlaylistItem(currentPlaylistId, itemId); + const playlist = await api.getPlaylist(currentPlaylistId); + renderItems(playlist.items || []); + refreshAfterMutation(); + showToast(t('playlist.toast.item_duplicated')); + } catch (err) { + showToast(err.message, 'error'); + } + }); + }); + + // #105 replace: reuse the add-item picker in "replace" mode — swaps content/widget + // in place, preserving duration/schedule/zone (server-side). + itemsEl.querySelectorAll('.item-replace').forEach(btn => { + btn.addEventListener('click', (e) => { + const itemId = e.currentTarget.dataset.itemId; + showAddItemModal(currentPlaylistId, { replaceItemId: itemId }); + }); + }); + itemsEl.querySelectorAll('.item-move').forEach(btn => { btn.addEventListener('click', async (e) => { if (btn.disabled) return; @@ -537,12 +569,15 @@ function inlineEdit(playlist, field) { } } -async function showAddItemModal(playlistId) { +async function showAddItemModal(playlistId, opts = {}) { + // #105: when opts.replaceItemId is set, picking an item REPLACES that item's + // content/widget in place (preserving duration/schedule/zone) instead of adding. + const replaceItemId = opts.replaceItemId || null; const modal = document.createElement('div'); modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:1000'; modal.innerHTML = `
-

${t('playlist.add_modal_title')}

+

${replaceItemId ? t('playlist.replace_modal_title') : t('playlist.add_modal_title')}

@@ -597,7 +632,7 @@ async function showAddItemModal(playlistId) {
${esc(name)}
${esc(sub)}
- +
`; }).join(''); @@ -610,6 +645,18 @@ async function showAddItemModal(playlistId) { const data = type === 'widget' ? { widget_id: id } : { content_id: id }; try { btn.disabled = true; + if (replaceItemId) { + btn.textContent = t('playlist.replacing'); + // PUT supports a content/widget swap; the server nulls the opposite FK and + // preserves duration/schedule/zone. Close on success and re-render the list. + await api.updatePlaylistItem(playlistId, replaceItemId, data); + modal.remove(); + const playlist = await api.getPlaylist(playlistId); + renderItems(playlist.items || []); + refreshAfterMutation(); + showToast(t('playlist.toast.item_replaced')); + return; + } btn.textContent = t('playlist.adding'); await api.addPlaylistItem(playlistId, data); btn.textContent = t('playlist.added'); @@ -618,7 +665,7 @@ async function showAddItemModal(playlistId) { refreshAfterMutation(); } catch (err) { btn.disabled = false; - btn.textContent = t('playlist.add_btn'); + btn.textContent = replaceItemId ? t('playlist.replace_btn') : t('playlist.add_btn'); showToast(err.message, 'error'); } }); diff --git a/server/routes/playlists.js b/server/routes/playlists.js index e1bdc69..05c35fc 100644 --- a/server/routes/playlists.js +++ b/server/routes/playlists.js @@ -458,6 +458,37 @@ router.put('/:id/items/:itemId', requirePlaylistWrite, (req, res) => { values.push(duration_sec); } + // #105 replace: swap the item's content/widget in place while preserving zone_id, + // duration, sort_order and schedule rows. playlist_items is normalized (no + // type-specific columns — mime_type/remote_url/filepath/widget_type are JOINed at + // read time), so this is a clean FK swap across ANY content type (image<->video<-> + // youtube<->widget). Exactly one of content_id/widget_id ends up set; the other is + // nulled. Only acts when the request explicitly carries content_id or widget_id, so + // partial PUTs (duration/zone/sort) are unaffected. + const replacingContent = Object.prototype.hasOwnProperty.call(req.body, 'content_id'); + const replacingWidget = Object.prototype.hasOwnProperty.call(req.body, 'widget_id'); + if (replacingContent || replacingWidget) { + const newContentId = replacingContent ? req.body.content_id : null; + const newWidgetId = replacingWidget ? req.body.widget_id : null; + if (!newContentId && !newWidgetId) return res.status(400).json({ error: 'content_id or widget_id required to replace' }); + if (newContentId && newWidgetId) return res.status(400).json({ error: 'provide only one of content_id / widget_id' }); + if (newContentId) { + const content = db.prepare('SELECT id, workspace_id FROM content WHERE id = ?').get(newContentId); + if (!content) return res.status(404).json({ error: 'Content not found' }); + if (content.workspace_id && content.workspace_id !== req.playlist.workspace_id) { + return res.status(403).json({ error: 'Content is not in this playlist\'s workspace' }); + } + } else { + const widget = db.prepare('SELECT id, workspace_id FROM widgets WHERE id = ?').get(newWidgetId); + if (!widget) return res.status(404).json({ error: 'Widget not found' }); + if (widget.workspace_id && widget.workspace_id !== req.playlist.workspace_id) { + return res.status(403).json({ error: 'Widget is not in this playlist\'s workspace' }); + } + } + updates.push('content_id = ?'); values.push(newContentId || null); + updates.push('widget_id = ?'); values.push(newWidgetId || null); + } + if (updates.length > 0) { updates.push("updated_at = strftime('%s','now')"); values.push(req.params.itemId); @@ -490,6 +521,43 @@ router.delete('/:id/items/:itemId', requirePlaylistWrite, (req, res) => { res.json({ success: true }); }); +// #105 duplicate: append a copy of an item (same content/widget + zone + duration) +// plus its schedule rows (new ids). One transaction so a half-copied item can't exist. +router.post('/:id/items/:itemId/duplicate', requirePlaylistWrite, (req, res) => { + const item = db.prepare('SELECT * FROM playlist_items WHERE id = ? AND playlist_id = ?') + .get(req.params.itemId, req.params.id); + if (!item) return res.status(404).json({ error: 'item not found' }); + + const copy = db.transaction(() => { + const max = db.prepare('SELECT MAX(sort_order) as m FROM playlist_items WHERE playlist_id = ?').get(req.params.id); + const order = (max.m || 0) + 1; + const result = db.prepare(` + INSERT INTO playlist_items (playlist_id, content_id, widget_id, zone_id, sort_order, duration_sec) + VALUES (?, ?, ?, ?, ?, ?) + `).run(req.params.id, item.content_id, item.widget_id, item.zone_id, order, item.duration_sec); + const newId = result.lastInsertRowid; + const scheds = db.prepare('SELECT active_days, start_time, end_time, start_date, end_date, sort_order FROM playlist_item_schedules WHERE playlist_item_id = ?').all(req.params.itemId); + const insSched = db.prepare('INSERT INTO playlist_item_schedules (id, playlist_item_id, active_days, start_time, end_time, start_date, end_date, sort_order) VALUES (?,?,?,?,?,?,?,?)'); + for (const s of scheds) insSched.run(uuidv4(), newId, s.active_days, s.start_time, s.end_time, s.start_date, s.end_date, s.sort_order); + return newId; + }); + const newId = copy(); + markDraft(req.params.id); + + const newItem = db.prepare(` + SELECT pi.*, + COALESCE(c.filename, w.name) as filename, + c.mime_type, c.filepath, c.thumbnail_path, + c.duration_sec as content_duration, c.file_size, c.remote_url, + w.name as widget_name, w.widget_type, w.config as widget_config + FROM playlist_items pi + LEFT JOIN content c ON pi.content_id = c.id + LEFT JOIN widgets w ON pi.widget_id = w.id + WHERE pi.id = ? + `).get(newId); + res.status(201).json(newItem); +}); + // Reorder items router.post('/:id/items/reorder', requirePlaylistWrite, (req, res) => { const { order } = req.body;