diff --git a/core/server.js b/core/server.js index 81572d3..634c90e 100644 --- a/core/server.js +++ b/core/server.js @@ -566,7 +566,13 @@ export async function createPithos(opts = {}) { try { return await createShareUrl(dir, bucket, key, until); } catch (e) { if (e.code !== 'pending') throw new HttpError(e.status || 502, e.message); - await flushNow(); // send what is waiting, then try once more + // send what is waiting, then try once more + try { await flushNow(); } catch (fe) { + const why = /not enough hosts/i.test(fe.message) ? 'not enough Sia hosts are reachable right now' + : /failed to open upload|cannot find the file/i.test(fe.message) ? 'some waiting files lost their copy on this computer (see the Drives page)' + : 'the upload to Sia failed'; + throw new HttpError(502, `This file is not on Sia yet, and sending it now did not work: ${why}. Try again in a minute.`); + } try { return await createShareUrl(dir, bucket, key, until); } catch (e2) { throw new HttpError(e2.status || 502, e2.code === 'pending' ? 'This file is still uploading to Sia. Try again in a minute.' : e2.message); } } diff --git a/ui/app.css b/ui/app.css index f951425..99d4a6d 100644 --- a/ui/app.css +++ b/ui/app.css @@ -481,3 +481,7 @@ a.brand:hover > span:first-of-type { color: var(--accent); } .stuck-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; } .stuck-list li { display: flex; gap: 8px; align-items: center; justify-content: space-between; flex-wrap: wrap; } .stuck-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1 1 220px; } + +/* Toasts with actions (Share: Open / Manage) */ +.toast.action { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.toast.action > span { flex: 1 1 auto; } diff --git a/ui/app.js b/ui/app.js index bd71797..21cd2f9 100644 --- a/ui/app.js +++ b/ui/app.js @@ -80,6 +80,15 @@ function fmtDuration(ms) { return `${Math.floor(s / 86400)}d ${Math.floor((s % 86400) / 3600)}h`; } +// A toast with buttons ([label, fn] pairs); stays a little longer. +function actionToast(text, actions = [], ms = 9000) { + const el = h('div', { class: 'toast action' }, h('span', {}, text), + actions.map(([label, fn]) => h('button', { type: 'button', class: 'btn small', onclick: () => { el.remove(); fn(); } }, label))); + $('#toasts').append(el); + setTimeout(() => el.remove(), ms); + return el; +} + async function copy(text, what = 'Copied') { try { await navigator.clipboard.writeText(text); toast(what); } catch { prompt('Copy:', text); } @@ -1704,7 +1713,7 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) { } catch { put(accessChip, 'Access'); } } paintAccess(); - const shareHere = () => (isHosted() ? folderShareDialog(user, bucket, prefix).then(paintAccess) : siaFolderLink(prefix)); + const shareHere = () => (isHosted() ? folderShareDialog(user, bucket, prefix).then(paintAccess) : quickShare({ prefix })); const shareBtn = h('button', { type: 'button', class: 'chip', title: prefix ? 'Share this folder' : 'Share this drive', onclick: shareHere }, 'šŸ”— Share'); put(head, h('div', { class: 'stack' }, h('div', { class: 'row crumb-row' }, crumbs, accessChip, shareBtn), accountLine()), @@ -1749,14 +1758,14 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) { } 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)] : ['Public link…', () => siaFolderLink(f)], ['Rename…', () => renameItem(f)], ['Delete', () => deleteFolder(f), 'danger']]); - const fileMenu = (o) => itemMenu([[fileKind(o.key) === 'audio' ? 'Play' : isHtml(o.key) ? 'Show as text' : 'Open', () => openViewer(o)], isHtml(o.key) && ['Open as page ↗', () => openPage(o.key)], ['Download', () => downloadObject(o.key)], ['Share link…', () => share(o.key)], ['Rename…', () => renameItem(o.key)], ['Delete', () => deleteObject(o.key), 'danger']]); + const folderMenu = (f) => itemMenu([['Open', () => go(f)], isHosted() ? ['Share…', () => folderShareDialog(user, bucket, f)] : ['Copy public link', () => quickShare({ prefix: f })], !isHosted() && ['Public links…', () => siaFolderLink(f)], ['Rename…', () => renameItem(f)], ['Delete', () => deleteFolder(f), 'danger']]); + const fileMenu = (o) => itemMenu([[fileKind(o.key) === 'audio' ? 'Play' : isHtml(o.key) ? 'Show as text' : 'Open', () => openViewer(o)], isHtml(o.key) && ['Open as page ↗', () => openPage(o.key)], ['Download', () => downloadObject(o.key)], !isHosted() && ['Copy public link', () => quickShare({ key: o.key })], ['Share options…', () => share(o.key)], ['Rename…', () => renameItem(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))); + iconButton('share', 'Copy public link', () => quickShare({ key: o.key }))); if (mode === 'details') { put(card, h('table', { class: 'files' }, @@ -1773,7 +1782,7 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) { 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'), ' '), + h('button', { class: 'btn small', title: 'Copy a public link', onclick: () => quickShare({ key: o.key }) }, 'Share'), ' '), quickIcons(o), fileMenu(o)))))), more); } else if (mode === 'list') { @@ -2007,12 +2016,40 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) { // A public link anyone can open, at navigate.st/sia: the file is read from // your Sia account through Silent Mode, so it works with this computer off. + // Share in one click: the link already made for this file, folder or + // drive, or a new one valid for 10 years (a Sia share link is signed with + // an end date; Switch off ends it sooner), copied at once. Manage opens + // the full list. Drives on Silent Mode keep their own Share dialog. + async function quickShare({ key, prefix }) { + if (isHosted()) return key != null ? hostedShare(key) : folderShareDialog(user, bucket, prefix); + const folder = key == null; + const mine = (l) => l.bucket === bucket && (folder ? l.key == null && (l.prefix ?? null) === prefix : l.key === key); + const manage = () => (folder ? siaFolderLink(prefix) : share(key)); + const busy = h('div', { class: 'toast' }, h('span', { class: 'spinner' }), ' Making the public link…'); + $('#toasts').append(busy); + try { + let links = []; + try { links = await api('GET', '/api/sia-links'); } catch {} + let link = links.filter(mine).filter((l) => l.until * 1000 > Date.now()).sort((a, b) => b.until - a.until)[0]; + if (!link) link = await api('POST', `${base}/sia-link`, { key, prefix, days: 3650 }); + busy.remove(); + let copied = true; + try { await navigator.clipboard.writeText(link.url); } catch { copied = false; } + actionToast(copied ? 'Public link copied' : 'Public link ready', [['Open', () => window.open(link.url, '_blank', 'noopener')], ['Manage', manage]]); + if (!copied) prompt('Copy:', link.url); + } catch (e) { + busy.remove(); + const msg = folder && /a file is required/.test(e.message) ? 'Pithos is still running its previous engine. Restart Theseus to finish the update, then try again.' : e.message; + actionToast(msg, [['Manage', manage]], 12000).classList.add('error'); + } + } + // The links already made for this file, folder or drive come first, each // with Copy, Open and Switch off; then one line to make a new one. function siaLinkBlock({ key, prefix, heading = false }) { const folder = prefix != null && key == null; const mine = (l) => l.bucket === bucket && (folder ? l.key == null && (l.prefix ?? null) === prefix : l.key === key); - const days = h('select', { 'aria-label': 'Works for' }, [['30', '1 month'], ['365', '1 year'], ['1825', '5 years']].map(([v, l]) => h('option', { value: v, selected: v === '365' }, l))); + const days = h('select', { 'aria-label': 'Works for' }, [['365', '1 year'], ['1825', '5 years'], ['3650', '10 years']].map(([v, l]) => h('option', { value: v, selected: v === '3650' }, l))); const list = h('div', { class: 'stack' }); const err = h('p', { class: 'error small', hidden: true }); const theseusForm = (u) => { try { return `pithos.sia/l/${new URL(u).pathname.replace(/^\/sia\//, '')}`; } catch { return ''; } };