summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
Diffstat (limited to 'web')
-rw-r--r--web/css/components.css23
-rw-r--r--web/index.html28
-rw-r--r--web/js/api.js2
-rw-r--r--web/js/app.js125
-rw-r--r--web/js/keyboard.js34
-rw-r--r--web/js/state.js1
-rw-r--r--web/share.html27
7 files changed, 216 insertions, 24 deletions
diff --git a/web/css/components.css b/web/css/components.css
index d1ee759..f81fecf 100644
--- a/web/css/components.css
+++ b/web/css/components.css
@@ -140,3 +140,26 @@ label {
.toast.info { border-left: 4px solid var(--accent); }
.toast.success { border-left: 4px solid var(--success); }
.toast.error { border-left: 4px solid var(--danger); }
+
+/* Share rows */
+.share-row { cursor: pointer; outline: none; }
+.share-row.selected { background: var(--accent-soft); border-left: 3px solid var(--accent); padding-left: calc(0.25rem - 3px); }
+.share-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
+
+.shares-hint {
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+ padding: 0.5rem 0.25rem;
+ text-align: center;
+ border-top: 1px solid var(--border);
+}
+.shares-hint kbd {
+ display: inline-block;
+ padding: 0.08rem 0.3rem;
+ font-family: inherit;
+ font-size: 0.7rem;
+ background: var(--bg-elevated);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ color: var(--text-primary);
+}
diff --git a/web/index.html b/web/index.html
index c5a124f..dc8f1f0 100644
--- a/web/index.html
+++ b/web/index.html
@@ -124,14 +124,10 @@
<tr><td><kbd>Backspace</kbd></td><td>Go up one folder</td></tr>
<tr><td><kbd>r</kbd></td><td>Toggle shuffle</td></tr>
<tr><td><kbd>s</kbd></td><td>Toggle sets sidebar</td></tr>
- <tr><td><kbd>/</kbd></td><td>Focus search bar</td></tr>
- <tr><td><kbd>n</kbd></td><td>Open notes for selected media</td></tr>
- <tr><td><kbd>t</kbd></td><td>Toggle toolbar / filters</td></tr>
- <tr><td><kbd>,</kbd></td><td>Focus min duration filter</td></tr>
- <tr><td><kbd>.</kbd></td><td>Focus max duration filter</td></tr>
- <tr><td><kbd>?</kbd></td><td>Show / hide this help</td></tr>
+ <tr><td><kbd>S</kbd></td><td>Share selected media</td></tr>
+ <tr><td><kbd>L</kbd></td><td>My Shares (opens list)</td></tr>
</table>
- <p class="text-muted text-xs mt-2">Click the <strong>?</strong> button or press <kbd>?</kbd> anytime to show or hide this help.</p>
+ <p class="text-muted text-xs mt-2">Inside the <strong>My Shares</strong> list: <kbd>↑</kbd> / <kbd>↓</kbd> or <kbd>k</kbd> / <kbd>j</kbd> to navigate; <kbd>Enter</kbd> to copy the selected link; <kbd>Delete</kbd> or <kbd>d</kbd> to revoke it; <kbd>Esc</kbd> to close.</p>
</div>
</div>
</div>
@@ -215,5 +211,23 @@
</div>
</div>
</div>
+<!-- Shares Modal -->
+<div id="shares-modal" class="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="shares-title">
+ <div class="modal modal-wide">
+ <div class="modal-header">
+ <h3 id="shares-title">My Shares</h3>
+ <div class="spacer"></div>
+ <button id="shares-close" class="icon-btn" aria-label="Close">&#10005;</button>
+ </div>
+ <div id="shares-list"></div>
+ <div class="shares-hint">
+ <kbd>&#8593;</kbd> <kbd>&#8595;</kbd> <kbd>k</kbd> <kbd>j</kbd> navigate &middot;
+ <kbd>Enter</kbd> copy &middot;
+ <kbd>Delete</kbd> <kbd>d</kbd> revoke &middot;
+ <kbd>Esc</kbd> close
+ </div>
+ </div>
+</div>
+
</body>
</html>
diff --git a/web/js/api.js b/web/js/api.js
index 8e80a0d..518a58b 100644
--- a/web/js/api.js
+++ b/web/js/api.js
@@ -55,6 +55,8 @@ 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' }),
+ myShares: () => api('/api/shares'),
+ revokeShare: (token) => api(`/api/shares/${token}`, { method: 'DELETE' }),
tags: () => api('/api/tags'),
download: (id) => api(`/api/media/${id}/download`),
regenThumbnail: (id) => api(`/api/media/${id}/thumbnail`, { method: 'POST' }),
diff --git a/web/js/app.js b/web/js/app.js
index 2bd6aef..ebb9017 100644
--- a/web/js/app.js
+++ b/web/js/app.js
@@ -120,6 +120,12 @@ async function initApp() {
backspace: () => { navigateBack(); },
sidebar: toggleSidebar,
upload: () => showUpload(),
+ sharesToggle: toggleShares,
+ isSharesOpen: () => document.getElementById('shares-modal')?.classList.contains('open'),
+ sharesNavUp: () => sharesNav(-1),
+ sharesNavDown: () => sharesNav(1),
+ sharesCopy: copySelectedShare,
+ sharesDelete: deleteSelectedShare,
focusMinDuration: () => focusFilter('filter-min-duration'),
focusMaxDuration: () => focusFilter('filter-max-duration'),
});
@@ -128,6 +134,7 @@ async function initApp() {
initPWA();
initUpload();
initHelp();
+ initShares();
// Filters
document.getElementById('filter-type')?.addEventListener('change', (e) => { state.filters.type = e.target.value; state.folderPath = ''; loadMedia(); });
@@ -704,6 +711,124 @@ function showAdmin() {
if (btn) btn.classList.remove('hidden');
}
+// Shares modal
+function initShares() {
+ const modal = document.getElementById('shares-modal');
+ const closeBtn = document.getElementById('shares-close');
+
+ closeBtn?.addEventListener('click', () => closeSharesModal());
+ modal?.addEventListener('click', (e) => { if (e.target === modal) closeSharesModal(); });
+}
+
+function closeSharesModal() {
+ const modal = document.getElementById('shares-modal');
+ modal?.classList.remove('open');
+ state.sharesCurrentRow = -1;
+}
+
+async function toggleShares() {
+ const modal = document.getElementById('shares-modal');
+ if (!modal) return;
+ if (modal.classList.contains('open')) {
+ closeSharesModal();
+ return;
+ }
+ try {
+ const shares = await API.myShares();
+ renderSharesList(shares || []);
+ modal.classList.add('open');
+ } catch (err) {
+ toast(err.message || 'Failed to load shares', 'error');
+ }
+}
+
+function renderSharesList(shares) {
+ const el = document.getElementById('shares-list');
+ if (!el) return;
+ state.sharesData = shares;
+ state.sharesCurrentRow = -1;
+ if (!shares.length) {
+ el.innerHTML = '<p class="text-muted text-sm">No share links yet.</p>';
+ return;
+ }
+ const now = new Date();
+ el.innerHTML = shares.map((sh, i) => {
+ const expires = new Date(sh.expires_at);
+ const expired = expires < now;
+ const url = `${location.origin}/s/${sh.token}`;
+ return `
+ <div class="share-row flex gap-2 align-center py-1 border-b${i === 0 ? ' selected' : ''}" data-index="${i}" tabindex="0">
+ <span class="flex-1 text-sm truncate">${escapeHtml(sh.file_name || 'Unknown')}</span>
+ <span class="text-xs text-muted">${sh.media_type === 'video' ? '🎬' : '🎵'} ${expired ? '<span class="text-danger">Expired</span>' : fmtDate(expires)}</span>
+ <button class="icon-btn btn-sm" title="Copy link" data-copy="${url}">&#128203;</button>
+ <button class="icon-btn btn-sm" title="Revoke" data-revoke="${sh.token}">&#10005;</button>
+ </div>
+ `;
+ }).join('');
+ state.sharesCurrentRow = 0;
+ updateSharesSelection();
+ el.querySelectorAll('[data-copy]').forEach((b) => {
+ b.addEventListener('click', () => {
+ navigator.clipboard?.writeText(b.dataset.copy);
+ toast('Link copied');
+ });
+ });
+ el.querySelectorAll('[data-revoke]').forEach((b) => {
+ b.addEventListener('click', async () => {
+ try {
+ await API.revokeShare(b.dataset.revoke);
+ toast('Share revoked');
+ const shares = await API.myShares();
+ renderSharesList(shares || []);
+ } catch (err) { toast(err.message || 'Revoke failed', 'error'); }
+ });
+ });
+}
+
+function updateSharesSelection() {
+ const rows = document.querySelectorAll('#shares-list .share-row');
+ rows.forEach((r, i) => {
+ r.classList.toggle('selected', i === state.sharesCurrentRow);
+ if (i === state.sharesCurrentRow) r.focus();
+ });
+}
+
+function sharesNav(delta) {
+ const rows = document.querySelectorAll('#shares-list .share-row');
+ if (!rows.length) return;
+ state.sharesCurrentRow = Math.max(0, Math.min(rows.length - 1, state.sharesCurrentRow + delta));
+ updateSharesSelection();
+}
+
+function copySelectedShare() {
+ const rows = document.querySelectorAll('#shares-list .share-row');
+ const row = rows[state.sharesCurrentRow];
+ if (!row) return;
+ const copyBtn = row.querySelector('[data-copy]');
+ if (!copyBtn) { toast('Nothing to copy'); return; }
+ navigator.clipboard?.writeText(copyBtn.dataset.copy).then(() => toast('Share link copied'));
+}
+
+async function deleteSelectedShare() {
+ const rows = document.querySelectorAll('#shares-list .share-row');
+ const row = rows[state.sharesCurrentRow];
+ if (!row) { toast('No share selected'); return; }
+ const revokeBtn = row.querySelector('[data-revoke]');
+ if (!revokeBtn) return;
+ try {
+ await API.revokeShare(revokeBtn.dataset.revoke);
+ toast('Share revoked');
+ const shares = await API.myShares();
+ renderSharesList(shares || []);
+ } catch (err) { toast(err.message || 'Revoke failed', 'error'); }
+}
+
+function fmtDate(d) {
+ if (!d) return '';
+ const dt = typeof d === 'string' ? new Date(d) : d;
+ return dt.toLocaleDateString();
+}
+
function fmtDur(s) {
if (!s || s < 0) return '0:00';
const h = Math.floor(s / 3600);
diff --git a/web/js/keyboard.js b/web/js/keyboard.js
index 62dc652..1206559 100644
--- a/web/js/keyboard.js
+++ b/web/js/keyboard.js
@@ -2,6 +2,38 @@ export function initKeyboard(handlers) {
document.addEventListener('keydown', (e) => {
const tag = e.target.tagName;
const editing = tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable;
+
+ // Shares modal keyboard navigation (overrides global keys while open)
+ if (handlers.isSharesOpen?.()) {
+ switch (e.key) {
+ case 'ArrowUp':
+ case 'k':
+ e.preventDefault();
+ handlers.sharesNavUp?.(e);
+ return;
+ case 'ArrowDown':
+ case 'j':
+ e.preventDefault();
+ handlers.sharesNavDown?.(e);
+ return;
+ case 'Enter':
+ e.preventDefault();
+ handlers.sharesCopy?.(e);
+ return;
+ case 'Delete':
+ case 'd':
+ e.preventDefault();
+ handlers.sharesDelete?.(e);
+ return;
+ case 'Escape':
+ e.preventDefault();
+ handlers.sharesToggle?.(e);
+ return;
+ }
+ // Allow only Escape/Enter/Arrows/k/j/d inside the shares modal; ignore everything else
+ return;
+ }
+
if (editing) {
if (e.key === 'Escape') {
e.target.blur();
@@ -79,6 +111,7 @@ export function initKeyboard(handlers) {
case 'Backspace': handlers.backspace?.(e); break;
case 'r': handlers.shuffle?.(e); break;
case 's': handlers.sidebar?.(e); break;
+ case 'S': handlers.share?.(e); break;
case '/':
e.preventDefault();
handlers.search?.(e);
@@ -87,6 +120,7 @@ export function initKeyboard(handlers) {
case 't': handlers.toolbar?.(e); break;
case 'd': handlers.download?.(e); break;
case 'u': handlers.upload?.(e); break;
+ case 'L': handlers.sharesToggle?.(e); break;
case '?':
e.preventDefault();
handlers.help?.(e);
diff --git a/web/js/state.js b/web/js/state.js
index e8cc632..4d580b9 100644
--- a/web/js/state.js
+++ b/web/js/state.js
@@ -7,6 +7,7 @@ export const state = {
filters: { type: '', search: '', favorites: false, tags: '', sort: 'name', minDuration: '', maxDuration: '' },
isAdmin: false,
folderPath: '', // current subfolder path within the selected set
+ sharesCurrentRow: -1,
};
export function getState() { return state; }
diff --git a/web/share.html b/web/share.html
index 8ca053a..f8f7bee 100644
--- a/web/share.html
+++ b/web/share.html
@@ -35,13 +35,6 @@
font-size: 1.25rem;
color: var(--text-primary);
}
- .back-link {
- margin-top: 1.5rem;
- color: var(--accent);
- text-decoration: none;
- font-size: 0.9rem;
- }
- .back-link:hover { color: var(--accent-hover); }
/* Player overrides for standalone share page */
.player {
@@ -74,12 +67,12 @@
<audio id="media-audio" class="hidden"></audio>
<img id="cover-art" class="cover-art hidden" alt="Cover" loading="lazy">
<div id="big-play" class="big-play" role="button" aria-label="Play" tabindex="0">
- <div class="play-icon">▶</div>
+ <div class="play-icon">&#9654;</div>
</div>
</div>
<div class="controls">
- <button id="btn-toggle-stage" class="icon-btn" aria-label="Toggle stage">⛶</button>
- <button id="btn-play" class="icon-btn" aria-label="Play/Pause">▶</button>
+ <button id="btn-toggle-stage" class="icon-btn" aria-label="Toggle stage">&#9978;</button>
+ <button id="btn-play" class="icon-btn" aria-label="Play/Pause">&#9654;</button>
<div class="time" id="time-elapsed">0:00</div>
<div id="progress-track" class="progress-track" tabindex="0" aria-label="Seek">
<div id="progress-fill" class="progress-fill"></div>
@@ -87,13 +80,13 @@
</div>
<div class="time" id="time-total">0:00</div>
<div class="volume-control">
- <button id="btn-mute" class="icon-btn" aria-label="Mute">🔊</button>
+ <button id="btn-mute" class="icon-btn" aria-label="Mute">&#128266;</button>
<input id="volume-slider" type="range" min="0" max="1" step="0.05" value="1" class="volume-slider" aria-label="Volume">
</div>
- <button id="btn-fullscreen" class="icon-btn" aria-label="Fullscreen">⛶</button>
+ <button id="btn-download" class="icon-btn" aria-label="Download">&#11123;</button>
+ <button id="btn-fullscreen" class="icon-btn" aria-label="Fullscreen">&#9978;</button>
</div>
</aside>
- <a class="back-link" href="/">Back to Home</a>
</div>
</div>
@@ -102,7 +95,6 @@
initPlayer();
- // Parse injected metadata
const metaEl = document.getElementById('share-meta');
let data = null;
try {
@@ -111,9 +103,10 @@
if (data && data.media) {
loadMediaDirect(data.media, data.stream_url, data.thumb_url, 0);
- // Auto-play on first click via the big play overlay handled by player.js,
- // but start paused so the controls are visible. The overlay click already
- // wires to togglePlay.
+ const dlBtn = document.getElementById('btn-download');
+ if (dlBtn && data.download_url) {
+ dlBtn.addEventListener('click', () => { location.href = data.download_url; });
+ }
}
</script>