diff options
Diffstat (limited to 'web')
| -rw-r--r-- | web/css/components.css | 23 | ||||
| -rw-r--r-- | web/index.html | 28 | ||||
| -rw-r--r-- | web/js/api.js | 2 | ||||
| -rw-r--r-- | web/js/app.js | 125 | ||||
| -rw-r--r-- | web/js/keyboard.js | 34 | ||||
| -rw-r--r-- | web/js/state.js | 1 | ||||
| -rw-r--r-- | web/share.html | 27 |
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">✕</button> + </div> + <div id="shares-list"></div> + <div class="shares-hint"> + <kbd>↑</kbd> <kbd>↓</kbd> <kbd>k</kbd> <kbd>j</kbd> navigate · + <kbd>Enter</kbd> copy · + <kbd>Delete</kbd> <kbd>d</kbd> revoke · + <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}">📋</button> + <button class="icon-btn btn-sm" title="Revoke" data-revoke="${sh.token}">✕</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">▶</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">⛺</button> + <button id="btn-play" class="icon-btn" aria-label="Play/Pause">▶</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">🔊</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">⭳</button> + <button id="btn-fullscreen" class="icon-btn" aria-label="Fullscreen">⛺</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> |
