From ebd5b8ffe6633795d3967e1857ae9c51b2d22b76 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 4 Oct 2026 02:16:09 +0200 Subject: [PATCH 1/5] Pithos 0.3.7: Icons, List and Details views; fits the sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drives and folders can be shown as icons (pictures preview themselves), a compact list, or a details table with type and date, remembered per page. Per-item actions move into a ⋯ menu so rows fit a narrow panel. In the 400 px Theseus sidebar the navigation folds into a compact bar with the page name, the s3d status and a menu button, so the page gets the room. The Widen button is gone: widening squeezed the open tab and the right sidebar. Full page opens Pithos as an ordinary tab instead. --- package.json | 2 +- theseus/addon.json | 2 +- ui/app.css | 86 +++++++++++++++-- ui/app.js | 232 ++++++++++++++++++++++++++++++++++----------- ui/index.html | 3 +- 5 files changed, 261 insertions(+), 64 deletions(-) diff --git a/package.json b/package.json index c03f1eb..c32f3e1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pithos", - "version": "0.3.6", + "version": "0.3.7", "description": "A control panel for s3d, the Sia S3 gateway: daemon, users and keys, buckets, upload pipeline.", "license": "MPL-2.0", "type": "module", diff --git a/theseus/addon.json b/theseus/addon.json index 86112c4..1685801 100644 --- a/theseus/addon.json +++ b/theseus/addon.json @@ -1,7 +1,7 @@ { "id": "pithos", "name": "Pithos", - "version": "0.3.6", + "version": "0.3.7", "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/ui/app.css b/ui/app.css index d373e67..fdb67e6 100644 --- a/ui/app.css +++ b/ui/app.css @@ -200,20 +200,48 @@ pre.snippet { background: var(--surface-2); padding: 10px 12px; border-radius: 8 .spinner { width: 14px; height: 14px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin .8s linear infinite; display: inline-block; } @keyframes spin { to { transform: rotate(360deg); } } +.nav-toggle, .brand .here { display: none; } + +/* Narrow hosts (the Theseus sidebar, phones): one compact bar on top with the + page name, s3d status and a menu button; the menu folds down over the page. */ @media (max-width: 760px) { - .app { grid-template-columns: 1fr; } - .sidebar { position: static; height: auto; flex-direction: row; flex-wrap: wrap; align-items: center; padding: 10px 16px; } - nav { flex-direction: row; flex-wrap: wrap; } - .daemon-pill { margin-top: 0; margin-left: auto; } - main { padding: 20px 16px 40px; } + .app { grid-template-columns: 1fr; grid-template-rows: auto 1fr; } + .sidebar { position: sticky; top: 0; z-index: 5; height: auto; flex-direction: row; flex-wrap: wrap; align-items: center; + padding: 8px 12px; gap: 8px; border-right: none; border-bottom: 1px solid var(--border); } + .brand { flex: 1; min-width: 0; padding: 0; font-size: 15px; gap: 8px; } + .brand img { width: 22px; height: 22px; } + .brand > span:first-of-type { display: none; } + .brand .here { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .nav-toggle { display: inline-grid; place-items: center; width: 34px; height: 34px; border: 1px solid var(--border); border-radius: 8px; + background: var(--surface); color: var(--text); font: inherit; font-size: 17px; cursor: pointer; } + nav { display: none; order: 10; width: 100%; flex-direction: column; padding-top: 4px; border-top: 1px solid var(--border); } + .sidebar.nav-open nav { display: flex; } + .daemon-pill { margin: 0; padding: 5px 9px; font-size: 12px; } + main { padding: 16px 14px 40px; } .upload { grid-template-columns: 1fr 80px 50px; } - .hide-sm { display: none; } + .hide-sm, .wide-only { display: none; } + .page-head { margin-bottom: 14px; } + .page-head > .row { width: 100%; } + .page-head > .row > label { flex: 1 1 100%; min-width: 0; } + .page-head h1:only-child, .page-head > div > h1 { display: none; } + .tiles { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); } + .page-head > .row > label > span[style] { width: auto !important; flex: 1; min-width: 0; } + .card { padding: 14px; } + h1 { font-size: 19px; } + th, td { padding: 7px 6px; } + .modal { padding: 16px; } + .modal-backdrop { padding: 8px; } +} +@media (max-width: 420px) { + .daemon-pill .label { display: none; } + .daemon-pill { padding: 8px; } } /* Theseus sidebar: widen / open-in-tab controls */ .host-controls { display: flex; gap: 6px; flex-wrap: wrap; margin-top: auto; } .host-controls + .daemon-pill { margin-top: 0; } @media (max-width: 760px) { .host-controls { margin-top: 0; } } +@media (max-width: 420px) { .host-controls .btn { padding: 3px 7px; } } /* Narrow hosts (Theseus sidebar): never scroll sideways. */ html, body { overflow-x: hidden; } @@ -250,3 +278,49 @@ html, body { overflow-x: hidden; } border: 2px solid var(--muted); box-shadow: inset 0 -7px 0 color-mix(in srgb, var(--muted) 25%, transparent); } .drive-icon::after { content: ""; position: absolute; right: 7px; bottom: 3px; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); } .tile:hover .drive-icon { border-color: var(--accent); } + +/* View types: Icons / List / Details */ +.view-bar { display: flex; justify-content: flex-end; margin: -6px 0 8px; } +.view-bar:empty { display: none; } +.seg { display: inline-flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; background: var(--surface); } +.seg button { appearance: none; border: none; background: none; color: var(--muted); font: inherit; font-size: 15px; line-height: 1; padding: 6px 10px; cursor: pointer; } +.seg button + button { border-left: 1px solid var(--border); } +.seg button:hover { background: var(--surface-2); color: var(--text); } +.seg button.on { background: var(--accent-soft); color: var(--accent); } + +table.files td.name { max-width: 0; width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +table.files td, table.files th { white-space: nowrap; } +table.files td.name > * { vertical-align: middle; } +table.files td.name .ficon, table.files td.name .drive-icon { display: inline-block; margin-right: 8px; } +.ficon { flex: none; width: 1.4em; text-align: center; } +.drive-icon.sm { width: 26px; height: 17px; border-radius: 4px; border-width: 1.5px; box-shadow: inset 0 -4px 0 color-mix(in srgb, var(--muted) 25%, transparent); flex: none; } +.drive-icon.sm::after { width: 4px; height: 4px; right: 4px; bottom: 2px; } + +ul.rows { list-style: none; margin: 0; padding: 0; } +ul.rows li { display: flex; align-items: center; gap: 10px; padding: 6px 6px; border-radius: 6px; min-width: 0; } +ul.rows li + li { border-top: 1px solid var(--border); } +ul.rows li:hover, ul.rows li:focus-visible { background: var(--surface-2); outline: none; } +ul.rows .label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +ul.rows .meta { color: var(--muted); font-size: 12.5px; white-space: nowrap; font-variant-numeric: tabular-nums; } +.clickable { cursor: pointer; } +tr.clickable:focus-visible td { background: var(--surface-2); } +tr.clickable:focus-visible { outline: none; } + +.tile { position: relative; } +.tile-more { position: absolute; top: 6px; right: 6px; } +.tile .tile-more .more { opacity: .55; } +.tile:hover .tile-more .more, .tile:focus-within .tile-more .more { opacity: 1; } +.tiles.files { grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: 8px; } +.tiles.files .tile { padding: 8px; gap: 4px; align-items: stretch; text-align: center; } +.tiles.files .tile-name { font-weight: 500; font-size: 13px; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow-wrap: anywhere; } +.thumb { aspect-ratio: 4 / 3; border-radius: 6px; background: var(--surface); display: grid; place-items: center; overflow: hidden; } +.thumb img { width: 100%; height: 100%; object-fit: cover; display: block; } +.big-icon { font-size: 38px; line-height: 1; } +.usage { display: block; white-space: nowrap; } + +.more { padding: 1px 8px; font-size: 15px; line-height: 1.2; } +.menu { position: fixed; z-index: 30; min-width: 150px; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; + box-shadow: 0 8px 24px rgb(0 0 0 / .18); padding: 4px; display: flex; flex-direction: column; } +.menu button { appearance: none; border: none; background: none; color: var(--text); font: inherit; text-align: left; padding: 7px 10px; border-radius: 6px; cursor: pointer; } +.menu button:hover, .menu button:focus-visible { background: var(--surface-2); outline: none; } +.menu button.danger { color: var(--danger); } diff --git a/ui/app.js b/ui/app.js index 5fd16f8..3d427bb 100644 --- a/ui/app.js +++ b/ui/app.js @@ -198,6 +198,11 @@ function route() { const [, name = fallback, ...rest] = location.hash.replace(/^#/, '').split('/'); const view = views[name] ? name : fallback; for (const a of document.querySelectorAll('#nav a')) a.classList.toggle('active', a.dataset.view === view); + // Compact bar (narrow hosts): name the page, fold the menu away. + const here = document.querySelector(`#nav a[data-view="${view}"]`); + $('#here').textContent = here ? here.textContent : ''; + $('.sidebar').classList.remove('nav-open'); + $('#nav-toggle').setAttribute('aria-expanded', 'false'); const main = $('#main'); put(main); views[view](main, rest.map(decodeURIComponent)); @@ -1041,6 +1046,42 @@ function openCredentialsDialog() { // ---------------------------------------------------------------- buckets & objects +// View types for drives and folders: Icons, List or Details, remembered per page. +const VIEW_TYPES = [['icons', '▦', 'Icons'], ['list', '☰', 'List'], ['details', '≣', 'Details']]; +function viewMode(page, fallback) { + try { const v = localStorage.getItem(`pithos.view.${page}`); if (VIEW_TYPES.some(([k]) => k === v)) return v; } catch {} + return fallback; +} +function viewSwitch(page, current, onChange) { + return h('div', { class: 'seg', role: 'group', 'aria-label': 'View' }, VIEW_TYPES.map(([key, icon, label]) => + h('button', { type: 'button', class: key === current ? 'on' : '', title: label, 'aria-label': label, 'aria-pressed': String(key === current), + onclick: () => { try { localStorage.setItem(`pithos.view.${page}`, key); } catch {} onChange(key); } }, icon))); +} + +// "⋯" menu for one item: [label, action, kind?] rows; closes on any outside click. +function itemMenu(items) { + const btn = h('button', { type: 'button', class: 'btn small more', title: 'More', 'aria-label': 'More actions', 'aria-haspopup': 'menu' }, '⋯'); + btn.addEventListener('click', (e) => { + e.stopPropagation(); + document.querySelectorAll('.menu').forEach((m) => m.remove()); + const menu = h('div', { class: 'menu', role: 'menu' }, items.filter(Boolean).map(([label, run, kind]) => + h('button', { type: 'button', role: 'menuitem', class: kind || '', onclick: (ev) => { ev.stopPropagation(); menu.remove(); run(); } }, label))); + document.body.append(menu); + const r = btn.getBoundingClientRect(); + const w = menu.offsetWidth, ht = menu.offsetHeight; + menu.style.left = Math.max(8, Math.min(r.right - w, innerWidth - w - 8)) + 'px'; + menu.style.top = (r.bottom + 4 + ht > innerHeight - 8 ? Math.max(8, r.top - ht - 4) : r.bottom + 4) + 'px'; + const done = () => { menu.remove(); document.removeEventListener('mousedown', away, true); document.removeEventListener('keydown', esc, true); }; + const away = (ev) => { if (!menu.contains(ev.target)) done(); }; + const esc = (ev) => { if (ev.key === 'Escape') { ev.stopPropagation(); done(); btn.focus(); } }; + setTimeout(() => { document.addEventListener('mousedown', away, true); document.addEventListener('keydown', esc, true); }, 0); + menu.querySelector('button')?.focus(); + }); + return btn; +} +const go = (hash) => () => { location.hash = hash; }; +const onEnter = (fn) => (e) => { if (e.key === 'Enter' && e.target === e.currentTarget) fn(); }; + const EXT = (key) => (key.split('/').pop().match(/.([a-z0-9]+)$/i) || [])[1]?.toLowerCase() || ''; function fileKind(key) { const e = EXT(key); @@ -1055,6 +1096,10 @@ function suggestBucket(name) { if (b && b.length < 3) b += '-files'; return b.slice(0, 63).replace(/-+$/, ''); } +function fileType(key) { + const e = EXT(key); + return ({ image: 'Image', audio: 'Audio', video: 'Video', text: 'Text' }[fileKind(key)] || 'File') + (e ? ` (.${e})` : ''); +} function fileIcon(key) { return { image: '🖼️', audio: '🎵', video: '🎬', text: '📄' }[fileKind(key)] || '📦'; } @@ -1122,36 +1167,60 @@ async function bucketList(head, body, user, userSel) { h('div', {}, h('h1', {}, 'Drives'), h('p', { class: 'muted' }, 'Each drive is an S3 bucket: top-level storage owned by one S3 user. S3 apps call it a bucket.'), accountLine()), h('div', { class: 'row' }, userPicker(userSel), h('button', { class: 'btn primary', onclick: create }, 'New drive'))); const card = h('div', { class: 'card' }, h('p', { class: 'muted' }, 'Loading…')); - put(body, card); + const bar = h('div', { class: 'view-bar' }); + put(body, bar, card); const base = `/api/s3/${encodeURIComponent(user)}/buckets`; + let mode = viewMode('drives', 'icons'); + let list = []; async function load() { try { - const list = await api('GET', base); - if (!list.length) { put(card, h('div', { class: 'empty' }, h('p', {}, 'No drives yet.'), h('button', { class: 'btn primary', onclick: create }, 'Create a drive'))); return; } - // Buckets read as drives: the top level you put folders into. - put(card, h('div', { class: 'tiles' }, list.map((b) => { - const access = h('span', { class: 'badge' }, '…'); - api('GET', `${base}/${encodeURIComponent(b.name)}/policy`).then((p) => { - put(access, h('span', { class: `badge ${p.public === 'private' ? '' : 'warn'}` }, { private: '🔒 private', read: '🌐 public', 'read-list': '🌐 public + list', custom: 'custom' }[p.public])); - }).catch(() => put(access, h('span', { class: 'badge' }, '?'))); - return h('div', { class: 'tile', tabindex: '0', role: 'button', - onclick: () => { location.hash = `#/buckets/${encodeURIComponent(b.name)}`; }, - onkeydown: (e) => { if (e.key === 'Enter') location.hash = `#/buckets/${encodeURIComponent(b.name)}`; } }, - h('div', { class: 'drive-icon', 'aria-hidden': 'true' }), - h('div', { class: 'tile-name' }, b.name), - usageLine(b.name), - h('div', { class: 'tile-meta' }, access), - h('div', { class: 'tile-actions' }, - h('button', { class: 'btn small', onclick: (e) => { e.stopPropagation(); accessDialog(user, b.name).then(load); } }, 'Access'), - h('button', { class: 'btn small danger', onclick: (e) => { e.stopPropagation(); remove(b.name); } }, 'Delete'))); - }))); + list = await api('GET', base); + draw(); } catch (e) { put(card, h('p', { class: 'error' }, e.message)); } } + // Buckets read as drives: the top level you put folders into. + function draw() { + put(bar, list.length ? viewSwitch('drives', mode, (m) => { mode = m; draw(); }) : null); + if (!list.length) { put(card, h('div', { class: 'empty' }, h('p', {}, 'No drives yet.'), h('button', { class: 'btn primary', onclick: create }, 'Create a drive'))); return; } + const open = (b) => go(`#/buckets/${encodeURIComponent(b.name)}`); + const menu = (b) => itemMenu([['Open', open(b)], ['Access…', () => accessDialog(user, b.name).then(load)], ['Delete', () => remove(b.name), 'danger']]); + if (mode === 'details') { + put(card, h('table', { class: 'files' }, + h('thead', {}, h('tr', {}, h('th', {}, 'Drive'), h('th', {}, 'Contents'), h('th', { class: 'hide-sm' }, 'Access'), h('th', {}))), + h('tbody', {}, list.map((b) => h('tr', { class: 'clickable', tabindex: '0', onclick: open(b), onkeydown: onEnter(open(b)) }, + h('td', { class: 'name' }, h('span', { class: 'drive-icon sm', 'aria-hidden': 'true' }), h('span', { class: 'label' }, b.name)), + h('td', { class: 'muted' }, usageLine(b.name)), + h('td', { class: 'hide-sm' }, accessBadge(b.name)), + h('td', { class: 'actions', onclick: (e) => e.stopPropagation() }, menu(b))))))); + } else if (mode === 'list') { + put(card, h('ul', { class: 'rows' }, list.map((b) => h('li', { class: 'clickable', tabindex: '0', onclick: open(b), onkeydown: onEnter(open(b)) }, + h('span', { class: 'drive-icon sm', 'aria-hidden': 'true' }), + h('span', { class: 'label' }, b.name), + h('span', { class: 'meta' }, usageLine(b.name)), + h('span', { onclick: (e) => e.stopPropagation() }, menu(b)))))); + } else { + put(card, h('div', { class: 'tiles' }, list.map((b) => h('div', { class: 'tile', tabindex: '0', role: 'button', onclick: open(b), onkeydown: onEnter(open(b)) }, + h('div', { class: 'drive-icon', 'aria-hidden': 'true' }), + h('div', { class: 'tile-name' }, b.name), + usageLine(b.name), + h('div', { class: 'tile-meta' }, accessBadge(b.name)), + h('div', { class: 'tile-more', onclick: (e) => e.stopPropagation() }, menu(b)))))); + } + } + + function accessBadge(bucketName) { + const el = h('span', {}, h('span', { class: 'badge' }, '…')); + api('GET', `${base}/${encodeURIComponent(bucketName)}/policy`).then((p) => { + put(el, h('span', { class: `badge ${p.public === 'private' ? '' : 'warn'}` }, { private: '🔒 private', read: '🌐 public', 'read-list': '🌐 public + list', custom: 'custom' }[p.public])); + }).catch(() => put(el, '?')); + return el; + } + // Files and size on a drive, counted in the background. function usageLine(bucketName) { - const el = h('div', { class: 'small muted' }, 'counting…'); + const el = h('span', { class: 'small muted usage' }, 'counting…'); api('GET', `${base}/${encodeURIComponent(bucketName)}/usage`) .then((u) => put(el, `${u.files.toLocaleString()}${u.partial ? '+' : ''} file${u.files === 1 ? '' : 's'} · ${fmtBytes(u.bytes)}${u.partial ? '+' : ''}`)) .catch(() => put(el, '')); @@ -1217,7 +1286,9 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) { const uploads = h('div', { class: 'uploads' }); const card = h('div', { class: 'card dropzone' }, h('p', { class: 'muted' }, 'Loading…')); - put(body, uploads, card); + const bar = h('div', { class: 'view-bar' }); + put(body, uploads, bar, card); + let mode = viewMode('files', 'details'); let depth = 0; card.addEventListener('dragenter', (e) => { e.preventDefault(); depth++; card.classList.add('over'); }); @@ -1243,28 +1314,79 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) { const folders = pages.flatMap((p) => p.folders); const objects = pages.flatMap((p) => p.objects).filter((o) => o.key !== prefix); const last = pages[pages.length - 1]; + put(bar, folders.length || objects.length ? viewSwitch('files', mode, (m) => { mode = m; draw(); }) : null); if (!folders.length && !objects.length) { - put(card, h('div', { class: 'empty' }, h('p', {}, prefix ? 'This folder is empty.' : 'This bucket is empty.'), h('p', { class: 'small' }, 'Drag files here or use Upload.'))); + put(card, h('div', { class: 'empty' }, h('p', {}, prefix ? 'This folder is empty.' : 'This drive is empty.'), h('p', { class: 'small' }, 'Drag files here or use Upload.'))); return; } - put(card, h('table', {}, - h('thead', {}, h('tr', {}, h('th', {}, 'Name'), h('th', { class: 'num' }, 'Size'), h('th', { class: 'hide-sm' }, 'Modified'), h('th', {}))), - h('tbody', {}, - folders.map((f) => h('tr', { class: 'clickable', onclick: () => go(f) }, - h('td', {}, '📁 ', f.slice(prefix.length)), - h('td', { class: 'num muted' }, '–'), h('td', { class: 'hide-sm' }), - h('td', { class: 'actions' }, h('button', { class: 'btn small danger', onclick: (e) => { e.stopPropagation(); deleteFolder(f); } }, 'Delete')))), - objects.map((o) => h('tr', {}, - h('td', {}, h('a', { href: '#', onclick: (e) => { e.preventDefault(); openViewer(o); } }, `${fileIcon(o.key)} ${o.key.slice(prefix.length)}`)), - h('td', { class: 'num' }, fmtBytes(o.size)), - h('td', { class: 'hide-sm muted' }, fmtDate(o.modified)), - h('td', { class: 'actions' }, - h('button', { class: 'btn small', onclick: () => downloadObject(o.key) }, 'Download'), - ' ', - h('button', { class: 'btn small', onclick: () => share(o.key) }, 'Share'), - ' ', - h('button', { class: 'btn small danger', onclick: () => deleteObject(o.key) }, 'Delete')))))), - last?.truncated && h('div', { class: 'row', style: 'justify-content:center;margin-top:12px' }, h('button', { class: 'btn', onclick: () => load(last.nextToken) }, 'Load more'))); + const folderName = (f) => f.slice(prefix.length).replace(/\/$/, ''); + const fileName = (o) => o.key.slice(prefix.length); + const folderMenu = (f) => itemMenu([['Open', () => go(f)], ['Delete', () => deleteFolder(f), 'danger']]); + const fileMenu = (o) => itemMenu([['Open', () => openViewer(o)], ['Download', () => downloadObject(o.key)], ['Share link…', () => share(o.key)], ['Delete', () => deleteObject(o.key), 'danger']]); + const more = last?.truncated && h('div', { class: 'row', style: 'justify-content:center;margin-top:12px' }, h('button', { class: 'btn', onclick: () => load(last.nextToken) }, 'Load more')); + const stop = (e) => e.stopPropagation(); + + if (mode === 'details') { + put(card, h('table', { class: 'files' }, + h('thead', {}, h('tr', {}, h('th', {}, 'Name'), h('th', { class: 'num' }, 'Size'), h('th', { class: 'hide-sm' }, 'Type'), h('th', { class: 'hide-sm' }, 'Modified'), h('th', {}))), + h('tbody', {}, + folders.map((f) => h('tr', { class: 'clickable', tabindex: '0', onclick: () => go(f), onkeydown: onEnter(() => go(f)) }, + h('td', { class: 'name' }, h('span', { class: 'ficon', 'aria-hidden': 'true' }, '📁'), h('span', { class: 'label' }, folderName(f))), + h('td', { class: 'num muted' }, '–'), h('td', { class: 'hide-sm muted' }, 'Folder'), h('td', { class: 'hide-sm' }), + h('td', { class: 'actions', onclick: stop }, folderMenu(f)))), + objects.map((o) => h('tr', { class: 'clickable', tabindex: '0', onclick: () => openViewer(o), onkeydown: onEnter(() => openViewer(o)) }, + h('td', { class: 'name' }, h('span', { class: 'ficon', 'aria-hidden': 'true' }, fileIcon(o.key)), h('span', { class: 'label', title: fileName(o) }, fileName(o))), + h('td', { class: 'num' }, fmtBytes(o.size)), + h('td', { class: 'hide-sm muted' }, fileType(o.key)), + h('td', { class: 'hide-sm muted' }, fmtDate(o.modified)), + h('td', { class: 'actions', onclick: stop }, + h('span', { class: 'wide-only' }, h('button', { class: 'btn small', onclick: () => downloadObject(o.key) }, 'Download'), ' ', + h('button', { class: 'btn small', onclick: () => share(o.key) }, 'Share'), ' '), + fileMenu(o)))))), + more); + } else if (mode === 'list') { + put(card, h('ul', { class: 'rows' }, + folders.map((f) => h('li', { class: 'clickable', tabindex: '0', onclick: () => go(f), onkeydown: onEnter(() => go(f)) }, + h('span', { class: 'ficon', 'aria-hidden': 'true' }, '📁'), h('span', { class: 'label' }, folderName(f)), h('span', { class: 'meta' }), + h('span', { onclick: stop }, folderMenu(f)))), + objects.map((o) => h('li', { class: 'clickable', tabindex: '0', onclick: () => openViewer(o), onkeydown: onEnter(() => openViewer(o)) }, + h('span', { class: 'ficon', 'aria-hidden': 'true' }, fileIcon(o.key)), h('span', { class: 'label', title: fileName(o) }, fileName(o)), + h('span', { class: 'meta' }, fmtBytes(o.size)), + h('span', { onclick: stop }, fileMenu(o))))), + more); + } else { + put(card, h('div', { class: 'tiles files' }, + folders.map((f) => h('div', { class: 'tile', tabindex: '0', role: 'button', onclick: () => go(f), onkeydown: onEnter(() => go(f)) }, + h('div', { class: 'thumb' }, h('span', { class: 'big-icon', 'aria-hidden': 'true' }, '📁')), + h('div', { class: 'tile-name' }, folderName(f)), + h('div', { class: 'tile-more', onclick: stop }, folderMenu(f)))), + objects.map((o) => h('div', { class: 'tile', tabindex: '0', role: 'button', onclick: () => openViewer(o), onkeydown: onEnter(() => openViewer(o)) }, + thumbFor(o), + h('div', { class: 'tile-name', title: fileName(o) }, fileName(o)), + h('div', { class: 'small muted' }, fmtBytes(o.size)), + h('div', { class: 'tile-more', onclick: stop }, fileMenu(o))))), + more); + } + } + + // Icons view: pictures show themselves once scrolled into view; larger + // ones (and everything else) keep a type icon. + const thumbs = new IntersectionObserver((entries) => { + for (const en of entries) { + if (!en.isIntersecting) continue; + thumbs.unobserve(en.target); + const key = en.target.dataset.key; + const path = `${base}/object?key=${encodeURIComponent(key)}`; + (bridge ? bridge.invoke('view-url', { path }).then((r) => r.url) : Promise.resolve(`${path}&inline=1`)) + .then((src) => { const img = h('img', { alt: '', src }); img.onload = () => put(en.target, img); }) + .catch(() => {}); + } + }, { rootMargin: '200px' }); + cleanup.push(() => thumbs.disconnect()); + function thumbFor(o) { + const el = h('div', { class: 'thumb' }, h('span', { class: 'big-icon', 'aria-hidden': 'true' }, fileIcon(o.key))); + if (fileKind(o.key) === 'image' && o.size <= 8 * 1024 ** 2) { el.dataset.key = o.key; thumbs.observe(el); } + return el; } function uploadFiles(files) { @@ -1476,21 +1598,16 @@ function logs(main) { // ---------------------------------------------------------------- boot -// Theseus sidebar: the panel starts narrow; bucket tables want room, so offer -// the host's widen/restore, plus a regular tab for anyone who prefers one. +// Theseus sidebar: Pithos fits the narrow panel; for more room it opens as a +// regular tab, which steps back like any tab when another one is chosen. The +// panel closes so only one Pithos shows. function addSidebarControls() { - const row = h('div', { class: 'host-controls' }); - if (bridge.sidebar?.toggleMax) { - const widen = h('button', { class: 'btn small', title: 'Widen the sidebar', onclick: () => bridge.sidebar.toggleMax() }, '⤢ Widen'); - const label = (max) => { widen.textContent = max ? '⤡ Narrow' : '⤢ Widen'; widen.title = max ? 'Back to the normal sidebar width' : 'Widen the sidebar'; }; - bridge.sidebar.isMax?.().then(label).catch(() => {}); - bridge.sidebar.onMaxChange?.(label); - row.append(widen); - } - // Full page: open in a tab and close the panel, so only one Pithos shows. - row.append(h('button', { class: 'btn small', title: 'Open Pithos as a full page and close this panel', onclick: async () => { - try { await bridge.invoke('open-in-tab'); await bridge.sidebar?.close?.(); } catch (e) { fail(e); } - } }, 'Full page')); + // The panel may have been widened by an earlier Pithos; start narrow. + bridge.sidebar?.isMax?.().then((max) => { if (max) bridge.sidebar.restore?.() ?? bridge.sidebar.toggleMax?.(); }).catch(() => {}); + const row = h('div', { class: 'host-controls' }, + h('button', { class: 'btn small', title: 'Open Pithos in a tab and close this panel', onclick: async () => { + try { await bridge.invoke('open-in-tab'); await bridge.sidebar?.close?.(); } catch (e) { fail(e); } + } }, '⤢ Full page')); $('.sidebar').insertBefore(row, $('#daemon-pill')); } @@ -1571,6 +1688,11 @@ async function boot() { setInterval(refreshStatus, 15000); } +$('#nav-toggle').addEventListener('click', () => { + const open = $('.sidebar').classList.toggle('nav-open'); + $('#nav-toggle').setAttribute('aria-expanded', String(open)); +}); + $('#signin-form').addEventListener('submit', async (e) => { e.preventDefault(); const err = $('#signin-error'); diff --git a/ui/index.html b/ui/index.html index dbf414d..ffa9cb6 100644 --- a/ui/index.html +++ b/ui/index.html @@ -30,7 +30,7 @@ From d31e789c557aa0da2c11eaee2e97d496ee10e630 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 4 Oct 2026 02:23:06 +0200 Subject: [PATCH 2/5] Pithos 0.3.7: music keeps playing while you browse Opening an MP3 used to show it in a dialog, so the music stopped as soon as you went on looking through your drive. Audio now plays in a player docked at the bottom of Pithos that stays through folder, drive and page changes and plays on through the folder it started from, with previous, next, seek and the keyboard's media keys. An expired view link is renewed mid-track instead of stopping playback. --- ui/app.css | 27 +++++++++++++ ui/app.js | 110 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 136 insertions(+), 1 deletion(-) diff --git a/ui/app.css b/ui/app.css index fdb67e6..75fd5b9 100644 --- a/ui/app.css +++ b/ui/app.css @@ -324,3 +324,30 @@ tr.clickable:focus-visible { outline: none; } .menu button { appearance: none; border: none; background: none; color: var(--text); font: inherit; text-align: left; padding: 7px 10px; border-radius: 6px; cursor: pointer; } .menu button:hover, .menu button:focus-visible { background: var(--surface-2); outline: none; } .menu button.danger { color: var(--danger); } + +/* Music player, docked at the bottom; stays while you browse */ +.player { position: fixed; left: 0; right: 0; bottom: 0; z-index: 15; display: flex; align-items: center; gap: 12px; + padding: 8px 14px; background: var(--surface); border-top: 1px solid var(--border); box-shadow: 0 -4px 16px rgb(0 0 0 / .12); } +@media (min-width: 761px) { .player { left: 220px; } } +body.has-player main { padding-bottom: 96px; } +body.has-player .toasts { bottom: 84px; } +.pl-controls { display: flex; align-items: center; gap: 2px; flex: none; } +.pl-btn { appearance: none; border: none; background: none; color: var(--text); font: inherit; font-size: 16px; width: 32px; height: 32px; + border-radius: 50%; cursor: pointer; display: grid; place-items: center; } +.pl-btn:hover:not(:disabled) { background: var(--surface-2); } +.pl-btn:disabled { opacity: .35; cursor: default; } +.pl-main { width: 38px; height: 38px; background: var(--accent); color: var(--accent-ink); font-size: 15px; } +.pl-main:hover:not(:disabled) { background: var(--accent); filter: brightness(1.08); } +.pl-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } +.pl-head { display: flex; align-items: baseline; gap: 8px; min-width: 0; } +.pl-title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } +.pl-where { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1 1 0; min-width: 0; } +.pl-seek { display: flex; align-items: center; gap: 8px; } +.pl-seek input { flex: 1; min-width: 0; accent-color: var(--accent); margin: 0; } +.pl-time { font-size: 11.5px; color: var(--muted); font-variant-numeric: tabular-nums; min-width: 2.6em; text-align: center; } +@media (max-width: 420px) { + .player { gap: 6px; padding: 6px 8px; } + .pl-where { display: none; } + .pl-btn { width: 28px; height: 28px; font-size: 14px; } + .pl-main { width: 34px; height: 34px; } +} diff --git a/ui/app.js b/ui/app.js index 3d427bb..8a03d86 100644 --- a/ui/app.js +++ b/ui/app.js @@ -1322,7 +1322,7 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) { const folderName = (f) => f.slice(prefix.length).replace(/\/$/, ''); const fileName = (o) => o.key.slice(prefix.length); const folderMenu = (f) => itemMenu([['Open', () => go(f)], ['Delete', () => deleteFolder(f), 'danger']]); - const fileMenu = (o) => itemMenu([['Open', () => openViewer(o)], ['Download', () => downloadObject(o.key)], ['Share link…', () => share(o.key)], ['Delete', () => deleteObject(o.key), 'danger']]); + const fileMenu = (o) => itemMenu([[fileKind(o.key) === 'audio' ? 'Play' : 'Open', () => openViewer(o)], ['Download', () => downloadObject(o.key)], ['Share link…', () => share(o.key)], ['Delete', () => deleteObject(o.key), 'danger']]); const more = last?.truncated && h('div', { class: 'row', style: 'justify-content:center;margin-top:12px' }, h('button', { class: 'btn', onclick: () => load(last.nextToken) }, 'Load more')); const stop = (e) => e.stopPropagation(); @@ -1452,6 +1452,7 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) { async function openViewer(o) { const name = o.key.slice(prefix.length); const kind = fileKind(o.key); + if (kind === 'audio') return playFolder(o); const path = `${base}/object?key=${encodeURIComponent(o.key)}`; const src = async () => (bridge ? (await bridge.invoke('view-url', { path })).url : `${path}&inline=1`); let content; @@ -1468,6 +1469,17 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) { [{ label: 'Share', value: () => { share(o.key); return false; } }, { label: 'Download', value: () => { downloadObject(o.key); return false; } }, { label: 'Close', kind: 'primary', submit: true, result: true }]); } + // Music plays in the docked player, on through this folder's other tracks. + function playFolder(o) { + const where = `${bucket}/${prefix}`.replace(/\/$/, ''); + const tracks = pages.flatMap((p) => p.objects).filter((x) => fileKind(x.key) === 'audio').map((x) => { + const path = `${base}/object?key=${encodeURIComponent(x.key)}`; + return { key: x.key, name: x.key.slice(prefix.length), where, + src: async () => (bridge ? (await bridge.invoke('view-url', { path })).url : `${path}&inline=1`) }; + }); + player().play(tracks, Math.max(0, tracks.findIndex((t) => t.key === o.key))); + } + async function deleteObject(key) { if (!(await confirmModal('Delete object?', key))) return; try { await api('DELETE', `${base}/object?key=${encodeURIComponent(key)}`); toast('Deleted'); load(); } catch (e) { fail(e); } @@ -1503,6 +1515,102 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) { load(); } +// ---------------------------------------------------------------- music player + +// A player docked at the bottom of Pithos: it lives outside the page, so it +// keeps playing while you browse other folders, drives or pages, and plays +// on through the folder it was started from. +let musicPlayer = null; +function player() { + if (musicPlayer) return musicPlayer; + const audio = new Audio(); + audio.preload = 'metadata'; + let queue = []; + let at = -1; + let seeking = false; + let retried = false; + const mmss = (t) => (isFinite(t) ? `${Math.floor(t / 60)}:${String(Math.floor(t % 60)).padStart(2, '0')}` : '0:00'); + const title = h('div', { class: 'pl-title' }); + const where = h('div', { class: 'pl-where small muted' }); + const cur = h('span', { class: 'pl-time' }, '0:00'); + const dur = h('span', { class: 'pl-time' }, '0:00'); + const seek = h('input', { type: 'range', min: '0', max: '0', step: '1', value: '0', 'aria-label': 'Position', + oninput: () => { seeking = true; cur.textContent = mmss(+seek.value); }, + onchange: () => { audio.currentTime = +seek.value; seeking = false; } }); + const playBtn = h('button', { type: 'button', class: 'pl-btn pl-main', 'aria-label': 'Play', onclick: () => (audio.paused ? audio.play().catch(() => {}) : audio.pause()) }, '▶'); + 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() }, '✕'); + 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)), + closeBtn); + document.body.append(el); + + const showPlaying = () => { const p = !audio.paused; playBtn.textContent = p ? '⏸' : '▶'; playBtn.setAttribute('aria-label', p ? 'Pause' : 'Play'); if (navigator.mediaSession) navigator.mediaSession.playbackState = p ? 'playing' : 'paused'; }; + audio.addEventListener('play', showPlaying); + audio.addEventListener('pause', showPlaying); + audio.addEventListener('playing', () => { retried = false; }); + audio.addEventListener('loadedmetadata', () => { seek.max = String(Math.floor(audio.duration) || 0); dur.textContent = mmss(audio.duration); }); + audio.addEventListener('timeupdate', () => { if (!seeking) { seek.value = String(Math.floor(audio.currentTime)); cur.textContent = mmss(audio.currentTime); } }); + audio.addEventListener('ended', () => { if (at < queue.length - 1) next(); }); + // Theseus view links last 10 minutes: when one runs out mid-track, fetch a + // fresh link and carry on from the same spot. + audio.addEventListener('error', () => { + if (!queue[at] || retried) return toast(`Cannot play ${queue[at]?.name || 'this file'}`, 'error'); + retried = true; + load(at, audio.currentTime, true); + }); + + async function load(i, from = 0, play = true) { + at = i; + const t = queue[i]; + title.textContent = t.name; + title.title = t.name; + where.textContent = t.where; + prevBtn.disabled = i <= 0; + nextBtn.disabled = i >= queue.length - 1; + let src; + try { src = await t.src(); } catch (e) { return fail(e); } + if (at !== i) return; // another track was chosen meanwhile + audio.src = src; + if (from) audio.addEventListener('loadedmetadata', () => { audio.currentTime = from; }, { once: true }); + if (play) audio.play().catch(() => {}); + if (navigator.mediaSession && window.MediaMetadata) navigator.mediaSession.metadata = new MediaMetadata({ title: t.name.replace(/\.[^.]+$/, ''), album: t.where, artist: 'Pithos' }); + } + const next = () => { if (at < queue.length - 1) load(at + 1); }; + const prev = () => { if (audio.currentTime > 3 || at <= 0) audio.currentTime = 0; else load(at - 1); }; + function close() { + audio.pause(); + audio.removeAttribute('src'); + audio.load(); + queue = []; at = -1; + el.hidden = true; + document.body.classList.remove('has-player'); + } + if (navigator.mediaSession) { + const ms = navigator.mediaSession; + try { + ms.setActionHandler('play', () => audio.play().catch(() => {})); + ms.setActionHandler('pause', () => audio.pause()); + ms.setActionHandler('previoustrack', prev); + ms.setActionHandler('nexttrack', next); + ms.setActionHandler('seekto', (d) => { audio.currentTime = d.seekTime; }); + } catch {} + } + musicPlayer = { + // tracks: [{ key, name, where, src: async () => url }] + play(tracks, index) { + queue = tracks; + el.hidden = false; + document.body.classList.add('has-player'); + load(index); + }, + get current() { return queue[at]?.key || null; }, + }; + return musicPlayer; +} + // ---------------------------------------------------------------- settings function settings(main) { From 299c4c069c83658db3d49f8c3ebfe5f7838e50a6 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 4 Oct 2026 02:38:19 +0200 Subject: [PATCH 3/5] Pithos 0.3.8: a version footer that updates Pithos in place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pithos only updated when Theseus happened to check the extension channel on a restart, and nothing showed which version was running. A footer now shows pithos.sia and the version, as Aegis's does. In Theseus it checks the channel when Pithos opens and every six hours; a newer release shows as "↑ vX", one click downloads and verifies it, and a second asks Theseus to restart and apply it. ↻ checks on demand. --- core/server.js | 8 +++++ package.json | 2 +- theseus/addon.json | 2 +- theseus/index.js | 16 +++++++++ ui/app.css | 24 ++++++++++++++ ui/app.js | 82 ++++++++++++++++++++++++++++++++++++++++++++++ ui/index.html | 8 +++++ 7 files changed, 140 insertions(+), 2 deletions(-) diff --git a/core/server.js b/core/server.js index 67590ea..25fba25 100644 --- a/core/server.js +++ b/core/server.js @@ -65,6 +65,13 @@ export async function createPithos(opts = {}) { const tickets = new Map(); const subscribers = new Set(); let versionCache = null; + // Pithos's own version: addon.json in the Theseus add-on, package.json elsewhere. + const pithosVersion = (() => { + for (const f of ['../addon.json', '../package.json']) { + try { const v = JSON.parse(fs.readFileSync(new URL(f, import.meta.url), 'utf8')).version; if (v) return v; } catch {} + } + return null; + })(); const broadcast = (event, data) => { const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; @@ -144,6 +151,7 @@ export async function createPithos(opts = {}) { const v = await version(); return { host: hostName, + pithos: pithosVersion, daemon: daemon.status(), external: await probeExternal(c), s3d: { ...v, pinned: S3D_VERSION, installable: !!assetForPlatform(), outdated: !!(v?.version && cmpVersion(v.version, S3D_VERSION) < 0) }, diff --git a/package.json b/package.json index c32f3e1..b33f24d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pithos", - "version": "0.3.7", + "version": "0.3.8", "description": "A control panel for s3d, the Sia S3 gateway: daemon, users and keys, buckets, upload pipeline.", "license": "MPL-2.0", "type": "module", diff --git a/theseus/addon.json b/theseus/addon.json index 1685801..33eae35 100644 --- a/theseus/addon.json +++ b/theseus/addon.json @@ -1,7 +1,7 @@ { "id": "pithos", "name": "Pithos", - "version": "0.3.7", + "version": "0.3.8", "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/theseus/index.js b/theseus/index.js index 0531eaa..a9238c1 100644 --- a/theseus/index.js +++ b/theseus/index.js @@ -117,6 +117,22 @@ module.exports = { return { url: p.viewUrl(apiPath) }; }); + // Footer updater: stage the newest signed Pithos from the extension + // channel, then (second click) ask Theseus to restart and apply it. + // Theseus asks the user before restarting. Older Theseus builds lack + // these hooks; the panel then points to Settings > Extensions. + api.onMessage("update", async ({ step = "stage" } = {}) => { + if (step === "apply") { + if (typeof api.restartApp !== "function") return { fallback: "settings" }; + return await api.restartApp(); + } + if (typeof api.checkAndStageSelfUpdate !== "function") return { fallback: "settings" }; + const r = await api.checkAndStageSelfUpdate(); + const staged = r?.status === "staged" || r?.status === "already-staged"; + return { staged, status: r?.status || "unknown", detail: r?.detail || null, current: r?.current || null, next: r?.next || null }; + }); + api.onMessage("open-settings", ({ section = "" } = {}) => { api.openSettings?.(String(section)); return { ok: true }; }); + // A full browser-tab view, for anyone who prefers it to the sidebar. api.onMessage("open-in-tab", async () => { const p = await ensureServer(); diff --git a/ui/app.css b/ui/app.css index 75fd5b9..5aa2c24 100644 --- a/ui/app.css +++ b/ui/app.css @@ -351,3 +351,27 @@ body.has-player .toasts { bottom: 84px; } .pl-btn { width: 28px; height: 28px; font-size: 14px; } .pl-main { width: 34px; height: 34px; } } + +/* Footer: pithos.sia, the version and the updater (like Aegis's) */ +.brandfoot { position: fixed; left: 0; right: 0; bottom: 0; z-index: 14; height: 28px; display: flex; align-items: center; + justify-content: space-between; padding: 0 12px; background: var(--surface); border-top: 1px solid var(--border); + font-size: 11.5px; color: var(--muted); } +@media (min-width: 761px) { .brandfoot { left: 220px; } } +.brandlink { display: inline-flex; align-items: center; gap: 5px; color: var(--muted); text-decoration: none; padding: 2px 4px; border-radius: 4px; } +.brandlink:hover { color: var(--accent); } +.brandfoot-right { display: inline-flex; align-items: center; gap: 8px; } +.brandver { font-variant-numeric: tabular-nums; letter-spacing: .3px; } +.brandcheck { appearance: none; background: none; border: 0; color: var(--muted); cursor: pointer; padding: 0 2px; font: inherit; + font-size: 13px; line-height: 1; opacity: .75; } +.brandcheck:hover { color: var(--accent); opacity: 1; } +.brandcheck.spin { animation: spin 1s linear infinite; } +.brandupd { appearance: none; border: 0; font: inherit; font-size: 11.5px; font-weight: 600; padding: 2px 9px; border-radius: 999px; + background: var(--accent-soft); color: var(--accent); cursor: pointer; white-space: nowrap; } +.brandupd:disabled { cursor: default; } +.brandupd.ok, .brandupd.wait { background: var(--surface-2); color: var(--muted); font-weight: 500; } +.brandupd.err { background: var(--danger-soft); color: var(--danger); font-weight: 500; } +body.has-foot main { padding-bottom: 64px; } +body.has-foot .player { bottom: 28px; } +body.has-foot .toasts { bottom: 44px; } +body.has-foot.has-player main { padding-bottom: 124px; } +body.has-foot.has-player .toasts { bottom: 112px; } diff --git a/ui/app.js b/ui/app.js index 8a03d86..68068d5 100644 --- a/ui/app.js +++ b/ui/app.js @@ -1752,6 +1752,87 @@ $('#unlock-btn').addEventListener('click', async () => { } }); +// ---- Footer: pithos.sia, the version, and (in Theseus) the updater ----------- +// Like Aegis: checks the extension channel when Pithos opens and every few +// 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'; +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); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const d = (pa[i] || 0) - (pb[i] || 0); if (d) return d < 0 ? -1 : 1; } + return 0; +} +function setupFooter() { + const foot = $('#brandfoot'); + const ver = $('#brandver'); + const chip = $('#brandupd'); + const check = $('#brandcheck'); + const current = state.status?.pithos || null; + ver.textContent = current ? `v${current}` : ''; + foot.hidden = false; + document.body.classList.add('has-foot'); + if (bridge) { + $('#brandlink').addEventListener('click', (e) => { e.preventDefault(); bridge.invoke('open-external', { url: 'https://pithos.sia/' }).catch(fail); }); + } + if (!bridge || !current) return; // only the Theseus add-on updates itself + + let latest = null; + let busy = false; + const showChip = (text, cls, title, onclick) => { + chip.hidden = false; ver.hidden = true; + chip.className = `brandupd${cls ? ' ' + cls : ''}`; + chip.textContent = text; chip.title = title || ''; + chip.disabled = !onclick; chip.onclick = onclick || null; + }; + const rest = () => { chip.hidden = true; ver.hidden = false; }; + const flash = (text, cls, title) => { showChip(text, cls, title, null); setTimeout(() => { if (!busy && chip.classList.contains(cls)) paint(); }, 2500); }; + function paint() { + if (latest && cmpSemver(latest, current) > 0) showChip(`↑ v${latest}`, '', `Pithos v${latest} is available. Click to install it.`, install); + else rest(); + } + async function scan(manual) { + check.classList.add('spin'); + try { + const r = await fetch(`${UPDATES_URL}?t=${Math.floor(Date.now() / 60000)}`, { cache: 'no-store' }); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const list = (await r.json())?.addons || []; + latest = list.reduce((best, e) => (!best || cmpSemver(e.version, best) > 0 ? e.version : best), null); + if (busy) return; + if (manual && !(latest && cmpSemver(latest, current) > 0)) flash('✓ Up to date', 'ok', `Pithos v${current} is the latest release`); + else paint(); + } catch (e) { + if (manual) flash('⚠ Check failed', 'err', e.message); + } finally { check.classList.remove('spin'); } + } + async function install() { + busy = true; + showChip('Downloading…', 'wait', 'Downloading and verifying the signed release', null); + try { + const r = await bridge.invoke('update', { step: 'stage' }); + if (r?.fallback) { + busy = false; + return showChip('Update in Settings', '', 'This Theseus updates extensions in Settings › Extensions', () => bridge.invoke('open-settings', { section: 'addons' }).catch(fail)); + } + if (!r?.staged) { + busy = false; + return r?.status === 'up-to-date' ? flash('✓ Up to date', 'ok', '') : flash('⚠ Update failed', 'err', r?.detail || r?.status || ''); + } + const next = r.next ? ` v${r.next}` : ''; + showChip(`↻ Restart to apply${next}`, '', `Pithos${next} is ready. Theseus restarts to apply it and reopens your tabs.`, async () => { + try { + const a = await bridge.invoke('update', { step: 'apply' }); + if (a?.deferred) showChip(`Applies on next start${next}`, 'ok', 'Pithos updates the next time Theseus starts', null); + } catch (e) { flash('⚠ Restart failed', 'err', e.message); } + }); + } catch (e) { busy = false; flash('⚠ Update failed', 'err', e.message); } + } + check.hidden = false; + check.addEventListener('click', () => scan(true)); + setTimeout(() => scan(false), 1500); + setInterval(() => scan(false), 6 * 3600 * 1000); +} + // The full-page tab opened from the Theseus panel: one button back. function addBackToSidebar() { const row = h('div', { class: 'host-controls' }, @@ -1789,6 +1870,7 @@ async function boot() { state.logSeq = state.logs.at(-1)?.seq || 0; } catch {} await refreshStatus(); + setupFooter(); if (!bridge && state.status?.host === 'theseus') addBackToSidebar(); connectEvents(); window.addEventListener('hashchange', route); diff --git a/ui/index.html b/ui/index.html index ffa9cb6..71da02c 100644 --- a/ui/index.html +++ b/ui/index.html @@ -46,6 +46,14 @@
+
From 606408035cc0edd3038ea4ae56d504273ea7895e Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 4 Oct 2026 03:00:58 +0200 Subject: [PATCH 4/5] =?UTF-8?q?Pithos=200.3.8:=20=E2=98=B0=20menu,=20one?= =?UTF-8?q?=20Account=20page,=20compact=20file=20rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the sidebar the menu now drops down from a ☰ in the top-right corner, opposite the Pithos name, and holds every page plus Open as full page, instead of pushing the page down. Get started and Sia connection become one Account page: the guide until an account is connected, then the account with its label, Add another account (the guide, titled Add a Sia account) and the other accounts on this computer, each one click to switch back to. On narrow screens file rows show Download and Share as icons, hide the size, and wrap names to two lines at most. --- ui/app.css | 38 +++++++++--- ui/app.js | 163 +++++++++++++++++++++++++++++++++----------------- ui/index.html | 5 +- 3 files changed, 140 insertions(+), 66 deletions(-) diff --git a/ui/app.css b/ui/app.css index 5aa2c24..5e50057 100644 --- a/ui/app.css +++ b/ui/app.css @@ -210,12 +210,19 @@ pre.snippet { background: var(--surface-2); padding: 10px 12px; border-radius: 8 padding: 8px 12px; gap: 8px; border-right: none; border-bottom: 1px solid var(--border); } .brand { flex: 1; min-width: 0; padding: 0; font-size: 15px; gap: 8px; } .brand img { width: 22px; height: 22px; } - .brand > span:first-of-type { display: none; } - .brand .here { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .brand .here { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-weight: 500; } + .brand .here:not(:empty)::before { content: "· "; } .nav-toggle { display: inline-grid; place-items: center; width: 34px; height: 34px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--text); font: inherit; font-size: 17px; cursor: pointer; } - nav { display: none; order: 10; width: 100%; flex-direction: column; padding-top: 4px; border-top: 1px solid var(--border); } + /* ☰ opens the menu over the page, from the top-right corner. */ + nav { display: none; position: absolute; top: calc(100% + 4px); right: 10px; z-index: 30; width: min(240px, calc(100vw - 20px)); + flex-direction: column; padding: 6px; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; + box-shadow: 0 10px 28px rgb(0 0 0 / .22); } .sidebar.nav-open nav { display: flex; } + .sidebar.nav-open .nav-toggle { background: var(--accent-soft); color: var(--accent); border-color: transparent; } + nav .nav-sep { display: block; height: 1px; background: var(--border); margin: 6px 4px; } + nav a.nav-action { display: block; color: var(--muted); } + .host-controls { display: none; } .daemon-pill { margin: 0; padding: 5px 9px; font-size: 12px; } main { padding: 16px 14px 40px; } .upload { grid-template-columns: 1fr 80px 50px; } @@ -240,7 +247,7 @@ pre.snippet { background: var(--surface-2); padding: 10px 12px; border-radius: 8 /* Theseus sidebar: widen / open-in-tab controls */ .host-controls { display: flex; gap: 6px; flex-wrap: wrap; margin-top: auto; } .host-controls + .daemon-pill { margin-top: 0; } -@media (max-width: 760px) { .host-controls { margin-top: 0; } } +@media (max-width: 760px) { .host-controls { display: none; } } @media (max-width: 420px) { .host-controls .btn { padding: 3px 7px; } } /* Narrow hosts (Theseus sidebar): never scroll sideways. */ @@ -288,10 +295,14 @@ html, body { overflow-x: hidden; } .seg button:hover { background: var(--surface-2); color: var(--text); } .seg button.on { background: var(--accent-soft); color: var(--accent); } -table.files td.name { max-width: 0; width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +table.files td.name { max-width: 0; width: 100%; white-space: normal; } table.files td, table.files th { white-space: nowrap; } table.files td.name > * { vertical-align: middle; } -table.files td.name .ficon, table.files td.name .drive-icon { display: inline-block; margin-right: 8px; } +table.files td.name > .drive-icon { display: inline-block; margin-right: 8px; } +.cell-name { display: flex; align-items: center; gap: 8px; min-width: 0; } +/* File names wrap to two lines at most, then end in "…" */ +.cell-name .label, ul.rows .label { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; + overflow-wrap: anywhere; line-height: 1.35; min-width: 0; } .ficon { flex: none; width: 1.4em; text-align: center; } .drive-icon.sm { width: 26px; height: 17px; border-radius: 4px; border-width: 1.5px; box-shadow: inset 0 -4px 0 color-mix(in srgb, var(--muted) 25%, transparent); flex: none; } .drive-icon.sm::after { width: 4px; height: 4px; right: 4px; bottom: 2px; } @@ -300,7 +311,7 @@ ul.rows { list-style: none; margin: 0; padding: 0; } ul.rows li { display: flex; align-items: center; gap: 10px; padding: 6px 6px; border-radius: 6px; min-width: 0; } ul.rows li + li { border-top: 1px solid var(--border); } ul.rows li:hover, ul.rows li:focus-visible { background: var(--surface-2); outline: none; } -ul.rows .label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +ul.rows .label { flex: 1; } ul.rows .meta { color: var(--muted); font-size: 12.5px; white-space: nowrap; font-variant-numeric: tabular-nums; } .clickable { cursor: pointer; } tr.clickable:focus-visible td { background: var(--surface-2); } @@ -375,3 +386,16 @@ body.has-foot .player { bottom: 28px; } body.has-foot .toasts { bottom: 44px; } body.has-foot.has-player main { padding-bottom: 124px; } body.has-foot.has-player .toasts { bottom: 112px; } + +/* Download / Share as icons on narrow screens */ +.nav-sep, .nav-action { display: none; } +.narrow-only { display: none; } +.icon-row { gap: 2px; } +.btn.icon { padding: 4px 6px; color: var(--muted); } +.btn.icon:hover:not(:disabled) { color: var(--accent); } +.btn.icon svg { display: block; } +.row-actions { display: inline-flex; align-items: center; gap: 2px; flex: none; } +@media (max-width: 760px) { + .narrow-only { display: inline-flex; align-items: center; } + table.files td.actions { padding-left: 2px; } +} diff --git a/ui/app.js b/ui/app.js index 68068d5..d2cf5a7 100644 --- a/ui/app.js +++ b/ui/app.js @@ -187,22 +187,24 @@ function renderPill() { let cleanup = []; let logSink = null; -const views = { start: getStarted, overview, buckets, users, setup, settings, logs }; +const views = { start: getStarted, account, overview, buckets, users, setup, settings, logs }; +// Pages that live under Account in the menu. +const NAV_OF = { start: 'account', setup: 'account' }; function route() { cleanup.forEach((fn) => fn()); cleanup = []; logSink = null; // First run lands on the guided setup until it has been finished once. - const fallback = !setupDone() && !state.status?.registration?.registered ? 'start' : 'overview'; + const fallback = !setupDone() && !state.status?.registration?.registered ? 'account' : 'overview'; const [, name = fallback, ...rest] = location.hash.replace(/^#/, '').split('/'); const view = views[name] ? name : fallback; - for (const a of document.querySelectorAll('#nav a')) a.classList.toggle('active', a.dataset.view === view); + const navView = NAV_OF[view] || view; + for (const a of document.querySelectorAll('#nav a')) a.classList.toggle('active', a.dataset.view === navView); // Compact bar (narrow hosts): name the page, fold the menu away. - const here = document.querySelector(`#nav a[data-view="${view}"]`); + const here = document.querySelector(`#nav a[data-view="${navView}"]`); $('#here').textContent = here ? here.textContent : ''; - $('.sidebar').classList.remove('nav-open'); - $('#nav-toggle').setAttribute('aria-expanded', 'false'); + setMenu(false); const main = $('#main'); put(main); views[view](main, rest.map(decodeURIComponent)); @@ -230,7 +232,7 @@ function overview(main) { put(daemonCard, h('div', { class: 'row spread' }, h('h2', {}, 'Gateway'), h('span', { class: `badge ${st === 'running' || st === 'external' ? 'ok' : st === 'crashed' ? 'warn' : ''}` }, st)), h('dl', { class: 'kv' }, - h('dt', {}, 'Sia account'), h('dd', {}, h('strong', {}, accountName(accountInfo)), ' · ', h('a', { href: '#/setup' }, accountInfo && accountInfo.registered ? 'Manage' : 'Connect')), + h('dt', {}, 'Sia account'), h('dd', {}, h('strong', {}, accountName(accountInfo)), ' · ', h('a', { href: '#/account' }, accountInfo && accountInfo.registered ? 'Manage' : 'Connect')), h('dt', {}, 'S3 endpoint'), h('dd', {}, h('code', {}, `http://${s.config.apiAddress}`)), s.config.https && [h('dt', {}, 'S3 HTTPS'), h('dd', {}, h('code', {}, `https://${s.config.https}`))], h('dt', {}, 's3d'), h('dd', {}, s.s3d?.version ? `v${s.s3d.version}` : (s.daemon.binary ? 'unknown version' : 'not installed')), @@ -310,7 +312,7 @@ function bannersFor(s) { if (s.registration?.registered === false || s.daemon.needsLogin) { out.push(h('div', { class: 'banner info' }, h('span', {}, 'This s3d is not connected to a Sia indexer yet.'), - h('a', { class: 'btn primary', href: '#/start' }, 'Get started'))); + h('a', { class: 'btn primary', href: '#/account' }, 'Get started'))); } return out; } @@ -350,8 +352,8 @@ function getStarted(main) { const STEPS = ['Welcome', 'Sia Storage account', 'Install s3d', 'Recovery phrase', 'Connect', 'Start gateway', 'Access key', 'Done']; const body = h('div', { class: 'card wizard' }); main.append( - h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Get started'), - h('p', { class: 'muted' }, 'Set up your own S3 storage on Sia, one step at a time.'))), + h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, setupDone() ? 'Add a Sia account' : 'Get started'), + h('p', { class: 'muted' }, setupDone() ? 'Connect s3d to another Sia account, one step at a time.' : 'Set up your own S3 storage on Sia, one step at a time.'))), body, ); @@ -628,11 +630,53 @@ function getStarted(main) { // ---------------------------------------------------------------- setup (indexer login) +// Account: the Sia account this s3d stores and pays through. Before one is +// connected it is the step-by-step guide; after, the account itself, with +// adding another account and switching back to earlier ones. +function account(main, rest) { + const registered = !!state.status?.registration?.registered; + if (!registered && ['idle', 'failed'].includes(state.login.state)) { + getStarted(main, rest); + // Accounts set aside earlier stay one click away while adding a new one. + const slot = h('div'); + main.append(slot); + loadAccount().then(() => { const b = otherAccounts(() => route()); if (b) put(slot, h('div', { class: 'card', style: 'margin-top:16px' }, b)); }); + return; + } + return setup(main, rest); +} + +// Earlier Sia accounts set aside on this computer, each one click away. +function otherAccounts(after) { + const list = accountInfo?.archives || []; + if (!list.length) return null; + async function restore(a) { + const name = a.label || (a.indexerUrl ? new URL(a.indexerUrl).host : a.name); + const ok = await confirmModal(`Switch to ${name}?`, 's3d stops and switches back to that Sia account with its users, keys and drives. The current account is set aside and you can switch back the same way.', 'Switch', 'primary'); + if (!ok) return; + try { + await api('POST', '/api/account/restore', { name: a.name }); + await api('POST', '/api/daemon/start').catch(() => {}); + toast(`Switched to ${name}`); + await refreshStatus(); + await loadAccount(); + after(); + } catch (e) { fail(e); } + } + return h('div', {}, + h('h3', {}, 'Other accounts on this computer'), + h('p', { class: 'small muted', style: 'margin-top:0' }, 'Set aside when another account was added. Switching back restores its users, keys and drives; the current account is set aside the same way.'), + h('table', {}, h('tbody', {}, list.map((a) => h('tr', {}, + h('td', {}, h('strong', {}, a.label || (a.indexerUrl ? new URL(a.indexerUrl).host : a.name)), + h('div', { class: 'small muted' }, `${a.archivedAt ? fmtDate(a.archivedAt) : a.name}${a.users.length ? ` · users: ${a.users.join(', ')}` : ''}`)), + h('td', { class: 'actions' }, h('button', { class: 'btn small', disabled: !a.hasData, onclick: () => restore(a) }, 'Switch to'))))))); +} + function setup(main) { const body = h('div', { class: 'card' }); main.append( - h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Sia connection'), - h('p', { class: 'muted' }, 'Register this s3d with a Sia indexer. You do this once; the indexer then handles hosts and contracts for it.'))), + h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Account'), + h('p', { class: 'muted' }, 'The Sia account that stores, and pays for, everything on this s3d.'))), body, ); @@ -647,9 +691,10 @@ function setup(main) { if (l.state === 'done' || l.state === 'already') { const label = h('input', { type: 'text', placeholder: 'e.g. the email you sign in with', value: accountInfo?.label || '', maxlength: 120 }); - put(body, steps, - h('h2', {}, l.state === 'done' ? 'Connected' : 'Connected to Sia'), - h('p', {}, l.indexerUrl ? ['This s3d uses a Sia account on ', h('code', {}, l.indexerUrl), '. All S3 users, buckets and files on it are stored, and paid for, through that account.'] : 'This s3d is registered with an indexer.'), + put(body, + h('h2', {}, accountInfo?.label || (l.indexerUrl ? new URL(l.indexerUrl).host : 'Connected to Sia')), + h('p', { class: 'row small', style: 'margin-top:-6px' }, h('span', { class: 'badge ok' }, 'connected'), accountInfo?.label && l.indexerUrl && h('span', { class: 'muted' }, new URL(l.indexerUrl).host)), + h('p', {}, l.indexerUrl ? ['This s3d uses a Sia account on ', h('code', {}, l.indexerUrl), '. All S3 users, drives and files on it are stored, and paid for, through that account.'] : 'This s3d is registered with an indexer.'), h('label', { class: 'field', style: 'max-width:420px' }, h('span', {}, 'Account label'), h('div', { class: 'row', style: 'flex-wrap:nowrap' }, label, h('button', { class: 'btn', onclick: async () => { try { await api('PUT', '/api/account/label', { label: label.value }); await loadAccount(); toast('Label saved'); } catch (e) { fail(e); } } }, 'Save')), @@ -657,7 +702,7 @@ function setup(main) { h('div', { class: 'row', style: 'margin-top:16px' }, daemonState() !== 'running' && h('button', { class: 'btn primary', onclick: async () => { await daemonAction(daemonState() === 'crashed' ? 'restart' : 'start'); location.hash = '#/overview'; } }, 'Start s3d'), h('a', { class: 'btn', href: '#/users' }, 'Users & keys'), - h('button', { class: 'btn danger', onclick: switchAccount }, 'Switch Sia account…')), + h('button', { class: 'btn', onclick: switchAccount }, 'Add another account…')), archivesBlock()); return; } @@ -721,17 +766,9 @@ function setup(main) { archivesBlock()); } - // Earlier Sia accounts set aside by "Switch Sia account", restorable. function archivesBlock() { - const list = accountInfo?.archives || []; - if (!list.length) return null; - return h('div', { style: 'margin-top:22px' }, - h('h3', {}, 'Earlier Sia accounts on this computer'), - h('p', { class: 'small muted', style: 'margin-top:0' }, 'Set aside by "Switch Sia account". Restoring one puts its users, keys and buckets back (the current account is set aside the same way).'), - h('table', {}, h('tbody', {}, list.map((a) => h('tr', {}, - h('td', {}, h('strong', {}, a.label || (a.indexerUrl ? new URL(a.indexerUrl).host : a.name)), - h('div', { class: 'small muted' }, `${a.archivedAt ? fmtDate(a.archivedAt) : a.name}${a.users.length ? ` · users: ${a.users.join(', ')}` : ''}`)), - h('td', { class: 'actions' }, h('button', { class: 'btn small', disabled: !a.hasData, onclick: () => restoreAccount(a) }, 'Restore'))))))); + const b = otherAccounts(draw); + return b && h('div', { style: 'margin-top:22px' }, b); } async function switchAccount() { @@ -739,16 +776,16 @@ function setup(main) { if (daemonState() === 'running') { try { pending = await api('GET', '/api/stats'); } catch {} } const users = (await api('GET', '/api/users').catch(() => [])).map((u) => u.name); const waiting = pending && pending.pendingObjects > 0; - const ok = await modal('Switch Sia account?', h('div', { class: 'stack' }, - h('p', { style: 'margin:0' }, `s3d is connected to ${accountName(accountInfo)}. Switching connects it to a different Sia account.`), + const ok = await modal('Add another Sia account?', h('div', { class: 'stack' }, + h('p', { style: 'margin:0' }, `s3d is connected to ${accountName(accountInfo)}. s3d uses one Sia account at a time, so adding another sets this one aside on this computer; you can switch back to it any time on this page.`), h('ul', { class: 'small', style: 'margin:0;padding-left:1.2em' }, - h('li', {}, `The current account keeps its files. Its S3 users${users.length ? ` (${users.join(', ')})` : ''}, keys and buckets are set aside on this computer, not deleted, and can be restored here.`), + h('li', {}, `The current account keeps its files. Its S3 users${users.length ? ` (${users.join(', ')})` : ''}, keys and drives are set aside on this computer, not deleted, and you can switch back to it on the Account page.`), h('li', {}, 'The new account starts empty: you create users and keys again, and S3 apps need the new keys.'), - h('li', {}, 's3d stops now; the guide then connects the other account.')), + h('li', {}, 's3d stops now; the guide then connects the new account.')), waiting && h('div', { class: 'banner warn' }, h('span', {}, `${pending.pendingObjects} object(s) (${fmtBytes(pending.pendingSize)}) are still waiting to upload to Sia.`), h('button', { class: 'btn', type: 'button', onclick: async (e) => { e.currentTarget.disabled = true; try { await api('POST', '/api/flush'); toast('Pending uploads are on Sia'); } catch (err) { fail(err); } } }, 'Flush now'))), - [{ label: 'Cancel', result: false }, { label: 'Switch account', kind: 'primary', submit: true, result: true }]); + [{ label: 'Cancel', result: false }, { label: 'Add account', kind: 'primary', submit: true, result: true }]); if (!ok) return; try { const r = await api('POST', '/api/account/switch'); @@ -760,20 +797,6 @@ function setup(main) { } catch (e) { fail(e); } } - async function restoreAccount(a) { - const name = a.label || (a.indexerUrl ? new URL(a.indexerUrl).host : a.name); - const ok = await confirmModal(`Restore ${name}?`, 's3d stops and switches back to that Sia account with its users, keys and buckets. The current account is set aside and can be restored the same way.', 'Restore', 'primary'); - if (!ok) return; - try { - await api('POST', '/api/account/restore', { name: a.name }); - await api('POST', '/api/daemon/start').catch(() => {}); - toast(`Restored ${name}`); - await refreshStatus(); - await loadAccount(); - draw(); - } catch (e) { fail(e); } - } - async function start(e) { e.preventDefault(); const phrase = draft.mode === 'existing' ? draft.phrase.trim() : ''; @@ -1080,6 +1103,15 @@ function itemMenu(items) { return btn; } const go = (hash) => () => { location.hash = hash; }; +const ICONS = { + download: '', + share: '', +}; +function iconButton(name, label, run) { + const b = h('button', { type: 'button', class: 'btn small icon', title: label, 'aria-label': label, onclick: (e) => { e.stopPropagation(); run(); } }); + b.innerHTML = ICONS[name]; // fixed markup above, never user data + return b; +} const onEnter = (fn) => (e) => { if (e.key === 'Enter' && e.target === e.currentTarget) fn(); }; const EXT = (key) => (key.split('/').pop().match(/.([a-z0-9]+)$/i) || [])[1]?.toLowerCase() || ''; @@ -1158,7 +1190,7 @@ function accountLine() { loadAccount().then((a) => put(el, 'Sia account: ', h('strong', {}, accountName(a)), a && a.label && a.indexerUrl ? h('span', {}, ` (${new URL(a.indexerUrl).host})`) : null, - ' · ', h('a', { href: '#/setup' }, a && a.registered ? 'Manage' : 'Connect'))); + ' · ', h('a', { href: '#/account' }, a && a.registered ? 'Manage' : 'Connect'))); return el; } @@ -1325,24 +1357,28 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) { const fileMenu = (o) => itemMenu([[fileKind(o.key) === 'audio' ? 'Play' : 'Open', () => openViewer(o)], ['Download', () => downloadObject(o.key)], ['Share link…', () => share(o.key)], ['Delete', () => deleteObject(o.key), 'danger']]); const more = last?.truncated && h('div', { class: 'row', style: 'justify-content:center;margin-top:12px' }, h('button', { class: 'btn', onclick: () => load(last.nextToken) }, 'Load more')); const stop = (e) => e.stopPropagation(); + // Narrow screens: Download and Share as icons beside the ⋯ menu. + const quickIcons = (o) => h('span', { class: 'narrow-only icon-row' }, + iconButton('download', 'Download', () => downloadObject(o.key)), + iconButton('share', 'Share link', () => share(o.key))); if (mode === 'details') { put(card, h('table', { class: 'files' }, - h('thead', {}, h('tr', {}, h('th', {}, 'Name'), h('th', { class: 'num' }, 'Size'), h('th', { class: 'hide-sm' }, 'Type'), h('th', { class: 'hide-sm' }, 'Modified'), h('th', {}))), + h('thead', {}, h('tr', {}, h('th', {}, 'Name'), h('th', { class: 'num hide-sm' }, 'Size'), h('th', { class: 'hide-sm' }, 'Type'), h('th', { class: 'hide-sm' }, 'Modified'), h('th', {}))), h('tbody', {}, folders.map((f) => h('tr', { class: 'clickable', tabindex: '0', onclick: () => go(f), onkeydown: onEnter(() => go(f)) }, - h('td', { class: 'name' }, h('span', { class: 'ficon', 'aria-hidden': 'true' }, '📁'), h('span', { class: 'label' }, folderName(f))), - h('td', { class: 'num muted' }, '–'), h('td', { class: 'hide-sm muted' }, 'Folder'), h('td', { class: 'hide-sm' }), + h('td', { class: 'name' }, h('div', { class: 'cell-name' }, h('span', { class: 'ficon', 'aria-hidden': 'true' }, '📁'), h('span', { class: 'label' }, folderName(f)))), + h('td', { class: 'num muted hide-sm' }, '–'), h('td', { class: 'hide-sm muted' }, 'Folder'), h('td', { class: 'hide-sm' }), h('td', { class: 'actions', onclick: stop }, folderMenu(f)))), objects.map((o) => h('tr', { class: 'clickable', tabindex: '0', onclick: () => openViewer(o), onkeydown: onEnter(() => openViewer(o)) }, - h('td', { class: 'name' }, h('span', { class: 'ficon', 'aria-hidden': 'true' }, fileIcon(o.key)), h('span', { class: 'label', title: fileName(o) }, fileName(o))), - h('td', { class: 'num' }, fmtBytes(o.size)), + h('td', { class: 'name' }, h('div', { class: 'cell-name' }, h('span', { class: 'ficon', 'aria-hidden': 'true' }, fileIcon(o.key)), h('span', { class: 'label', title: fileName(o) }, fileName(o)))), + h('td', { class: 'num hide-sm' }, fmtBytes(o.size)), h('td', { class: 'hide-sm muted' }, fileType(o.key)), h('td', { class: 'hide-sm muted' }, fmtDate(o.modified)), h('td', { class: 'actions', onclick: stop }, h('span', { class: 'wide-only' }, h('button', { class: 'btn small', onclick: () => downloadObject(o.key) }, 'Download'), ' ', h('button', { class: 'btn small', onclick: () => share(o.key) }, 'Share'), ' '), - fileMenu(o)))))), + quickIcons(o), fileMenu(o)))))), more); } else if (mode === 'list') { put(card, h('ul', { class: 'rows' }, @@ -1351,8 +1387,8 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) { h('span', { onclick: stop }, folderMenu(f)))), objects.map((o) => h('li', { class: 'clickable', tabindex: '0', onclick: () => openViewer(o), onkeydown: onEnter(() => openViewer(o)) }, h('span', { class: 'ficon', 'aria-hidden': 'true' }, fileIcon(o.key)), h('span', { class: 'label', title: fileName(o) }, fileName(o)), - h('span', { class: 'meta' }, fmtBytes(o.size)), - h('span', { onclick: stop }, fileMenu(o))))), + h('span', { class: 'meta hide-sm' }, fmtBytes(o.size)), + h('span', { class: 'row-actions', onclick: stop }, quickIcons(o), fileMenu(o))))), more); } else { put(card, h('div', { class: 'tiles files' }, @@ -1717,6 +1753,14 @@ function addSidebarControls() { try { await bridge.invoke('open-in-tab'); await bridge.sidebar?.close?.(); } catch (e) { fail(e); } } }, '⤢ Full page')); $('.sidebar').insertBefore(row, $('#daemon-pill')); + addMenuAction('⤢ Open as full page', () => row.querySelector('button').click()); +} + +// An action at the end of the ☰ menu (narrow layouts only; wide ones show +// the same thing as a button under the navigation). +function addMenuAction(label, run) { + if (!$('#nav .nav-sep')) $('#nav').append(h('div', { class: 'nav-sep', role: 'separator' })); + $('#nav').append(h('a', { href: '#', class: 'nav-action', onclick: (e) => { e.preventDefault(); setMenu(false); run(); } }, label)); } // Theseus: Pithos opens only with the vault unlocked. The add-on asks Theseus @@ -1841,6 +1885,7 @@ function addBackToSidebar() { window.close(); // works for tabs the add-on opened; harmless otherwise } }, '⇤ Back to sidebar')); $('.sidebar').insertBefore(row, $('#daemon-pill')); + addMenuAction('⇤ Back to sidebar', () => row.querySelector('button').click()); } function showSignin() { @@ -1878,10 +1923,16 @@ async function boot() { setInterval(refreshStatus, 15000); } -$('#nav-toggle').addEventListener('click', () => { - const open = $('.sidebar').classList.toggle('nav-open'); +function setMenu(open) { + $('.sidebar').classList.toggle('nav-open', open); $('#nav-toggle').setAttribute('aria-expanded', String(open)); +} +$('#nav-toggle').addEventListener('click', (e) => { e.stopPropagation(); setMenu(!$('.sidebar').classList.contains('nav-open')); }); +document.addEventListener('mousedown', (e) => { + if ($('.sidebar').classList.contains('nav-open') && !$('#nav').contains(e.target) && !$('#nav-toggle').contains(e.target)) setMenu(false); }); +document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && $('.sidebar').classList.contains('nav-open')) { setMenu(false); $('#nav-toggle').focus(); } }); +$('#nav').addEventListener('click', (e) => { if (e.target.closest('a')) setMenu(false); }); $('#signin-form').addEventListener('submit', async (e) => { e.preventDefault(); diff --git a/ui/index.html b/ui/index.html index 71da02c..40f5f8d 100644 --- a/ui/index.html +++ b/ui/index.html @@ -32,16 +32,15 @@
From 9a9d4d4a3fe5111ac921d617aff727f2facf4ed3 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 4 Oct 2026 03:18:24 +0200 Subject: [PATCH 5/5] Pithos 0.3.9: player volume, share links made at once The music player had no volume control; it now has a slider beside a mute button (a pop-up slider on narrow screens), remembered between visits. The share dialog asked for a "public address of s3d" before making a link, which nobody could fill in usefully. The link is now made as the dialog opens, with a plain note that it opens only on this computer while s3d listens on 127.0.0.1; your own public address moved under "Use my own public address". The Access dialog says the same about what public means while s3d is local, and that access can be changed at any time. Dialogs now draw above the player. --- package.json | 2 +- theseus/addon.json | 2 +- ui/app.css | 19 ++++++++++++- ui/app.js | 68 ++++++++++++++++++++++++++++++++++++++-------- 4 files changed, 76 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index b33f24d..d7fa7e0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pithos", - "version": "0.3.8", + "version": "0.3.9", "description": "A control panel for s3d, the Sia S3 gateway: daemon, users and keys, buckets, upload pipeline.", "license": "MPL-2.0", "type": "module", diff --git a/theseus/addon.json b/theseus/addon.json index 33eae35..cbde166 100644 --- a/theseus/addon.json +++ b/theseus/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/ui/app.css b/ui/app.css index 5e50057..8a576d7 100644 --- a/ui/app.css +++ b/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/ui/app.js b/ui/app.js index d2cf5a7..43ce311 100644 --- a/ui/app.js +++ b/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);