Theseus: bundle Pithos 0.3.7
This commit is contained in:
parent
80fe82abee
commit
6ba16261ab
4 changed files with 260 additions and 63 deletions
|
|
@ -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=",
|
||||||
|
|
|
||||||
|
|
@ -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); }
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue