Theseus: bundle Pithos 0.3.8 with the menu and Account page

This commit is contained in:
Local Dev 2026-10-04 03:01:00 +02:00
parent 155b445c68
commit 5e67f81a94
3 changed files with 140 additions and 66 deletions

View file

@ -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); } 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 { flex: 1; min-width: 0; padding: 0; font-size: 15px; gap: 8px; }
.brand img { width: 22px; height: 22px; } .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; color: var(--muted); font-weight: 500; }
.brand .here { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .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; .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; } 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 { 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; } .daemon-pill { margin: 0; padding: 5px 9px; font-size: 12px; }
main { padding: 16px 14px 40px; } main { padding: 16px 14px 40px; }
.upload { grid-template-columns: 1fr 80px 50px; } .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 */ /* Theseus sidebar: widen / open-in-tab controls */
.host-controls { display: flex; gap: 6px; flex-wrap: wrap; margin-top: auto; } .host-controls { display: flex; gap: 6px; flex-wrap: wrap; margin-top: auto; }
.host-controls + .daemon-pill { margin-top: 0; } .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; } } @media (max-width: 420px) { .host-controls .btn { padding: 3px 7px; } }
/* Narrow hosts (Theseus sidebar): never scroll sideways. */ /* 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:hover { background: var(--surface-2); color: var(--text); }
.seg button.on { background: var(--accent-soft); color: var(--accent); } .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, table.files th { white-space: nowrap; }
table.files td.name > * { vertical-align: middle; } 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; } .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 { 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; } .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 { 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 + li { border-top: 1px solid var(--border); }
ul.rows li:hover, ul.rows li:focus-visible { background: var(--surface-2); outline: none; } 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; } ul.rows .meta { color: var(--muted); font-size: 12.5px; white-space: nowrap; font-variant-numeric: tabular-nums; }
.clickable { cursor: pointer; } .clickable { cursor: pointer; }
tr.clickable:focus-visible td { background: var(--surface-2); } 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 .toasts { bottom: 44px; }
body.has-foot.has-player main { padding-bottom: 124px; } body.has-foot.has-player main { padding-bottom: 124px; }
body.has-foot.has-player .toasts { bottom: 112px; } 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; }
}

View file

@ -187,22 +187,24 @@ function renderPill() {
let cleanup = []; let cleanup = [];
let logSink = null; 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() { function route() {
cleanup.forEach((fn) => fn()); cleanup.forEach((fn) => fn());
cleanup = []; cleanup = [];
logSink = null; logSink = null;
// First run lands on the guided setup until it has been finished once. // 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 [, name = fallback, ...rest] = location.hash.replace(/^#/, '').split('/');
const view = views[name] ? name : fallback; 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. // 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 : ''; $('#here').textContent = here ? here.textContent : '';
$('.sidebar').classList.remove('nav-open'); setMenu(false);
$('#nav-toggle').setAttribute('aria-expanded', 'false');
const main = $('#main'); const main = $('#main');
put(main); put(main);
views[view](main, rest.map(decodeURIComponent)); views[view](main, rest.map(decodeURIComponent));
@ -230,7 +232,7 @@ function overview(main) {
put(daemonCard, put(daemonCard,
h('div', { class: 'row spread' }, h('h2', {}, 'Gateway'), h('span', { class: `badge ${st === 'running' || st === 'external' ? 'ok' : st === 'crashed' ? 'warn' : ''}` }, st)), 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('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}`)), 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}`))], 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')), 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) { if (s.registration?.registered === false || s.daemon.needsLogin) {
out.push(h('div', { class: 'banner info' }, out.push(h('div', { class: 'banner info' },
h('span', {}, 'This s3d is not connected to a Sia indexer yet.'), 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; 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 STEPS = ['Welcome', 'Sia Storage account', 'Install s3d', 'Recovery phrase', 'Connect', 'Start gateway', 'Access key', 'Done'];
const body = h('div', { class: 'card wizard' }); const body = h('div', { class: 'card wizard' });
main.append( main.append(
h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Get started'), h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, setupDone() ? 'Add a Sia account' : 'Get started'),
h('p', { class: 'muted' }, 'Set up your own S3 storage on Sia, one step at a time.'))), 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, body,
); );
@ -628,11 +630,53 @@ function getStarted(main) {
// ---------------------------------------------------------------- setup (indexer login) // ---------------------------------------------------------------- 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) { function setup(main) {
const body = h('div', { class: 'card' }); const body = h('div', { class: 'card' });
main.append( main.append(
h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Sia connection'), h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Account'),
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('p', { class: 'muted' }, 'The Sia account that stores, and pays for, everything on this s3d.'))),
body, body,
); );
@ -647,9 +691,10 @@ function setup(main) {
if (l.state === 'done' || l.state === 'already') { 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 }); const label = h('input', { type: 'text', placeholder: 'e.g. the email you sign in with', value: accountInfo?.label || '', maxlength: 120 });
put(body, steps, put(body,
h('h2', {}, l.state === 'done' ? 'Connected' : 'Connected to Sia'), h('h2', {}, accountInfo?.label || (l.indexerUrl ? new URL(l.indexerUrl).host : '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.'), 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('label', { class: 'field', style: 'max-width:420px' }, h('span', {}, 'Account label'),
h('div', { class: 'row', style: 'flex-wrap:nowrap' }, 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')), 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' }, 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'), 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('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()); archivesBlock());
return; return;
} }
@ -721,17 +766,9 @@ function setup(main) {
archivesBlock()); archivesBlock());
} }
// Earlier Sia accounts set aside by "Switch Sia account", restorable.
function archivesBlock() { function archivesBlock() {
const list = accountInfo?.archives || []; const b = otherAccounts(draw);
if (!list.length) return null; return b && h('div', { style: 'margin-top:22px' }, b);
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')))))));
} }
async function switchAccount() { async function switchAccount() {
@ -739,16 +776,16 @@ function setup(main) {
if (daemonState() === 'running') { try { pending = await api('GET', '/api/stats'); } catch {} } if (daemonState() === 'running') { try { pending = await api('GET', '/api/stats'); } catch {} }
const users = (await api('GET', '/api/users').catch(() => [])).map((u) => u.name); const users = (await api('GET', '/api/users').catch(() => [])).map((u) => u.name);
const waiting = pending && pending.pendingObjects > 0; const waiting = pending && pending.pendingObjects > 0;
const ok = await modal('Switch Sia account?', h('div', { class: 'stack' }, const ok = await modal('Add another 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.`), 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('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', {}, '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' }, waiting && h('div', { class: 'banner warn' },
h('span', {}, `${pending.pendingObjects} object(s) (${fmtBytes(pending.pendingSize)}) are still waiting to upload to Sia.`), 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'))), 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; if (!ok) return;
try { try {
const r = await api('POST', '/api/account/switch'); const r = await api('POST', '/api/account/switch');
@ -760,20 +797,6 @@ function setup(main) {
} catch (e) { fail(e); } } 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) { async function start(e) {
e.preventDefault(); e.preventDefault();
const phrase = draft.mode === 'existing' ? draft.phrase.trim() : ''; const phrase = draft.mode === 'existing' ? draft.phrase.trim() : '';
@ -1080,6 +1103,15 @@ function itemMenu(items) {
return btn; return btn;
} }
const go = (hash) => () => { location.hash = hash; }; const go = (hash) => () => { location.hash = hash; };
const ICONS = {
download: '<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2.5v8M4.5 7.5 8 11l3.5-3.5M3 13.5h10"/></svg>',
share: '<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M6.6 9.4a2.6 2.6 0 0 0 3.7 0l2.2-2.2a2.6 2.6 0 0 0-3.7-3.7l-.9.9"/><path d="M9.4 6.6a2.6 2.6 0 0 0-3.7 0L3.5 8.8a2.6 2.6 0 0 0 3.7 3.7l.9-.9"/></svg>',
};
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 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() || ''; const EXT = (key) => (key.split('/').pop().match(/.([a-z0-9]+)$/i) || [])[1]?.toLowerCase() || '';
@ -1158,7 +1190,7 @@ function accountLine() {
loadAccount().then((a) => put(el, loadAccount().then((a) => put(el,
'Sia account: ', h('strong', {}, accountName(a)), 'Sia account: ', h('strong', {}, accountName(a)),
a && a.label && a.indexerUrl ? h('span', {}, ` (${new URL(a.indexerUrl).host})`) : null, 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; 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 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 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(); 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') { if (mode === 'details') {
put(card, h('table', { class: 'files' }, 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', {}, h('tbody', {},
folders.map((f) => h('tr', { class: 'clickable', tabindex: '0', onclick: () => go(f), onkeydown: onEnter(() => go(f)) }, 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: 'name' }, h('div', { class: 'cell-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: 'num muted hide-sm' }, '–'), h('td', { class: 'hide-sm muted' }, 'Folder'), h('td', { class: 'hide-sm' }),
h('td', { class: 'actions', onclick: stop }, folderMenu(f)))), h('td', { class: 'actions', onclick: stop }, folderMenu(f)))),
objects.map((o) => h('tr', { class: 'clickable', tabindex: '0', onclick: () => openViewer(o), onkeydown: onEnter(() => openViewer(o)) }, 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: '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' }, fmtBytes(o.size)), h('td', { class: 'num hide-sm' }, fmtBytes(o.size)),
h('td', { class: 'hide-sm muted' }, fileType(o.key)), h('td', { class: 'hide-sm muted' }, fileType(o.key)),
h('td', { class: 'hide-sm muted' }, fmtDate(o.modified)), h('td', { class: 'hide-sm muted' }, fmtDate(o.modified)),
h('td', { class: 'actions', onclick: stop }, h('td', { class: 'actions', onclick: stop },
h('span', { class: 'wide-only' }, h('button', { class: 'btn small', onclick: () => downloadObject(o.key) }, 'Download'), ' ', 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'), ' '), h('button', { class: 'btn small', onclick: () => share(o.key) }, 'Share'), ' '),
fileMenu(o)))))), quickIcons(o), fileMenu(o)))))),
more); more);
} else if (mode === 'list') { } else if (mode === 'list') {
put(card, h('ul', { class: 'rows' }, put(card, h('ul', { class: 'rows' },
@ -1351,8 +1387,8 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) {
h('span', { onclick: stop }, folderMenu(f)))), h('span', { onclick: stop }, folderMenu(f)))),
objects.map((o) => h('li', { class: 'clickable', tabindex: '0', onclick: () => openViewer(o), onkeydown: onEnter(() => openViewer(o)) }, 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: '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', { class: 'meta hide-sm' }, fmtBytes(o.size)),
h('span', { onclick: stop }, fileMenu(o))))), h('span', { class: 'row-actions', onclick: stop }, quickIcons(o), fileMenu(o))))),
more); more);
} else { } else {
put(card, h('div', { class: 'tiles files' }, 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); } try { await bridge.invoke('open-in-tab'); await bridge.sidebar?.close?.(); } catch (e) { fail(e); }
} }, '⤢ Full page')); } }, '⤢ Full page'));
$('.sidebar').insertBefore(row, $('#daemon-pill')); $('.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 // 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 window.close(); // works for tabs the add-on opened; harmless otherwise
} }, '⇤ Back to sidebar')); } }, '⇤ Back to sidebar'));
$('.sidebar').insertBefore(row, $('#daemon-pill')); $('.sidebar').insertBefore(row, $('#daemon-pill'));
addMenuAction('⇤ Back to sidebar', () => row.querySelector('button').click());
} }
function showSignin() { function showSignin() {
@ -1878,10 +1923,16 @@ async function boot() {
setInterval(refreshStatus, 15000); setInterval(refreshStatus, 15000);
} }
$('#nav-toggle').addEventListener('click', () => { function setMenu(open) {
const open = $('.sidebar').classList.toggle('nav-open'); $('.sidebar').classList.toggle('nav-open', open);
$('#nav-toggle').setAttribute('aria-expanded', String(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) => { $('#signin-form').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();

View file

@ -32,16 +32,15 @@
<aside class="sidebar"> <aside class="sidebar">
<div class="brand"><img src="icon.svg" alt="" width="28" height="28"><span>Pithos</span><span class="here" id="here"></span></div> <div class="brand"><img src="icon.svg" alt="" width="28" height="28"><span>Pithos</span><span class="here" id="here"></span></div>
<nav id="nav"> <nav id="nav">
<a href="#/start" data-view="start">Get started</a>
<a href="#/overview" data-view="overview">Overview</a> <a href="#/overview" data-view="overview">Overview</a>
<a href="#/buckets" data-view="buckets">Drives</a> <a href="#/buckets" data-view="buckets">Drives</a>
<a href="#/users" data-view="users">Users &amp; keys</a> <a href="#/users" data-view="users">Users &amp; keys</a>
<a href="#/setup" data-view="setup">Sia connection</a> <a href="#/account" data-view="account">Account</a>
<a href="#/settings" data-view="settings">Settings</a> <a href="#/settings" data-view="settings">Settings</a>
<a href="#/logs" data-view="logs">Logs</a> <a href="#/logs" data-view="logs">Logs</a>
</nav> </nav>
<div class="daemon-pill" id="daemon-pill"><span class="dot"></span><span class="label">…</span></div> <div class="daemon-pill" id="daemon-pill"><span class="dot"></span><span class="label">…</span></div>
<button class="nav-toggle" id="nav-toggle" type="button" aria-label="Menu" aria-expanded="false" aria-controls="nav">☰</button> <button class="nav-toggle" id="nav-toggle" type="button" aria-label="Menu" aria-haspopup="menu" aria-expanded="false" aria-controls="nav">☰</button>
</aside> </aside>
<main id="main"></main> <main id="main"></main>
</div> </div>