375 lines
17 KiB
JavaScript
375 lines
17 KiB
JavaScript
|
|
// Pithos in the browser: Drives on Silent Mode, opened with a key file.
|
|||
|
|
// Everything is decrypted in this page; see lib/hosted.js for the protocol.
|
|||
|
|
|
|||
|
|
import * as Hosted from './lib/hosted.js';
|
|||
|
|
|
|||
|
|
const $ = (s) => document.querySelector(s);
|
|||
|
|
const h = (tag, attrs = {}, ...kids) => {
|
|||
|
|
const el = document.createElement(tag);
|
|||
|
|
for (const [k, v] of Object.entries(attrs)) {
|
|||
|
|
if (v == null || v === false) continue;
|
|||
|
|
if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
|
|||
|
|
else if (k === 'class') el.className = v;
|
|||
|
|
else el.setAttribute(k, v === true ? '' : v);
|
|||
|
|
}
|
|||
|
|
for (const c of kids.flat(Infinity)) if (c != null && c !== false) el.append(c instanceof Node ? c : String(c));
|
|||
|
|
return el;
|
|||
|
|
};
|
|||
|
|
const put = (el, ...kids) => { el.replaceChildren(); for (const c of kids.flat(Infinity)) if (c != null && c !== false) el.append(c instanceof Node ? c : String(c)); };
|
|||
|
|
|
|||
|
|
function toast(msg, err) {
|
|||
|
|
const t = h('div', { class: `toast${err ? ' err' : ''}` }, msg);
|
|||
|
|
$('#toasts').append(t);
|
|||
|
|
setTimeout(() => t.remove(), err ? 7000 : 3500);
|
|||
|
|
return t;
|
|||
|
|
}
|
|||
|
|
const fail = (e) => toast(e?.message || String(e), true);
|
|||
|
|
|
|||
|
|
function fmtBytes(n) {
|
|||
|
|
if (!Number.isFinite(n)) return '';
|
|||
|
|
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|||
|
|
let i = 0;
|
|||
|
|
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
|||
|
|
return `${n < 10 && i ? n.toFixed(1) : Math.round(n)} ${u[i]}`;
|
|||
|
|
}
|
|||
|
|
const fmtDate = (s) => { const d = new Date(s); return Number.isNaN(+d) ? '' : d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }); };
|
|||
|
|
const baseName = (p) => p.replace(/\/$/, '').split('/').pop();
|
|||
|
|
const KIND = (k) => {
|
|||
|
|
const e = k.split('.').pop().toLowerCase();
|
|||
|
|
if (/^(png|jpe?g|gif|webp|svg|avif)$/.test(e)) return 'image';
|
|||
|
|
if (/^(mp3|ogg|oga|wav|flac|m4a|opus)$/.test(e)) return 'audio';
|
|||
|
|
if (/^(mp4|webm|mov)$/.test(e)) return 'video';
|
|||
|
|
if (/^(txt|md|json|csv|log|xml|yml|yaml|ini|conf|js|css|html?)$/.test(e)) return 'text';
|
|||
|
|
return 'file';
|
|||
|
|
};
|
|||
|
|
const ICON = { folder: '📁', image: '🖼️', audio: '🎵', video: '🎬', text: '📄', file: '📦', drive: '💽' };
|
|||
|
|
|
|||
|
|
// ---- session -----------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
const S = { key: null, token: null, drives: null, user: null, fixedBucket: null, label: '' };
|
|||
|
|
|
|||
|
|
async function signInWith(text) {
|
|||
|
|
const key = await Hosted.readKeyFile(text);
|
|||
|
|
if (key.kind === 'owner') {
|
|||
|
|
const s = await Hosted.signIn(key.identitySeed);
|
|||
|
|
if (s.state !== 'active') throw new Error(s.state === 'empty' ? 'Nothing is on Silent Mode for this account yet. Move your drives there from Pithos first.' : 'This account is being moved; try again in a few minutes.');
|
|||
|
|
const s3 = await Hosted.ownerS3(s.token);
|
|||
|
|
const token = s.token;
|
|||
|
|
let rulesCache = null;
|
|||
|
|
S.token = token;
|
|||
|
|
S.user = s3.user;
|
|||
|
|
S.label = `Signed in · ${s.id.slice(0, 8)}…`;
|
|||
|
|
try { const me = await Hosted.api('GET', '/hosted/v1/me', { token }); if (me.gate?.name) S.label = `Signed in as ${me.gate.name}`; } catch {}
|
|||
|
|
S.drives = new Hosted.Drives({
|
|||
|
|
accessKeyId: s3.accessKeyId, secretKey: s3.secretKey, master: key.encryptionKey,
|
|||
|
|
rules: async () => {
|
|||
|
|
if (!rulesCache || Date.now() - rulesCache.at > 60_000) rulesCache = { at: Date.now(), list: await Hosted.api('GET', '/hosted/v1/rules', { token }).catch(() => []) };
|
|||
|
|
return Array.isArray(rulesCache.list) ? rulesCache.list : rulesCache.list?.rules || [];
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
} else {
|
|||
|
|
if (key.endpoint && new URL(key.endpoint).host !== location.host && !/silentmode\.st$/.test(new URL(key.endpoint).hostname)) {
|
|||
|
|
throw new Error(`This key is for ${new URL(key.endpoint).host}, not Drives on Silent Mode. Use it with Pithos or another S3 app.`);
|
|||
|
|
}
|
|||
|
|
S.drives = new Hosted.Drives({ accessKeyId: key.accessKeyId, secretKey: key.secretKey });
|
|||
|
|
S.fixedBucket = key.bucket || null;
|
|||
|
|
S.label = `S3 key ${key.accessKeyId.slice(0, 6)}…`;
|
|||
|
|
}
|
|||
|
|
S.key = key;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function signedIn() {
|
|||
|
|
$('#signin').hidden = true;
|
|||
|
|
$('#app').hidden = false;
|
|||
|
|
$('#who').textContent = S.label;
|
|||
|
|
$('#who').hidden = false;
|
|||
|
|
$('#signout').hidden = false;
|
|||
|
|
if (!location.hash || location.hash === '#/') location.hash = S.fixedBucket ? `#/d/${encodeURIComponent(S.fixedBucket)}/` : '#/';
|
|||
|
|
route();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function signOut() {
|
|||
|
|
if (S.token) await Hosted.signOut(S.token);
|
|||
|
|
location.hash = '';
|
|||
|
|
location.reload();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---- sign-in screen --------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
async function showSignin() {
|
|||
|
|
$('#app').hidden = true;
|
|||
|
|
$('#signin').hidden = false;
|
|||
|
|
const saved = await Hosted.remembered();
|
|||
|
|
$('#unlock').hidden = !saved;
|
|||
|
|
$('#keyform').hidden = !!saved;
|
|||
|
|
if (saved) $('#unlock-pass').focus();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function signinError(e) {
|
|||
|
|
const el = $('#signin-error');
|
|||
|
|
el.textContent = e?.message || String(e);
|
|||
|
|
el.hidden = false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function trySignIn(text, rememberPass) {
|
|||
|
|
$('#signin-error').hidden = true;
|
|||
|
|
const btns = [$('#go'), ...document.querySelectorAll('#unlock-form button')];
|
|||
|
|
btns.forEach((b) => { b.disabled = true; });
|
|||
|
|
try {
|
|||
|
|
await signInWith(text);
|
|||
|
|
if (rememberPass) { await Hosted.remember(text, rememberPass); }
|
|||
|
|
signedIn();
|
|||
|
|
} catch (e) { signinError(e); } finally { btns.forEach((b) => { b.disabled = false; }); }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let pickedText = null;
|
|||
|
|
async function pickFile(file) {
|
|||
|
|
if (!file) return;
|
|||
|
|
if (file.size > 64 * 1024) return signinError(new Error('That file is too big to be a key file.'));
|
|||
|
|
pickedText = await file.text();
|
|||
|
|
$('#drop strong').textContent = `Key: ${file.name}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function wireSignin() {
|
|||
|
|
const drop = $('#drop');
|
|||
|
|
drop.addEventListener('click', () => $('#keyfile').click());
|
|||
|
|
drop.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); $('#keyfile').click(); } });
|
|||
|
|
$('#keyfile').addEventListener('change', () => pickFile($('#keyfile').files[0]));
|
|||
|
|
drop.addEventListener('dragover', (e) => { e.preventDefault(); drop.classList.add('over'); });
|
|||
|
|
drop.addEventListener('dragleave', () => drop.classList.remove('over'));
|
|||
|
|
drop.addEventListener('drop', (e) => { e.preventDefault(); drop.classList.remove('over'); pickFile(e.dataTransfer.files[0]); });
|
|||
|
|
$('#remember').addEventListener('change', () => { $('#pass-row').hidden = !$('#remember').checked; });
|
|||
|
|
$('#go').addEventListener('click', () => {
|
|||
|
|
const text = pickedText || $('#keytext').value.trim();
|
|||
|
|
if (!text) return signinError(new Error('Choose your key file first, or paste it.'));
|
|||
|
|
let pass = null;
|
|||
|
|
if ($('#remember').checked) {
|
|||
|
|
pass = $('#pass').value;
|
|||
|
|
if (pass.length < 10) return signinError(new Error('Use a passphrase of at least 10 characters to remember the key.'));
|
|||
|
|
}
|
|||
|
|
trySignIn(text, pass);
|
|||
|
|
});
|
|||
|
|
$('#unlock-form').addEventListener('submit', async (e) => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
try {
|
|||
|
|
const text = await Hosted.recall($('#unlock-pass').value);
|
|||
|
|
if (!text) return showSignin();
|
|||
|
|
await trySignIn(text, null);
|
|||
|
|
} catch (err) { signinError(err); }
|
|||
|
|
});
|
|||
|
|
$('#forget').addEventListener('click', async () => {
|
|||
|
|
await Hosted.forget();
|
|||
|
|
$('#unlock').hidden = true;
|
|||
|
|
$('#keyform').hidden = false;
|
|||
|
|
});
|
|||
|
|
$('#signout').addEventListener('click', signOut);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---- routes: #/ (drives) · #/d/<drive>/<folder>/ -----------------------------------------------
|
|||
|
|
|
|||
|
|
function current() {
|
|||
|
|
const m = /^#\/d\/([^/]+)\/(.*)$/.exec(location.hash);
|
|||
|
|
if (!m) return { bucket: null, prefix: '' };
|
|||
|
|
const prefix = m[2].split('/').map((s) => { try { return decodeURIComponent(s); } catch { return s; } }).join('/');
|
|||
|
|
return { bucket: decodeURIComponent(m[1]), prefix };
|
|||
|
|
}
|
|||
|
|
const linkTo = (bucket, prefix = '') => `#/d/${encodeURIComponent(bucket)}/${prefix.split('/').map(encodeURIComponent).join('/')}`;
|
|||
|
|
|
|||
|
|
let routeSeq = 0;
|
|||
|
|
async function route() {
|
|||
|
|
if (!S.drives) return;
|
|||
|
|
const seq = ++routeSeq;
|
|||
|
|
const { bucket, prefix } = current();
|
|||
|
|
if (!bucket) return showDrives(seq);
|
|||
|
|
return showFolder(seq, bucket, prefix);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function crumbs(bucket, prefix) {
|
|||
|
|
const parts = [];
|
|||
|
|
if (!S.fixedBucket) parts.push(h('a', { href: '#/' }, 'Drives'));
|
|||
|
|
if (bucket) {
|
|||
|
|
if (parts.length) parts.push(h('span', { class: 'sep' }, '›'));
|
|||
|
|
parts.push(h('a', { href: linkTo(bucket) }, `${ICON.drive} ${bucket}`));
|
|||
|
|
let acc = '';
|
|||
|
|
for (const seg of prefix.split('/').filter(Boolean)) {
|
|||
|
|
acc += seg + '/';
|
|||
|
|
parts.push(h('span', { class: 'sep' }, '›'), h('a', { href: linkTo(bucket, acc) }, seg));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
put($('#crumbs'), parts);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function showDrives(seq) {
|
|||
|
|
crumbs(null, '');
|
|||
|
|
$('#toolbar').hidden = true;
|
|||
|
|
put($('#list'), h('p', { class: 'empty' }, 'Loading…'));
|
|||
|
|
try {
|
|||
|
|
const list = await S.drives.listBuckets();
|
|||
|
|
if (seq !== routeSeq) return;
|
|||
|
|
if (!list.length) return put($('#list'), h('p', { class: 'empty' }, 'No drives yet. Create one in Pithos.'));
|
|||
|
|
put($('#list'), h('table', { class: 'files' },
|
|||
|
|
h('thead', {}, h('tr', {}, h('th', {}, 'Drive'), h('th', { class: 'hide-sm' }, 'Created'))),
|
|||
|
|
h('tbody', {}, list.map((b) => h('tr', { class: 'clickable', onclick: () => { location.hash = linkTo(b.name); } },
|
|||
|
|
h('td', {}, h('span', { class: 'name' }, h('span', { class: 'ico' }, ICON.drive), h('span', { class: 'label' }, b.name))),
|
|||
|
|
h('td', { class: 'num hide-sm' }, fmtDate(b.created)))))));
|
|||
|
|
$('#count').textContent = '';
|
|||
|
|
} catch (e) {
|
|||
|
|
if (seq === routeSeq) put($('#list'), h('p', { class: 'empty error' }, e.message));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function showFolder(seq, bucket, prefix) {
|
|||
|
|
crumbs(bucket, prefix);
|
|||
|
|
$('#toolbar').hidden = false;
|
|||
|
|
put($('#list'), h('p', { class: 'empty' }, 'Loading…'));
|
|||
|
|
let l;
|
|||
|
|
try {
|
|||
|
|
l = await S.drives.list(bucket, prefix);
|
|||
|
|
} catch (e) {
|
|||
|
|
// a folder key may only list its own folder: go there
|
|||
|
|
const only = /may only list (.+)$/.exec(e.message || '');
|
|||
|
|
if (only && !prefix && seq === routeSeq) { location.hash = linkTo(bucket, only[1]); return; }
|
|||
|
|
if (seq === routeSeq) put($('#list'), h('p', { class: 'empty error' }, e.message));
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if (seq !== routeSeq) return;
|
|||
|
|
const rows = [];
|
|||
|
|
for (const f of l.folders) {
|
|||
|
|
rows.push(h('tr', { class: 'clickable', onclick: () => { location.hash = linkTo(bucket, f.path); } },
|
|||
|
|
h('td', {}, h('span', { class: 'name' }, h('span', { class: 'ico' }, ICON.folder), h('span', { class: 'label' }, baseName(f.path)), f.locked && h('span', { class: 'lock' }, 'encrypted'))),
|
|||
|
|
h('td', { class: 'num hide-sm' }, ''), h('td', { class: 'num hide-sm' }, ''),
|
|||
|
|
h('td', { class: 'actions', onclick: (e) => e.stopPropagation() },
|
|||
|
|
!S.fixedBucket || S.key.kind === 'owner' ? h('button', { class: 'btn small danger', onclick: () => deleteFolder(bucket, f.path) }, 'Delete') : null)));
|
|||
|
|
}
|
|||
|
|
for (const o of l.files) {
|
|||
|
|
const kind = KIND(o.key);
|
|||
|
|
const open = o.locked ? null : () => view(bucket, o);
|
|||
|
|
rows.push(h('tr', { class: open ? 'clickable' : '', onclick: open },
|
|||
|
|
h('td', {}, h('span', { class: 'name' }, h('span', { class: 'ico' }, ICON[kind]), h('span', { class: 'label', title: o.key }, baseName(o.key)), o.locked && h('span', { class: 'lock' }, 'encrypted'))),
|
|||
|
|
h('td', { class: 'num hide-sm' }, fmtBytes(o.size)),
|
|||
|
|
h('td', { class: 'num hide-sm' }, fmtDate(o.modified)),
|
|||
|
|
h('td', { class: 'actions', onclick: (e) => e.stopPropagation() },
|
|||
|
|
!o.locked && h('button', { class: 'btn small', onclick: () => download(bucket, o.key) }, 'Download'), ' ',
|
|||
|
|
!o.locked && h('button', { class: 'btn small', onclick: () => rename(bucket, o.key) }, 'Rename'), ' ',
|
|||
|
|
h('button', { class: 'btn small danger', onclick: () => remove(bucket, o.key) }, 'Delete'))));
|
|||
|
|
}
|
|||
|
|
$('#count').textContent = `${l.folders.length} folders · ${l.files.length} files`;
|
|||
|
|
if (!rows.length) return put($('#list'), h('p', { class: 'empty' }, 'This folder is empty. Drop files here or press Upload.'));
|
|||
|
|
put($('#list'), h('table', { class: 'files' },
|
|||
|
|
h('thead', {}, h('tr', {}, h('th', {}, 'Name'), h('th', { class: 'hide-sm' }, 'Size'), h('th', { class: 'hide-sm' }, 'Modified'), h('th', {}))),
|
|||
|
|
h('tbody', {}, rows)));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---- actions ------------------------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
async function uploadFiles(files) {
|
|||
|
|
const { bucket, prefix } = current();
|
|||
|
|
if (!bucket || !files.length) return;
|
|||
|
|
for (const file of files) {
|
|||
|
|
const bar = h('progress', { max: file.size || 1, value: 0 });
|
|||
|
|
const t = h('div', { class: 'toast' }, `Uploading ${file.name}`, bar);
|
|||
|
|
$('#toasts').append(t);
|
|||
|
|
try {
|
|||
|
|
await S.drives.write(bucket, prefix + file.name, file, (n) => { bar.value = n; });
|
|||
|
|
t.replaceChildren(`Uploaded ${file.name}`);
|
|||
|
|
} catch (e) { t.classList.add('err'); t.replaceChildren(`${file.name}: ${e.message}`); }
|
|||
|
|
setTimeout(() => t.remove(), 4000);
|
|||
|
|
}
|
|||
|
|
route();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function newFolder() {
|
|||
|
|
const { bucket, prefix } = current();
|
|||
|
|
const name = (prompt('Folder name') || '').trim();
|
|||
|
|
if (!name) return;
|
|||
|
|
if (name.includes('/')) return fail(new Error('A folder name cannot contain /'));
|
|||
|
|
try { await S.drives.mkdir(bucket, `${prefix}${name}/`); route(); } catch (e) { fail(e); }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function saveBlob(blob, name) {
|
|||
|
|
const url = URL.createObjectURL(blob);
|
|||
|
|
const a = h('a', { href: url, download: name });
|
|||
|
|
document.body.append(a);
|
|||
|
|
a.click();
|
|||
|
|
a.remove();
|
|||
|
|
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function download(bucket, key) {
|
|||
|
|
const t = toast(`Preparing ${baseName(key)}…`);
|
|||
|
|
try { saveBlob(await S.drives.blob(bucket, key), baseName(key)); t.remove(); } catch (e) { t.remove(); fail(e); }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function rename(bucket, key) {
|
|||
|
|
const name = (prompt('New name', baseName(key)) || '').trim();
|
|||
|
|
if (!name || name === baseName(key)) return;
|
|||
|
|
if (name.includes('/')) return fail(new Error('A name cannot contain /'));
|
|||
|
|
const t = toast(`Renaming ${baseName(key)}…`);
|
|||
|
|
try { await S.drives.rename(bucket, key, key.slice(0, key.length - baseName(key).length) + name); t.remove(); route(); } catch (e) { t.remove(); fail(e); }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function remove(bucket, key) {
|
|||
|
|
if (!confirm(`Delete ${baseName(key)}? This cannot be undone.`)) return;
|
|||
|
|
try { await S.drives.remove(bucket, key); toast('Deleted'); route(); } catch (e) { fail(e); }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function deleteFolder(bucket, prefix) {
|
|||
|
|
if (!confirm(`Delete the folder ${baseName(prefix)} and everything in it? This cannot be undone.`)) return;
|
|||
|
|
const t = toast(`Deleting ${baseName(prefix)}…`);
|
|||
|
|
try { await S.drives.removeFolder(bucket, prefix); t.remove(); toast('Folder deleted'); route(); } catch (e) { t.remove(); fail(e); }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---- viewer ---------------------------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
let viewerUrl = null;
|
|||
|
|
async function view(bucket, o) {
|
|||
|
|
const kind = KIND(o.key);
|
|||
|
|
if (kind === 'file') return download(bucket, o.key);
|
|||
|
|
const body = $('#viewer-body');
|
|||
|
|
$('#viewer-name').textContent = baseName(o.key);
|
|||
|
|
put(body, h('p', { class: 'muted' }, 'Opening…'));
|
|||
|
|
$('#viewer').hidden = false;
|
|||
|
|
$('#viewer-dl').onclick = () => download(bucket, o.key);
|
|||
|
|
try {
|
|||
|
|
let src;
|
|||
|
|
if (!o.encrypted && (kind === 'audio' || kind === 'video')) src = await S.drives.presigned(bucket, o.storageKey);
|
|||
|
|
else {
|
|||
|
|
if (kind !== 'text' && o.size > 300 * 1024 * 1024) throw new Error('This file is too large to open in the browser; download it instead.');
|
|||
|
|
const blob = await S.drives.blob(bucket, o.key);
|
|||
|
|
if (kind === 'text') {
|
|||
|
|
if (blob.size > 2 * 1024 * 1024) throw new Error('This text file is too large to show; download it instead.');
|
|||
|
|
return put(body, h('pre', {}, await blob.text()));
|
|||
|
|
}
|
|||
|
|
viewerUrl = URL.createObjectURL(blob);
|
|||
|
|
src = viewerUrl;
|
|||
|
|
}
|
|||
|
|
if (kind === 'image') put(body, h('img', { src, alt: baseName(o.key) }));
|
|||
|
|
else if (kind === 'audio') put(body, h('audio', { src, controls: true, autoplay: true }));
|
|||
|
|
else put(body, h('video', { src, controls: true, autoplay: true }));
|
|||
|
|
} catch (e) { put(body, h('p', { class: 'error' }, e.message)); }
|
|||
|
|
}
|
|||
|
|
function closeViewer() {
|
|||
|
|
$('#viewer').hidden = true;
|
|||
|
|
put($('#viewer-body'));
|
|||
|
|
if (viewerUrl) { URL.revokeObjectURL(viewerUrl); viewerUrl = null; }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---- boot ------------------------------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
function wireApp() {
|
|||
|
|
$('#upload').addEventListener('click', () => $('#files').click());
|
|||
|
|
$('#files').addEventListener('change', () => { uploadFiles([...$('#files').files]); $('#files').value = ''; });
|
|||
|
|
$('#newfolder').addEventListener('click', newFolder);
|
|||
|
|
const list = $('#list');
|
|||
|
|
list.addEventListener('dragover', (e) => { if (current().bucket) { e.preventDefault(); list.classList.add('over'); } });
|
|||
|
|
list.addEventListener('dragleave', () => list.classList.remove('over'));
|
|||
|
|
list.addEventListener('drop', (e) => { e.preventDefault(); list.classList.remove('over'); uploadFiles([...e.dataTransfer.files]); });
|
|||
|
|
$('#viewer-close').addEventListener('click', closeViewer);
|
|||
|
|
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !$('#viewer').hidden) closeViewer(); });
|
|||
|
|
window.addEventListener('hashchange', route);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
wireSignin();
|
|||
|
|
wireApp();
|
|||
|
|
showSignin().catch(fail);
|