mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
Add media folder organization to content library
New content_folders table with hierarchical parent_id and per-user scoping. content.folder_id added (ON DELETE SET NULL so deleting a folder drops items back to root). New /api/folders route exposes list/create/rename/move/delete with cycle detection on move. Content library UI: breadcrumb navigation, subfolder grid, "+ New Folder" creates inside the current folder, drag-and-drop content items onto folder cards to move them, and the edit modal has a folder dropdown showing each folder's full path. Per-user scoping is enforced server-side: every folder query filters by user_id, and folder ownership is checked on both folder mutations and content.folder_id updates. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8866e305f0
commit
fcecf805ed
|
|
@ -39,9 +39,34 @@ export const api = {
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Content
|
// 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}`),
|
getContentItem: (id) => request(`/content/${id}`),
|
||||||
deleteContent: (id) => request(`/content/${id}`, { method: 'DELETE' }),
|
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) => {
|
uploadContent: async (file, onProgress) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
|
|
||||||
|
|
@ -71,13 +71,12 @@ export function render(container) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:flex;gap:12px;margin-bottom:16px;align-items:center;flex-wrap:wrap">
|
<div style="display:flex;gap:12px;margin-bottom:12px;align-items:center;flex-wrap:wrap">
|
||||||
<input type="text" id="contentSearch" class="input" placeholder="Search content..." style="max-width:250px;width:100%">
|
<input type="text" id="contentSearch" class="input" placeholder="Search content..." style="max-width:250px;width:100%">
|
||||||
<select id="folderFilter" class="input" style="max-width:180px;width:100%;background:var(--bg-input)">
|
|
||||||
<option value="">All Folders</option>
|
|
||||||
</select>
|
|
||||||
<button class="btn btn-secondary btn-sm" id="newFolderBtn">+ New Folder</button>
|
<button class="btn btn-secondary btn-sm" id="newFolderBtn">+ New Folder</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="folderBreadcrumb" style="display:flex;gap:6px;align-items:center;margin-bottom:12px;font-size:13px;flex-wrap:wrap"></div>
|
||||||
|
<div id="folderGrid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;margin-bottom:16px"></div>
|
||||||
<div class="content-grid" id="contentGrid">
|
<div class="content-grid" id="contentGrid">
|
||||||
<div class="empty-state" style="grid-column:1/-1"><h3>Loading...</h3></div>
|
<div class="empty-state" style="grid-column:1/-1"><h3>Loading...</h3></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -148,36 +147,41 @@ export function render(container) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Content search + folder filter
|
// Content search filters items currently shown in the grid.
|
||||||
function filterContent() {
|
function filterContent() {
|
||||||
const q = document.getElementById('contentSearch').value.toLowerCase();
|
const q = document.getElementById('contentSearch').value.toLowerCase();
|
||||||
const folder = document.getElementById('folderFilter').value;
|
|
||||||
document.querySelectorAll('.content-item').forEach(item => {
|
document.querySelectorAll('.content-item').forEach(item => {
|
||||||
const name = item.querySelector('.content-item-name')?.textContent.toLowerCase() || '';
|
const name = item.querySelector('.content-item-name')?.textContent.toLowerCase() || '';
|
||||||
const itemFolder = item.dataset.folder || '';
|
item.style.display = (!q || name.includes(q)) ? '' : 'none';
|
||||||
const matchSearch = !q || name.includes(q);
|
});
|
||||||
const matchFolder = !folder || itemFolder === folder;
|
document.querySelectorAll('.folder-card').forEach(card => {
|
||||||
item.style.display = (matchSearch && matchFolder) ? '' : 'none';
|
const name = card.dataset.name?.toLowerCase() || '';
|
||||||
|
card.style.display = (!q || name.includes(q)) ? '' : 'none';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
document.getElementById('contentSearch').oninput = filterContent;
|
document.getElementById('contentSearch').oninput = filterContent;
|
||||||
document.getElementById('folderFilter').onchange = filterContent;
|
|
||||||
|
|
||||||
// New folder
|
// Create folder in the current folder.
|
||||||
document.getElementById('newFolderBtn').onclick = () => {
|
document.getElementById('newFolderBtn').onclick = async () => {
|
||||||
const name = prompt('Folder name:');
|
const name = prompt('Folder name:');
|
||||||
if (name) {
|
if (!name || !name.trim()) return;
|
||||||
// Just add to the dropdown - folders are created when content is moved into them
|
try {
|
||||||
const opt = document.createElement('option');
|
await api.createFolder(name.trim(), state.currentFolderId);
|
||||||
opt.value = name; opt.textContent = name;
|
showToast(`Folder "${name}" created`, 'success');
|
||||||
document.getElementById('folderFilter').appendChild(opt);
|
loadContent();
|
||||||
showToast(`Folder "${name}" created. Edit content to move it here.`, 'info');
|
} catch (err) { showToast(err.message, 'error'); }
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
loadContent();
|
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) {
|
async function handleFiles(files) {
|
||||||
const progress = document.getElementById('uploadProgress');
|
const progress = document.getElementById('uploadProgress');
|
||||||
const progressFill = document.getElementById('uploadProgressFill');
|
const progressFill = document.getElementById('uploadProgressFill');
|
||||||
|
|
@ -205,26 +209,116 @@ async function handleFiles(files) {
|
||||||
|
|
||||||
async function loadContent() {
|
async function loadContent() {
|
||||||
const grid = document.getElementById('contentGrid');
|
const grid = document.getElementById('contentGrid');
|
||||||
if (!grid) return;
|
const folderGrid = document.getElementById('folderGrid');
|
||||||
|
const breadcrumb = document.getElementById('folderBreadcrumb');
|
||||||
|
if (!grid || !folderGrid || !breadcrumb) return;
|
||||||
|
|
||||||
try {
|
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 = `
|
||||||
|
<a href="#" data-folder-nav="" style="color:var(--text-secondary);text-decoration:none">All Content</a>
|
||||||
|
${path.map(f => `
|
||||||
|
<span style="color:var(--text-muted)">/</span>
|
||||||
|
<a href="#" data-folder-nav="${f.id}" style="color:var(--text-primary);text-decoration:none">${esc(f.name)}</a>
|
||||||
|
`).join('')}
|
||||||
|
${state.currentFolderId ? `
|
||||||
|
<button class="btn btn-secondary btn-sm" id="renameFolderBtn" style="margin-left:auto">Rename</button>
|
||||||
|
<button class="btn btn-danger btn-sm" id="deleteFolderBtn">Delete folder</button>
|
||||||
|
` : ''}
|
||||||
|
`;
|
||||||
|
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 => `
|
||||||
|
<div class="folder-card" data-folder-id="${f.id}" data-name="${esc(f.name)}"
|
||||||
|
style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-md);padding:14px;cursor:pointer;display:flex;align-items:center;gap:10px"
|
||||||
|
data-drop-folder="${f.id}">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||||
|
</svg>
|
||||||
|
<div style="font-size:14px;font-weight:500;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(f.name)}</div>
|
||||||
|
</div>
|
||||||
|
`).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) {
|
if (!content.length) {
|
||||||
grid.innerHTML = `
|
grid.innerHTML = subfolders.length ? '' : `
|
||||||
<div class="empty-state" style="grid-column:1/-1">
|
<div class="empty-state" style="grid-column:1/-1">
|
||||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/>
|
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/>
|
||||||
<polyline points="13 2 13 9 20 9"/>
|
<polyline points="13 2 13 9 20 9"/>
|
||||||
</svg>
|
</svg>
|
||||||
<h3>No content yet</h3>
|
<h3>${state.currentFolderId ? 'This folder is empty' : 'No content yet'}</h3>
|
||||||
<p>Upload videos and images to get started.</p>
|
<p>${state.currentFolderId ? 'Drag content here, or use the Move action.' : 'Upload videos and images to get started.'}</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
grid.innerHTML = content.map(c => `
|
grid.innerHTML = content.map(c => `
|
||||||
<div class="content-item" data-content-id="${c.id}" data-folder="${c.folder || ''}">
|
<div class="content-item" draggable="true" data-content-id="${c.id}" data-folder="${c.folder || ''}">
|
||||||
<div class="content-item-preview">
|
<div class="content-item-preview">
|
||||||
${c.mime_type === 'video/youtube'
|
${c.mime_type === 'video/youtube'
|
||||||
? `<div style="position:relative;width:100%;height:100%;background:#000;display:flex;align-items:center;justify-content:center">
|
? `<div style="position:relative;width:100%;height:100%;background:#000;display:flex;align-items:center;justify-content:center">
|
||||||
|
|
@ -283,15 +377,12 @@ async function loadContent() {
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
|
||||||
// Populate folder dropdown
|
// Drag-to-move: each content item exposes its id; folder cards are the drop targets.
|
||||||
const folderSelect = document.getElementById('folderFilter');
|
grid.querySelectorAll('.content-item').forEach(item => {
|
||||||
const folders = [...new Set(content.filter(c => c.folder).map(c => c.folder))].sort();
|
item.addEventListener('dragstart', (e) => {
|
||||||
folders.forEach(f => {
|
e.dataTransfer.setData('text/content-id', item.dataset.contentId);
|
||||||
if (!folderSelect.querySelector(`option[value="${f}"]`)) {
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
const opt = document.createElement('option');
|
});
|
||||||
opt.value = f; opt.textContent = `${f} (${content.filter(c => c.folder === f).length})`;
|
|
||||||
folderSelect.appendChild(opt);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete handler via event delegation
|
// Delete handler via event delegation
|
||||||
|
|
@ -397,6 +488,13 @@ function showEditModal(contentItem, onSave) {
|
||||||
<option value="image/webp" ${contentItem.mime_type === 'image/webp' ? 'selected' : ''}>Image (WebP)</option>
|
<option value="image/webp" ${contentItem.mime_type === 'image/webp' ? 'selected' : ''}>Image (WebP)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Folder</label>
|
||||||
|
<select id="editFolderId" class="input" style="background:var(--bg-input)">
|
||||||
|
<option value="">— Root —</option>
|
||||||
|
${state.folders.map(f => `<option value="${f.id}" ${contentItem.folder_id === f.id ? 'selected' : ''}>${esc(folderPath(f, state.folders))}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
${!isRemote ? `
|
${!isRemote ? `
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Replace File</label>
|
<label>Replace File</label>
|
||||||
|
|
@ -428,10 +526,12 @@ function showEditModal(contentItem, onSave) {
|
||||||
const headers = { Authorization: 'Bearer ' + token };
|
const headers = { Authorization: 'Bearer ' + token };
|
||||||
|
|
||||||
// Update metadata
|
// Update metadata
|
||||||
|
const folderId = overlay.querySelector('#editFolderId')?.value || '';
|
||||||
const updateData = {};
|
const updateData = {};
|
||||||
if (filename !== contentItem.filename) updateData.filename = filename;
|
if (filename !== contentItem.filename) updateData.filename = filename;
|
||||||
if (mimeType !== contentItem.mime_type) updateData.mime_type = mimeType;
|
if (mimeType !== contentItem.mime_type) updateData.mime_type = mimeType;
|
||||||
if (remoteUrl !== undefined && remoteUrl !== contentItem.remote_url) updateData.remote_url = remoteUrl;
|
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) {
|
if (Object.keys(updateData).length > 0) {
|
||||||
await fetch('/api/content/' + contentItem.id, {
|
await fetch('/api/content/' + contentItem.id, {
|
||||||
|
|
@ -491,4 +591,17 @@ function showPreview(content) {
|
||||||
document.body.appendChild(overlay);
|
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() {}
|
export function cleanup() {}
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,17 @@ const migrations = [
|
||||||
"ALTER TABLE playlists ADD COLUMN published_snapshot TEXT",
|
"ALTER TABLE playlists ADD COLUMN published_snapshot TEXT",
|
||||||
// Phase 4: group scheduling (column add only — full migration with CHECK below)
|
// 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",
|
"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) {
|
for (const sql of migrations) {
|
||||||
try { db.exec(sql); } catch (e) { /* already exists */ }
|
try { db.exec(sql); } catch (e) { /* already exists */ }
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,23 @@ const upload = require('../middleware/upload');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
const { checkStorageLimit, checkRemoteUrl } = require('../middleware/subscription');
|
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; <uuid> for that folder.
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
const isAdmin = req.user.role === 'superadmin';
|
const isAdmin = req.user.role === 'superadmin';
|
||||||
const folder = req.query.folder;
|
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)'}`;
|
let sql = `SELECT * FROM content ${isAdmin ? 'WHERE 1=1' : 'WHERE (user_id = ? OR user_id IS NULL)'}`;
|
||||||
const params = isAdmin ? [] : [req.user.id];
|
const params = isAdmin ? [] : [req.user.id];
|
||||||
if (folder) { sql += ' AND folder = ?'; params.push(folder); }
|
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 ?';
|
sql += ' ORDER BY folder, created_at DESC LIMIT ? OFFSET ?';
|
||||||
params.push(Math.min(parseInt(req.query.limit) || 100, 500), parseInt(req.query.offset) || 0);
|
params.push(Math.min(parseInt(req.query.limit) || 100, 500), parseInt(req.query.offset) || 0);
|
||||||
const content = db.prepare(sql).all(...params);
|
const content = db.prepare(sql).all(...params);
|
||||||
|
|
@ -213,13 +223,27 @@ router.put('/:id', (req, res) => {
|
||||||
const content = checkContentAccess(req, res);
|
const content = checkContentAccess(req, res);
|
||||||
if (!content) return;
|
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 updates = [];
|
||||||
const values = [];
|
const values = [];
|
||||||
if (filename !== undefined) { updates.push('filename = ?'); values.push(filename); }
|
if (filename !== undefined) { updates.push('filename = ?'); values.push(filename); }
|
||||||
if (mime_type !== undefined) { updates.push('mime_type = ?'); values.push(mime_type); }
|
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 (remote_url !== undefined) { updates.push('remote_url = ?'); values.push(remote_url || null); }
|
||||||
if (folder !== undefined) { updates.push('folder = ?'); values.push(folder || 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) {
|
if (updates.length > 0) {
|
||||||
values.push(req.params.id);
|
values.push(req.params.id);
|
||||||
|
|
|
||||||
104
server/routes/folders.js
Normal file
104
server/routes/folders.js
Normal file
|
|
@ -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;
|
||||||
|
|
@ -202,6 +202,7 @@ app.get('/api/content/:id/thumbnail', (req, res) => {
|
||||||
const { requireAuth } = require('./middleware/auth');
|
const { requireAuth } = require('./middleware/auth');
|
||||||
app.use('/api/devices', requireAuth, require('./routes/devices'));
|
app.use('/api/devices', requireAuth, require('./routes/devices'));
|
||||||
app.use('/api/content', requireAuth, require('./routes/content'));
|
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/assignments', requireAuth, require('./routes/assignments'));
|
||||||
app.use('/api/provision', requireAuth, require('./routes/provisioning'));
|
app.use('/api/provision', requireAuth, require('./routes/provisioning'));
|
||||||
app.use('/api/layouts', requireAuth, require('./routes/layouts'));
|
app.use('/api/layouts', requireAuth, require('./routes/layouts'));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue