import { api } from '../api.js'; import { showToast } from '../components/toast.js'; import { esc, hydrateAuthImages } from '../utils.js'; import { t } from '../i18n.js'; // #216: languages offered in the caption/subtitle pickers. Codes are BCP-47 primary tags — // enough for signage; extend as needed. const SUBTITLE_LANGS = [ ['en', 'English'], ['es', 'Español'], ['fr', 'Français'], ['de', 'Deutsch'], ['pt', 'Português'], ['it', 'Italiano'], ['nl', 'Nederlands'], ['ja', '日本語'], ['ko', '한국어'], ['zh', '中文'], ]; 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`; } // #157: classify a content item's expiry state for the card. `expired` when the server // deactivated it (is_active===0) or its expires_at has passed; `dateLabel` is the local // expiry date/time (present whenever expires_at is set, past or future). function expiryInfo(c) { const hasExpiry = c.expires_at != null && c.expires_at !== ''; const ts = hasExpiry ? Number(c.expires_at) * 1000 : null; const past = ts != null && ts <= Date.now(); const expired = c.is_active === 0 || past; const dateLabel = ts != null ? new Date(ts).toLocaleString([], { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : ''; return { expired, dateLabel }; } // Epoch seconds -> a value in the viewer's LOCAL wall-clock // (YYYY-MM-DDTHH:MM). Empty string for no expiry. function toLocalDatetimeInput(epochSec) { if (epochSec == null || epochSec === '') return ''; const d = new Date(Number(epochSec) * 1000); const p = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`; } export function render(container) { container.innerHTML = `

${t('content.drop')}

${t('content.upload_hint')}

${t('content.remote_url')}

${t('content.remote_desc')}

${t('content.youtube')}

${t('content.youtube_desc')}

${t('common.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(t('content.error_enter_url'), 'error'); return; } try { await api.addRemoteContent(url, name, mimeType); showToast(t('content.toast.remote_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(t('content.error_enter_youtube_url'), 'error'); return; } try { await api.addYoutubeContent(url, name); showToast(t('content.toast.youtube_added'), 'success'); document.getElementById('youtubeUrlInput').value = ''; document.getElementById('youtubeNameInput').value = ''; loadContent(); } catch (err) { showToast(err.message, 'error'); } }); // #214: search/type/sort now query the server so results span the whole workspace, // not just the items already rendered on the current page. Search is debounced to // avoid a request per keystroke. let searchTimer = null; document.getElementById('contentSearch').oninput = (e) => { clearTimeout(searchTimer); const v = e.target.value; searchTimer = setTimeout(() => { state.search = v.trim(); loadContent(); }, 300); }; document.getElementById('contentTypeFilter').onchange = (e) => { state.type = e.target.value; loadContent(); }; document.getElementById('contentSort').onchange = (e) => { state.sort = e.target.value; loadContent(); }; // #157: "Show expired" — reloads the grid including deactivated / past-expiry items so // they can be inspected and restored (clear/extend expiry in the edit modal). document.getElementById('showExpiredToggle').onchange = (e) => { state.showExpired = e.target.checked; loadContent(); }; // Create folder in the current folder. document.getElementById('newFolderBtn').onclick = async () => { const name = prompt(t('content.prompt_folder_name')); if (!name || !name.trim()) return; try { await api.createFolder(name.trim(), state.currentFolderId); showToast(t('content.toast.folder_created_named', { name }), '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) showExpired: false, // #157: include is_active=0 / past-expiry items in the library view search: '', // #214: server-side text search (spans the whole workspace) type: 'all', // #214: type filter — all | video | image | youtube | web sort: 'date_desc', // #214: sort order — date_desc | date_asc | name | size selected: new Set(), // #213: ids selected for batch operations (scoped to the current view) lastClickedId: null, // #213: anchor for shift-click range selection }; async function handleFiles(files) { const list = Array.from(files); if (list.length === 0) return; const progress = document.getElementById('uploadProgress'); const progressFill = document.getElementById('uploadProgressFill'); const progressText = document.getElementById('uploadProgressText'); // #212: send all selected files in a single request with aggregate progress, instead // of one sequential XHR per file. progress.style.display = 'block'; progressFill.style.width = '0%'; const label = list.length === 1 ? list[0].name : t('content.upload_progress_count', { count: list.length }); progressText.textContent = label; try { await api.uploadContent(list, (pct) => { progressFill.style.width = pct + '%'; progressText.textContent = `${label} — ${pct}%`; }, state.currentFolderId); showToast( list.length === 1 ? t('content.toast.uploaded_named', { name: list[0].name }) : t('content.toast.uploaded_count', { count: list.length }), 'success' ); } catch (err) { showToast(t('content.toast.upload_failed_named', { name: label, error: 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, state.showExpired, { q: state.search, type: state.type, sort: state.sort, }), api.getFolders(), ]); state.folders = folders; // #214: while a search or type filter is active, results span the whole workspace, // so surface a count and note the folder scope no longer applies. const countEl = document.getElementById('contentResultCount'); if (countEl) { const filtering = state.search || (state.type && state.type !== 'all'); countEl.textContent = filtering ? t('content.result_count', { count: content.length }) : ''; } // 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 = ` ${t('content.breadcrumb_root')} ${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 ? t('content.toast.moved') : t('content.toast.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(t('content.prompt_rename_folder'), current?.name || ''); if (!name || !name.trim() || name === current?.name) return; try { await api.renameFolder(state.currentFolderId, name.trim()); showToast(t('content.toast.folder_renamed'), 'success'); loadContent(); } catch (err) { showToast(err.message, 'error'); } }; const deleteBtn = breadcrumb.querySelector('#deleteFolderBtn'); if (deleteBtn) deleteBtn.onclick = async () => { if (!confirm(t('content.confirm_delete_folder'))) return; try { const parentId = folderById.get(state.currentFolderId)?.parent_id || null; await api.deleteFolder(state.currentFolderId); showToast(t('content.toast.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(t('content.toast.moved'), 'success'); loadContent(); } catch (err) { showToast(err.message, 'error'); } }); }); if (!content.length) { grid.innerHTML = subfolders.length ? '' : `

${state.currentFolderId ? t('content.empty_folder_title') : t('content.no_content')}

${state.currentFolderId ? t('content.empty_folder_desc') : t('content.no_content_desc')}

`; return; } grid.innerHTML = content.map(c => { const exp = expiryInfo(c); return `
${c.mime_type === 'video/youtube' ? `
${esc(c.filename)}
` : c.remote_url ? `
${t('content.type_remote_short')}
` : c.thumbnail_path ? `${esc(c.filename)}` : c.mime_type?.startsWith('video/') ? `
` : `${esc(c.filename)}` }
${esc(c.filename)}
${c.mime_type === 'video/youtube' ? t('content.type_youtube') : c.remote_url ? t('content.type_remote') : (c.mime_type?.startsWith('video/') ? t('content.type_video') : t('content.type_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}` : ''}
${exp.expired ? `
${t('content.expired_badge')}${exp.dateLabel ? ` · ${exp.dateLabel}` : ''}
` : (exp.dateLabel ? `
${t('content.expires_label', { date: exp.dateLabel })}
` : '')}
`; }).join(''); hydrateAuthImages(grid); // 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'; }); }); // #213: selection checkboxes (with shift-click range). `content` is the current page's // ordered list, so a range fills between the anchor and the clicked item. grid.querySelectorAll('.content-select').forEach(cb => { cb.addEventListener('click', (e) => { const id = cb.dataset.contentId; if (e.shiftKey && state.lastClickedId) { const order = content.map(c => c.id); const a = order.indexOf(state.lastClickedId); const b = order.indexOf(id); if (a !== -1 && b !== -1) { const [lo, hi] = a < b ? [a, b] : [b, a]; const on = cb.checked; // apply the clicked box's new state across the range for (let i = lo; i <= hi; i++) { if (on) state.selected.add(order[i]); else state.selected.delete(order[i]); } } } else if (cb.checked) { state.selected.add(id); } else { state.selected.delete(id); } state.lastClickedId = id; loadContent(); // re-render to reflect range + selection outlines + toolbar }); }); // Delete handler via event delegation grid.onclick = async (e) => { // #213: ignore clicks originating on a selection checkbox (handled above). if (e.target.closest('.content-select-wrap')) return; // 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 = t('content.btn_deleting'); await api.deleteContent(id); showToast(t('content.toast.deleted'), 'success'); loadContent(); } catch (err) { showToast(err.message, 'error'); btn.disabled = false; btn.textContent = t('content.btn_delete'); btn.dataset.confirming = 'false'; } return; } // First click - show confirm state btn.dataset.confirming = 'true'; btn.innerHTML = t('content.btn_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 = ` ${t('content.btn_delete')}`; btn.style.background = ''; btn.style.color = ''; } }, 3000); }; // #213: batch-operations toolbar reflects the current selection. renderBatchToolbar(content); } catch (err) { grid.innerHTML = `

${t('content.failed_to_load')}

${esc(err.message)}

`; } } // #213: the batch toolbar — shown only when something is selected. `visible` is the current // page's items, used by "select all". Actions validate/act atomically server-side; on success // the selection is cleared and the grid reloaded. function renderBatchToolbar(visible) { const bar = document.getElementById('batchToolbar'); if (!bar) return; const count = state.selected.size; if (count === 0) { bar.style.display = 'none'; bar.innerHTML = ''; return; } const allVisibleSelected = visible.length > 0 && visible.every(c => state.selected.has(c.id)); bar.style.display = 'flex'; bar.style.cssText = 'display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;padding:10px 14px;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg)'; bar.innerHTML = ` ${t('content.batch_selected', { count })}
`; bar.querySelector('#batchSelectAll').onclick = () => { if (allVisibleSelected) visible.forEach(c => state.selected.delete(c.id)); else visible.forEach(c => state.selected.add(c.id)); loadContent(); }; bar.querySelector('#batchMoveFolder').onchange = async (e) => { const val = e.target.value; if (!val) return; const folderId = val === '__root__' ? null : val; const ids = [...state.selected]; try { await api.batchMoveContent(ids, folderId); showToast(t('content.toast.batch_moved', { count: ids.length }), 'success'); state.selected.clear(); state.lastClickedId = null; loadContent(); } catch (err) { showToast(err.message, 'error'); e.target.value = ''; } }; const delBtn = bar.querySelector('#batchDelete'); delBtn.onclick = async () => { const ids = [...state.selected]; if (delBtn.dataset.confirming !== 'true') { delBtn.dataset.confirming = 'true'; delBtn.textContent = t('content.batch_delete_confirm', { count: ids.length }); setTimeout(() => { if (delBtn.dataset.confirming === 'true') { delBtn.dataset.confirming = 'false'; delBtn.textContent = t('content.batch_delete', { count: ids.length }); } }, 3000); return; } try { delBtn.disabled = true; await api.batchDeleteContent(ids); showToast(t('content.toast.batch_deleted', { count: ids.length }), 'success'); state.selected.clear(); state.lastClickedId = null; loadContent(); } catch (err) { showToast(err.message, 'error'); delBtn.disabled = false; delBtn.dataset.confirming = 'false'; delBtn.textContent = t('content.batch_delete', { count: ids.length }); } }; } function showEditModal(contentItem, onSave) { const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; overlay.style.display = 'flex'; const isRemote = !!contentItem.remote_url; const isYoutube = contentItem.mime_type === 'video/youtube'; const isUploadedVideo = !isRemote && contentItem.mime_type?.startsWith('video/'); // #216: language `) .join(''); 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; // #157: expiry (datetime-local local wall-clock -> epoch seconds; empty = never). const expiryRaw = overlay.querySelector('#editExpiresAt')?.value || ''; const newExpiry = expiryRaw ? Math.floor(new Date(expiryRaw).getTime() / 1000) : null; const curExpiry = contentItem.expires_at != null ? Number(contentItem.expires_at) : null; if (newExpiry !== curExpiry) updateData.expires_at = newExpiry; // #217: YouTube-only "unstable connection" quality cap. const unstableEl = overlay.querySelector('#editUnstableConnection'); if (unstableEl) { const newUnstable = unstableEl.checked ? 1 : 0; if (newUnstable !== (contentItem.unstable_connection ? 1 : 0)) updateData.unstable_connection = newUnstable; } // #216: YouTube captions (checkbox + language). const captionsEl = overlay.querySelector('#editCaptionsEnabled'); if (captionsEl) { const newCaptions = captionsEl.checked ? 1 : 0; if (newCaptions !== (contentItem.captions_enabled ? 1 : 0)) updateData.captions_enabled = newCaptions; const capLang = overlay.querySelector('#editCaptionsLang')?.value || null; if (capLang !== (contentItem.captions_lang || 'en')) updateData.captions_lang = capLang; } // #216: uploaded-video subtitle language change / removal (the FILE is sent separately below). const subtitleFile = overlay.querySelector('#editSubtitleFile')?.files[0]; const subLangEl = overlay.querySelector('#editSubtitleLang'); const subRemove = overlay.querySelector('#editSubtitleRemove')?.checked; if (subRemove) { updateData.subtitle_url = null; updateData.subtitle_lang = null; } else if (subLangEl && !subtitleFile) { // Lang-only change (no new file) — the upload endpoint handles lang when a file IS sent. const subLang = subLangEl.value || null; if (contentItem.subtitle_url && subLang !== (contentItem.subtitle_lang || 'en')) updateData.subtitle_lang = subLang; } 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 }); } // #216: upload a new subtitle .vtt if one was chosen (skipped when "remove" is ticked). if (subtitleFile && !subRemove) { const subForm = new FormData(); subForm.append('subtitle', subtitleFile); if (subLangEl?.value) subForm.append('subtitle_lang', subLangEl.value); await fetch('/api/content/' + contentItem.id + '/subtitle', { method: 'POST', headers, body: subForm }); } overlay.remove(); showToast(t('content.toast.updated'), 'success'); if (onSave) onSave(); } catch (err) { showToast(err.message || t('content.error_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 ? `(${t('content.type_remote')})` : ''}
`; 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() {}