diff options
Diffstat (limited to 'web/js')
| -rw-r--r-- | web/js/api.js | 2 | ||||
| -rw-r--r-- | web/js/app.js | 62 | ||||
| -rw-r--r-- | web/js/state.js | 1 | ||||
| -rw-r--r-- | web/js/tests/progress-actions.test.js | 120 | ||||
| -rw-r--r-- | web/js/views/media-actions.js | 21 | ||||
| -rw-r--r-- | web/js/views/media-grid.js | 41 | ||||
| -rw-r--r-- | web/js/views/media-info.js | 22 |
7 files changed, 263 insertions, 6 deletions
diff --git a/web/js/api.js b/web/js/api.js index 9b0436e..5decd89 100644 --- a/web/js/api.js +++ b/web/js/api.js @@ -56,6 +56,8 @@ export const API = { }, mediaDetail: (id) => api(`/api/media/${id}`), progress: (mediaId, positionSeconds) => api('/api/progress', { method: 'POST', body: { media_id: mediaId, position_seconds: positionSeconds } }), + progressStatus: (mediaId, status) => api('/api/progress/status', { method: 'POST', body: { media_id: mediaId, status } }), + inProgress: () => api('/api/in-progress'), favorite: (id) => api(`/api/media/${id}/favorite`, { method: 'POST' }), notes: (id) => api(`/api/media/${id}/notes`), saveNote: (id, content) => api(`/api/media/${id}/notes`, { method: 'POST', body: { content } }), diff --git a/web/js/app.js b/web/js/app.js index 5590293..d7665fa 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -40,6 +40,8 @@ import { } from './views/media-grid.js'; import { downloadSelected, + markAsFinished, + markAsNotStarted, openNotesForSelected, regenThumb, selectedMediaId, @@ -151,6 +153,8 @@ async function initApp() { initSearch({ onChange: (q) => { const parsed = parseQuery(q); + state.virtualSet = ''; + syncChromeVirtualSets(); applySearchSet(parsed); delete parsed.set; Object.assign(state.filters, parsed); @@ -165,7 +169,13 @@ async function initApp() { document.addEventListener('search:navigate-results', () => navDown()); initShuffle({ onChange: () => loadMedia() }); initPlaybackNav({ isShuffle }); - initSets({ onLoadMedia: loadMedia }); + initSets({ + onLoadMedia: () => { + state.virtualSet = ''; + syncChromeVirtualSets(); + loadMedia(); + }, + }); initMediaGrid({ isShuffle, shuffleRevision, @@ -175,7 +185,10 @@ async function initApp() { openSet, playSelected, regenThumb, + markAsFinished: markAsFinishedAndRefresh, + markAsNotStarted: markAsNotStartedAndRefresh, toggleFavorite, + onVirtualSetCleared: syncChromeVirtualSets, }); initKeyboard(keyboardHandlers()); initNotes(() => toast('Note saved')); @@ -185,7 +198,10 @@ async function initApp() { initUpload({ onLoadMedia: loadMedia }); initHelp(); initShares(); - initMediaInfo(); + initMediaInfo({ + markAsFinished: markAsFinishedAndRefresh, + markAsNotStarted: markAsNotStartedAndRefresh, + }); initTags({ onFilterChange: () => { document.dispatchEvent(new CustomEvent('filters:changed')); @@ -220,6 +236,7 @@ function applySearchSet(parsed) { state.sets.find((set) => set.name.toLowerCase() === needle) || state.sets.find((set) => set.name.toLowerCase().includes(needle)); if (!match) return; + state.virtualSet = ''; state.selectedSetId = match.id; state.selectedSetIds = [match.id]; updateSetRowsUI(); @@ -342,6 +359,7 @@ function activateGridElement(el) { function openSet(id) { if (!Number.isFinite(id)) return; + state.virtualSet = ''; state.selectedSetId = id; state.selectedSetIds = [id]; state.folderPath = ''; @@ -350,7 +368,47 @@ function openSet(id) { loadMedia(); } +async function markAsFinishedAndRefresh(id) { + const updated = await markAsFinished(id); + if (updated && state.virtualSet === 'in-progress') await loadMedia(); + return updated; +} + +async function markAsNotStartedAndRefresh(id) { + const updated = await markAsNotStarted(id); + if (updated && state.virtualSet === 'in-progress') await loadMedia(); + return updated; +} + +function openInProgress() { + state.virtualSet = state.virtualSet === 'in-progress' ? '' : 'in-progress'; + state.selectedSetId = null; + state.selectedSetIds = []; + state.folderPath = ''; + state.mediaPage = 0; + updateSetRowsUI(); + syncChromeVirtualSets(); + loadMedia(); +} + +function syncChromeVirtualSets() { + document.getElementById('in-progress-toggle')?.classList.toggle('active', state.virtualSet === 'in-progress'); +} + function initChrome() { + const shuffleBtn = document.getElementById('shuffle-toggle'); + if (shuffleBtn && !document.getElementById('in-progress-toggle')) { + const btn = document.createElement('button'); + btn.id = 'in-progress-toggle'; + btn.className = 'icon-btn'; + btn.type = 'button'; + btn.title = 'In Progress'; + btn.setAttribute('aria-label', 'Show in-progress media'); + btn.textContent = '◷'; + shuffleBtn.insertAdjacentElement('afterend', btn); + } + document.getElementById('in-progress-toggle')?.addEventListener('click', openInProgress); + document.getElementById('sidebar-toggle')?.addEventListener('click', () => { toggleSidebar(); }); diff --git a/web/js/state.js b/web/js/state.js index b5057b9..be60d61 100644 --- a/web/js/state.js +++ b/web/js/state.js @@ -3,6 +3,7 @@ export const state = { sets: [], selectedSetId: null, selectedSetIds: [], // multi-selection + virtualSet: '', media: [], filters: { type: '', diff --git a/web/js/tests/progress-actions.test.js b/web/js/tests/progress-actions.test.js new file mode 100644 index 0000000..1694ea3 --- /dev/null +++ b/web/js/tests/progress-actions.test.js @@ -0,0 +1,120 @@ +import { API } from '../api.js'; +import { state } from '../state.js'; +import { initMediaGrid, loadMedia } from '../views/media-grid.js'; + +const failures = []; +const requests = []; + +const grid = mockElement(); +const emptyHint = mockElement(); +const breadcrumb = mockElement(); +const resultCount = mockElement(); + +globalThis.location = { pathname: '/index.html', href: '' }; +globalThis.document = { + getElementById(id) { + if (id === 'media-grid') return grid; + if (id === 'empty-hint') return emptyHint; + if (id === 'breadcrumb-bar') return breadcrumb; + if (id === 'result-count') return resultCount; + return null; + }, + querySelectorAll() { return []; }, +}; + +globalThis.fetch = async (url, options = {}) => { + requests.push({ url: String(url), options }); + if (String(url) === '/api/in-progress') { + return jsonResponse([ + { + id: 7, + file_name: 'resume.mp4', + type: 'video', + duration: 125, + file_size_bytes: 2048, + thumbnail_path: 'thumbs/resume.jpg', + }, + ]); + } + return jsonResponse({ status: 'ok' }); +}; + +function assert(cond, msg) { + if (!cond) failures.push(msg || 'assertion failed'); +} + +function mockElement() { + return { + classList: { + add() {}, + remove() {}, + toggle() {}, + contains() { return false; }, + }, + innerHTML: '', + textContent: '', + addEventListener() {}, + querySelectorAll() { return []; }, + querySelector() { return null; }, + }; +} + +function jsonResponse(body) { + return { + ok: true, + status: 200, + headers: { get: () => 'application/json' }, + json: async () => body, + }; +} + +async function testProgressStatusAPIWrapper() { + requests.length = 0; + await API.progressStatus(7, 'finished'); + const req = requests[0]; + assert(req?.url === '/api/progress/status', 'progressStatus should call the progress status endpoint'); + assert(req?.options?.method === 'POST', 'progressStatus should POST'); + assert( + req?.options?.body === JSON.stringify({ media_id: 7, status: 'finished' }), + 'progressStatus should send media_id and status', + ); +} + +async function testInProgressAPIWrapper() { + requests.length = 0; + const list = await API.inProgress(); + assert(requests[0]?.url === '/api/in-progress', 'inProgress should call the in-progress endpoint'); + assert(Array.isArray(list), 'inProgress should return the decoded list'); +} + +async function testInProgressVirtualGrid() { + requests.length = 0; + state.virtualSet = 'in-progress'; + state.selectedSetId = null; + state.selectedSetIds = []; + state.folderPath = ''; + state.mediaPage = 0; + initMediaGrid(); + + await loadMedia(); + + assert(requests[0]?.url === '/api/in-progress', 'virtual set should load via API.inProgress'); + assert(state.media.length === 1 && state.media[0].id === 7, 'virtual set should store flat media results'); + assert(grid.innerHTML.includes('data-action="mark-finished"'), 'cards should render a finished action'); + assert(grid.innerHTML.includes('data-action="mark-not-started"'), 'cards should render a not-started action'); + assert(resultCount.textContent === '1 items', 'result count should reflect in-progress media'); +} + +console.log('Running progress action tests...'); +await testProgressStatusAPIWrapper(); +await testInProgressAPIWrapper(); +await testInProgressVirtualGrid(); + +if (failures.length) { + console.error('FAILURES:'); + failures.forEach((m) => console.error(' - ' + m)); + process.exit(1); +} else { + console.log('All progress action tests passed.'); + process.exit(0); +} diff --git a/web/js/views/media-actions.js b/web/js/views/media-actions.js index 2367241..93f3b0b 100644 --- a/web/js/views/media-actions.js +++ b/web/js/views/media-actions.js @@ -28,6 +28,27 @@ export async function toggleFavorite(id, btn) { } } +async function setProgressStatus(id, status, successMessage) { + const mediaId = id || selectedMediaId(); + if (!mediaId) return false; + try { + await API.progressStatus(mediaId, status); + toast(successMessage); + return true; + } catch (err) { + toast(err.message || 'Progress update failed', 'error'); + return false; + } +} + +export function markAsFinished(id) { + return setProgressStatus(id, 'finished', 'Marked as finished'); +} + +export function markAsNotStarted(id) { + return setProgressStatus(id, 'not_started', 'Marked as not started'); +} + export async function openNotesForSelected() { const el = currentElement(); if (!el) return; diff --git a/web/js/views/media-grid.js b/web/js/views/media-grid.js index e82ae98..542277e 100644 --- a/web/js/views/media-grid.js +++ b/web/js/views/media-grid.js @@ -76,7 +76,15 @@ export async function loadMedia() { const setIds = state.selectedSetIds.length > 1 ? state.selectedSetIds.join(',') : ''; const singleSetId = state.selectedSetIds.length === 1 ? state.selectedSetIds[0] : null; - if (!singleSetId && !setIds && !callbacks.isShuffle?.() && !hasActiveFilters()) { + if (state.virtualSet === 'in-progress') { + breadcrumb?.classList.add('hidden'); + syncMediaPage('virtual:in-progress'); + const data = await API.inProgress(); + const list = Array.isArray(data) ? data : data?.media || []; + setMedia(list); + const page = renderGrid(list, 'No in-progress media.'); + resultCount.textContent = resultText(list.length, page); + } else if (!singleSetId && !setIds && !callbacks.isShuffle?.() && !hasActiveFilters()) { breadcrumb?.classList.add('hidden'); setMedia([]); syncMediaPage('sets'); @@ -176,6 +184,13 @@ export function mediaWithBrowsePath(media, path) { export function navigateBack() { if (!state.folderPath) { + if (state.virtualSet) { + state.virtualSet = ''; + state.mediaPage = 0; + callbacks.onVirtualSetCleared?.(); + loadMedia(); + return; + } if (state.selectedSetId || state.selectedSetIds.length) { state.selectedSetId = null; state.selectedSetIds = []; @@ -349,13 +364,13 @@ function renderFolder(folder, index) { `; } -function renderGrid(items) { +function renderGrid(items, emptyMessage = 'No results.') { const grid = document.getElementById('media-grid'); if (!grid) return; if (!items.length) { const page = paginateItems([], state.mediaPage); state.mediaPage = page.page; - grid.innerHTML = `<p class="text-muted text-sm grid-full">No results.</p>`; + grid.innerHTML = `<p class="text-muted text-sm grid-full">${escapeHtml(emptyMessage)}</p>`; clearSelection(); return page; } @@ -409,6 +424,8 @@ function bindMediaItems(grid) { const downloadBtn = el.querySelector('[data-action="download"]'); const tagBtn = el.querySelector('[data-action="tags"]'); const thumbBtn = el.querySelector('[data-action="regen-thumb"]'); + const finishedBtn = el.querySelector('[data-action="mark-finished"]'); + const notStartedBtn = el.querySelector('[data-action="mark-not-started"]'); playBtn?.addEventListener('click', (e) => { e.stopPropagation(); selectByElement(el); @@ -439,9 +456,24 @@ function bindMediaItems(grid) { e.stopPropagation(); callbacks.regenThumb?.(el.dataset.id); }); + finishedBtn?.addEventListener('click', async (e) => { + e.stopPropagation(); + await callbacks.markAsFinished?.(el.dataset.id); + }); + notStartedBtn?.addEventListener('click', async (e) => { + e.stopPropagation(); + await callbacks.markAsNotStarted?.(el.dataset.id); + }); }); } +function renderProgressActions() { + return ` + <button class="icon-btn btn-sm" data-action="mark-finished" title="Mark as finished">✓</button> + <button class="icon-btn btn-sm" data-action="mark-not-started" title="Mark as not started">↺</button> + `; +} + function renderItem(m, index) { const sizeText = fmtSize(m.file_size_bytes); const flattenedFolder = m.flattened_folder ? 'true' : 'false'; @@ -455,6 +487,7 @@ function renderItem(m, index) { <button class="icon-btn btn-sm" data-action="play" title="Play">▶</button> <button class="icon-btn btn-sm" data-action="favorite" title="Favorite">♥</button> <button class="icon-btn btn-sm" data-action="notes" title="Notes">📝</button> + ${renderProgressActions()} <button class="icon-btn btn-sm" data-action="download" title="Download">⬇</button> <button class="icon-btn btn-sm" data-action="tags" title="Tags">🏷</button> <button class="icon-btn btn-sm" data-action="regen-thumb" title="Regenerate thumbnail">🔄</button> @@ -479,6 +512,7 @@ function renderItem(m, index) { <button class="icon-btn btn-sm" data-action="play" title="Play">▶</button> <button class="icon-btn btn-sm" data-action="favorite" title="Favorite">♥</button> <button class="icon-btn btn-sm" data-action="notes" title="Notes">📝</button> + ${renderProgressActions()} <button class="icon-btn btn-sm" data-action="download" title="Download">⬇</button> <button class="icon-btn btn-sm" data-action="tags" title="Tags">🏷</button> <button class="icon-btn btn-sm" data-action="regen-thumb" title="Regenerate thumbnail">🔄</button> @@ -500,6 +534,7 @@ function renderItem(m, index) { <button class="icon-btn btn-sm" data-action="play" title="Play">▶</button> <button class="icon-btn btn-sm" data-action="favorite" title="Favorite">♥</button> <button class="icon-btn btn-sm" data-action="notes" title="Notes">📝</button> + ${renderProgressActions()} <button class="icon-btn btn-sm" data-action="download" title="Download">⬇</button> <button class="icon-btn btn-sm" data-action="tags" title="Tags">🏷</button> </div> diff --git a/web/js/views/media-info.js b/web/js/views/media-info.js index 51e576c..bbb1f98 100644 --- a/web/js/views/media-info.js +++ b/web/js/views/media-info.js @@ -3,13 +3,29 @@ import { fmtDateTime, fmtSize } from '../dom.js'; import { escapeHtml, fmtDur, toast } from '../utils.js'; import { selectedMediaId } from './media-actions.js'; -export function initMediaInfo() { +let callbacks = {}; + +export function initMediaInfo(options = {}) { + callbacks = options; const modal = document.getElementById('media-info-modal'); const closeBtn = document.getElementById('media-info-close'); closeBtn?.addEventListener('click', closeMediaInfo); modal?.addEventListener('click', (e) => { if (e.target === modal) closeMediaInfo(); }); + modal?.addEventListener('click', async (e) => { + const button = e.target.closest('[data-media-info-action]'); + if (!button) return; + const id = button.dataset.mediaId; + if (!id) return; + let updated = false; + if (button.dataset.mediaInfoAction === 'mark-finished') { + updated = await callbacks.markAsFinished?.(id); + } else if (button.dataset.mediaInfoAction === 'mark-not-started') { + updated = await callbacks.markAsNotStarted?.(id); + } + if (updated) await openMediaInfo(id); + }); } export function closeMediaInfo() { @@ -113,6 +129,10 @@ function renderMediaInfo(detail) { .join(''); const raw = escapeHtml(JSON.stringify(detail || {}, null, 2)); return ` + <div class="flex flex-wrap gap-2 mt-1 mb-2"> + <button class="btn btn-ghost btn-sm" type="button" data-media-info-action="mark-finished" data-media-id="${escapeHtml(String(media.id || ''))}">✓ Finished</button> + <button class="btn btn-ghost btn-sm" type="button" data-media-info-action="mark-not-started" data-media-id="${escapeHtml(String(media.id || ''))}">↺ Not started</button> + </div> <table class="media-info-table">${table}</table> <details class="media-info-raw"> <summary>Raw API detail</summary> |
