summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-30 22:45:54 +0300
committerPaul Buetow <paul@buetow.org>2026-04-30 22:45:54 +0300
commit7483a67557ee30911e36c851e7bd5591740ba7f8 (patch)
treee6a3f052b5a5bb6c900d61a9772ad636c44c6a11 /web
parent4b7614b5b234181318be38b6ef4b471faf161498 (diff)
[ma] Add missing SPA controls: download, tags, thumbnail/cover regen, upload, filesize display, duration/filesize/tag filters, favorites
Diffstat (limited to 'web')
-rw-r--r--web/css/components.css8
-rw-r--r--web/css/layout.css16
-rw-r--r--web/index.html37
-rw-r--r--web/js/api.js7
-rw-r--r--web/js/app.js159
-rw-r--r--web/js/keyboard.js2
-rw-r--r--web/js/state.js2
7 files changed, 224 insertions, 7 deletions
diff --git a/web/css/components.css b/web/css/components.css
index 90b48d9..d1ee759 100644
--- a/web/css/components.css
+++ b/web/css/components.css
@@ -83,6 +83,14 @@ label {
color: var(--text-secondary);
}
+.input-sm {
+ width: auto;
+ min-width: 5rem;
+ max-width: 8rem;
+ padding: 0.35rem 0.5rem;
+ font-size: 0.78rem;
+}
+
/* Error message */
.error-message {
color: var(--danger);
diff --git a/web/css/layout.css b/web/css/layout.css
index ccaeb88..8860f77 100644
--- a/web/css/layout.css
+++ b/web/css/layout.css
@@ -298,6 +298,22 @@ body {
.text-75 { font-size: 0.75rem; }
.text-90 { font-size: 0.9rem; }
.flex-1-18 { flex: 1 1 18rem; }
+.set-row {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ padding-right: 0.25rem;
+}
+.tag-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.2rem;
+ padding: 0.2rem 0.5rem;
+ border-radius: var(--radius-sm);
+ background: var(--accent-soft);
+ color: var(--accent);
+ font-size: 0.78rem;
+}
.grid-full { grid-column: 1 / -1; }
/* Toolbar */
diff --git a/web/index.html b/web/index.html
index ef607ac..4b63f13 100644
--- a/web/index.html
+++ b/web/index.html
@@ -24,6 +24,7 @@
<button id="admin-toggle" class="icon-btn hidden" title="Admin panel (a)">⚙</button>
<button id="theme-toggle" class="icon-btn" title="Toggle theme">☀</button>
<span id="user-name" class="text-secondary text-xs"></span>
+ <button id="upload-toggle" class="icon-btn" title="Upload">⬆</button>
<button id="logout-btn" class="btn btn-ghost btn-sm">Log out</button>
<button id="menu-btn" class="icon-btn" title="Menu" aria-label="Open sets">☰</button>
</header>
@@ -42,6 +43,11 @@
<option value="audio">Audio</option>
</select>
<button id="filter-favorites" class="btn btn-ghost btn-sm">❤ Favorites</button>
+ <input id="filter-tags" type="text" placeholder="Tags (comma)" aria-label="Filter by tags" class="input-sm">
+ <input id="filter-min-duration" type="number" min="0" placeholder="Min s" aria-label="Min duration seconds" class="input-sm">
+ <input id="filter-max-duration" type="number" min="0" placeholder="Max s" aria-label="Max duration seconds" class="input-sm">
+ <input id="filter-min-filesize" type="number" min="0" placeholder="Min MB" aria-label="Min file size MB" class="input-sm">
+ <input id="filter-max-filesize" type="number" min="0" placeholder="Max MB" aria-label="Max file size MB" class="input-sm">
<div class="spacer"></div>
<span id="result-count" class="text-muted text-xs"></span>
</div>
@@ -93,6 +99,37 @@
</div>
</div>
+<!-- Tags Modal -->
+<div id="tags-modal" class="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="tags-title">
+ <div class="modal">
+ <div class="modal-header">
+ <h3 id="tags-title">Tags</h3>
+ <div class="spacer"></div>
+ <button id="tags-close" class="icon-btn" aria-label="Close">✕</button>
+ </div>
+ <div id="tags-list" class="flex flex-wrap gap-2 mb-2"></div>
+ <div class="flex gap-2">
+ <input id="tags-new" type="text" placeholder="New tag…" autocomplete="off">
+ <button id="tags-add" class="btn btn-primary btn-sm">Add</button>
+ </div>
+ </div>
+</div>
+
+<!-- Upload Modal -->
+<div id="upload-modal" class="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="upload-title">
+ <div class="modal">
+ <div class="modal-header">
+ <h3 id="upload-title">Upload</h3>
+ <div class="spacer"></div>
+ <button id="upload-close" class="icon-btn" aria-label="Close">✕</button>
+ </div>
+ <form id="upload-form" class="flex flex-col gap-2">
+ <input id="upload-file" type="file" required>
+ <button type="submit" class="btn btn-primary btn-sm">Upload to current set</button>
+ </form>
+ </div>
+</div>
+
<!-- Admin Modal -->
<div id="admin-modal" class="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="admin-title">
<div class="modal modal-wide">
diff --git a/web/js/api.js b/web/js/api.js
index 6c2f0df..02e30c9 100644
--- a/web/js/api.js
+++ b/web/js/api.js
@@ -55,6 +55,13 @@ export const API = {
saveNote: (id, content) => api(`/api/media/${id}/notes`, { method: 'POST', body: { content } }),
deleteNote: (id) => api(`/api/media/${id}/notes`, { method: 'DELETE' }),
share: (id) => api(`/api/media/${id}/shares`, { method: 'POST' }),
+ tags: () => api('/api/tags'),
+ download: (id) => api(`/api/media/${id}/download`),
+ regenThumbnail: (id) => api(`/api/media/${id}/thumbnail`, { method: 'POST' }),
+ regenCover: (setId) => api(`/api/sets/${setId}/cover`, { method: 'POST' }),
+ upload: (setId, formData) => api(`/api/sets/${setId}/upload`, { method: 'POST', body: formData }),
+ addTag: (id, tag) => api(`/api/media/${id}/tags`, { method: 'POST', body: { tag } }),
+ removeTag: (id, tag) => api(`/api/media/${id}/tags/${encodeURIComponent(tag)}`, { method: 'DELETE' }),
trash: () => api('/api/admin/trash'),
users: () => api('/api/admin/users'),
createUser: (body) => api('/api/admin/users', { method: 'POST', body }),
diff --git a/web/js/app.js b/web/js/app.js
index f0dc5aa..ea1021c 100644
--- a/web/js/app.js
+++ b/web/js/app.js
@@ -10,7 +10,6 @@ import { initAdmin } from './admin.js';
import { state, setMedia } from './state.js';
import { initPWA } from './pwa.js';
-const qs = document.querySelector.bind(document);
const pageMap = { '/index.html': 'spa', '/login.html': 'login', '/bootstrap.html': 'bootstrap' };
main();
@@ -98,10 +97,13 @@ async function initApp() {
share: () => shareSelected(),
search: focusSearch,
notes: openNotesForSelected,
+ tags: openTagsForSelected,
+ download: downloadSelected,
});
initNotes(() => toast('Note saved'));
initAdmin();
initPWA();
+ initUpload();
// Filters
document.getElementById('filter-type')?.addEventListener('change', (e) => { state.filters.type = e.target.value; loadMedia(); });
@@ -110,6 +112,11 @@ async function initApp() {
e.target.classList.toggle('active', state.filters.favorites);
loadMedia();
});
+ document.getElementById('filter-tags')?.addEventListener('change', (e) => { state.filters.tags = e.target.value; loadMedia(); });
+ document.getElementById('filter-min-duration')?.addEventListener('change', (e) => { state.filters.minDuration = e.target.value; loadMedia(); });
+ document.getElementById('filter-max-duration')?.addEventListener('change', (e) => { state.filters.maxDuration = e.target.value; loadMedia(); });
+ document.getElementById('filter-min-filesize')?.addEventListener('change', (e) => { state.filters.minFilesizeMB = e.target.value; loadMedia(); });
+ document.getElementById('filter-max-filesize')?.addEventListener('change', (e) => { state.filters.maxFilesizeMB = e.target.value; loadMedia(); });
// Menu toggle
document.getElementById('menu-btn')?.addEventListener('click', () => {
@@ -119,11 +126,9 @@ async function initApp() {
try { await API.logout(); location.href = '/login.html'; } catch {}
});
- const user = await API.loginProfile ? API.loginProfile : undefined; // not a route; we just try to bootstrap auth via /api/sets
try {
const sets = await API.sets();
state.sets = sets || [];
- state.isAdmin = !!state.sets?.__isAdmin; // backend may not send this; we'll infer from admin endpoint later
// Admin check via admin users endpoint
API.users().then(() => { state.isAdmin = true; showAdmin(); }).catch(() => {});
renderSets();
@@ -137,13 +142,22 @@ async function initApp() {
const el = e.target.closest('.media-card, .media-row');
if (el) { selectByElement(el); playSelected(); }
});
+
+ // Tags modal close
+ document.getElementById('tags-close')?.addEventListener('click', closeTagsModal);
+ document.getElementById('tags-modal')?.addEventListener('click', (e) => { if (e.target === document.getElementById('tags-modal')) closeTagsModal(); });
+ document.getElementById('tags-add')?.addEventListener('click', addTagForSelected);
+ document.getElementById('tags-new')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addTagForSelected(); } });
}
function renderSets() {
const el = document.getElementById('set-list');
if (!el) return;
el.innerHTML = state.sets.map((s) =>
- `<a href="#" class="set-item" data-id="${s.id}">${escapeHtml(s.name)}</a>`
+ `<div class="set-row">
+ <a href="#" class="set-item flex-1-18" data-id="${s.id}">${escapeHtml(s.name)}</a>
+ <button class="icon-btn btn-sm" data-cover-set="${s.id}" title="Regenerate cover">🔄</button>
+ </div>`
).join('');
el.querySelectorAll('.set-item').forEach((a) => {
a.addEventListener('click', (e) => {
@@ -153,6 +167,14 @@ function renderSets() {
loadMedia();
});
});
+ el.querySelectorAll('[data-cover-set]').forEach((b) => {
+ b.addEventListener('click', async (e) => {
+ e.stopPropagation();
+ const id = b.dataset.coverSet;
+ try { await API.regenCover(id); toast('Cover regenerated'); }
+ catch (err) { toast(err.message || 'Cover failed', 'error'); }
+ });
+ });
}
function setActiveSet(id) {
@@ -179,6 +201,11 @@ async function loadMedia() {
type: state.filters.type,
search: state.filters.search,
favorites: state.filters.favorites ? 'true' : '',
+ tags: state.filters.tags || '',
+ min_duration: state.filters.minDuration || '',
+ max_duration: state.filters.maxDuration || '',
+ filesize_min: state.filters.minFilesizeMB ? String(parseInt(state.filters.minFilesizeMB, 10) << 20) : '',
+ filesize_max: state.filters.maxFilesizeMB ? String(parseInt(state.filters.maxFilesizeMB, 10) << 20) : '',
sort: isShuffle() ? 'random' : 'name',
limit: '200',
};
@@ -202,19 +229,26 @@ function renderGrid(items) {
const playBtn = el.querySelector('[data-action="play"]');
const favBtn = el.querySelector('[data-action="favorite"]');
const noteBtn = el.querySelector('[data-action="notes"]');
+ const downloadBtn = el.querySelector('[data-action="download"]');
+ const tagBtn = el.querySelector('[data-action="tags"]');
+ const thumbBtn = el.querySelector('[data-action="regen-thumb"]');
playBtn?.addEventListener('click', (e) => { e.stopPropagation(); selectByElement(el); playSelected(); });
favBtn?.addEventListener('click', (e) => { e.stopPropagation(); toggleFavorite(el.dataset.id, favBtn); });
noteBtn?.addEventListener('click', (e) => { e.stopPropagation(); openNotesForSelected(); });
+ downloadBtn?.addEventListener('click', (e) => { e.stopPropagation(); window.open(`/api/media/${el.dataset.id}/download`, '_blank'); });
+ tagBtn?.addEventListener('click', (e) => { e.stopPropagation(); openTagsForElement(el); });
+ thumbBtn?.addEventListener('click', (e) => { e.stopPropagation(); regenThumb(el.dataset.id); });
});
}
function renderItem(m, index) {
+ const sizeText = fmtSize(m.file_size_bytes);
if (m.type === 'video') {
return `
<div class="media-card" data-id="${m.id}" data-index="${index}" tabindex="0" role="button" aria-label="${escapeHtml(m.file_name)}">
<div class="thumb-wrap">
${m.thumbnail_path ? `<img src="/api/media/${m.id}/thumbnail" alt="" loading="lazy">` : `<span class="placeholder">No image</span>`}
- <span class="badge">${fmtDur(m.duration)}</span>
+ <span class="badge">${fmtDur(m.duration)}${sizeText ? ' • ' + sizeText : ''}</span>
</div>
<div class="meta">
<div class="title">${escapeHtml(m.file_name)}</div>
@@ -224,6 +258,9 @@ 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>
+ <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>
</div>
</div>
`;
@@ -233,12 +270,15 @@ function renderItem(m, index) {
<span class="row-icon">🎵</span>
<div class="row-body">
<div class="row-title">${escapeHtml(m.file_name)}</div>
- <div class="row-meta">${escapeHtml(m.codec || '')} ${m.bitrate ? Math.round(m.bitrate/1000)+'kbps' : ''}</div>
+ <div class="row-meta">${escapeHtml(m.codec || '')} ${m.bitrate ? Math.round(m.bitrate/1000)+'kbps' : ''} ${sizeText ? '• ' + sizeText : ''}</div>
</div>
<span class="row-duration">${fmtDur(m.duration)}</span>
<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>
+ <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>
</div>
`;
}
@@ -290,6 +330,104 @@ async function openNotesForSelected() {
openNotes(id, content);
}
+async function downloadSelected() {
+ const el = currentElement();
+ if (!el) return;
+ window.open(`/api/media/${el.dataset.id}/download`, '_blank');
+}
+
+// Tags modal
+let tagsCurrentMediaId = null;
+
+async function openTagsForSelected() {
+ const el = currentElement();
+ if (!el) return;
+ openTagsForElement(el);
+}
+
+async function openTagsForElement(el) {
+ const id = el.dataset.id;
+ tagsCurrentMediaId = id;
+ const detail = await API.mediaDetail(id);
+ const tags = detail?.tags || [];
+ renderTagsList(tags);
+ document.getElementById('tags-modal')?.classList.add('open');
+ document.getElementById('tags-new')?.focus();
+}
+
+function closeTagsModal() {
+ document.getElementById('tags-modal')?.classList.remove('open');
+ tagsCurrentMediaId = null;
+}
+
+function renderTagsList(tags) {
+ const el = document.getElementById('tags-list');
+ if (!el) return;
+ if (!tags.length) { el.innerHTML = '<span class="text-muted text-xs">No tags.</span>'; return; }
+ el.innerHTML = tags.map((t) =>
+ `<span class="tag-chip">${escapeHtml(t.name)} <button class="icon-btn btn-sm tag-remove" data-tag="${escapeHtml(t.name)}" title="Remove">✕</button></span>`
+ ).join('');
+ el.querySelectorAll('.tag-remove').forEach((b) => {
+ b.addEventListener('click', async () => {
+ if (!tagsCurrentMediaId) return;
+ try {
+ await API.removeTag(tagsCurrentMediaId, b.dataset.tag);
+ const detail = await API.mediaDetail(tagsCurrentMediaId);
+ renderTagsList(detail?.tags || []);
+ } catch (err) { toast(err.message || 'Remove tag failed', 'error'); }
+ });
+ });
+}
+
+async function addTagForSelected() {
+ if (!tagsCurrentMediaId) return;
+ const input = document.getElementById('tags-new');
+ const name = input?.value.trim();
+ if (!name) return;
+ try {
+ await API.addTag(tagsCurrentMediaId, name);
+ input.value = '';
+ const detail = await API.mediaDetail(tagsCurrentMediaId);
+ renderTagsList(detail?.tags || []);
+ } catch (err) { toast(err.message || 'Add tag failed', 'error'); }
+}
+
+async function regenThumb(mediaId) {
+ try {
+ await API.regenThumbnail(mediaId);
+ toast('Thumbnail regenerated');
+ } catch (err) { toast(err.message || 'Thumbnail failed', 'error'); }
+}
+
+// Upload modal
+function initUpload() {
+ const modal = document.getElementById('upload-modal');
+ const closeBtn = document.getElementById('upload-close');
+ const form = document.getElementById('upload-form');
+ const fileInput = document.getElementById('upload-file');
+
+ // Open via an icon button in header (already added in HTML)
+ document.getElementById('upload-toggle')?.addEventListener('click', () => modal?.classList.add('open'));
+ closeBtn?.addEventListener('click', () => modal?.classList.remove('open'));
+ modal?.addEventListener('click', (e) => { if (e.target === modal) modal.classList.remove('open'); });
+
+ form?.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ if (!state.selectedSetId) { toast('Select a set first', 'error'); return; }
+ const file = fileInput?.files[0];
+ if (!file) { toast('Choose a file', 'error'); return; }
+ const fd = new FormData();
+ fd.append('file', file);
+ try {
+ await API.upload(state.selectedSetId, fd);
+ toast('Upload complete');
+ fileInput.value = '';
+ modal?.classList.remove('open');
+ loadMedia();
+ } catch (err) { toast(err.message || 'Upload failed', 'error'); }
+ });
+}
+
function showAdmin() {
const btn = document.getElementById('admin-toggle');
if (btn) btn.classList.remove('hidden');
@@ -305,6 +443,15 @@ function fmtDur(s) {
return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
}
+function fmtSize(bytes) {
+ if (!bytes || bytes <= 0) return '';
+ const kb = bytes / 1024;
+ if (kb < 1024) return Math.round(kb) + ' KB';
+ const mb = kb / 1024;
+ if (mb < 1024) return Math.round(mb * 10) / 10 + ' MB';
+ return Math.round((mb / 1024) * 10) / 10 + ' GB';
+}
+
function escapeHtml(s) {
return (s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
diff --git a/web/js/keyboard.js b/web/js/keyboard.js
index 58ba0f3..dbf1ac0 100644
--- a/web/js/keyboard.js
+++ b/web/js/keyboard.js
@@ -23,6 +23,8 @@ export function initKeyboard(handlers) {
case 's': handlers.share?.(e); break;
case '/': e.preventDefault(); handlers.search?.(e); break;
case 'n': handlers.notes?.(e); break;
+ case 't': handlers.tags?.(e); break;
+ case 'd': handlers.download?.(e); break;
}
});
}
diff --git a/web/js/state.js b/web/js/state.js
index 6cdfe1d..fca16a6 100644
--- a/web/js/state.js
+++ b/web/js/state.js
@@ -3,7 +3,7 @@ export const state = {
sets: [],
selectedSetId: null,
media: [],
- filters: { type: '', search: '', favorites: false, tags: '', sort: 'name' },
+ filters: { type: '', search: '', favorites: false, tags: '', sort: 'name', minDuration: '', maxDuration: '', minFilesizeMB: '', maxFilesizeMB: '' },
isAdmin: false,
};