Merge master: Pithos 0.3.9 (menu, Account page, views, player) under the hosting work
Pithos moved to 0.3.9 on master while hosting was built here, so the version stays master's until the next release is cut. The hosted pieces were carried into master's new layout: Share for folders lives in the item menu, files the server can read keep their mark in every view, and other accounts show their key and cannot be switched back to when they moved to Silent Mode.
This commit is contained in:
commit
fcc104a325
7 changed files with 729 additions and 125 deletions
|
|
@ -69,6 +69,13 @@ export async function createPithos(opts = {}) {
|
|||
const tickets = new Map();
|
||||
const subscribers = new Set();
|
||||
let versionCache = null;
|
||||
// Pithos's own version: addon.json in the Theseus add-on, package.json elsewhere.
|
||||
const pithosVersion = (() => {
|
||||
for (const f of ['../addon.json', '../package.json']) {
|
||||
try { const v = JSON.parse(fs.readFileSync(new URL(f, import.meta.url), 'utf8')).version; if (v) return v; } catch {}
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
let hosted = null; // "On Silent Mode", installed below
|
||||
const broadcast = (event, data) => {
|
||||
|
|
@ -153,6 +160,7 @@ export async function createPithos(opts = {}) {
|
|||
const v = await version();
|
||||
const local = {
|
||||
host: hostName,
|
||||
pithos: pithosVersion,
|
||||
daemon: daemon.status(),
|
||||
external: await probeExternal(c),
|
||||
s3d: { ...v, pinned: S3D_VERSION, installable: !!assetForPlatform(), outdated: !!(v?.version && cmpVersion(v.version, S3D_VERSION) < 0) },
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "pithos",
|
||||
"version": "0.3.7",
|
||||
"version": "0.3.9",
|
||||
"description": "A control panel for s3d, the Sia S3 gateway: daemon, users and keys, buckets, upload pipeline.",
|
||||
"license": "MPL-2.0",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "pithos",
|
||||
"name": "Pithos",
|
||||
"version": "0.3.7",
|
||||
"version": "0.3.9",
|
||||
"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=",
|
||||
|
|
|
|||
|
|
@ -126,6 +126,22 @@ module.exports = {
|
|||
return { url: p.viewUrl(apiPath) };
|
||||
});
|
||||
|
||||
// Footer updater: stage the newest signed Pithos from the extension
|
||||
// channel, then (second click) ask Theseus to restart and apply it.
|
||||
// Theseus asks the user before restarting. Older Theseus builds lack
|
||||
// these hooks; the panel then points to Settings > Extensions.
|
||||
api.onMessage("update", async ({ step = "stage" } = {}) => {
|
||||
if (step === "apply") {
|
||||
if (typeof api.restartApp !== "function") return { fallback: "settings" };
|
||||
return await api.restartApp();
|
||||
}
|
||||
if (typeof api.checkAndStageSelfUpdate !== "function") return { fallback: "settings" };
|
||||
const r = await api.checkAndStageSelfUpdate();
|
||||
const staged = r?.status === "staged" || r?.status === "already-staged";
|
||||
return { staged, status: r?.status || "unknown", detail: r?.detail || null, current: r?.current || null, next: r?.next || null };
|
||||
});
|
||||
api.onMessage("open-settings", ({ section = "" } = {}) => { api.openSettings?.(String(section)); return { ok: true }; });
|
||||
|
||||
// A full browser-tab view, for anyone who prefers it to the sidebar.
|
||||
api.onMessage("open-in-tab", async () => {
|
||||
const p = await ensureServer();
|
||||
|
|
|
|||
182
ui/app.css
182
ui/app.css
|
|
@ -184,7 +184,7 @@ pre.snippet { background: var(--surface-2); padding: 10px 12px; border-radius: 8
|
|||
.steps span.on { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
||||
|
||||
/* modal + toasts */
|
||||
.modal-backdrop { position: fixed; inset: 0; background: rgb(0 0 0 / .45); display: grid; place-items: center; padding: 16px; z-index: 10; }
|
||||
.modal-backdrop { position: fixed; inset: 0; background: rgb(0 0 0 / .45); display: grid; place-items: center; padding: 16px; z-index: 25; }
|
||||
.modal { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 22px; width: min(560px, 100%); max-height: 90vh; overflow: auto; }
|
||||
.modal h2 { margin-bottom: 14px; }
|
||||
.modal .foot { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
|
||||
|
|
@ -200,20 +200,55 @@ 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 .here { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-weight: 500; }
|
||||
.brand .here:not(:empty)::before { content: "· "; }
|
||||
.nav-toggle { display: inline-grid; place-items: center; width: 34px; height: 34px; border: 1px solid var(--border); border-radius: 8px;
|
||||
background: var(--surface); color: var(--text); font: inherit; font-size: 17px; cursor: pointer; }
|
||||
/* ☰ opens the menu over the page, from the top-right corner. */
|
||||
nav { display: none; position: absolute; top: calc(100% + 4px); right: 10px; z-index: 30; width: min(240px, calc(100vw - 20px));
|
||||
flex-direction: column; padding: 6px; background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
|
||||
box-shadow: 0 10px 28px rgb(0 0 0 / .22); }
|
||||
.sidebar.nav-open nav { display: flex; }
|
||||
.sidebar.nav-open .nav-toggle { background: var(--accent-soft); color: var(--accent); border-color: transparent; }
|
||||
nav .nav-sep { display: block; height: 1px; background: var(--border); margin: 6px 4px; }
|
||||
nav a.nav-action { display: block; color: var(--muted); }
|
||||
.host-controls { display: none; }
|
||||
.daemon-pill { margin: 0; padding: 5px 9px; font-size: 12px; }
|
||||
main { padding: 16px 14px 40px; }
|
||||
.upload { grid-template-columns: 1fr 80px 50px; }
|
||||
.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: 760px) { .host-controls { display: none; } }
|
||||
@media (max-width: 420px) { .host-controls .btn { padding: 3px 7px; } }
|
||||
|
||||
/* Narrow hosts (Theseus sidebar): never scroll sideways. */
|
||||
html, body { overflow-x: hidden; }
|
||||
|
|
@ -250,3 +285,134 @@ 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%; white-space: normal; }
|
||||
table.files td, table.files th { white-space: nowrap; }
|
||||
table.files td.name > * { vertical-align: middle; }
|
||||
table.files td.name > .drive-icon { display: inline-block; margin-right: 8px; }
|
||||
.cell-name { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
/* File names wrap to two lines at most, then end in "…" */
|
||||
.cell-name .label, ul.rows .label { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||
overflow-wrap: anywhere; line-height: 1.35; min-width: 0; }
|
||||
.ficon { flex: none; width: 1.4em; text-align: center; }
|
||||
.drive-icon.sm { width: 26px; height: 17px; border-radius: 4px; border-width: 1.5px; box-shadow: inset 0 -4px 0 color-mix(in srgb, var(--muted) 25%, transparent); flex: none; }
|
||||
.drive-icon.sm::after { width: 4px; height: 4px; right: 4px; bottom: 2px; }
|
||||
|
||||
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; }
|
||||
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); }
|
||||
|
||||
/* Music player, docked at the bottom; stays while you browse */
|
||||
.player { position: fixed; left: 0; right: 0; bottom: 0; z-index: 15; display: flex; align-items: center; gap: 12px;
|
||||
padding: 8px 14px; background: var(--surface); border-top: 1px solid var(--border); box-shadow: 0 -4px 16px rgb(0 0 0 / .12); }
|
||||
@media (min-width: 761px) { .player { left: 220px; } }
|
||||
body.has-player main { padding-bottom: 96px; }
|
||||
body.has-player .toasts { bottom: 84px; }
|
||||
.pl-controls { display: flex; align-items: center; gap: 2px; flex: none; }
|
||||
.pl-btn { appearance: none; border: none; background: none; color: var(--text); font: inherit; font-size: 16px; width: 32px; height: 32px;
|
||||
border-radius: 50%; cursor: pointer; display: grid; place-items: center; }
|
||||
.pl-btn:hover:not(:disabled) { background: var(--surface-2); }
|
||||
.pl-btn:disabled { opacity: .35; cursor: default; }
|
||||
.pl-main { width: 38px; height: 38px; background: var(--accent); color: var(--accent-ink); font-size: 15px; }
|
||||
.pl-main:hover:not(:disabled) { background: var(--accent); filter: brightness(1.08); }
|
||||
.pl-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.pl-head { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
|
||||
.pl-title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||
.pl-where { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1 1 0; min-width: 0; }
|
||||
.pl-seek { display: flex; align-items: center; gap: 8px; }
|
||||
.pl-seek input { flex: 1; min-width: 0; accent-color: var(--accent); margin: 0; }
|
||||
.pl-time { font-size: 11.5px; color: var(--muted); font-variant-numeric: tabular-nums; min-width: 2.6em; text-align: center; }
|
||||
@media (max-width: 420px) {
|
||||
.player { gap: 6px; padding: 6px 8px; }
|
||||
.pl-where { display: none; }
|
||||
.pl-btn { width: 28px; height: 28px; font-size: 14px; }
|
||||
.pl-main { width: 34px; height: 34px; }
|
||||
}
|
||||
|
||||
/* Footer: pithos.sia, the version and the updater (like Aegis's) */
|
||||
.brandfoot { position: fixed; left: 0; right: 0; bottom: 0; z-index: 14; height: 28px; display: flex; align-items: center;
|
||||
justify-content: space-between; padding: 0 12px; background: var(--surface); border-top: 1px solid var(--border);
|
||||
font-size: 11.5px; color: var(--muted); }
|
||||
@media (min-width: 761px) { .brandfoot { left: 220px; } }
|
||||
.brandlink { display: inline-flex; align-items: center; gap: 5px; color: var(--muted); text-decoration: none; padding: 2px 4px; border-radius: 4px; }
|
||||
.brandlink:hover { color: var(--accent); }
|
||||
.brandfoot-right { display: inline-flex; align-items: center; gap: 8px; }
|
||||
.brandver { font-variant-numeric: tabular-nums; letter-spacing: .3px; }
|
||||
.brandcheck { appearance: none; background: none; border: 0; color: var(--muted); cursor: pointer; padding: 0 2px; font: inherit;
|
||||
font-size: 13px; line-height: 1; opacity: .75; }
|
||||
.brandcheck:hover { color: var(--accent); opacity: 1; }
|
||||
.brandcheck.spin { animation: spin 1s linear infinite; }
|
||||
.brandupd { appearance: none; border: 0; font: inherit; font-size: 11.5px; font-weight: 600; padding: 2px 9px; border-radius: 999px;
|
||||
background: var(--accent-soft); color: var(--accent); cursor: pointer; white-space: nowrap; }
|
||||
.brandupd:disabled { cursor: default; }
|
||||
.brandupd.ok, .brandupd.wait { background: var(--surface-2); color: var(--muted); font-weight: 500; }
|
||||
.brandupd.err { background: var(--danger-soft); color: var(--danger); font-weight: 500; }
|
||||
body.has-foot main { padding-bottom: 64px; }
|
||||
body.has-foot .player { bottom: 28px; }
|
||||
body.has-foot .toasts { bottom: 44px; }
|
||||
body.has-foot.has-player main { padding-bottom: 124px; }
|
||||
body.has-foot.has-player .toasts { bottom: 112px; }
|
||||
|
||||
/* Download / Share as icons on narrow screens */
|
||||
.nav-sep, .nav-action { display: none; }
|
||||
.narrow-only { display: none; }
|
||||
.icon-row { gap: 2px; }
|
||||
.btn.icon { padding: 4px 6px; color: var(--muted); }
|
||||
.btn.icon:hover:not(:disabled) { color: var(--accent); }
|
||||
.btn.icon svg { display: block; }
|
||||
.row-actions { display: inline-flex; align-items: center; gap: 2px; flex: none; }
|
||||
@media (max-width: 760px) {
|
||||
.narrow-only { display: inline-flex; align-items: center; }
|
||||
table.files td.actions { padding-left: 2px; }
|
||||
}
|
||||
|
||||
/* Player volume */
|
||||
.pl-volume { position: relative; display: flex; align-items: center; gap: 4px; flex: none; }
|
||||
.pl-volpop { display: flex; align-items: center; }
|
||||
.pl-volpop > .pl-btn { display: none; }
|
||||
.pl-vol { width: 90px; accent-color: var(--accent); margin: 0; }
|
||||
.pl-volbtn { font-size: 15px; }
|
||||
@media (max-width: 760px) {
|
||||
.pl-volpop { display: none; position: absolute; bottom: calc(100% + 10px); right: -6px; padding: 8px 10px; gap: 6px;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 8px 24px rgb(0 0 0 / .2); }
|
||||
.pl-volume.open .pl-volpop { display: flex; }
|
||||
.pl-volpop > .pl-btn { display: grid; }
|
||||
.pl-vol { width: 120px; }
|
||||
}
|
||||
/* "More options" disclosure in dialogs */
|
||||
details.more-opts > summary { cursor: pointer; color: var(--muted); font-size: 13px; }
|
||||
details.more-opts > summary:hover { color: var(--text); }
|
||||
|
|
|
|||
630
ui/app.js
630
ui/app.js
|
|
@ -189,17 +189,24 @@ function renderPill() {
|
|||
|
||||
let cleanup = [];
|
||||
let logSink = null;
|
||||
const views = { start: getStarted, overview, buckets, users, setup, settings, logs };
|
||||
const views = { start: getStarted, account, overview, buckets, users, setup, settings, logs };
|
||||
// Pages that live under Account in the menu.
|
||||
const NAV_OF = { start: 'account', setup: 'account' };
|
||||
|
||||
function route() {
|
||||
cleanup.forEach((fn) => fn());
|
||||
cleanup = [];
|
||||
logSink = null;
|
||||
// First run lands on the guided setup until it has been finished once.
|
||||
const fallback = !setupDone() && !state.status?.registration?.registered ? 'start' : 'overview';
|
||||
const fallback = !setupDone() && !state.status?.registration?.registered ? 'account' : 'overview';
|
||||
const [, name = fallback, ...rest] = location.hash.replace(/^#/, '').split('/');
|
||||
const view = views[name] ? name : fallback;
|
||||
for (const a of document.querySelectorAll('#nav a')) a.classList.toggle('active', a.dataset.view === view);
|
||||
const navView = NAV_OF[view] || view;
|
||||
for (const a of document.querySelectorAll('#nav a')) a.classList.toggle('active', a.dataset.view === navView);
|
||||
// Compact bar (narrow hosts): name the page, fold the menu away.
|
||||
const here = document.querySelector(`#nav a[data-view="${navView}"]`);
|
||||
$('#here').textContent = here ? here.textContent : '';
|
||||
setMenu(false);
|
||||
const main = $('#main');
|
||||
put(main);
|
||||
views[view](main, rest.map(decodeURIComponent));
|
||||
|
|
@ -244,7 +251,7 @@ function overview(main) {
|
|||
put(daemonCard,
|
||||
h('div', { class: 'row spread' }, h('h2', {}, 'Gateway'), h('span', { class: `badge ${st === 'running' || st === 'external' ? 'ok' : st === 'crashed' ? 'warn' : ''}` }, st)),
|
||||
h('dl', { class: 'kv' },
|
||||
h('dt', {}, 'Sia account'), h('dd', {}, h('strong', {}, accountName(accountInfo)), ' · ', h('a', { href: '#/setup' }, accountInfo && accountInfo.registered ? 'Manage' : 'Connect')),
|
||||
h('dt', {}, 'Sia account'), h('dd', {}, h('strong', {}, accountName(accountInfo)), ' · ', h('a', { href: '#/account' }, accountInfo && accountInfo.registered ? 'Manage' : 'Connect')),
|
||||
h('dt', {}, 'S3 endpoint'), h('dd', {}, h('code', {}, `http://${s.config.apiAddress}`)),
|
||||
s.config.https && [h('dt', {}, 'S3 HTTPS'), h('dd', {}, h('code', {}, `https://${s.config.https}`))],
|
||||
h('dt', {}, 's3d'), h('dd', {}, s.s3d?.version ? `v${s.s3d.version}` : (s.daemon.binary ? 'unknown version' : 'not installed')),
|
||||
|
|
@ -341,7 +348,7 @@ function bannersFor(s) {
|
|||
if (s.registration?.registered === false || s.daemon.needsLogin) {
|
||||
out.push(h('div', { class: 'banner info' },
|
||||
h('span', {}, 'This s3d is not connected to a Sia indexer yet.'),
|
||||
h('a', { class: 'btn primary', href: '#/start' }, 'Get started')));
|
||||
h('a', { class: 'btn primary', href: '#/account' }, 'Get started')));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
@ -381,8 +388,8 @@ function getStarted(main) {
|
|||
const STEPS = ['Welcome', 'Sia Storage account', 'Install s3d', 'Recovery phrase', 'Connect', 'Start gateway', 'Access key', 'Done'];
|
||||
const body = h('div', { class: 'card wizard' });
|
||||
main.append(
|
||||
h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Get started'),
|
||||
h('p', { class: 'muted' }, 'Set up your own S3 storage on Sia, one step at a time.'))),
|
||||
h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, setupDone() ? 'Add a Sia account' : 'Get started'),
|
||||
h('p', { class: 'muted' }, setupDone() ? 'Connect s3d to another Sia account, one step at a time.' : 'Set up your own S3 storage on Sia, one step at a time.'))),
|
||||
body,
|
||||
);
|
||||
|
||||
|
|
@ -659,11 +666,54 @@ function getStarted(main) {
|
|||
|
||||
// ---------------------------------------------------------------- setup (indexer login)
|
||||
|
||||
// Account: the Sia account this s3d stores and pays through. Before one is
|
||||
// connected it is the step-by-step guide; after, the account itself, with
|
||||
// adding another account and switching back to earlier ones.
|
||||
function account(main, rest) {
|
||||
const registered = !!state.status?.registration?.registered;
|
||||
if (!registered && ['idle', 'failed'].includes(state.login.state)) {
|
||||
getStarted(main, rest);
|
||||
// Accounts set aside earlier stay one click away while adding a new one.
|
||||
const slot = h('div');
|
||||
main.append(slot);
|
||||
loadAccount().then(() => { const b = otherAccounts(() => route()); if (b) put(slot, h('div', { class: 'card', style: 'margin-top:16px' }, b)); });
|
||||
return;
|
||||
}
|
||||
return setup(main, rest);
|
||||
}
|
||||
|
||||
// Earlier Sia accounts set aside on this computer, each one click away.
|
||||
function otherAccounts(after) {
|
||||
const list = accountInfo?.archives || [];
|
||||
if (!list.length) return null;
|
||||
async function restore(a) {
|
||||
const name = a.label || (a.indexerUrl ? new URL(a.indexerUrl).host : a.name);
|
||||
const ok = await confirmModal(`Switch to ${name}?`, 's3d stops and switches back to that Sia account with its users, keys and drives. The current account is set aside and you can switch back the same way.', 'Switch', 'primary');
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api('POST', '/api/account/restore', { name: a.name });
|
||||
await api('POST', '/api/daemon/start').catch(() => {});
|
||||
toast(`Switched to ${name}`);
|
||||
await refreshStatus();
|
||||
await loadAccount();
|
||||
after();
|
||||
} catch (e) { fail(e); }
|
||||
}
|
||||
return h('div', {},
|
||||
h('h3', {}, 'Other accounts on this computer'),
|
||||
h('p', { class: 'small muted', style: 'margin-top:0' }, 'Set aside when another account was added. Switching back restores its users, keys and drives; the current account is set aside the same way.'),
|
||||
h('table', {}, h('tbody', {}, list.map((a) => h('tr', {},
|
||||
h('td', {}, h('strong', {}, a.label || (a.indexerUrl ? new URL(a.indexerUrl).host : a.name)),
|
||||
h('div', { class: 'small muted' }, `${a.archivedAt ? fmtDate(a.archivedAt) : a.name}${a.fingerprint ? ` · key ${a.fingerprint}` : ''}${a.users.length ? ` · users: ${a.users.join(', ')}` : ''}`),
|
||||
a.movedTo && h('div', { class: 'small muted' }, `Left behind when these drives moved to ${a.movedTo}; kept as a backup only.`)),
|
||||
h('td', { class: 'actions' }, h('button', { class: 'btn small', disabled: !a.hasData || !!a.movedTo, onclick: () => restore(a) }, 'Switch to')))))));
|
||||
}
|
||||
|
||||
function setup(main) {
|
||||
const body = h('div', { class: 'card' });
|
||||
main.append(
|
||||
h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Sia connection'),
|
||||
h('p', { class: 'muted' }, 'Register this s3d with a Sia indexer. You do this once; the indexer then handles hosts and contracts for it.'))),
|
||||
h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Account'),
|
||||
h('p', { class: 'muted' }, 'The Sia account that stores, and pays for, everything on this s3d.'))),
|
||||
body,
|
||||
);
|
||||
|
||||
|
|
@ -678,9 +728,10 @@ function setup(main) {
|
|||
|
||||
if (l.state === 'done' || l.state === 'already') {
|
||||
const label = h('input', { type: 'text', placeholder: 'e.g. the email you sign in with', value: accountInfo?.label || '', maxlength: 120 });
|
||||
put(body, steps,
|
||||
h('h2', {}, l.state === 'done' ? 'Connected' : 'Connected to Sia'),
|
||||
h('p', {}, l.indexerUrl ? ['This s3d uses a Sia account on ', h('code', {}, l.indexerUrl), '. All S3 users, buckets and files on it are stored, and paid for, through that account.'] : 'This s3d is registered with an indexer.'),
|
||||
put(body,
|
||||
h('h2', {}, accountInfo?.label || (l.indexerUrl ? new URL(l.indexerUrl).host : 'Connected to Sia')),
|
||||
h('p', { class: 'row small', style: 'margin-top:-6px' }, h('span', { class: 'badge ok' }, 'connected'), accountInfo?.label && l.indexerUrl && h('span', { class: 'muted' }, new URL(l.indexerUrl).host)),
|
||||
h('p', {}, l.indexerUrl ? ['This s3d uses a Sia account on ', h('code', {}, l.indexerUrl), '. All S3 users, drives and files on it are stored, and paid for, through that account.'] : 'This s3d is registered with an indexer.'),
|
||||
h('label', { class: 'field', style: 'max-width:420px' }, h('span', {}, 'Account label'),
|
||||
h('div', { class: 'row', style: 'flex-wrap:nowrap' }, label,
|
||||
h('button', { class: 'btn', onclick: async () => { try { await api('PUT', '/api/account/label', { label: label.value }); await loadAccount(); toast('Label saved'); } catch (e) { fail(e); } } }, 'Save')),
|
||||
|
|
@ -689,7 +740,7 @@ function setup(main) {
|
|||
h('div', { class: 'row', style: 'margin-top:16px' },
|
||||
daemonState() !== 'running' && h('button', { class: 'btn primary', onclick: async () => { await daemonAction(daemonState() === 'crashed' ? 'restart' : 'start'); location.hash = '#/overview'; } }, 'Start s3d'),
|
||||
h('a', { class: 'btn', href: '#/users' }, 'Users & keys'),
|
||||
h('button', { class: 'btn danger', onclick: switchAccount }, 'Switch Sia account…')),
|
||||
h('button', { class: 'btn', onclick: switchAccount }, 'Add another account…')),
|
||||
archivesBlock());
|
||||
return;
|
||||
}
|
||||
|
|
@ -753,17 +804,9 @@ function setup(main) {
|
|||
archivesBlock());
|
||||
}
|
||||
|
||||
// Earlier Sia accounts set aside by "Switch Sia account", restorable.
|
||||
function archivesBlock() {
|
||||
const list = accountInfo?.archives || [];
|
||||
if (!list.length) return null;
|
||||
return h('div', { style: 'margin-top:22px' },
|
||||
h('h3', {}, 'Earlier Sia accounts on this computer'),
|
||||
h('p', { class: 'small muted', style: 'margin-top:0' }, 'Set aside by "Switch Sia account". Restoring one puts its users, keys and buckets back (the current account is set aside the same way).'),
|
||||
h('table', {}, h('tbody', {}, list.map((a) => h('tr', {},
|
||||
h('td', {}, h('strong', {}, a.label || (a.indexerUrl ? new URL(a.indexerUrl).host : a.name)),
|
||||
h('div', { class: 'small muted' }, `${a.archivedAt ? fmtDate(a.archivedAt) : a.name}${a.fingerprint ? ` · key ${a.fingerprint}` : ''}${a.users.length ? ` · users: ${a.users.join(', ')}` : ''}`)),
|
||||
h('td', { class: 'actions' }, h('button', { class: 'btn small', disabled: !a.hasData, onclick: () => restoreAccount(a) }, 'Restore')))))));
|
||||
const b = otherAccounts(draw);
|
||||
return b && h('div', { style: 'margin-top:22px' }, b);
|
||||
}
|
||||
|
||||
async function switchAccount() {
|
||||
|
|
@ -771,16 +814,16 @@ function setup(main) {
|
|||
if (daemonState() === 'running') { try { pending = await api('GET', '/api/stats'); } catch {} }
|
||||
const users = (await api('GET', '/api/users').catch(() => [])).map((u) => u.name);
|
||||
const waiting = pending && pending.pendingObjects > 0;
|
||||
const ok = await modal('Switch Sia account?', h('div', { class: 'stack' },
|
||||
h('p', { style: 'margin:0' }, `s3d is connected to ${accountName(accountInfo)}. Switching connects it to a different Sia account.`),
|
||||
const ok = await modal('Add another Sia account?', h('div', { class: 'stack' },
|
||||
h('p', { style: 'margin:0' }, `s3d is connected to ${accountName(accountInfo)}. s3d uses one Sia account at a time, so adding another sets this one aside on this computer; you can switch back to it any time on this page.`),
|
||||
h('ul', { class: 'small', style: 'margin:0;padding-left:1.2em' },
|
||||
h('li', {}, `The current account keeps its files. Its S3 users${users.length ? ` (${users.join(', ')})` : ''}, keys and buckets are set aside on this computer, not deleted, and can be restored here.`),
|
||||
h('li', {}, `The current account keeps its files. Its S3 users${users.length ? ` (${users.join(', ')})` : ''}, keys and drives are set aside on this computer, not deleted, and you can switch back to it on the Account page.`),
|
||||
h('li', {}, 'The new account starts empty: you create users and keys again, and S3 apps need the new keys.'),
|
||||
h('li', {}, 's3d stops now; the guide then connects the other account.')),
|
||||
h('li', {}, 's3d stops now; the guide then connects the new account.')),
|
||||
waiting && h('div', { class: 'banner warn' },
|
||||
h('span', {}, `${pending.pendingObjects} object(s) (${fmtBytes(pending.pendingSize)}) are still waiting to upload to Sia.`),
|
||||
h('button', { class: 'btn', type: 'button', onclick: async (e) => { e.currentTarget.disabled = true; try { await api('POST', '/api/flush'); toast('Pending uploads are on Sia'); } catch (err) { fail(err); } } }, 'Flush now'))),
|
||||
[{ label: 'Cancel', result: false }, { label: 'Switch account', kind: 'primary', submit: true, result: true }]);
|
||||
[{ label: 'Cancel', result: false }, { label: 'Add account', kind: 'primary', submit: true, result: true }]);
|
||||
if (!ok) return;
|
||||
try {
|
||||
const r = await api('POST', '/api/account/switch');
|
||||
|
|
@ -792,20 +835,6 @@ function setup(main) {
|
|||
} catch (e) { fail(e); }
|
||||
}
|
||||
|
||||
async function restoreAccount(a) {
|
||||
const name = a.label || (a.indexerUrl ? new URL(a.indexerUrl).host : a.name);
|
||||
const ok = await confirmModal(`Restore ${name}?`, 's3d stops and switches back to that Sia account with its users, keys and buckets. The current account is set aside and can be restored the same way.', 'Restore', 'primary');
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api('POST', '/api/account/restore', { name: a.name });
|
||||
await api('POST', '/api/daemon/start').catch(() => {});
|
||||
toast(`Restored ${name}`);
|
||||
await refreshStatus();
|
||||
await loadAccount();
|
||||
draw();
|
||||
} catch (e) { fail(e); }
|
||||
}
|
||||
|
||||
async function start(e) {
|
||||
e.preventDefault();
|
||||
const phrase = draft.mode === 'existing' ? draft.phrase.trim() : '';
|
||||
|
|
@ -1078,6 +1107,51 @@ 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 ICONS = {
|
||||
download: '<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2.5v8M4.5 7.5 8 11l3.5-3.5M3 13.5h10"/></svg>',
|
||||
share: '<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M6.6 9.4a2.6 2.6 0 0 0 3.7 0l2.2-2.2a2.6 2.6 0 0 0-3.7-3.7l-.9.9"/><path d="M9.4 6.6a2.6 2.6 0 0 0-3.7 0L3.5 8.8a2.6 2.6 0 0 0 3.7 3.7l.9-.9"/></svg>',
|
||||
};
|
||||
function iconButton(name, label, run) {
|
||||
const b = h('button', { type: 'button', class: 'btn small icon', title: label, 'aria-label': label, onclick: (e) => { e.stopPropagation(); run(); } });
|
||||
b.innerHTML = ICONS[name]; // fixed markup above, never user data
|
||||
return b;
|
||||
}
|
||||
const onEnter = (fn) => (e) => { if (e.key === 'Enter' && e.target === e.currentTarget) fn(); };
|
||||
|
||||
const EXT = (key) => (key.split('/').pop().match(/.([a-z0-9]+)$/i) || [])[1]?.toLowerCase() || '';
|
||||
function fileKind(key) {
|
||||
const e = EXT(key);
|
||||
|
|
@ -1092,6 +1166,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)] || '📦';
|
||||
}
|
||||
|
|
@ -1176,7 +1254,7 @@ function accountLine() {
|
|||
loadAccount().then((a) => put(el,
|
||||
'Sia account: ', h('strong', {}, accountName(a)),
|
||||
a && a.label && a.indexerUrl ? h('span', {}, ` (${new URL(a.indexerUrl).host})`) : null,
|
||||
' · ', h('a', { href: '#/setup' }, a && a.registered ? 'Manage' : 'Connect')));
|
||||
' · ', h('a', { href: '#/account' }, a && a.registered ? 'Manage' : 'Connect')));
|
||||
return el;
|
||||
}
|
||||
|
||||
|
|
@ -1185,36 +1263,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: isHosted() ? '🔒 encrypted' : '🔒 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: isHosted() ? '🔒 encrypted' : '🔒 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, ''));
|
||||
|
|
@ -1254,7 +1356,8 @@ async function accessDialog(user, bucket) {
|
|||
opts.map(([val, label, desc]) => h('label', { class: 'check', style: 'align-items:flex-start' },
|
||||
h('input', { type: 'radio', name: 'acc', checked: choice === val, onchange: () => { choice = val; } }),
|
||||
h('span', {}, h('strong', {}, label), h('br'), h('span', { class: 'small muted' }, desc)))),
|
||||
h('p', { class: 'small muted', style: 'margin:0' }, 'Public objects are served at ', h('code', {}, `${endpoint}/${bucket}/<key>`), '. Writes and deletes always need a key.'),
|
||||
h('p', { class: 'small muted', style: 'margin:0' }, 'Public objects are served at ', h('code', {}, `${endpoint}/${bucket}/<key>`), '. Writes and deletes always need a key. You can change this at any time.'),
|
||||
localOnly() && h('div', { class: 'banner info small', style: 'margin:0' }, h('span', {}, 'Right now s3d listens only on this computer, so "public" means public to programs on this computer. Other people reach public files once s3d has a public address.')),
|
||||
h('p', { class: 'small muted', style: 'margin:0' }, 'Access applies to the whole bucket: s3d cannot make a single folder public, or give other people write rights. Keep public files in a bucket of their own, or send a share link for a single file.')),
|
||||
[{ label: 'Cancel', result: null }, { label: 'Save', kind: 'primary', submit: true, value: () => choice }]);
|
||||
if (!v || v === current) return;
|
||||
|
|
@ -1281,7 +1384,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'); });
|
||||
|
|
@ -1307,30 +1412,83 @@ 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' },
|
||||
isHosted() && [h('button', { class: 'btn small', onclick: (e) => { e.stopPropagation(); folderShareDialog(user, bucket, f); } }, 'Share'), ' '],
|
||||
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)}`), readableMark(o)),
|
||||
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)], isHosted() && ['Share…', () => folderShareDialog(user, bucket, f)], ['Delete', () => deleteFolder(f), 'danger']]);
|
||||
const fileMenu = (o) => itemMenu([[fileKind(o.key) === 'audio' ? 'Play' : 'Open', () => openViewer(o)], ['Download', () => downloadObject(o.key)], ['Share link…', () => share(o.key)], ['Delete', () => deleteObject(o.key), 'danger']]);
|
||||
const more = last?.truncated && h('div', { class: 'row', style: 'justify-content:center;margin-top:12px' }, h('button', { class: 'btn', onclick: () => load(last.nextToken) }, 'Load more'));
|
||||
const stop = (e) => e.stopPropagation();
|
||||
// Narrow screens: Download and Share as icons beside the ⋯ menu.
|
||||
const quickIcons = (o) => h('span', { class: 'narrow-only icon-row' },
|
||||
iconButton('download', 'Download', () => downloadObject(o.key)),
|
||||
iconButton('share', 'Share link', () => share(o.key)));
|
||||
|
||||
if (mode === 'details') {
|
||||
put(card, h('table', { class: 'files' },
|
||||
h('thead', {}, h('tr', {}, h('th', {}, 'Name'), h('th', { class: 'num hide-sm' }, 'Size'), h('th', { class: 'hide-sm' }, 'Type'), h('th', { class: 'hide-sm' }, 'Modified'), h('th', {}))),
|
||||
h('tbody', {},
|
||||
folders.map((f) => h('tr', { class: 'clickable', tabindex: '0', onclick: () => go(f), onkeydown: onEnter(() => go(f)) },
|
||||
h('td', { class: 'name' }, h('div', { class: 'cell-name' }, h('span', { class: 'ficon', 'aria-hidden': 'true' }, '📁'), h('span', { class: 'label' }, folderName(f)))),
|
||||
h('td', { class: 'num muted hide-sm' }, '–'), h('td', { class: 'hide-sm muted' }, 'Folder'), h('td', { class: 'hide-sm' }),
|
||||
h('td', { class: 'actions', onclick: stop }, folderMenu(f)))),
|
||||
objects.map((o) => h('tr', { class: 'clickable', tabindex: '0', onclick: () => openViewer(o), onkeydown: onEnter(() => openViewer(o)) },
|
||||
h('td', { class: 'name' }, h('div', { class: 'cell-name' }, h('span', { class: 'ficon', 'aria-hidden': 'true' }, fileIcon(o.key)), h('span', { class: 'label', title: fileName(o) }, fileName(o), readableMark(o)))),
|
||||
h('td', { class: 'num hide-sm' }, fmtBytes(o.size)),
|
||||
h('td', { class: 'hide-sm muted' }, fileType(o.key)),
|
||||
h('td', { class: 'hide-sm muted' }, fmtDate(o.modified)),
|
||||
h('td', { class: 'actions', onclick: stop },
|
||||
h('span', { class: 'wide-only' }, h('button', { class: 'btn small', onclick: () => downloadObject(o.key) }, 'Download'), ' ',
|
||||
h('button', { class: 'btn small', onclick: () => share(o.key) }, 'Share'), ' '),
|
||||
quickIcons(o), 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), readableMark(o)),
|
||||
h('span', { class: 'meta hide-sm' }, fmtBytes(o.size)),
|
||||
h('span', { class: 'row-actions', onclick: stop }, quickIcons(o), fileMenu(o))))),
|
||||
more);
|
||||
} else {
|
||||
put(card, h('div', { class: 'tiles files' },
|
||||
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), readableMark(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;
|
||||
}
|
||||
|
||||
// On Silent Mode, files the server can read: shared on purpose, or stored
|
||||
|
|
@ -1408,6 +1566,7 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) {
|
|||
async function openViewer(o) {
|
||||
const name = o.key.slice(prefix.length);
|
||||
const kind = fileKind(o.key);
|
||||
if (kind === 'audio') return playFolder(o);
|
||||
const path = `${base}/object?key=${encodeURIComponent(o.key)}`;
|
||||
const src = async () => (bridge ? (await bridge.invoke('view-url', { path })).url : `${path}&inline=1`);
|
||||
let content;
|
||||
|
|
@ -1424,6 +1583,17 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) {
|
|||
[{ label: 'Share', value: () => { share(o.key); return false; } }, { label: 'Download', value: () => { downloadObject(o.key); return false; } }, { label: 'Close', kind: 'primary', submit: true, result: true }]);
|
||||
}
|
||||
|
||||
// Music plays in the docked player, on through this folder's other tracks.
|
||||
function playFolder(o) {
|
||||
const where = `${bucket}/${prefix}`.replace(/\/$/, '');
|
||||
const tracks = pages.flatMap((p) => p.objects).filter((x) => fileKind(x.key) === 'audio').map((x) => {
|
||||
const path = `${base}/object?key=${encodeURIComponent(x.key)}`;
|
||||
return { key: x.key, name: x.key.slice(prefix.length), where,
|
||||
src: async () => (bridge ? (await bridge.invoke('view-url', { path })).url : `${path}&inline=1`) };
|
||||
});
|
||||
player().play(tracks, Math.max(0, tracks.findIndex((t) => t.key === o.key)));
|
||||
}
|
||||
|
||||
async function deleteObject(key) {
|
||||
if (!(await confirmModal('Delete object?', key))) return;
|
||||
try { await api('DELETE', `${base}/object?key=${encodeURIComponent(key)}`); toast('Deleted'); load(); } catch (e) { fail(e); }
|
||||
|
|
@ -1434,27 +1604,41 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) {
|
|||
try { const r = await api('DELETE', `${base}/prefix?prefix=${encodeURIComponent(p)}`); toast(`Deleted ${r.deleted} objects`); load(); } catch (e) { fail(e); }
|
||||
}
|
||||
|
||||
// Share: the link is made as soon as the dialog opens and remade when the
|
||||
// validity or address changes. s3d listens only on this computer, so
|
||||
// without an address of your own the link opens only here; it says so.
|
||||
async function share(key) {
|
||||
if (isHosted()) return hostedShare(key);
|
||||
const expires = h('select', {}, [['3600', '1 hour'], ['86400', '1 day'], ['604800', '7 days (max)']].map(([v, l]) => h('option', { value: v }, l)));
|
||||
const expires = h('select', {}, [['3600', '1 hour'], ['86400', '1 day'], ['604800', '7 days (max)']].map(([v, l]) => h('option', { value: v, selected: v === '86400' }, l)));
|
||||
const savedEndpoint = localStorage.getItem('pithos.publicEndpoint') || '';
|
||||
const endpoint = h('input', { type: 'url', value: savedEndpoint, placeholder: `http://${state.status?.config.apiAddress}` });
|
||||
const out = h('div');
|
||||
const endpoint = h('input', { type: 'url', value: savedEndpoint, placeholder: 'https://files.example.com' });
|
||||
const out = h('div', {}, h('p', { class: 'muted small' }, 'Making the link…'));
|
||||
const note = h('div');
|
||||
let link = '';
|
||||
const gen = async () => {
|
||||
const q = new URLSearchParams({ key, expires: expires.value });
|
||||
if (endpoint.value.trim()) { q.set('endpoint', endpoint.value.trim()); localStorage.setItem('pithos.publicEndpoint', endpoint.value.trim()); }
|
||||
const own = endpoint.value.trim();
|
||||
try { localStorage.setItem('pithos.publicEndpoint', own); } catch {}
|
||||
if (own) q.set('endpoint', own);
|
||||
try {
|
||||
const { url } = await api('GET', `${base}/presign?${q}`);
|
||||
put(out, secretRow('Link', url));
|
||||
} catch (e) { fail(e); }
|
||||
return false;
|
||||
({ url: link } = await api('GET', `${base}/presign?${q}`));
|
||||
put(out, secretRow('Link', link));
|
||||
} catch (e) { put(out, h('p', { class: 'error small' }, e.message)); }
|
||||
put(note, !own && localOnly() && h('div', { class: 'banner info small', style: 'margin:0' },
|
||||
h('span', {}, 'This link opens only on this computer: s3d listens on ', h('code', {}, state.status?.config.apiAddress || '127.0.0.1'), ', which other devices cannot reach. To share with other people, s3d needs a public address (for example your own server in front of it).')));
|
||||
};
|
||||
expires.addEventListener('change', gen);
|
||||
endpoint.addEventListener('change', gen);
|
||||
gen();
|
||||
await modal('Share link', h('div', { class: 'stack' },
|
||||
h('p', { class: 'small muted', style: 'margin:0' }, 'A signed link that works without a key until it expires.'),
|
||||
h('label', { class: 'field' }, h('span', {}, 'Valid for'), expires),
|
||||
h('label', { class: 'field' }, h('span', {}, 'Public address of s3d'), endpoint, h('small', {}, 'The address recipients will use to reach s3d (e.g. behind a reverse proxy). Leave blank for the local endpoint.')),
|
||||
out),
|
||||
[{ label: 'Close', result: true }, { label: 'Create link', kind: 'primary', submit: true, value: gen }]);
|
||||
out,
|
||||
note,
|
||||
h('details', { class: 'more-opts', open: !!savedEndpoint },
|
||||
h('summary', {}, 'Use my own public address'),
|
||||
h('label', { class: 'field', style: 'margin-top:8px' }, endpoint, h('small', {}, 'The address where other people reach this s3d, e.g. a reverse proxy on your server. The link is remade when you change it.')))),
|
||||
[{ label: 'Copy link', value: async () => { if (link) await copy(link, 'Link copied'); return false; } }, { label: 'Done', kind: 'primary', submit: true, result: true }]);
|
||||
}
|
||||
|
||||
// On Silent Mode a link is a rule on the server, and the file is stored
|
||||
|
|
@ -1771,6 +1955,126 @@ async function hostedAccessDialog(user, bucket) {
|
|||
[{ label: 'Close', result: true }, { label: 'Share the whole drive…', kind: 'primary', submit: true, value: () => { folderShareDialog(user, bucket, ''); return true; } }]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- music player
|
||||
|
||||
// A player docked at the bottom of Pithos: it lives outside the page, so it
|
||||
// keeps playing while you browse other folders, drives or pages, and plays
|
||||
// on through the folder it was started from.
|
||||
let musicPlayer = null;
|
||||
function player() {
|
||||
if (musicPlayer) return musicPlayer;
|
||||
const audio = new Audio();
|
||||
audio.preload = 'metadata';
|
||||
let queue = [];
|
||||
let at = -1;
|
||||
let seeking = false;
|
||||
let retried = false;
|
||||
const mmss = (t) => (isFinite(t) ? `${Math.floor(t / 60)}:${String(Math.floor(t % 60)).padStart(2, '0')}` : '0:00');
|
||||
const title = h('div', { class: 'pl-title' });
|
||||
const where = h('div', { class: 'pl-where small muted' });
|
||||
const cur = h('span', { class: 'pl-time' }, '0:00');
|
||||
const dur = h('span', { class: 'pl-time' }, '0:00');
|
||||
const seek = h('input', { type: 'range', min: '0', max: '0', step: '1', value: '0', 'aria-label': 'Position',
|
||||
oninput: () => { seeking = true; cur.textContent = mmss(+seek.value); },
|
||||
onchange: () => { audio.currentTime = +seek.value; seeking = false; } });
|
||||
const playBtn = h('button', { type: 'button', class: 'pl-btn pl-main', 'aria-label': 'Play', onclick: () => (audio.paused ? audio.play().catch(() => {}) : audio.pause()) }, '▶');
|
||||
const prevBtn = h('button', { type: 'button', class: 'pl-btn', 'aria-label': 'Previous', title: 'Previous', onclick: () => prev() }, '⏮');
|
||||
const nextBtn = h('button', { type: 'button', class: 'pl-btn', 'aria-label': 'Next', title: 'Next', onclick: () => next() }, '⏭');
|
||||
const closeBtn = h('button', { type: 'button', class: 'pl-btn', 'aria-label': 'Stop and close the player', title: 'Close', onclick: () => close() }, '✕');
|
||||
// Volume: a slider beside a mute button; on narrow screens the button
|
||||
// opens the slider in a small pop-up above it. Remembered between visits.
|
||||
const saved = (() => { try { return JSON.parse(localStorage.getItem('pithos.volume') || 'null'); } catch { return null; } })();
|
||||
audio.volume = saved && isFinite(saved.v) ? Math.min(1, Math.max(0, saved.v)) : 1;
|
||||
audio.muted = !!saved?.m;
|
||||
const volIcon = () => (audio.muted || audio.volume === 0 ? '🔇' : audio.volume < 0.5 ? '🔉' : '🔊');
|
||||
const vol = h('input', { type: 'range', min: '0', max: '1', step: '0.05', value: String(audio.volume), class: 'pl-vol', 'aria-label': 'Volume',
|
||||
oninput: () => { audio.volume = +vol.value; if (audio.volume > 0) audio.muted = false; } });
|
||||
const volBtn = h('button', { type: 'button', class: 'pl-btn pl-volbtn', title: 'Mute', 'aria-label': 'Mute', onclick: () => {
|
||||
if (matchMedia('(max-width: 760px)').matches) { volBox.classList.toggle('open'); return; }
|
||||
audio.muted = !audio.muted;
|
||||
} }, volIcon());
|
||||
const muteInPop = h('button', { type: 'button', class: 'pl-btn', title: 'Mute', 'aria-label': 'Mute', onclick: () => { audio.muted = !audio.muted; } }, '🔇');
|
||||
const volBox = h('div', { class: 'pl-volume' }, volBtn, h('div', { class: 'pl-volpop' }, muteInPop, vol));
|
||||
audio.addEventListener('volumechange', () => {
|
||||
volBtn.textContent = volIcon();
|
||||
const m = audio.muted ? 'Unmute' : 'Mute';
|
||||
volBtn.title = muteInPop.title = m; muteInPop.setAttribute('aria-label', m);
|
||||
muteInPop.textContent = audio.muted ? '🔈' : '🔇';
|
||||
vol.value = String(audio.muted ? 0 : audio.volume);
|
||||
try { localStorage.setItem('pithos.volume', JSON.stringify({ v: audio.volume, m: audio.muted })); } catch {}
|
||||
});
|
||||
document.addEventListener('mousedown', (e) => { if (!volBox.contains(e.target)) volBox.classList.remove('open'); });
|
||||
const el = h('div', { class: 'player', role: 'region', 'aria-label': 'Music player', hidden: true },
|
||||
h('div', { class: 'pl-controls' }, prevBtn, playBtn, nextBtn),
|
||||
h('div', { class: 'pl-info' }, h('div', { class: 'pl-head' }, title, where), h('div', { class: 'pl-seek' }, cur, seek, dur)),
|
||||
volBox,
|
||||
closeBtn);
|
||||
document.body.append(el);
|
||||
|
||||
const showPlaying = () => { const p = !audio.paused; playBtn.textContent = p ? '⏸' : '▶'; playBtn.setAttribute('aria-label', p ? 'Pause' : 'Play'); if (navigator.mediaSession) navigator.mediaSession.playbackState = p ? 'playing' : 'paused'; };
|
||||
audio.addEventListener('play', showPlaying);
|
||||
audio.addEventListener('pause', showPlaying);
|
||||
audio.addEventListener('playing', () => { retried = false; });
|
||||
audio.addEventListener('loadedmetadata', () => { seek.max = String(Math.floor(audio.duration) || 0); dur.textContent = mmss(audio.duration); });
|
||||
audio.addEventListener('timeupdate', () => { if (!seeking) { seek.value = String(Math.floor(audio.currentTime)); cur.textContent = mmss(audio.currentTime); } });
|
||||
audio.addEventListener('ended', () => { if (at < queue.length - 1) next(); });
|
||||
// Theseus view links last 10 minutes: when one runs out mid-track, fetch a
|
||||
// fresh link and carry on from the same spot.
|
||||
audio.addEventListener('error', () => {
|
||||
if (!queue[at] || retried) return toast(`Cannot play ${queue[at]?.name || 'this file'}`, 'error');
|
||||
retried = true;
|
||||
load(at, audio.currentTime, true);
|
||||
});
|
||||
|
||||
async function load(i, from = 0, play = true) {
|
||||
at = i;
|
||||
const t = queue[i];
|
||||
title.textContent = t.name;
|
||||
title.title = t.name;
|
||||
where.textContent = t.where;
|
||||
prevBtn.disabled = i <= 0;
|
||||
nextBtn.disabled = i >= queue.length - 1;
|
||||
let src;
|
||||
try { src = await t.src(); } catch (e) { return fail(e); }
|
||||
if (at !== i) return; // another track was chosen meanwhile
|
||||
audio.src = src;
|
||||
if (from) audio.addEventListener('loadedmetadata', () => { audio.currentTime = from; }, { once: true });
|
||||
if (play) audio.play().catch(() => {});
|
||||
if (navigator.mediaSession && window.MediaMetadata) navigator.mediaSession.metadata = new MediaMetadata({ title: t.name.replace(/\.[^.]+$/, ''), album: t.where, artist: 'Pithos' });
|
||||
}
|
||||
const next = () => { if (at < queue.length - 1) load(at + 1); };
|
||||
const prev = () => { if (audio.currentTime > 3 || at <= 0) audio.currentTime = 0; else load(at - 1); };
|
||||
function close() {
|
||||
audio.pause();
|
||||
audio.removeAttribute('src');
|
||||
audio.load();
|
||||
queue = []; at = -1;
|
||||
el.hidden = true;
|
||||
document.body.classList.remove('has-player');
|
||||
}
|
||||
if (navigator.mediaSession) {
|
||||
const ms = navigator.mediaSession;
|
||||
try {
|
||||
ms.setActionHandler('play', () => audio.play().catch(() => {}));
|
||||
ms.setActionHandler('pause', () => audio.pause());
|
||||
ms.setActionHandler('previoustrack', prev);
|
||||
ms.setActionHandler('nexttrack', next);
|
||||
ms.setActionHandler('seekto', (d) => { audio.currentTime = d.seekTime; });
|
||||
} catch {}
|
||||
}
|
||||
musicPlayer = {
|
||||
// tracks: [{ key, name, where, src: async () => url }]
|
||||
play(tracks, index) {
|
||||
queue = tracks;
|
||||
el.hidden = false;
|
||||
document.body.classList.add('has-player');
|
||||
load(index);
|
||||
},
|
||||
get current() { return queue[at]?.key || null; },
|
||||
};
|
||||
return musicPlayer;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- settings
|
||||
|
||||
function settings(main) {
|
||||
|
|
@ -1898,22 +2202,25 @@ 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'));
|
||||
addMenuAction('⤢ Open as full page', () => row.querySelector('button').click());
|
||||
}
|
||||
|
||||
// An action at the end of the ☰ menu (narrow layouts only; wide ones show
|
||||
// the same thing as a button under the navigation).
|
||||
function addMenuAction(label, run) {
|
||||
if (!$('#nav .nav-sep')) $('#nav').append(h('div', { class: 'nav-sep', role: 'separator' }));
|
||||
$('#nav').append(h('a', { href: '#', class: 'nav-action', onclick: (e) => { e.preventDefault(); setMenu(false); run(); } }, label));
|
||||
}
|
||||
|
||||
// Theseus: Pithos opens only with the vault unlocked. The add-on asks Theseus
|
||||
|
|
@ -1949,6 +2256,92 @@ $('#unlock-btn').addEventListener('click', async () => {
|
|||
}
|
||||
});
|
||||
|
||||
// ---- Footer: pithos.sia, the version, and (in Theseus) the updater -----------
|
||||
// Like Aegis: checks the extension channel when Pithos opens and every few
|
||||
// hours; a newer release replaces the version with "↑ vX". One click
|
||||
// downloads and verifies it, the next restarts Theseus to apply it.
|
||||
const UPDATES_URL = 'https://navigate.st/bns/theseus.x/extensions/pithos/updates.json';
|
||||
// s3d's S3 port is reachable only from this computer (the default).
|
||||
function localOnly() {
|
||||
const a = state.status?.config?.apiAddress || '';
|
||||
return /^(127\.|localhost|\[::1\])/.test(a);
|
||||
}
|
||||
function cmpSemver(a, b) {
|
||||
const pa = String(a || '0').split('.').map((n) => Number(n) || 0);
|
||||
const pb = String(b || '0').split('.').map((n) => Number(n) || 0);
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const d = (pa[i] || 0) - (pb[i] || 0); if (d) return d < 0 ? -1 : 1; }
|
||||
return 0;
|
||||
}
|
||||
function setupFooter() {
|
||||
const foot = $('#brandfoot');
|
||||
const ver = $('#brandver');
|
||||
const chip = $('#brandupd');
|
||||
const check = $('#brandcheck');
|
||||
const current = state.status?.pithos || null;
|
||||
ver.textContent = current ? `v${current}` : '';
|
||||
foot.hidden = false;
|
||||
document.body.classList.add('has-foot');
|
||||
if (bridge) {
|
||||
$('#brandlink').addEventListener('click', (e) => { e.preventDefault(); bridge.invoke('open-external', { url: 'https://pithos.sia/' }).catch(fail); });
|
||||
}
|
||||
if (!bridge || !current) return; // only the Theseus add-on updates itself
|
||||
|
||||
let latest = null;
|
||||
let busy = false;
|
||||
const showChip = (text, cls, title, onclick) => {
|
||||
chip.hidden = false; ver.hidden = true;
|
||||
chip.className = `brandupd${cls ? ' ' + cls : ''}`;
|
||||
chip.textContent = text; chip.title = title || '';
|
||||
chip.disabled = !onclick; chip.onclick = onclick || null;
|
||||
};
|
||||
const rest = () => { chip.hidden = true; ver.hidden = false; };
|
||||
const flash = (text, cls, title) => { showChip(text, cls, title, null); setTimeout(() => { if (!busy && chip.classList.contains(cls)) paint(); }, 2500); };
|
||||
function paint() {
|
||||
if (latest && cmpSemver(latest, current) > 0) showChip(`↑ v${latest}`, '', `Pithos v${latest} is available. Click to install it.`, install);
|
||||
else rest();
|
||||
}
|
||||
async function scan(manual) {
|
||||
check.classList.add('spin');
|
||||
try {
|
||||
const r = await fetch(`${UPDATES_URL}?t=${Math.floor(Date.now() / 60000)}`, { cache: 'no-store' });
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const list = (await r.json())?.addons || [];
|
||||
latest = list.reduce((best, e) => (!best || cmpSemver(e.version, best) > 0 ? e.version : best), null);
|
||||
if (busy) return;
|
||||
if (manual && !(latest && cmpSemver(latest, current) > 0)) flash('✓ Up to date', 'ok', `Pithos v${current} is the latest release`);
|
||||
else paint();
|
||||
} catch (e) {
|
||||
if (manual) flash('⚠ Check failed', 'err', e.message);
|
||||
} finally { check.classList.remove('spin'); }
|
||||
}
|
||||
async function install() {
|
||||
busy = true;
|
||||
showChip('Downloading…', 'wait', 'Downloading and verifying the signed release', null);
|
||||
try {
|
||||
const r = await bridge.invoke('update', { step: 'stage' });
|
||||
if (r?.fallback) {
|
||||
busy = false;
|
||||
return showChip('Update in Settings', '', 'This Theseus updates extensions in Settings › Extensions', () => bridge.invoke('open-settings', { section: 'addons' }).catch(fail));
|
||||
}
|
||||
if (!r?.staged) {
|
||||
busy = false;
|
||||
return r?.status === 'up-to-date' ? flash('✓ Up to date', 'ok', '') : flash('⚠ Update failed', 'err', r?.detail || r?.status || '');
|
||||
}
|
||||
const next = r.next ? ` v${r.next}` : '';
|
||||
showChip(`↻ Restart to apply${next}`, '', `Pithos${next} is ready. Theseus restarts to apply it and reopens your tabs.`, async () => {
|
||||
try {
|
||||
const a = await bridge.invoke('update', { step: 'apply' });
|
||||
if (a?.deferred) showChip(`Applies on next start${next}`, 'ok', 'Pithos updates the next time Theseus starts', null);
|
||||
} catch (e) { flash('⚠ Restart failed', 'err', e.message); }
|
||||
});
|
||||
} catch (e) { busy = false; flash('⚠ Update failed', 'err', e.message); }
|
||||
}
|
||||
check.hidden = false;
|
||||
check.addEventListener('click', () => scan(true));
|
||||
setTimeout(() => scan(false), 1500);
|
||||
setInterval(() => scan(false), 6 * 3600 * 1000);
|
||||
}
|
||||
|
||||
// The full-page tab opened from the Theseus panel: one button back.
|
||||
function addBackToSidebar() {
|
||||
const row = h('div', { class: 'host-controls' },
|
||||
|
|
@ -1957,6 +2350,7 @@ function addBackToSidebar() {
|
|||
window.close(); // works for tabs the add-on opened; harmless otherwise
|
||||
} }, '⇤ Back to sidebar'));
|
||||
$('.sidebar').insertBefore(row, $('#daemon-pill'));
|
||||
addMenuAction('⇤ Back to sidebar', () => row.querySelector('button').click());
|
||||
}
|
||||
|
||||
function showSignin() {
|
||||
|
|
@ -1986,6 +2380,7 @@ async function boot() {
|
|||
state.logSeq = state.logs.at(-1)?.seq || 0;
|
||||
} catch {}
|
||||
await refreshStatus();
|
||||
setupFooter();
|
||||
if (!bridge && state.status?.host === 'theseus') addBackToSidebar();
|
||||
connectEvents();
|
||||
window.addEventListener('hashchange', route);
|
||||
|
|
@ -1993,6 +2388,17 @@ async function boot() {
|
|||
setInterval(refreshStatus, 15000);
|
||||
}
|
||||
|
||||
function setMenu(open) {
|
||||
$('.sidebar').classList.toggle('nav-open', open);
|
||||
$('#nav-toggle').setAttribute('aria-expanded', String(open));
|
||||
}
|
||||
$('#nav-toggle').addEventListener('click', (e) => { e.stopPropagation(); setMenu(!$('.sidebar').classList.contains('nav-open')); });
|
||||
document.addEventListener('mousedown', (e) => {
|
||||
if ($('.sidebar').classList.contains('nav-open') && !$('#nav').contains(e.target) && !$('#nav-toggle').contains(e.target)) setMenu(false);
|
||||
});
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && $('.sidebar').classList.contains('nav-open')) { setMenu(false); $('#nav-toggle').focus(); } });
|
||||
$('#nav').addEventListener('click', (e) => { if (e.target.closest('a')) setMenu(false); });
|
||||
|
||||
$('#signin-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const err = $('#signin-error');
|
||||
|
|
|
|||
|
|
@ -30,21 +30,29 @@
|
|||
|
||||
<div id="app" class="app" hidden>
|
||||
<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">
|
||||
<a href="#/start" data-view="start">Get started</a>
|
||||
<a href="#/overview" data-view="overview">Overview</a>
|
||||
<a href="#/buckets" data-view="buckets">Drives</a>
|
||||
<a href="#/users" data-view="users">Users & keys</a>
|
||||
<a href="#/setup" data-view="setup">Sia connection</a>
|
||||
<a href="#/account" data-view="account">Account</a>
|
||||
<a href="#/settings" data-view="settings">Settings</a>
|
||||
<a href="#/logs" data-view="logs">Logs</a>
|
||||
</nav>
|
||||
<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-haspopup="menu" aria-expanded="false" aria-controls="nav">☰</button>
|
||||
</aside>
|
||||
<main id="main"></main>
|
||||
</div>
|
||||
|
||||
<footer class="brandfoot" id="brandfoot" hidden>
|
||||
<a class="brandlink" id="brandlink" href="https://pithos.sia/" target="_blank" rel="noopener" title="Open pithos.sia"><img src="icon.svg" alt="" width="13" height="13"><span>pithos.sia</span></a>
|
||||
<span class="brandfoot-right">
|
||||
<button class="brandcheck" id="brandcheck" type="button" title="Check for updates" aria-label="Check for updates" hidden>↻</button>
|
||||
<button class="brandupd" id="brandupd" type="button" hidden></button>
|
||||
<span class="brandver" id="brandver"></span>
|
||||
</span>
|
||||
</footer>
|
||||
<div id="modal-root"></div>
|
||||
<div id="toasts" class="toasts" aria-live="polite"></div>
|
||||
<script type="module" src="app.js"></script>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue