summaryrefslogtreecommitdiff
path: root/web/js
diff options
context:
space:
mode:
Diffstat (limited to 'web/js')
-rw-r--r--web/js/api.js2
-rw-r--r--web/js/app.js62
-rw-r--r--web/js/state.js1
-rw-r--r--web/js/tests/progress-actions.test.js120
-rw-r--r--web/js/views/media-actions.js21
-rw-r--r--web/js/views/media-grid.js41
-rw-r--r--web/js/views/media-info.js22
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>