import { api } from '../api.js'; import { showToast } from '../components/toast.js'; import { esc } from '../utils.js'; function formatFileSize(bytes) { if (!bytes) return '--'; if (bytes >= 1073741824) return `${(bytes / 1073741824).toFixed(1)} GB`; if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(1)} MB`; if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`; return `${bytes} B`; } export function render(container) { container.innerHTML = `

Drop files here or click to upload

Supports MP4, WebM, AVI, MKV, JPEG, PNG, GIF, WebP

Remote URL

Stream directly from a URL. Saves local bandwidth.

YouTube

Embed a YouTube video on your displays.

Loading...

`; // File upload handling const uploadArea = document.getElementById('uploadArea'); const fileInput = document.getElementById('fileInput'); uploadArea.addEventListener('click', () => fileInput.click()); uploadArea.addEventListener('dragover', (e) => { e.preventDefault(); uploadArea.classList.add('dragover'); }); uploadArea.addEventListener('dragleave', () => { uploadArea.classList.remove('dragover'); }); uploadArea.addEventListener('drop', (e) => { e.preventDefault(); uploadArea.classList.remove('dragover'); handleFiles(e.dataTransfer.files); }); fileInput.addEventListener('change', () => { handleFiles(fileInput.files); fileInput.value = ''; }); // Remote URL handling document.getElementById('addRemoteBtn').addEventListener('click', async () => { const url = document.getElementById('remoteUrlInput').value.trim(); const name = document.getElementById('remoteNameInput').value.trim(); const mimeType = document.getElementById('remoteMimeType').value; if (!url) { showToast('Enter a URL', 'error'); return; } try { await api.addRemoteContent(url, name, mimeType); showToast('Remote content added', 'success'); document.getElementById('remoteUrlInput').value = ''; document.getElementById('remoteNameInput').value = ''; loadContent(); } catch (err) { showToast(err.message, 'error'); } }); // YouTube URL handling document.getElementById('addYoutubeBtn').addEventListener('click', async () => { const url = document.getElementById('youtubeUrlInput').value.trim(); const name = document.getElementById('youtubeNameInput').value.trim(); if (!url) { showToast('Enter a YouTube URL', 'error'); return; } try { await api.addYoutubeContent(url, name); showToast('YouTube video added', 'success'); document.getElementById('youtubeUrlInput').value = ''; document.getElementById('youtubeNameInput').value = ''; loadContent(); } catch (err) { showToast(err.message, 'error'); } }); // Content search filters items currently shown in the grid. function filterContent() { const q = document.getElementById('contentSearch').value.toLowerCase(); document.querySelectorAll('.content-item').forEach(item => { const name = item.querySelector('.content-item-name')?.textContent.toLowerCase() || ''; item.style.display = (!q || name.includes(q)) ? '' : 'none'; }); document.querySelectorAll('.folder-card').forEach(card => { const name = card.dataset.name?.toLowerCase() || ''; card.style.display = (!q || name.includes(q)) ? '' : 'none'; }); } document.getElementById('contentSearch').oninput = filterContent; // Create folder in the current folder. document.getElementById('newFolderBtn').onclick = async () => { const name = prompt('Folder name:'); if (!name || !name.trim()) return; try { await api.createFolder(name.trim(), state.currentFolderId); showToast(`Folder "${name}" created`, 'success'); loadContent(); } catch (err) { showToast(err.message, 'error'); } }; loadContent(); } // View state — current folder navigation. Lives at module scope so the back button // and other handlers can read it without threading it through every callback. const state = { currentFolderId: null, // null = root folders: [], // all folders for this user (flat tree) }; async function handleFiles(files) { const progress = document.getElementById('uploadProgress'); const progressFill = document.getElementById('uploadProgressFill'); const progressText = document.getElementById('uploadProgressText'); for (const file of files) { progress.style.display = 'block'; progressFill.style.width = '0%'; progressText.textContent = `Uploading ${file.name}...`; try { await api.uploadContent(file, (pct) => { progressFill.style.width = pct + '%'; progressText.textContent = `Uploading ${file.name}... ${pct}%`; }); showToast(`${file.name} uploaded successfully`, 'success'); } catch (err) { showToast(`Failed to upload ${file.name}: ${err.message}`, 'error'); } } progress.style.display = 'none'; loadContent(); } async function loadContent() { const grid = document.getElementById('contentGrid'); const folderGrid = document.getElementById('folderGrid'); const breadcrumb = document.getElementById('folderBreadcrumb'); if (!grid || !folderGrid || !breadcrumb) return; try { const [content, folders] = await Promise.all([ api.getContent(state.currentFolderId === null ? null : state.currentFolderId), api.getFolders(), ]); state.folders = folders; // Breadcrumb path: walk parent_id chain from current folder up to root. const folderById = new Map(folders.map(f => [f.id, f])); const path = []; let cursor = state.currentFolderId ? folderById.get(state.currentFolderId) : null; while (cursor) { path.unshift(cursor); cursor = cursor.parent_id ? folderById.get(cursor.parent_id) : null; } breadcrumb.innerHTML = ` All Content ${path.map(f => ` / ${esc(f.name)} `).join('')} ${state.currentFolderId ? ` ` : ''} `; breadcrumb.querySelectorAll('[data-folder-nav]').forEach(a => { a.addEventListener('click', (e) => { e.preventDefault(); const id = a.dataset.folderNav; state.currentFolderId = id || null; loadContent(); }); // Make breadcrumb segments drop targets too — otherwise the only way to move // a file out of a folder is via the edit modal. Dropping on "All Content" // moves to root; dropping on a parent name moves there. a.addEventListener('dragover', (e) => { if (!e.dataTransfer.types.includes('text/content-id')) return; e.preventDefault(); a.style.background = 'var(--primary)'; a.style.color = '#fff'; a.style.padding = '2px 8px'; a.style.borderRadius = '4px'; }); a.addEventListener('dragleave', () => { a.style.background = ''; a.style.color = ''; a.style.padding = ''; a.style.borderRadius = ''; }); a.addEventListener('drop', async (e) => { e.preventDefault(); a.style.background = ''; a.style.color = ''; a.style.padding = ''; a.style.borderRadius = ''; const contentId = e.dataTransfer.getData('text/content-id'); if (!contentId) return; const targetFolderId = a.dataset.folderNav || null; // empty string = root try { await api.moveContent(contentId, targetFolderId); showToast(targetFolderId ? 'Moved' : 'Moved to root', 'success'); loadContent(); } catch (err) { showToast(err.message, 'error'); } }); }); const renameBtn = breadcrumb.querySelector('#renameFolderBtn'); if (renameBtn) renameBtn.onclick = async () => { const current = folderById.get(state.currentFolderId); const name = prompt('Rename folder:', current?.name || ''); if (!name || !name.trim() || name === current?.name) return; try { await api.renameFolder(state.currentFolderId, name.trim()); showToast('Folder renamed', 'success'); loadContent(); } catch (err) { showToast(err.message, 'error'); } }; const deleteBtn = breadcrumb.querySelector('#deleteFolderBtn'); if (deleteBtn) deleteBtn.onclick = async () => { if (!confirm('Delete this folder? Content inside moves back to the root level. Subfolders will also be deleted.')) return; try { const parentId = folderById.get(state.currentFolderId)?.parent_id || null; await api.deleteFolder(state.currentFolderId); showToast('Folder deleted', 'success'); state.currentFolderId = parentId; loadContent(); } catch (err) { showToast(err.message, 'error'); } }; // Render subfolders of the current folder. const subfolders = folders.filter(f => (f.parent_id || null) === state.currentFolderId); folderGrid.innerHTML = subfolders.map(f => `
${esc(f.name)}
`).join(''); folderGrid.querySelectorAll('.folder-card').forEach(card => { card.addEventListener('click', () => { state.currentFolderId = card.dataset.folderId; loadContent(); }); // Drop target for dragging content items into this folder. card.addEventListener('dragover', (e) => { e.preventDefault(); card.style.outline = '2px solid var(--primary)'; }); card.addEventListener('dragleave', () => { card.style.outline = ''; }); card.addEventListener('drop', async (e) => { e.preventDefault(); card.style.outline = ''; const contentId = e.dataTransfer.getData('text/content-id'); if (!contentId) return; try { await api.moveContent(contentId, card.dataset.folderId); showToast('Moved', 'success'); loadContent(); } catch (err) { showToast(err.message, 'error'); } }); }); if (!content.length) { grid.innerHTML = subfolders.length ? '' : `

${state.currentFolderId ? 'This folder is empty' : 'No content yet'}

${state.currentFolderId ? 'Drag content here, or use the Move action.' : 'Upload videos and images to get started.'}

`; return; } grid.innerHTML = content.map(c => `
${c.mime_type === 'video/youtube' ? `
${esc(c.filename)}
` : c.remote_url ? `
Remote
` : c.thumbnail_path ? `${esc(c.filename)}` : c.mime_type?.startsWith('video/') ? `
` : `${esc(c.filename)}` }
${esc(c.filename)}
${c.mime_type === 'video/youtube' ? 'YouTube' : c.remote_url ? 'Remote URL' : (c.mime_type?.startsWith('video/') ? 'Video' : 'Image')} ${c.duration_sec ? ` · ${Math.floor(c.duration_sec / 60)}:${String(Math.floor(c.duration_sec % 60)).padStart(2, '0')}` : ''} ${c.file_size ? ' · ' + formatFileSize(c.file_size) : ''} ${c.width && c.height ? ` · ${c.width}x${c.height}` : ''}
`).join(''); // Drag-to-move: each content item exposes its id; folder cards are the drop targets. grid.querySelectorAll('.content-item').forEach(item => { item.addEventListener('dragstart', (e) => { e.dataTransfer.setData('text/content-id', item.dataset.contentId); e.dataTransfer.effectAllowed = 'move'; }); }); // Delete handler via event delegation grid.onclick = async (e) => { // Preview on click (not on delete button) const previewTarget = e.target.closest('.content-item-preview'); if (previewTarget) { const item = previewTarget.closest('.content-item'); const id = item?.dataset.contentId; if (id) { const c = content.find(x => x.id === id); if (c) showPreview(c); } return; } // Edit button const editBtn = e.target.closest('[data-edit-content]'); if (editBtn) { const id = editBtn.dataset.editContent; const c = content.find(x => x.id === id); if (c) showEditModal(c, loadContent); return; } const btn = e.target.closest('[data-delete-content]'); if (!btn) return; e.stopPropagation(); const id = btn.dataset.deleteContent; // If already confirming, do the delete if (btn.dataset.confirming === 'true') { try { btn.disabled = true; btn.textContent = 'Deleting...'; await api.deleteContent(id); showToast('Content deleted', 'success'); loadContent(); } catch (err) { showToast(err.message, 'error'); btn.disabled = false; btn.textContent = 'Delete'; btn.dataset.confirming = 'false'; } return; } // First click - show confirm state btn.dataset.confirming = 'true'; btn.innerHTML = 'Confirm Delete?'; btn.style.background = 'var(--danger)'; btn.style.color = 'white'; // Reset after 3 seconds if not clicked setTimeout(() => { if (btn.dataset.confirming === 'true') { btn.dataset.confirming = 'false'; btn.innerHTML = ` Delete`; btn.style.background = ''; btn.style.color = ''; } }, 3000); }; } catch (err) { grid.innerHTML = `

Failed to load content

${esc(err.message)}

`; } } function showEditModal(contentItem, onSave) { const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; overlay.style.display = 'flex'; const isRemote = !!contentItem.remote_url; overlay.innerHTML = ` `; document.body.appendChild(overlay); overlay.querySelector('#closeEditModal').onclick = () => overlay.remove(); overlay.querySelector('#cancelEditBtn').onclick = () => overlay.remove(); overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; overlay.querySelector('#saveEditBtn').onclick = async () => { const filename = overlay.querySelector('#editFilename').value.trim(); const mimeType = overlay.querySelector('#editMimeType').value; const remoteUrl = overlay.querySelector('#editRemoteUrl')?.value.trim(); const replaceFile = overlay.querySelector('#editFileReplace')?.files[0]; try { const token = localStorage.getItem('token'); const headers = { Authorization: 'Bearer ' + token }; // Update metadata const folderId = overlay.querySelector('#editFolderId')?.value || ''; const updateData = {}; if (filename !== contentItem.filename) updateData.filename = filename; if (mimeType !== contentItem.mime_type) updateData.mime_type = mimeType; if (remoteUrl !== undefined && remoteUrl !== contentItem.remote_url) updateData.remote_url = remoteUrl; if ((contentItem.folder_id || '') !== folderId) updateData.folder_id = folderId || null; if (Object.keys(updateData).length > 0) { await fetch('/api/content/' + contentItem.id, { method: 'PUT', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify(updateData) }); } // Replace file if provided if (replaceFile) { const formData = new FormData(); formData.append('file', replaceFile); await fetch('/api/content/' + contentItem.id + '/replace', { method: 'PUT', headers, body: formData }); } overlay.remove(); showToast('Content updated', 'success'); if (onSave) onSave(); } catch (err) { showToast(err.message || 'Update failed', 'error'); } }; } function showPreview(content) { const isYoutube = content.mime_type === 'video/youtube'; const isVideo = !isYoutube && content.mime_type?.startsWith('video/'); const src = content.remote_url || `/uploads/content/${content.filepath}`; const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; overlay.style.display = 'flex'; overlay.innerHTML = `
${isYoutube ? `` : isVideo ? `` : `` }
${esc(content.filename)}
${esc(content.mime_type)} ${content.remote_url ? '(Remote URL)' : ''}
`; overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; overlay.querySelector('#closePreview').onclick = () => overlay.remove(); document.body.appendChild(overlay); } // Build a "Parent / Child / Leaf" path for a folder so the move-to dropdown is unambiguous // when two folders share a name in different branches. function folderPath(folder, all) { const byId = new Map(all.map(f => [f.id, f])); const parts = [folder.name]; let cursor = folder; while (cursor.parent_id && byId.has(cursor.parent_id)) { cursor = byId.get(cursor.parent_id); parts.unshift(cursor.name); } return parts.join(' / '); } export function cleanup() {}