From 5e67f81a94c62862445739c87e5b32c05345f764 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 4 Oct 2026 03:01:00 +0200 Subject: [PATCH] Theseus: bundle Pithos 0.3.8 with the menu and Account page --- bundled-addons/pithos/ui/app.css | 38 +++++-- bundled-addons/pithos/ui/app.js | 163 ++++++++++++++++++---------- bundled-addons/pithos/ui/index.html | 5 +- 3 files changed, 140 insertions(+), 66 deletions(-) diff --git a/bundled-addons/pithos/ui/app.css b/bundled-addons/pithos/ui/app.css index 5aa2c246..5e500575 100644 --- a/bundled-addons/pithos/ui/app.css +++ b/bundled-addons/pithos/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/bundled-addons/pithos/ui/app.js b/bundled-addons/pithos/ui/app.js index 68068d52..d2cf5a73 100644 --- a/bundled-addons/pithos/ui/app.js +++ b/bundled-addons/pithos/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/bundled-addons/pithos/ui/index.html b/bundled-addons/pithos/ui/index.html index 71da02c1..40f5f8d8 100644 --- a/bundled-addons/pithos/ui/index.html +++ b/bundled-addons/pithos/ui/index.html @@ -32,16 +32,15 @@