diff --git a/bundled-addons/pithos/addon.json b/bundled-addons/pithos/addon.json index 33eae35d..cbde1669 100644 --- a/bundled-addons/pithos/addon.json +++ b/bundled-addons/pithos/addon.json @@ -1,7 +1,7 @@ { "id": "pithos", "name": "Pithos", - "version": "0.3.8", + "version": "0.3.9", "description": "Run s3d, the Sia S3 gateway, from the Theseus sidebar: connect it to a Sia indexer, create S3 users and access keys, browse and share buckets, and watch uploads reach Sia.", "author": "Silent Mode", "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM2RkYzk3Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMWU4ZjZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIyIDZoMjB2NWMwIDItMiAzLTIgNSA4IDMgMTQgMTEgMTQgMjEgMCAxMi05IDIxLTIyIDIxUzEwIDQ5IDEwIDM3YzAtMTAgNi0xOCAxNC0yMSAwLTItMi0zLTItNXoiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMTQgMzBoMzZNMTMgNDBoMzgiIHN0cm9rZT0iIzBiMWYxOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9Ii41NSIvPjwvc3ZnPgo=", diff --git a/bundled-addons/pithos/ui/app.css b/bundled-addons/pithos/ui/app.css index 5e500575..8a576d70 100644 --- a/bundled-addons/pithos/ui/app.css +++ b/bundled-addons/pithos/ui/app.css @@ -184,7 +184,7 @@ pre.snippet { background: var(--surface-2); padding: 10px 12px; border-radius: 8 .steps span.on { background: var(--accent-soft); color: var(--accent); font-weight: 600; } /* modal + toasts */ -.modal-backdrop { position: fixed; inset: 0; background: rgb(0 0 0 / .45); display: grid; place-items: center; padding: 16px; z-index: 10; } +.modal-backdrop { position: fixed; inset: 0; background: rgb(0 0 0 / .45); display: grid; place-items: center; padding: 16px; z-index: 25; } .modal { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 22px; width: min(560px, 100%); max-height: 90vh; overflow: auto; } .modal h2 { margin-bottom: 14px; } .modal .foot { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; } @@ -399,3 +399,20 @@ body.has-foot.has-player .toasts { bottom: 112px; } .narrow-only { display: inline-flex; align-items: center; } table.files td.actions { padding-left: 2px; } } + +/* Player volume */ +.pl-volume { position: relative; display: flex; align-items: center; gap: 4px; flex: none; } +.pl-volpop { display: flex; align-items: center; } +.pl-volpop > .pl-btn { display: none; } +.pl-vol { width: 90px; accent-color: var(--accent); margin: 0; } +.pl-volbtn { font-size: 15px; } +@media (max-width: 760px) { + .pl-volpop { display: none; position: absolute; bottom: calc(100% + 10px); right: -6px; padding: 8px 10px; gap: 6px; + background: var(--surface); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 8px 24px rgb(0 0 0 / .2); } + .pl-volume.open .pl-volpop { display: flex; } + .pl-volpop > .pl-btn { display: grid; } + .pl-vol { width: 120px; } +} +/* "More options" disclosure in dialogs */ +details.more-opts > summary { cursor: pointer; color: var(--muted); font-size: 13px; } +details.more-opts > summary:hover { color: var(--text); } diff --git a/bundled-addons/pithos/ui/app.js b/bundled-addons/pithos/ui/app.js index d2cf5a73..43ce311d 100644 --- a/bundled-addons/pithos/ui/app.js +++ b/bundled-addons/pithos/ui/app.js @@ -1291,7 +1291,8 @@ async function accessDialog(user, bucket) { opts.map(([val, label, desc]) => h('label', { class: 'check', style: 'align-items:flex-start' }, h('input', { type: 'radio', name: 'acc', checked: choice === val, onchange: () => { choice = val; } }), h('span', {}, h('strong', {}, label), h('br'), h('span', { class: 'small muted' }, desc)))), - h('p', { class: 'small muted', style: 'margin:0' }, 'Public objects are served at ', h('code', {}, `${endpoint}/${bucket}/`), '. Writes and deletes always need a key.'), + h('p', { class: 'small muted', style: 'margin:0' }, 'Public objects are served at ', h('code', {}, `${endpoint}/${bucket}/`), '. Writes and deletes always need a key. You can change this at any time.'), + localOnly() && h('div', { class: 'banner info small', style: 'margin:0' }, h('span', {}, 'Right now s3d listens only on this computer, so "public" means public to programs on this computer. Other people reach public files once s3d has a public address.')), h('p', { class: 'small muted', style: 'margin:0' }, 'Access applies to the whole bucket: s3d cannot make a single folder public, or give other people write rights. Keep public files in a bucket of their own, or send a share link for a single file.')), [{ label: 'Cancel', result: null }, { label: 'Save', kind: 'primary', submit: true, value: () => choice }]); if (!v || v === current) return; @@ -1526,26 +1527,40 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) { try { const r = await api('DELETE', `${base}/prefix?prefix=${encodeURIComponent(p)}`); toast(`Deleted ${r.deleted} objects`); load(); } catch (e) { fail(e); } } + // Share: the link is made as soon as the dialog opens and remade when the + // validity or address changes. s3d listens only on this computer, so + // without an address of your own the link opens only here; it says so. async function share(key) { - const expires = h('select', {}, [['3600', '1 hour'], ['86400', '1 day'], ['604800', '7 days (max)']].map(([v, l]) => h('option', { value: v }, l))); + const expires = h('select', {}, [['3600', '1 hour'], ['86400', '1 day'], ['604800', '7 days (max)']].map(([v, l]) => h('option', { value: v, selected: v === '86400' }, l))); const savedEndpoint = localStorage.getItem('pithos.publicEndpoint') || ''; - const endpoint = h('input', { type: 'url', value: savedEndpoint, placeholder: `http://${state.status?.config.apiAddress}` }); - const out = h('div'); + const endpoint = h('input', { type: 'url', value: savedEndpoint, placeholder: 'https://files.example.com' }); + const out = h('div', {}, h('p', { class: 'muted small' }, 'Making the linkโ€ฆ')); + const note = h('div'); + let link = ''; const gen = async () => { const q = new URLSearchParams({ key, expires: expires.value }); - if (endpoint.value.trim()) { q.set('endpoint', endpoint.value.trim()); localStorage.setItem('pithos.publicEndpoint', endpoint.value.trim()); } + const own = endpoint.value.trim(); + try { localStorage.setItem('pithos.publicEndpoint', own); } catch {} + if (own) q.set('endpoint', own); try { - const { url } = await api('GET', `${base}/presign?${q}`); - put(out, secretRow('Link', url)); - } catch (e) { fail(e); } - return false; + ({ url: link } = await api('GET', `${base}/presign?${q}`)); + put(out, secretRow('Link', link)); + } catch (e) { put(out, h('p', { class: 'error small' }, e.message)); } + put(note, !own && localOnly() && h('div', { class: 'banner info small', style: 'margin:0' }, + h('span', {}, 'This link opens only on this computer: s3d listens on ', h('code', {}, state.status?.config.apiAddress || '127.0.0.1'), ', which other devices cannot reach. To share with other people, s3d needs a public address (for example your own server in front of it).'))); }; + expires.addEventListener('change', gen); + endpoint.addEventListener('change', gen); + gen(); await modal('Share link', h('div', { class: 'stack' }, h('p', { class: 'small muted', style: 'margin:0' }, 'A signed link that works without a key until it expires.'), h('label', { class: 'field' }, h('span', {}, 'Valid for'), expires), - h('label', { class: 'field' }, h('span', {}, 'Public address of s3d'), endpoint, h('small', {}, 'The address recipients will use to reach s3d (e.g. behind a reverse proxy). Leave blank for the local endpoint.')), - out), - [{ label: 'Close', result: true }, { label: 'Create link', kind: 'primary', submit: true, value: gen }]); + out, + note, + h('details', { class: 'more-opts', open: !!savedEndpoint }, + h('summary', {}, 'Use my own public address'), + h('label', { class: 'field', style: 'margin-top:8px' }, endpoint, h('small', {}, 'The address where other people reach this s3d, e.g. a reverse proxy on your server. The link is remade when you change it.')))), + [{ label: 'Copy link', value: async () => { if (link) await copy(link, 'Link copied'); return false; } }, { label: 'Done', kind: 'primary', submit: true, result: true }]); } load(); @@ -1577,9 +1592,33 @@ function player() { const prevBtn = h('button', { type: 'button', class: 'pl-btn', 'aria-label': 'Previous', title: 'Previous', onclick: () => prev() }, 'โฎ'); const nextBtn = h('button', { type: 'button', class: 'pl-btn', 'aria-label': 'Next', title: 'Next', onclick: () => next() }, 'โญ'); const closeBtn = h('button', { type: 'button', class: 'pl-btn', 'aria-label': 'Stop and close the player', title: 'Close', onclick: () => close() }, 'โœ•'); + // Volume: a slider beside a mute button; on narrow screens the button + // opens the slider in a small pop-up above it. Remembered between visits. + const saved = (() => { try { return JSON.parse(localStorage.getItem('pithos.volume') || 'null'); } catch { return null; } })(); + audio.volume = saved && isFinite(saved.v) ? Math.min(1, Math.max(0, saved.v)) : 1; + audio.muted = !!saved?.m; + const volIcon = () => (audio.muted || audio.volume === 0 ? '๐Ÿ”‡' : audio.volume < 0.5 ? '๐Ÿ”‰' : '๐Ÿ”Š'); + const vol = h('input', { type: 'range', min: '0', max: '1', step: '0.05', value: String(audio.volume), class: 'pl-vol', 'aria-label': 'Volume', + oninput: () => { audio.volume = +vol.value; if (audio.volume > 0) audio.muted = false; } }); + const volBtn = h('button', { type: 'button', class: 'pl-btn pl-volbtn', title: 'Mute', 'aria-label': 'Mute', onclick: () => { + if (matchMedia('(max-width: 760px)').matches) { volBox.classList.toggle('open'); return; } + audio.muted = !audio.muted; + } }, volIcon()); + const muteInPop = h('button', { type: 'button', class: 'pl-btn', title: 'Mute', 'aria-label': 'Mute', onclick: () => { audio.muted = !audio.muted; } }, '๐Ÿ”‡'); + const volBox = h('div', { class: 'pl-volume' }, volBtn, h('div', { class: 'pl-volpop' }, muteInPop, vol)); + audio.addEventListener('volumechange', () => { + volBtn.textContent = volIcon(); + const m = audio.muted ? 'Unmute' : 'Mute'; + volBtn.title = muteInPop.title = m; muteInPop.setAttribute('aria-label', m); + muteInPop.textContent = audio.muted ? '๐Ÿ”ˆ' : '๐Ÿ”‡'; + vol.value = String(audio.muted ? 0 : audio.volume); + try { localStorage.setItem('pithos.volume', JSON.stringify({ v: audio.volume, m: audio.muted })); } catch {} + }); + document.addEventListener('mousedown', (e) => { if (!volBox.contains(e.target)) volBox.classList.remove('open'); }); const el = h('div', { class: 'player', role: 'region', 'aria-label': 'Music player', hidden: true }, h('div', { class: 'pl-controls' }, prevBtn, playBtn, nextBtn), h('div', { class: 'pl-info' }, h('div', { class: 'pl-head' }, title, where), h('div', { class: 'pl-seek' }, cur, seek, dur)), + volBox, closeBtn); document.body.append(el); @@ -1801,6 +1840,11 @@ $('#unlock-btn').addEventListener('click', async () => { // hours; a newer release replaces the version with "โ†‘ vX". One click // downloads and verifies it, the next restarts Theseus to apply it. const UPDATES_URL = 'https://navigate.st/bns/theseus.x/extensions/pithos/updates.json'; +// s3d's S3 port is reachable only from this computer (the default). +function localOnly() { + const a = state.status?.config?.apiAddress || ''; + return /^(127\.|localhost|\[::1\])/.test(a); +} function cmpSemver(a, b) { const pa = String(a || '0').split('.').map((n) => Number(n) || 0); const pb = String(b || '0').split('.').map((n) => Number(n) || 0);