feat(playlists): duplicate + replace playlist items in place (#105)

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) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-06-15 14:36:19 -05:00
parent d64244b5ac
commit e6ebf2a380
9 changed files with 162 additions and 4 deletions

View file

@ -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`),

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -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é',

View file

@ -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',

View file

@ -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',

View file

@ -343,6 +343,12 @@ function renderItems(items) {
<button class="btn-icon item-schedule" data-item-id="${item.id}" title="${t('itemsched.title')}" aria-label="${t('itemsched.title')}" style="color:${item.schedules && item.schedules.length ? '#38bdf8' : 'var(--text-muted)'};background:none;border:none;cursor:pointer;padding:4px;border-radius:4px">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 14"/></svg>
</button>
<button class="btn-icon item-replace" data-item-id="${item.id}" title="${t('playlist.replace_item')}" aria-label="${t('playlist.replace_item')}" style="color:var(--text-muted);background:none;border:none;cursor:pointer;padding:4px;border-radius:4px">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
</button>
<button class="btn-icon item-duplicate" data-item-id="${item.id}" title="${t('playlist.duplicate_item')}" aria-label="${t('playlist.duplicate_item')}" style="color:var(--text-muted);background:none;border:none;cursor:pointer;padding:4px;border-radius:4px">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
</button>
<button class="btn-icon item-move" data-item-id="${item.id}" data-dir="up" title="${t('playlist.move_up')}" aria-label="${t('playlist.move_up')}" ${i === 0 ? 'disabled' : ''} style="color:var(--text-muted);background:none;border:none;cursor:pointer;padding:4px;border-radius:4px;${i === 0 ? 'opacity:0.3;cursor:not-allowed' : ''}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="18 15 12 9 6 15"/></svg>
</button>
@ -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 = `
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:24px;max-width:560px;width:95vw;max-height:80vh;display:flex;flex-direction:column">
<h3 style="margin-bottom:16px;color:var(--text-primary)">${t('playlist.add_modal_title')}</h3>
<h3 style="margin-bottom:16px;color:var(--text-primary)">${replaceItemId ? t('playlist.replace_modal_title') : t('playlist.add_modal_title')}</h3>
<div style="display:flex;gap:8px;margin-bottom:12px">
<button class="btn btn-primary btn-sm tab-btn active" data-tab="content">${t('playlist.tab_content')}</button>
<button class="btn btn-secondary btn-sm tab-btn" data-tab="widgets">${t('playlist.tab_widgets')}</button>
@ -597,7 +632,7 @@ async function showAddItemModal(playlistId) {
<div style="font-size:13px;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(name)}</div>
<div style="font-size:11px;color:var(--text-muted)">${esc(sub)}</div>
</div>
<button class="btn btn-primary btn-sm add-item-btn" data-id="${esc(item.id)}" data-type="${isWidget ? 'widget' : 'content'}">${t('playlist.add_btn')}</button>
<button class="btn btn-primary btn-sm add-item-btn" data-id="${esc(item.id)}" data-type="${isWidget ? 'widget' : 'content'}">${replaceItemId ? t('playlist.replace_btn') : t('playlist.add_btn')}</button>
</div>
`;
}).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');
}
});

View file

@ -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;