mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-29 09:23:16 -06:00
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:
parent
d64244b5ac
commit
e6ebf2a380
|
|
@ -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`),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -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é',
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue