diff --git a/frontend/js/views/playlists.js b/frontend/js/views/playlists.js new file mode 100644 index 0000000..d00bf1d --- /dev/null +++ b/frontend/js/views/playlists.js @@ -0,0 +1,507 @@ +import { api } from '../api.js'; +import { showToast } from '../components/toast.js'; + +// Escape user-controlled strings for safe HTML interpolation +function esc(str) { + const d = document.createElement('div'); + d.textContent = str || ''; + return d.innerHTML; +} + +function formatDate(ts) { + if (!ts) return '--'; + return new Date(ts * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); +} + +function getTypeIcon(item) { + if (item.widget_id) return ''; + if (item.mime_type && item.mime_type.startsWith('video/')) return ''; + return ''; +} + +let currentPlaylistId = null; + +export function render(container) { + const hash = window.location.hash; + const match = hash.match(/#\/playlists\/(.+)/); + if (match) { + currentPlaylistId = match[1]; + renderDetail(container, match[1]); + } else { + currentPlaylistId = null; + renderList(container); + } +} + +export function cleanup() { + currentPlaylistId = null; +} + +// ==================== LIST VIEW ==================== + +async function renderList(container) { + container.innerHTML = ` + +
+
Loading...
+
+ `; + + document.getElementById('createPlaylistBtn').addEventListener('click', showCreateModal); + loadPlaylists(); +} + +async function loadPlaylists() { + const grid = document.getElementById('playlistGrid'); + if (!grid) return; + + try { + const playlists = await api.getPlaylists(); + if (!playlists.length) { + grid.innerHTML = ` +
+ + + + +

No playlists yet

+

Create your first playlist to organize content for your displays.

+
+ `; + return; + } + + grid.innerHTML = playlists.map(p => ` + +
+
${esc(p.name)}
+
${p.item_count} item${p.item_count !== 1 ? 's' : ''}
+
+ ${p.description ? `
${esc(p.description)}
` : ''} +
Created ${formatDate(p.created_at)}
+
+ `).join(''); + } catch (err) { + grid.innerHTML = `
Failed to load playlists: ${esc(err.message)}
`; + } +} + +function showCreateModal() { + 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 = ` +
+

New Playlist

+ + +
+ + +
+
+ `; + document.body.appendChild(modal); + + const nameInput = document.getElementById('newPlaylistName'); + nameInput.focus(); + + document.getElementById('cancelCreateBtn').addEventListener('click', () => modal.remove()); + modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); }); + + async function doCreate() { + const name = nameInput.value.trim(); + if (!name) { nameInput.focus(); return; } + const desc = document.getElementById('newPlaylistDesc').value.trim(); + try { + const pl = await api.createPlaylist(name, desc); + modal.remove(); + showToast('Playlist created'); + window.location.hash = `#/playlists/${pl.id}`; + } catch (err) { + showToast(err.message, 'error'); + } + } + + document.getElementById('confirmCreateBtn').addEventListener('click', doCreate); + nameInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') doCreate(); }); +} + +// ==================== DETAIL VIEW ==================== + +async function renderDetail(container, playlistId) { + container.innerHTML = ` +
Loading...
+ `; + + try { + const playlist = await api.getPlaylist(playlistId); + renderDetailContent(container, playlist); + } catch (err) { + container.innerHTML = ` +
+

Failed to load playlist: ${esc(err.message)}

+ Back to Playlists +
+ `; + } +} + +function renderDetailContent(container, playlist) { + container.innerHTML = ` + + +
+
+ `; + + renderItems(playlist.items || []); + + // Inline rename + document.getElementById('playlistTitle').addEventListener('click', () => inlineEdit(playlist, 'name')); + document.getElementById('playlistDesc').addEventListener('click', () => inlineEdit(playlist, 'description')); + + // Add content + document.getElementById('addItemBtn').addEventListener('click', () => showAddItemModal(playlist.id)); + + // Delete playlist + document.getElementById('deletePlaylistBtn').addEventListener('click', async () => { + if (!confirm(`Delete "${playlist.name}"? This cannot be undone.`)) return; + try { + await api.deletePlaylist(playlist.id); + showToast('Playlist deleted'); + window.location.hash = '#/playlists'; + } catch (err) { + showToast(err.message, 'error'); + } + }); +} + +function renderItems(items) { + const itemsEl = document.getElementById('playlistItems'); + if (!itemsEl) return; + + if (!items.length) { + itemsEl.innerHTML = ` +
+

This playlist is empty

+

Click "Add Content" to add items.

+
+ `; + return; + } + + itemsEl.innerHTML = items.map((item, i) => ` +
+
${i + 1}
+
+ ${item.thumbnail_path + ? `` + : `
${getTypeIcon(item)}
` + } +
+
+
${esc(item.filename || item.widget_name || 'Unknown')}
+
${item.widget_id ? 'Widget' : (item.mime_type || 'Unknown type')}
+
+
+ + + sec +
+ +
+ `).join(''); + + // Duration change handlers + itemsEl.querySelectorAll('.item-duration').forEach(input => { + input.addEventListener('change', async (e) => { + const itemId = e.target.dataset.itemId; + const val = parseInt(e.target.value, 10); + if (!val || val < 1) { e.target.value = 10; return; } + try { + await api.updatePlaylistItem(currentPlaylistId, itemId, { duration_sec: val }); + } catch (err) { + showToast(err.message, 'error'); + } + }); + }); + + // Remove handlers + itemsEl.querySelectorAll('.item-remove').forEach(btn => { + btn.addEventListener('click', async (e) => { + const itemId = e.currentTarget.dataset.itemId; + try { + await api.deletePlaylistItem(currentPlaylistId, itemId); + const playlist = await api.getPlaylist(currentPlaylistId); + renderItems(playlist.items || []); + showToast('Item removed'); + } catch (err) { + showToast(err.message, 'error'); + } + }); + }); + + // Drag-to-reorder + setupDragReorder(itemsEl); +} + +function setupDragReorder(container) { + let dragEl = null; + + container.addEventListener('dragstart', (e) => { + dragEl = e.target.closest('.playlist-item'); + if (!dragEl) return; + dragEl.style.opacity = '0.4'; + e.dataTransfer.effectAllowed = 'move'; + }); + + container.addEventListener('dragend', () => { + if (dragEl) dragEl.style.opacity = ''; + dragEl = null; + container.querySelectorAll('.playlist-item').forEach(el => el.style.borderTop = ''); + }); + + container.addEventListener('dragover', (e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + const target = e.target.closest('.playlist-item'); + container.querySelectorAll('.playlist-item').forEach(el => el.style.borderTop = ''); + if (target && target !== dragEl) { + target.style.borderTop = '2px solid var(--primary)'; + } + }); + + container.addEventListener('drop', async (e) => { + e.preventDefault(); + const target = e.target.closest('.playlist-item'); + if (!target || !dragEl || target === dragEl) return; + + // Reorder DOM + container.insertBefore(dragEl, target); + + // Collect new order + const order = Array.from(container.querySelectorAll('.playlist-item')) + .map(el => parseInt(el.dataset.itemId, 10)); + + try { + const items = await api.reorderPlaylistItems(currentPlaylistId, order); + renderItems(items); + } catch (err) { + showToast(err.message, 'error'); + // Reload to fix state + const playlist = await api.getPlaylist(currentPlaylistId); + renderItems(playlist.items || []); + } + }); +} + +// ==================== INLINE EDIT ==================== + +function inlineEdit(playlist, field) { + const el = field === 'name' ? document.getElementById('playlistTitle') : document.getElementById('playlistDesc'); + if (!el) return; + + const current = playlist[field] || ''; + const isName = field === 'name'; + + if (isName) { + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'input'; + input.value = current; + input.style.cssText = 'font-size:24px;font-weight:700;padding:2px 8px;width:100%'; + el.replaceWith(input); + input.focus(); + input.select(); + + async function save() { + const val = input.value.trim(); + if (!val) { input.value = current; return; } + try { + const updated = await api.updatePlaylist(playlist.id, { [field]: val }); + playlist[field] = updated[field]; + } catch (err) { + showToast(err.message, 'error'); + } + const newEl = document.createElement('h1'); + newEl.id = 'playlistTitle'; + newEl.style.cursor = 'pointer'; + newEl.title = 'Click to rename'; + newEl.textContent = playlist.name; + input.replaceWith(newEl); + newEl.addEventListener('click', () => inlineEdit(playlist, 'name')); + } + + input.addEventListener('blur', save); + input.addEventListener('keydown', (e) => { if (e.key === 'Enter') input.blur(); if (e.key === 'Escape') { input.value = current; input.blur(); } }); + } else { + const input = document.createElement('textarea'); + input.className = 'input'; + input.value = current; + input.style.cssText = 'font-size:13px;padding:4px 8px;width:100%;height:40px;resize:vertical'; + el.replaceWith(input); + input.focus(); + + async function save() { + const val = input.value.trim(); + try { + const updated = await api.updatePlaylist(playlist.id, { description: val }); + playlist.description = updated.description; + } catch (err) { + showToast(err.message, 'error'); + } + const newEl = document.createElement('div'); + newEl.className = 'subtitle'; + newEl.id = 'playlistDesc'; + newEl.style.cursor = 'pointer'; + newEl.title = 'Click to edit description'; + if (playlist.description) { + newEl.textContent = playlist.description; + } else { + newEl.innerHTML = 'Add a description...'; + } + input.replaceWith(newEl); + newEl.addEventListener('click', () => inlineEdit(playlist, 'description')); + } + + input.addEventListener('blur', save); + input.addEventListener('keydown', (e) => { if (e.key === 'Escape') { input.value = current; input.blur(); } }); + } +} + +// ==================== ADD ITEM MODAL ==================== + +async function showAddItemModal(playlistId) { + 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 = ` +
+

Add Content to Playlist

+
+ + +
+ +
+
+ +
+
+ `; + document.body.appendChild(modal); + + let activeTab = 'content'; + let allContent = []; + let allWidgets = []; + + // Load data + try { + [allContent, allWidgets] = await Promise.all([ + api.getContent(), + api.getWidgets ? api.getWidgets() : Promise.resolve([]) + ]); + } catch (err) { + document.getElementById('addItemList').innerHTML = `
Failed to load: ${esc(err.message)}
`; + } + + function renderTab() { + const list = document.getElementById('addItemList'); + const search = (document.getElementById('addItemSearch')?.value || '').toLowerCase(); + const items = activeTab === 'content' ? allContent : allWidgets; + const filtered = items.filter(item => { + const name = (item.filename || item.name || '').toLowerCase(); + return name.includes(search); + }); + + if (!filtered.length) { + list.innerHTML = `
No ${activeTab} found
`; + return; + } + + list.innerHTML = filtered.map(item => { + const isWidget = activeTab === 'widgets'; + const name = item.filename || item.name || 'Unknown'; + const sub = isWidget ? (item.widget_type || 'Widget') : (item.mime_type || ''); + const thumb = item.thumbnail_path ? `/uploads/thumbnails/${esc(item.thumbnail_path.split('/').pop())}` : null; + return ` +
+
+ ${thumb ? `` : '
'} +
+
+
${esc(name)}
+
${esc(sub)}
+
+ +
+ `; + }).join(''); + + // Add button handlers + list.querySelectorAll('.add-item-btn').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const id = btn.dataset.id; + const type = btn.dataset.type; + const data = type === 'widget' ? { widget_id: id } : { content_id: id }; + try { + btn.disabled = true; + btn.textContent = 'Adding...'; + await api.addPlaylistItem(playlistId, data); + btn.textContent = 'Added'; + btn.classList.remove('btn-primary'); + btn.classList.add('btn-secondary'); + // Refresh the detail view items + const playlist = await api.getPlaylist(playlistId); + renderItems(playlist.items || []); + } catch (err) { + btn.disabled = false; + btn.textContent = 'Add'; + showToast(err.message, 'error'); + } + }); + }); + } + + // Tab switching + modal.querySelectorAll('.tab-btn').forEach(btn => { + btn.addEventListener('click', () => { + activeTab = btn.dataset.tab; + modal.querySelectorAll('.tab-btn').forEach(b => { + b.classList.toggle('btn-primary', b.dataset.tab === activeTab); + b.classList.toggle('btn-secondary', b.dataset.tab !== activeTab); + b.classList.toggle('active', b.dataset.tab === activeTab); + }); + renderTab(); + }); + }); + + // Search + document.getElementById('addItemSearch').addEventListener('input', renderTab); + + // Close + document.getElementById('closeAddModal').addEventListener('click', () => modal.remove()); + modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); }); + + renderTab(); +}