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 = `
+
+
+ `;
+
+ 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 = `
+
+ `;
+ }
+}
+
+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
+ ? `
.pop())})
`
+ : `
${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();
+}