diff --git a/frontend/js/api.js b/frontend/js/api.js index 6b7b91e..22da7a8 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -39,9 +39,34 @@ export const api = { }), // Content - getContent: () => request('/content'), + getContent: (folderId) => { + if (folderId === undefined) return request('/content'); + const q = folderId === null ? 'root' : encodeURIComponent(folderId); + return request(`/content?folder_id=${q}`); + }, getContentItem: (id) => request(`/content/${id}`), deleteContent: (id) => request(`/content/${id}`, { method: 'DELETE' }), + updateContent: (id, data) => request(`/content/${id}`, { method: 'PUT', body: JSON.stringify(data) }), + moveContent: (id, folderId) => request(`/content/${id}`, { + method: 'PUT', + body: JSON.stringify({ folder_id: folderId }) + }), + + // Folders + getFolders: () => request('/folders'), + createFolder: (name, parentId) => request('/folders', { + method: 'POST', + body: JSON.stringify({ name, parent_id: parentId || null }) + }), + renameFolder: (id, name) => request(`/folders/${id}`, { + method: 'PUT', + body: JSON.stringify({ name }) + }), + moveFolder: (id, parentId) => request(`/folders/${id}`, { + method: 'PUT', + body: JSON.stringify({ parent_id: parentId || null }) + }), + deleteFolder: (id) => request(`/folders/${id}`, { method: 'DELETE' }), uploadContent: async (file, onProgress) => { const formData = new FormData(); formData.append('file', file); diff --git a/frontend/js/views/content-library.js b/frontend/js/views/content-library.js index 7dfa248..8f51d1c 100644 --- a/frontend/js/views/content-library.js +++ b/frontend/js/views/content-library.js @@ -71,13 +71,12 @@ export function render(container) { -
+
-
+
+

Loading...

@@ -148,36 +147,41 @@ export function render(container) { } }); - // Content search + folder filter + // Content search filters items currently shown in the grid. function filterContent() { const q = document.getElementById('contentSearch').value.toLowerCase(); - const folder = document.getElementById('folderFilter').value; document.querySelectorAll('.content-item').forEach(item => { const name = item.querySelector('.content-item-name')?.textContent.toLowerCase() || ''; - const itemFolder = item.dataset.folder || ''; - const matchSearch = !q || name.includes(q); - const matchFolder = !folder || itemFolder === folder; - item.style.display = (matchSearch && matchFolder) ? '' : 'none'; + 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; - document.getElementById('folderFilter').onchange = filterContent; - // New folder - document.getElementById('newFolderBtn').onclick = () => { + // Create folder in the current folder. + document.getElementById('newFolderBtn').onclick = async () => { const name = prompt('Folder name:'); - if (name) { - // Just add to the dropdown - folders are created when content is moved into them - const opt = document.createElement('option'); - opt.value = name; opt.textContent = name; - document.getElementById('folderFilter').appendChild(opt); - showToast(`Folder "${name}" created. Edit content to move it here.`, 'info'); - } + 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'); @@ -205,26 +209,116 @@ async function handleFiles(files) { async function loadContent() { const grid = document.getElementById('contentGrid'); - if (!grid) return; + const folderGrid = document.getElementById('folderGrid'); + const breadcrumb = document.getElementById('folderBreadcrumb'); + if (!grid || !folderGrid || !breadcrumb) return; try { - const content = await api.getContent(); + 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(); + }); + }); + 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 = ` + grid.innerHTML = subfolders.length ? '' : `
-

No content yet

-

Upload videos and images to get started.

+

${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' ? `
@@ -283,15 +377,12 @@ async function loadContent() {
`).join(''); - // Populate folder dropdown - const folderSelect = document.getElementById('folderFilter'); - const folders = [...new Set(content.filter(c => c.folder).map(c => c.folder))].sort(); - folders.forEach(f => { - if (!folderSelect.querySelector(`option[value="${f}"]`)) { - const opt = document.createElement('option'); - opt.value = f; opt.textContent = `${f} (${content.filter(c => c.folder === f).length})`; - folderSelect.appendChild(opt); - } + // 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 @@ -397,6 +488,13 @@ function showEditModal(contentItem, onSave) {
+
+ + +
${!isRemote ? `
@@ -428,10 +526,12 @@ function showEditModal(contentItem, onSave) { 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, { @@ -491,4 +591,17 @@ function showPreview(content) { 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() {} diff --git a/server/db/database.js b/server/db/database.js index 26c2e27..ae6783f 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -65,6 +65,17 @@ const migrations = [ "ALTER TABLE playlists ADD COLUMN published_snapshot TEXT", // Phase 4: group scheduling (column add only — full migration with CHECK below) "ALTER TABLE schedules ADD COLUMN group_id TEXT REFERENCES device_groups(id) ON DELETE SET NULL", + // Hierarchical content folders (per-user) + `CREATE TABLE IF NOT EXISTS content_folders ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + parent_id TEXT REFERENCES content_folders(id) ON DELETE CASCADE, + name TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) + )`, + "CREATE INDEX IF NOT EXISTS idx_content_folders_user ON content_folders(user_id, parent_id)", + "ALTER TABLE content ADD COLUMN folder_id TEXT REFERENCES content_folders(id) ON DELETE SET NULL", + "CREATE INDEX IF NOT EXISTS idx_content_folder ON content(folder_id)", ]; for (const sql of migrations) { try { db.exec(sql); } catch (e) { /* already exists */ } diff --git a/server/routes/content.js b/server/routes/content.js index 81d3fde..40d8347 100644 --- a/server/routes/content.js +++ b/server/routes/content.js @@ -8,13 +8,23 @@ const upload = require('../middleware/upload'); const config = require('../config'); const { checkStorageLimit, checkRemoteUrl } = require('../middleware/subscription'); -// List content for current user (admins see all) +// List content for current user (admins see all). +// folder_id filter: omit for everything; "root" or "" for root-level only; for that folder. router.get('/', (req, res) => { const isAdmin = req.user.role === 'superadmin'; const folder = req.query.folder; + const folderId = req.query.folder_id; let sql = `SELECT * FROM content ${isAdmin ? 'WHERE 1=1' : 'WHERE (user_id = ? OR user_id IS NULL)'}`; const params = isAdmin ? [] : [req.user.id]; if (folder) { sql += ' AND folder = ?'; params.push(folder); } + if (folderId !== undefined) { + if (folderId === 'root' || folderId === '') { + sql += ' AND folder_id IS NULL'; + } else { + sql += ' AND folder_id = ?'; + params.push(folderId); + } + } sql += ' ORDER BY folder, created_at DESC LIMIT ? OFFSET ?'; params.push(Math.min(parseInt(req.query.limit) || 100, 500), parseInt(req.query.offset) || 0); const content = db.prepare(sql).all(...params); @@ -213,13 +223,27 @@ router.put('/:id', (req, res) => { const content = checkContentAccess(req, res); if (!content) return; - const { filename, mime_type, remote_url, folder } = req.body; + const { filename, mime_type, remote_url, folder, folder_id } = req.body; const updates = []; const values = []; if (filename !== undefined) { updates.push('filename = ?'); values.push(filename); } if (mime_type !== undefined) { updates.push('mime_type = ?'); values.push(mime_type); } if (remote_url !== undefined) { updates.push('remote_url = ?'); values.push(remote_url || null); } if (folder !== undefined) { updates.push('folder = ?'); values.push(folder || null); } + if (folder_id !== undefined) { + // Verify the destination folder belongs to the same user (admins can move anywhere). + let target = null; + if (folder_id) { + target = db.prepare('SELECT user_id FROM content_folders WHERE id = ?').get(folder_id); + if (!target) return res.status(400).json({ error: 'Invalid folder_id' }); + const isAdmin = ['admin', 'superadmin'].includes(req.user.role); + if (!isAdmin && target.user_id !== req.user.id) { + return res.status(403).json({ error: 'Cannot move content to another user\'s folder' }); + } + } + updates.push('folder_id = ?'); + values.push(folder_id || null); + } if (updates.length > 0) { values.push(req.params.id); diff --git a/server/routes/folders.js b/server/routes/folders.js new file mode 100644 index 0000000..37f17ee --- /dev/null +++ b/server/routes/folders.js @@ -0,0 +1,104 @@ +const express = require('express'); +const router = express.Router(); +const { v4: uuidv4 } = require('uuid'); +const { db } = require('../db/database'); + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +// Verify a folder belongs to the current user (or null = root, also allowed). +// Returns the row, or null if it exists but isn't owned by the user. +function ownedFolder(req, folderId) { + if (!folderId) return { id: null }; + if (!UUID_RE.test(folderId)) return null; + const row = db.prepare('SELECT * FROM content_folders WHERE id = ?').get(folderId); + if (!row) return null; + const isAdmin = ['admin', 'superadmin'].includes(req.user.role); + if (!isAdmin && row.user_id !== req.user.id) return null; + return row; +} + +// List folders for the current user. Returns the full tree as a flat array; +// the client builds the hierarchy from parent_id. +router.get('/', (req, res) => { + const isAdmin = req.user.role === 'superadmin'; + const rows = isAdmin + ? db.prepare('SELECT * FROM content_folders ORDER BY name COLLATE NOCASE').all() + : db.prepare('SELECT * FROM content_folders WHERE user_id = ? ORDER BY name COLLATE NOCASE').all(req.user.id); + res.json(rows); +}); + +// Create a folder. +router.post('/', (req, res) => { + const name = (req.body.name || '').trim(); + if (!name) return res.status(400).json({ error: 'name is required' }); + if (name.length > 100) return res.status(400).json({ error: 'name too long' }); + + const parentId = req.body.parent_id || null; + if (parentId) { + const parent = ownedFolder(req, parentId); + if (!parent || parent.id === null) return res.status(400).json({ error: 'Invalid parent_id' }); + } + + const id = uuidv4(); + db.prepare( + 'INSERT INTO content_folders (id, user_id, parent_id, name) VALUES (?, ?, ?, ?)' + ).run(id, req.user.id, parentId, name); + + res.status(201).json(db.prepare('SELECT * FROM content_folders WHERE id = ?').get(id)); +}); + +// Rename / move a folder. +router.put('/:id', (req, res) => { + const folder = ownedFolder(req, req.params.id); + if (!folder || folder.id === null) return res.status(404).json({ error: 'Folder not found' }); + + const updates = []; + const values = []; + + if (req.body.name !== undefined) { + const name = String(req.body.name).trim(); + if (!name) return res.status(400).json({ error: 'name cannot be empty' }); + if (name.length > 100) return res.status(400).json({ error: 'name too long' }); + updates.push('name = ?'); + values.push(name); + } + + if (req.body.parent_id !== undefined) { + const newParent = req.body.parent_id || null; + if (newParent === folder.id) return res.status(400).json({ error: 'Folder cannot be its own parent' }); + if (newParent) { + const parent = ownedFolder(req, newParent); + if (!parent || parent.id === null) return res.status(400).json({ error: 'Invalid parent_id' }); + // Reject cycles: walk up from the new parent and ensure we never hit this folder. + let cursor = parent; + const seen = new Set([folder.id]); + while (cursor && cursor.parent_id) { + if (seen.has(cursor.parent_id)) { + return res.status(400).json({ error: 'Move would create a cycle' }); + } + seen.add(cursor.parent_id); + cursor = db.prepare('SELECT * FROM content_folders WHERE id = ?').get(cursor.parent_id); + } + } + updates.push('parent_id = ?'); + values.push(newParent); + } + + if (updates.length === 0) return res.json(folder); + + values.push(folder.id); + db.prepare(`UPDATE content_folders SET ${updates.join(', ')} WHERE id = ?`).run(...values); + res.json(db.prepare('SELECT * FROM content_folders WHERE id = ?').get(folder.id)); +}); + +// Delete a folder. Content inside it falls back to root via ON DELETE SET NULL. +// Subfolders cascade-delete; if the user wants to keep them they should move them first. +router.delete('/:id', (req, res) => { + const folder = ownedFolder(req, req.params.id); + if (!folder || folder.id === null) return res.status(404).json({ error: 'Folder not found' }); + + db.prepare('DELETE FROM content_folders WHERE id = ?').run(folder.id); + res.json({ success: true }); +}); + +module.exports = router; diff --git a/server/server.js b/server/server.js index ddaee60..aa20bae 100644 --- a/server/server.js +++ b/server/server.js @@ -202,6 +202,7 @@ app.get('/api/content/:id/thumbnail', (req, res) => { const { requireAuth } = require('./middleware/auth'); app.use('/api/devices', requireAuth, require('./routes/devices')); app.use('/api/content', requireAuth, require('./routes/content')); +app.use('/api/folders', requireAuth, require('./routes/folders')); app.use('/api/assignments', requireAuth, require('./routes/assignments')); app.use('/api/provision', requireAuth, require('./routes/provisioning')); app.use('/api/layouts', requireAuth, require('./routes/layouts'));