Theseus: bundle Pithos 0.3.7

This commit is contained in:
Local Dev 2026-10-04 02:16:11 +02:00
parent 80fe82abee
commit 6ba16261ab
4 changed files with 260 additions and 63 deletions

View file

@ -1,7 +1,7 @@
{ {
"id": "pithos", "id": "pithos",
"name": "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.", "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", "author": "Silent Mode",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM2RkYzk3Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMWU4ZjZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIyIDZoMjB2NWMwIDItMiAzLTIgNSA4IDMgMTQgMTEgMTQgMjEgMCAxMi05IDIxLTIyIDIxUzEwIDQ5IDEwIDM3YzAtMTAgNi0xOCAxNC0yMSAwLTItMi0zLTItNXoiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMTQgMzBoMzZNMTMgNDBoMzgiIHN0cm9rZT0iIzBiMWYxOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9Ii41NSIvPjwvc3ZnPgo=", "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM2RkYzk3Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMWU4ZjZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIyIDZoMjB2NWMwIDItMiAzLTIgNSA4IDMgMTQgMTEgMTQgMjEgMCAxMi05IDIxLTIyIDIxUzEwIDQ5IDEwIDM3YzAtMTAgNi0xOCAxNC0yMSAwLTItMi0zLTItNXoiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMTQgMzBoMzZNMTMgNDBoMzgiIHN0cm9rZT0iIzBiMWYxOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9Ii41NSIvPjwvc3ZnPgo=",

View file

@ -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; } .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); } } @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) { @media (max-width: 760px) {
.app { grid-template-columns: 1fr; } .app { grid-template-columns: 1fr; grid-template-rows: auto 1fr; }
.sidebar { position: static; height: auto; flex-direction: row; flex-wrap: wrap; align-items: center; padding: 10px 16px; } .sidebar { position: sticky; top: 0; z-index: 5; height: auto; flex-direction: row; flex-wrap: wrap; align-items: center;
nav { flex-direction: row; flex-wrap: wrap; } padding: 8px 12px; gap: 8px; border-right: none; border-bottom: 1px solid var(--border); }
.daemon-pill { margin-top: 0; margin-left: auto; } .brand { flex: 1; min-width: 0; padding: 0; font-size: 15px; gap: 8px; }
main { padding: 20px 16px 40px; } .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; } .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 */ /* 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 { margin-top: 0; } }
@media (max-width: 420px) { .host-controls .btn { padding: 3px 7px; } }
/* Narrow hosts (Theseus sidebar): never scroll sideways. */ /* Narrow hosts (Theseus sidebar): never scroll sideways. */
html, body { overflow-x: hidden; } 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); } 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); } .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); } .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); }

View file

@ -198,6 +198,11 @@ function route() {
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); 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'); const main = $('#main');
put(main); put(main);
views[view](main, rest.map(decodeURIComponent)); views[view](main, rest.map(decodeURIComponent));
@ -1041,6 +1046,42 @@ function openCredentialsDialog() {
// ---------------------------------------------------------------- buckets & objects // ---------------------------------------------------------------- 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() || ''; const EXT = (key) => (key.split('/').pop().match(/.([a-z0-9]+)$/i) || [])[1]?.toLowerCase() || '';
function fileKind(key) { function fileKind(key) {
const e = EXT(key); const e = EXT(key);
@ -1055,6 +1096,10 @@ function suggestBucket(name) {
if (b && b.length < 3) b += '-files'; if (b && b.length < 3) b += '-files';
return b.slice(0, 63).replace(/-+$/, ''); 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) { function fileIcon(key) {
return { image: '🖼️', audio: '🎵', video: '🎬', text: '📄' }[fileKind(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', {}, 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'))); 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…')); 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`; const base = `/api/s3/${encodeURIComponent(user)}/buckets`;
let mode = viewMode('drives', 'icons');
let list = [];
async function load() { async function load() {
try { try {
const list = await api('GET', base); 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; } draw();
// 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')));
})));
} catch (e) { put(card, h('p', { class: 'error' }, e.message)); } } 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. // Files and size on a drive, counted in the background.
function usageLine(bucketName) { 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`) 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 ? '+' : ''}`)) .then((u) => put(el, `${u.files.toLocaleString()}${u.partial ? '+' : ''} file${u.files === 1 ? '' : 's'} · ${fmtBytes(u.bytes)}${u.partial ? '+' : ''}`))
.catch(() => put(el, '')); .catch(() => put(el, ''));
@ -1217,7 +1286,9 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) {
const uploads = h('div', { class: 'uploads' }); const uploads = h('div', { class: 'uploads' });
const card = h('div', { class: 'card dropzone' }, h('p', { class: 'muted' }, 'Loading…')); 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; let depth = 0;
card.addEventListener('dragenter', (e) => { e.preventDefault(); depth++; card.classList.add('over'); }); 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 folders = pages.flatMap((p) => p.folders);
const objects = pages.flatMap((p) => p.objects).filter((o) => o.key !== prefix); const objects = pages.flatMap((p) => p.objects).filter((o) => o.key !== prefix);
const last = pages[pages.length - 1]; 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) { 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; return;
} }
put(card, h('table', {}, const folderName = (f) => f.slice(prefix.length).replace(/\/$/, '');
h('thead', {}, h('tr', {}, h('th', {}, 'Name'), h('th', { class: 'num' }, 'Size'), h('th', { class: 'hide-sm' }, 'Modified'), h('th', {}))), const fileName = (o) => o.key.slice(prefix.length);
h('tbody', {}, const folderMenu = (f) => itemMenu([['Open', () => go(f)], ['Delete', () => deleteFolder(f), 'danger']]);
folders.map((f) => h('tr', { class: 'clickable', onclick: () => go(f) }, const fileMenu = (o) => itemMenu([['Open', () => openViewer(o)], ['Download', () => downloadObject(o.key)], ['Share link…', () => share(o.key)], ['Delete', () => deleteObject(o.key), 'danger']]);
h('td', {}, '📁 ', f.slice(prefix.length)), 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'));
h('td', { class: 'num muted' }, '–'), h('td', { class: 'hide-sm' }), const stop = (e) => e.stopPropagation();
h('td', { class: 'actions' }, h('button', { class: 'btn small danger', onclick: (e) => { e.stopPropagation(); deleteFolder(f); } }, 'Delete')))),
objects.map((o) => h('tr', {}, if (mode === 'details') {
h('td', {}, h('a', { href: '#', onclick: (e) => { e.preventDefault(); openViewer(o); } }, `${fileIcon(o.key)} ${o.key.slice(prefix.length)}`)), put(card, h('table', { class: 'files' },
h('td', { class: 'num' }, fmtBytes(o.size)), 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('td', { class: 'hide-sm muted' }, fmtDate(o.modified)), h('tbody', {},
h('td', { class: 'actions' }, folders.map((f) => h('tr', { class: 'clickable', tabindex: '0', onclick: () => go(f), onkeydown: onEnter(() => go(f)) },
h('button', { class: 'btn small', onclick: () => downloadObject(o.key) }, 'Download'), 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('button', { class: 'btn small', onclick: () => share(o.key) }, 'Share'), 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('button', { class: 'btn small danger', onclick: () => deleteObject(o.key) }, 'Delete')))))), h('td', { class: 'name' }, h('span', { class: 'ficon', 'aria-hidden': 'true' }, fileIcon(o.key)), h('span', { class: 'label', title: fileName(o) }, fileName(o))),
last?.truncated && h('div', { class: 'row', style: 'justify-content:center;margin-top:12px' }, h('button', { class: 'btn', onclick: () => load(last.nextToken) }, 'Load more'))); 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) { function uploadFiles(files) {
@ -1476,21 +1598,16 @@ function logs(main) {
// ---------------------------------------------------------------- boot // ---------------------------------------------------------------- boot
// Theseus sidebar: the panel starts narrow; bucket tables want room, so offer // Theseus sidebar: Pithos fits the narrow panel; for more room it opens as a
// the host's widen/restore, plus a regular tab for anyone who prefers one. // regular tab, which steps back like any tab when another one is chosen. The
// panel closes so only one Pithos shows.
function addSidebarControls() { function addSidebarControls() {
const row = h('div', { class: 'host-controls' }); // The panel may have been widened by an earlier Pithos; start narrow.
if (bridge.sidebar?.toggleMax) { bridge.sidebar?.isMax?.().then((max) => { if (max) bridge.sidebar.restore?.() ?? bridge.sidebar.toggleMax?.(); }).catch(() => {});
const widen = h('button', { class: 'btn small', title: 'Widen the sidebar', onclick: () => bridge.sidebar.toggleMax() }, '⤢ Widen'); const row = h('div', { class: 'host-controls' },
const label = (max) => { widen.textContent = max ? '⤡ Narrow' : '⤢ Widen'; widen.title = max ? 'Back to the normal sidebar width' : 'Widen the sidebar'; }; h('button', { class: 'btn small', title: 'Open Pithos in a tab and close this panel', onclick: async () => {
bridge.sidebar.isMax?.().then(label).catch(() => {}); try { await bridge.invoke('open-in-tab'); await bridge.sidebar?.close?.(); } catch (e) { fail(e); }
bridge.sidebar.onMaxChange?.(label); } }, '⤢ Full page'));
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'));
$('.sidebar').insertBefore(row, $('#daemon-pill')); $('.sidebar').insertBefore(row, $('#daemon-pill'));
} }
@ -1571,6 +1688,11 @@ async function boot() {
setInterval(refreshStatus, 15000); 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) => { $('#signin-form').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const err = $('#signin-error'); const err = $('#signin-error');

View file

@ -30,7 +30,7 @@
<div id="app" class="app" hidden> <div id="app" class="app" hidden>
<aside class="sidebar"> <aside class="sidebar">
<div class="brand"><img src="icon.svg" alt="" width="28" height="28"><span>Pithos</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="#/start" data-view="start">Get started</a>
<a href="#/overview" data-view="overview">Overview</a> <a href="#/overview" data-view="overview">Overview</a>
@ -41,6 +41,7 @@
<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>
</aside> </aside>
<main id="main"></main> <main id="main"></main>
</div> </div>