${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'));