From 6ba16261ab2c02531c8a9318691f54d78f67377c Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 4 Oct 2026 02:16:11 +0200 Subject: [PATCH] Theseus: bundle Pithos 0.3.7 --- bundled-addons/pithos/addon.json | 2 +- bundled-addons/pithos/ui/app.css | 86 ++++++++++- bundled-addons/pithos/ui/app.js | 232 +++++++++++++++++++++------- bundled-addons/pithos/ui/index.html | 3 +- 4 files changed, 260 insertions(+), 63 deletions(-) diff --git a/bundled-addons/pithos/addon.json b/bundled-addons/pithos/addon.json index 86112c41..1685801b 100644 --- a/bundled-addons/pithos/addon.json +++ b/bundled-addons/pithos/addon.json @@ -1,7 +1,7 @@ { "id": "pithos", "name": "Pithos", - "version": "0.3.6", + "version": "0.3.7", "description": "Run s3d, the Sia S3 gateway, from the Theseus sidebar: connect it to a Sia indexer, create S3 users and access keys, browse and share buckets, and watch uploads reach Sia.", "author": "Silent Mode", "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM2RkYzk3Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMWU4ZjZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIyIDZoMjB2NWMwIDItMiAzLTIgNSA4IDMgMTQgMTEgMTQgMjEgMCAxMi05IDIxLTIyIDIxUzEwIDQ5IDEwIDM3YzAtMTAgNi0xOCAxNC0yMSAwLTItMi0zLTItNXoiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMTQgMzBoMzZNMTMgNDBoMzgiIHN0cm9rZT0iIzBiMWYxOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9Ii41NSIvPjwvc3ZnPgo=", diff --git a/bundled-addons/pithos/ui/app.css b/bundled-addons/pithos/ui/app.css index d373e670..fdb67e65 100644 --- a/bundled-addons/pithos/ui/app.css +++ b/bundled-addons/pithos/ui/app.css @@ -200,20 +200,48 @@ pre.snippet { background: var(--surface-2); padding: 10px 12px; border-radius: 8 .spinner { width: 14px; height: 14px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin .8s linear infinite; display: inline-block; } @keyframes spin { to { transform: rotate(360deg); } } +.nav-toggle, .brand .here { display: none; } + +/* Narrow hosts (the Theseus sidebar, phones): one compact bar on top with the + page name, s3d status and a menu button; the menu folds down over the page. */ @media (max-width: 760px) { - .app { grid-template-columns: 1fr; } - .sidebar { position: static; height: auto; flex-direction: row; flex-wrap: wrap; align-items: center; padding: 10px 16px; } - nav { flex-direction: row; flex-wrap: wrap; } - .daemon-pill { margin-top: 0; margin-left: auto; } - main { padding: 20px 16px 40px; } + .app { grid-template-columns: 1fr; grid-template-rows: auto 1fr; } + .sidebar { position: sticky; top: 0; z-index: 5; height: auto; flex-direction: row; flex-wrap: wrap; align-items: center; + padding: 8px 12px; gap: 8px; border-right: none; border-bottom: 1px solid var(--border); } + .brand { flex: 1; min-width: 0; padding: 0; font-size: 15px; gap: 8px; } + .brand img { width: 22px; height: 22px; } + .brand > span:first-of-type { display: none; } + .brand .here { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .nav-toggle { display: inline-grid; place-items: center; width: 34px; height: 34px; border: 1px solid var(--border); border-radius: 8px; + background: var(--surface); color: var(--text); font: inherit; font-size: 17px; cursor: pointer; } + nav { display: none; order: 10; width: 100%; flex-direction: column; padding-top: 4px; border-top: 1px solid var(--border); } + .sidebar.nav-open nav { display: flex; } + .daemon-pill { margin: 0; padding: 5px 9px; font-size: 12px; } + main { padding: 16px 14px 40px; } .upload { grid-template-columns: 1fr 80px 50px; } - .hide-sm { display: none; } + .hide-sm, .wide-only { display: none; } + .page-head { margin-bottom: 14px; } + .page-head > .row { width: 100%; } + .page-head > .row > label { flex: 1 1 100%; min-width: 0; } + .page-head h1:only-child, .page-head > div > h1 { display: none; } + .tiles { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); } + .page-head > .row > label > span[style] { width: auto !important; flex: 1; min-width: 0; } + .card { padding: 14px; } + h1 { font-size: 19px; } + th, td { padding: 7px 6px; } + .modal { padding: 16px; } + .modal-backdrop { padding: 8px; } +} +@media (max-width: 420px) { + .daemon-pill .label { display: none; } + .daemon-pill { padding: 8px; } } /* Theseus sidebar: widen / open-in-tab controls */ .host-controls { display: flex; gap: 6px; flex-wrap: wrap; margin-top: auto; } .host-controls + .daemon-pill { margin-top: 0; } @media (max-width: 760px) { .host-controls { margin-top: 0; } } +@media (max-width: 420px) { .host-controls .btn { padding: 3px 7px; } } /* Narrow hosts (Theseus sidebar): never scroll sideways. */ html, body { overflow-x: hidden; } @@ -250,3 +278,49 @@ html, body { overflow-x: hidden; } border: 2px solid var(--muted); box-shadow: inset 0 -7px 0 color-mix(in srgb, var(--muted) 25%, transparent); } .drive-icon::after { content: ""; position: absolute; right: 7px; bottom: 3px; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); } .tile:hover .drive-icon { border-color: var(--accent); } + +/* View types: Icons / List / Details */ +.view-bar { display: flex; justify-content: flex-end; margin: -6px 0 8px; } +.view-bar:empty { display: none; } +.seg { display: inline-flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; background: var(--surface); } +.seg button { appearance: none; border: none; background: none; color: var(--muted); font: inherit; font-size: 15px; line-height: 1; padding: 6px 10px; cursor: pointer; } +.seg button + button { border-left: 1px solid var(--border); } +.seg button:hover { background: var(--surface-2); color: var(--text); } +.seg button.on { background: var(--accent-soft); color: var(--accent); } + +table.files td.name { max-width: 0; width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +table.files td, table.files th { white-space: nowrap; } +table.files td.name > * { vertical-align: middle; } +table.files td.name .ficon, table.files td.name .drive-icon { display: inline-block; margin-right: 8px; } +.ficon { flex: none; width: 1.4em; text-align: center; } +.drive-icon.sm { width: 26px; height: 17px; border-radius: 4px; border-width: 1.5px; box-shadow: inset 0 -4px 0 color-mix(in srgb, var(--muted) 25%, transparent); flex: none; } +.drive-icon.sm::after { width: 4px; height: 4px; right: 4px; bottom: 2px; } + +ul.rows { list-style: none; margin: 0; padding: 0; } +ul.rows li { display: flex; align-items: center; gap: 10px; padding: 6px 6px; border-radius: 6px; min-width: 0; } +ul.rows li + li { border-top: 1px solid var(--border); } +ul.rows li:hover, ul.rows li:focus-visible { background: var(--surface-2); outline: none; } +ul.rows .label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +ul.rows .meta { color: var(--muted); font-size: 12.5px; white-space: nowrap; font-variant-numeric: tabular-nums; } +.clickable { cursor: pointer; } +tr.clickable:focus-visible td { background: var(--surface-2); } +tr.clickable:focus-visible { outline: none; } + +.tile { position: relative; } +.tile-more { position: absolute; top: 6px; right: 6px; } +.tile .tile-more .more { opacity: .55; } +.tile:hover .tile-more .more, .tile:focus-within .tile-more .more { opacity: 1; } +.tiles.files { grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: 8px; } +.tiles.files .tile { padding: 8px; gap: 4px; align-items: stretch; text-align: center; } +.tiles.files .tile-name { font-weight: 500; font-size: 13px; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow-wrap: anywhere; } +.thumb { aspect-ratio: 4 / 3; border-radius: 6px; background: var(--surface); display: grid; place-items: center; overflow: hidden; } +.thumb img { width: 100%; height: 100%; object-fit: cover; display: block; } +.big-icon { font-size: 38px; line-height: 1; } +.usage { display: block; white-space: nowrap; } + +.more { padding: 1px 8px; font-size: 15px; line-height: 1.2; } +.menu { position: fixed; z-index: 30; min-width: 150px; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; + box-shadow: 0 8px 24px rgb(0 0 0 / .18); padding: 4px; display: flex; flex-direction: column; } +.menu button { appearance: none; border: none; background: none; color: var(--text); font: inherit; text-align: left; padding: 7px 10px; border-radius: 6px; cursor: pointer; } +.menu button:hover, .menu button:focus-visible { background: var(--surface-2); outline: none; } +.menu button.danger { color: var(--danger); } diff --git a/bundled-addons/pithos/ui/app.js b/bundled-addons/pithos/ui/app.js index 5fd16f8b..3d427bbd 100644 --- a/bundled-addons/pithos/ui/app.js +++ b/bundled-addons/pithos/ui/app.js @@ -198,6 +198,11 @@ function route() { const [, name = fallback, ...rest] = location.hash.replace(/^#/, '').split('/'); const view = views[name] ? name : fallback; for (const a of document.querySelectorAll('#nav a')) a.classList.toggle('active', a.dataset.view === view); + // Compact bar (narrow hosts): name the page, fold the menu away. + const here = document.querySelector(`#nav a[data-view="${view}"]`); + $('#here').textContent = here ? here.textContent : ''; + $('.sidebar').classList.remove('nav-open'); + $('#nav-toggle').setAttribute('aria-expanded', 'false'); const main = $('#main'); put(main); views[view](main, rest.map(decodeURIComponent)); @@ -1041,6 +1046,42 @@ function openCredentialsDialog() { // ---------------------------------------------------------------- buckets & objects +// View types for drives and folders: Icons, List or Details, remembered per page. +const VIEW_TYPES = [['icons', '▦', 'Icons'], ['list', '☰', 'List'], ['details', '≣', 'Details']]; +function viewMode(page, fallback) { + try { const v = localStorage.getItem(`pithos.view.${page}`); if (VIEW_TYPES.some(([k]) => k === v)) return v; } catch {} + return fallback; +} +function viewSwitch(page, current, onChange) { + return h('div', { class: 'seg', role: 'group', 'aria-label': 'View' }, VIEW_TYPES.map(([key, icon, label]) => + h('button', { type: 'button', class: key === current ? 'on' : '', title: label, 'aria-label': label, 'aria-pressed': String(key === current), + onclick: () => { try { localStorage.setItem(`pithos.view.${page}`, key); } catch {} onChange(key); } }, icon))); +} + +// "⋯" menu for one item: [label, action, kind?] rows; closes on any outside click. +function itemMenu(items) { + const btn = h('button', { type: 'button', class: 'btn small more', title: 'More', 'aria-label': 'More actions', 'aria-haspopup': 'menu' }, '⋯'); + btn.addEventListener('click', (e) => { + e.stopPropagation(); + document.querySelectorAll('.menu').forEach((m) => m.remove()); + const menu = h('div', { class: 'menu', role: 'menu' }, items.filter(Boolean).map(([label, run, kind]) => + h('button', { type: 'button', role: 'menuitem', class: kind || '', onclick: (ev) => { ev.stopPropagation(); menu.remove(); run(); } }, label))); + document.body.append(menu); + const r = btn.getBoundingClientRect(); + const w = menu.offsetWidth, ht = menu.offsetHeight; + menu.style.left = Math.max(8, Math.min(r.right - w, innerWidth - w - 8)) + 'px'; + menu.style.top = (r.bottom + 4 + ht > innerHeight - 8 ? Math.max(8, r.top - ht - 4) : r.bottom + 4) + 'px'; + const done = () => { menu.remove(); document.removeEventListener('mousedown', away, true); document.removeEventListener('keydown', esc, true); }; + const away = (ev) => { if (!menu.contains(ev.target)) done(); }; + const esc = (ev) => { if (ev.key === 'Escape') { ev.stopPropagation(); done(); btn.focus(); } }; + setTimeout(() => { document.addEventListener('mousedown', away, true); document.addEventListener('keydown', esc, true); }, 0); + menu.querySelector('button')?.focus(); + }); + return btn; +} +const go = (hash) => () => { location.hash = hash; }; +const onEnter = (fn) => (e) => { if (e.key === 'Enter' && e.target === e.currentTarget) fn(); }; + const EXT = (key) => (key.split('/').pop().match(/.([a-z0-9]+)$/i) || [])[1]?.toLowerCase() || ''; function fileKind(key) { const e = EXT(key); @@ -1055,6 +1096,10 @@ function suggestBucket(name) { if (b && b.length < 3) b += '-files'; return b.slice(0, 63).replace(/-+$/, ''); } +function fileType(key) { + const e = EXT(key); + return ({ image: 'Image', audio: 'Audio', video: 'Video', text: 'Text' }[fileKind(key)] || 'File') + (e ? ` (.${e})` : ''); +} function fileIcon(key) { return { image: '🖼️', audio: '🎵', video: '🎬', text: '📄' }[fileKind(key)] || '📦'; } @@ -1122,36 +1167,60 @@ async function bucketList(head, body, user, userSel) { h('div', {}, h('h1', {}, 'Drives'), h('p', { class: 'muted' }, 'Each drive is an S3 bucket: top-level storage owned by one S3 user. S3 apps call it a bucket.'), accountLine()), h('div', { class: 'row' }, userPicker(userSel), h('button', { class: 'btn primary', onclick: create }, 'New drive'))); const card = h('div', { class: 'card' }, h('p', { class: 'muted' }, 'Loading…')); - put(body, card); + const bar = h('div', { class: 'view-bar' }); + put(body, bar, card); const base = `/api/s3/${encodeURIComponent(user)}/buckets`; + let mode = viewMode('drives', 'icons'); + let list = []; async function load() { try { - const list = await api('GET', base); - if (!list.length) { put(card, h('div', { class: 'empty' }, h('p', {}, 'No drives yet.'), h('button', { class: 'btn primary', onclick: create }, 'Create a drive'))); return; } - // Buckets read as drives: the top level you put folders into. - put(card, h('div', { class: 'tiles' }, list.map((b) => { - const access = h('span', { class: 'badge' }, '…'); - api('GET', `${base}/${encodeURIComponent(b.name)}/policy`).then((p) => { - put(access, h('span', { class: `badge ${p.public === 'private' ? '' : 'warn'}` }, { private: '🔒 private', read: '🌐 public', 'read-list': '🌐 public + list', custom: 'custom' }[p.public])); - }).catch(() => put(access, h('span', { class: 'badge' }, '?'))); - return h('div', { class: 'tile', tabindex: '0', role: 'button', - onclick: () => { location.hash = `#/buckets/${encodeURIComponent(b.name)}`; }, - onkeydown: (e) => { if (e.key === 'Enter') location.hash = `#/buckets/${encodeURIComponent(b.name)}`; } }, - h('div', { class: 'drive-icon', 'aria-hidden': 'true' }), - h('div', { class: 'tile-name' }, b.name), - usageLine(b.name), - h('div', { class: 'tile-meta' }, access), - h('div', { class: 'tile-actions' }, - h('button', { class: 'btn small', onclick: (e) => { e.stopPropagation(); accessDialog(user, b.name).then(load); } }, 'Access'), - h('button', { class: 'btn small danger', onclick: (e) => { e.stopPropagation(); remove(b.name); } }, 'Delete'))); - }))); + list = await api('GET', base); + draw(); } catch (e) { put(card, h('p', { class: 'error' }, e.message)); } } + // Buckets read as drives: the top level you put folders into. + function draw() { + put(bar, list.length ? viewSwitch('drives', mode, (m) => { mode = m; draw(); }) : null); + if (!list.length) { put(card, h('div', { class: 'empty' }, h('p', {}, 'No drives yet.'), h('button', { class: 'btn primary', onclick: create }, 'Create a drive'))); return; } + const open = (b) => go(`#/buckets/${encodeURIComponent(b.name)}`); + const menu = (b) => itemMenu([['Open', open(b)], ['Access…', () => accessDialog(user, b.name).then(load)], ['Delete', () => remove(b.name), 'danger']]); + if (mode === 'details') { + put(card, h('table', { class: 'files' }, + h('thead', {}, h('tr', {}, h('th', {}, 'Drive'), h('th', {}, 'Contents'), h('th', { class: 'hide-sm' }, 'Access'), h('th', {}))), + h('tbody', {}, list.map((b) => h('tr', { class: 'clickable', tabindex: '0', onclick: open(b), onkeydown: onEnter(open(b)) }, + h('td', { class: 'name' }, h('span', { class: 'drive-icon sm', 'aria-hidden': 'true' }), h('span', { class: 'label' }, b.name)), + h('td', { class: 'muted' }, usageLine(b.name)), + h('td', { class: 'hide-sm' }, accessBadge(b.name)), + h('td', { class: 'actions', onclick: (e) => e.stopPropagation() }, menu(b))))))); + } else if (mode === 'list') { + put(card, h('ul', { class: 'rows' }, list.map((b) => h('li', { class: 'clickable', tabindex: '0', onclick: open(b), onkeydown: onEnter(open(b)) }, + h('span', { class: 'drive-icon sm', 'aria-hidden': 'true' }), + h('span', { class: 'label' }, b.name), + h('span', { class: 'meta' }, usageLine(b.name)), + h('span', { onclick: (e) => e.stopPropagation() }, menu(b)))))); + } else { + put(card, h('div', { class: 'tiles' }, list.map((b) => h('div', { class: 'tile', tabindex: '0', role: 'button', onclick: open(b), onkeydown: onEnter(open(b)) }, + h('div', { class: 'drive-icon', 'aria-hidden': 'true' }), + h('div', { class: 'tile-name' }, b.name), + usageLine(b.name), + h('div', { class: 'tile-meta' }, accessBadge(b.name)), + h('div', { class: 'tile-more', onclick: (e) => e.stopPropagation() }, menu(b)))))); + } + } + + function accessBadge(bucketName) { + const el = h('span', {}, h('span', { class: 'badge' }, '…')); + api('GET', `${base}/${encodeURIComponent(bucketName)}/policy`).then((p) => { + put(el, h('span', { class: `badge ${p.public === 'private' ? '' : 'warn'}` }, { private: '🔒 private', read: '🌐 public', 'read-list': '🌐 public + list', custom: 'custom' }[p.public])); + }).catch(() => put(el, '?')); + return el; + } + // Files and size on a drive, counted in the background. function usageLine(bucketName) { - const el = h('div', { class: 'small muted' }, 'counting…'); + const el = h('span', { class: 'small muted usage' }, 'counting…'); api('GET', `${base}/${encodeURIComponent(bucketName)}/usage`) .then((u) => put(el, `${u.files.toLocaleString()}${u.partial ? '+' : ''} file${u.files === 1 ? '' : 's'} · ${fmtBytes(u.bytes)}${u.partial ? '+' : ''}`)) .catch(() => put(el, '')); @@ -1217,7 +1286,9 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) { const uploads = h('div', { class: 'uploads' }); const card = h('div', { class: 'card dropzone' }, h('p', { class: 'muted' }, 'Loading…')); - put(body, uploads, card); + const bar = h('div', { class: 'view-bar' }); + put(body, uploads, bar, card); + let mode = viewMode('files', 'details'); let depth = 0; card.addEventListener('dragenter', (e) => { e.preventDefault(); depth++; card.classList.add('over'); }); @@ -1243,28 +1314,79 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) { const folders = pages.flatMap((p) => p.folders); const objects = pages.flatMap((p) => p.objects).filter((o) => o.key !== prefix); const last = pages[pages.length - 1]; + put(bar, folders.length || objects.length ? viewSwitch('files', mode, (m) => { mode = m; draw(); }) : null); if (!folders.length && !objects.length) { - put(card, h('div', { class: 'empty' }, h('p', {}, prefix ? 'This folder is empty.' : 'This bucket is empty.'), h('p', { class: 'small' }, 'Drag files here or use Upload.'))); + put(card, h('div', { class: 'empty' }, h('p', {}, prefix ? 'This folder is empty.' : 'This drive is empty.'), h('p', { class: 'small' }, 'Drag files here or use Upload.'))); return; } - put(card, h('table', {}, - h('thead', {}, h('tr', {}, h('th', {}, 'Name'), h('th', { class: 'num' }, 'Size'), h('th', { class: 'hide-sm' }, 'Modified'), h('th', {}))), - h('tbody', {}, - folders.map((f) => h('tr', { class: 'clickable', onclick: () => go(f) }, - h('td', {}, '📁 ', f.slice(prefix.length)), - h('td', { class: 'num muted' }, '–'), h('td', { class: 'hide-sm' }), - h('td', { class: 'actions' }, h('button', { class: 'btn small danger', onclick: (e) => { e.stopPropagation(); deleteFolder(f); } }, 'Delete')))), - objects.map((o) => h('tr', {}, - h('td', {}, h('a', { href: '#', onclick: (e) => { e.preventDefault(); openViewer(o); } }, `${fileIcon(o.key)} ${o.key.slice(prefix.length)}`)), - h('td', { class: 'num' }, fmtBytes(o.size)), - h('td', { class: 'hide-sm muted' }, fmtDate(o.modified)), - h('td', { class: 'actions' }, - h('button', { class: 'btn small', onclick: () => downloadObject(o.key) }, 'Download'), - ' ', - h('button', { class: 'btn small', onclick: () => share(o.key) }, 'Share'), - ' ', - h('button', { class: 'btn small danger', onclick: () => deleteObject(o.key) }, 'Delete')))))), - last?.truncated && h('div', { class: 'row', style: 'justify-content:center;margin-top:12px' }, h('button', { class: 'btn', onclick: () => load(last.nextToken) }, 'Load more'))); + const folderName = (f) => f.slice(prefix.length).replace(/\/$/, ''); + const fileName = (o) => o.key.slice(prefix.length); + const folderMenu = (f) => itemMenu([['Open', () => go(f)], ['Delete', () => deleteFolder(f), 'danger']]); + const fileMenu = (o) => itemMenu([['Open', () => openViewer(o)], ['Download', () => downloadObject(o.key)], ['Share link…', () => share(o.key)], ['Delete', () => deleteObject(o.key), 'danger']]); + const more = last?.truncated && h('div', { class: 'row', style: 'justify-content:center;margin-top:12px' }, h('button', { class: 'btn', onclick: () => load(last.nextToken) }, 'Load more')); + const stop = (e) => e.stopPropagation(); + + if (mode === 'details') { + put(card, h('table', { class: 'files' }, + h('thead', {}, h('tr', {}, h('th', {}, 'Name'), h('th', { class: 'num' }, 'Size'), h('th', { class: 'hide-sm' }, 'Type'), h('th', { class: 'hide-sm' }, 'Modified'), h('th', {}))), + h('tbody', {}, + folders.map((f) => h('tr', { class: 'clickable', tabindex: '0', onclick: () => go(f), onkeydown: onEnter(() => go(f)) }, + h('td', { class: 'name' }, h('span', { class: 'ficon', 'aria-hidden': 'true' }, '📁'), h('span', { class: 'label' }, folderName(f))), + h('td', { class: 'num muted' }, '–'), h('td', { class: 'hide-sm muted' }, 'Folder'), h('td', { class: 'hide-sm' }), + h('td', { class: 'actions', onclick: stop }, folderMenu(f)))), + objects.map((o) => h('tr', { class: 'clickable', tabindex: '0', onclick: () => openViewer(o), onkeydown: onEnter(() => openViewer(o)) }, + h('td', { class: 'name' }, h('span', { class: 'ficon', 'aria-hidden': 'true' }, fileIcon(o.key)), h('span', { class: 'label', title: fileName(o) }, fileName(o))), + h('td', { class: 'num' }, fmtBytes(o.size)), + h('td', { class: 'hide-sm muted' }, fileType(o.key)), + h('td', { class: 'hide-sm muted' }, fmtDate(o.modified)), + h('td', { class: 'actions', onclick: stop }, + h('span', { class: 'wide-only' }, h('button', { class: 'btn small', onclick: () => downloadObject(o.key) }, 'Download'), ' ', + h('button', { class: 'btn small', onclick: () => share(o.key) }, 'Share'), ' '), + fileMenu(o)))))), + more); + } else if (mode === 'list') { + put(card, h('ul', { class: 'rows' }, + folders.map((f) => h('li', { class: 'clickable', tabindex: '0', onclick: () => go(f), onkeydown: onEnter(() => go(f)) }, + h('span', { class: 'ficon', 'aria-hidden': 'true' }, '📁'), h('span', { class: 'label' }, folderName(f)), h('span', { class: 'meta' }), + h('span', { onclick: stop }, folderMenu(f)))), + objects.map((o) => h('li', { class: 'clickable', tabindex: '0', onclick: () => openViewer(o), onkeydown: onEnter(() => openViewer(o)) }, + h('span', { class: 'ficon', 'aria-hidden': 'true' }, fileIcon(o.key)), h('span', { class: 'label', title: fileName(o) }, fileName(o)), + h('span', { class: 'meta' }, fmtBytes(o.size)), + h('span', { onclick: stop }, fileMenu(o))))), + more); + } else { + put(card, h('div', { class: 'tiles files' }, + folders.map((f) => h('div', { class: 'tile', tabindex: '0', role: 'button', onclick: () => go(f), onkeydown: onEnter(() => go(f)) }, + h('div', { class: 'thumb' }, h('span', { class: 'big-icon', 'aria-hidden': 'true' }, '📁')), + h('div', { class: 'tile-name' }, folderName(f)), + h('div', { class: 'tile-more', onclick: stop }, folderMenu(f)))), + objects.map((o) => h('div', { class: 'tile', tabindex: '0', role: 'button', onclick: () => openViewer(o), onkeydown: onEnter(() => openViewer(o)) }, + thumbFor(o), + h('div', { class: 'tile-name', title: fileName(o) }, fileName(o)), + h('div', { class: 'small muted' }, fmtBytes(o.size)), + h('div', { class: 'tile-more', onclick: stop }, fileMenu(o))))), + more); + } + } + + // Icons view: pictures show themselves once scrolled into view; larger + // ones (and everything else) keep a type icon. + const thumbs = new IntersectionObserver((entries) => { + for (const en of entries) { + if (!en.isIntersecting) continue; + thumbs.unobserve(en.target); + const key = en.target.dataset.key; + const path = `${base}/object?key=${encodeURIComponent(key)}`; + (bridge ? bridge.invoke('view-url', { path }).then((r) => r.url) : Promise.resolve(`${path}&inline=1`)) + .then((src) => { const img = h('img', { alt: '', src }); img.onload = () => put(en.target, img); }) + .catch(() => {}); + } + }, { rootMargin: '200px' }); + cleanup.push(() => thumbs.disconnect()); + function thumbFor(o) { + const el = h('div', { class: 'thumb' }, h('span', { class: 'big-icon', 'aria-hidden': 'true' }, fileIcon(o.key))); + if (fileKind(o.key) === 'image' && o.size <= 8 * 1024 ** 2) { el.dataset.key = o.key; thumbs.observe(el); } + return el; } function uploadFiles(files) { @@ -1476,21 +1598,16 @@ function logs(main) { // ---------------------------------------------------------------- boot -// Theseus sidebar: the panel starts narrow; bucket tables want room, so offer -// the host's widen/restore, plus a regular tab for anyone who prefers one. +// Theseus sidebar: Pithos fits the narrow panel; for more room it opens as a +// regular tab, which steps back like any tab when another one is chosen. The +// panel closes so only one Pithos shows. function addSidebarControls() { - const row = h('div', { class: 'host-controls' }); - if (bridge.sidebar?.toggleMax) { - const widen = h('button', { class: 'btn small', title: 'Widen the sidebar', onclick: () => bridge.sidebar.toggleMax() }, '⤢ Widen'); - const label = (max) => { widen.textContent = max ? '⤡ Narrow' : '⤢ Widen'; widen.title = max ? 'Back to the normal sidebar width' : 'Widen the sidebar'; }; - bridge.sidebar.isMax?.().then(label).catch(() => {}); - bridge.sidebar.onMaxChange?.(label); - row.append(widen); - } - // Full page: open in a tab and close the panel, so only one Pithos shows. - row.append(h('button', { class: 'btn small', title: 'Open Pithos as a full page and close this panel', onclick: async () => { - try { await bridge.invoke('open-in-tab'); await bridge.sidebar?.close?.(); } catch (e) { fail(e); } - } }, 'Full page')); + // The panel may have been widened by an earlier Pithos; start narrow. + bridge.sidebar?.isMax?.().then((max) => { if (max) bridge.sidebar.restore?.() ?? bridge.sidebar.toggleMax?.(); }).catch(() => {}); + const row = h('div', { class: 'host-controls' }, + h('button', { class: 'btn small', title: 'Open Pithos in a tab and close this panel', onclick: async () => { + try { await bridge.invoke('open-in-tab'); await bridge.sidebar?.close?.(); } catch (e) { fail(e); } + } }, '⤢ Full page')); $('.sidebar').insertBefore(row, $('#daemon-pill')); } @@ -1571,6 +1688,11 @@ async function boot() { setInterval(refreshStatus, 15000); } +$('#nav-toggle').addEventListener('click', () => { + const open = $('.sidebar').classList.toggle('nav-open'); + $('#nav-toggle').setAttribute('aria-expanded', String(open)); +}); + $('#signin-form').addEventListener('submit', async (e) => { e.preventDefault(); const err = $('#signin-error'); diff --git a/bundled-addons/pithos/ui/index.html b/bundled-addons/pithos/ui/index.html index dbf414de..ffa9cb6e 100644 --- a/bundled-addons/pithos/ui/index.html +++ b/bundled-addons/pithos/ui/index.html @@ -30,7 +30,7 @@