'use strict'; // Streamable-style library, watch page and uploads in a 2003 Steam skin. // Talks to intake's /_site/api; uploads go straight to intake's UPLOAD_URL (cfg.uploadUrl). const app = document.getElementById('app'); const dialogs = document.getElementById('dialogs'); const BLOCK = 12; // one 4px gap + one 8px block, like Steam's VGUI ProgressBar let cfg = { uploadUrl: '', publicUrl: '', uploadHint: '' }; // SITE_NAME and SERVER_NAME, filled into index.html by intake const SITE = document.title, BRAND = SITE.split(' ')[0], SERVER = document.documentElement.dataset.server; let me = null; const state = { files: [], page: 0, pages: 1, total: null }; function h(tag, attrs = {}, ...kids) { const el = document.createElement(tag); for (const [k, v] of Object.entries(attrs)) { if (k.startsWith('on')) el.addEventListener(k.slice(2), v); else if (k === 'class') el.className = v; else if (k === 'style') el.style.cssText = v; else if (k === 'value') el.value = v; else if (v !== false && v != null) el.setAttribute(k, v === true ? '' : v); } for (const kid of kids.flat(Infinity)) if (kid != null && kid !== false) el.append(kid.nodeType ? kid : String(kid)); return el; } class LoggedOut extends Error {} async function api(path, opts = {}) { // no-store: answers differ per user, so never reuse one from the browser cache const init = { method: opts.method || 'GET', credentials: 'same-origin', cache: 'no-store', headers: {} }; if (opts.body !== undefined) { init.headers['content-type'] = 'application/json'; init.body = JSON.stringify(opts.body); } let r; try { r = await fetch('/_site/api' + path, init); } catch { throw new Error(`Lost connection to ${SERVER}.`); } let data = {}; try { data = await r.json(); } catch {} if (r.status === 401 && path !== '/login') throw new LoggedOut(); if (!r.ok) throw new Error(data.error || 'Error ' + r.status); return data; } const fmtSize = (b) => { const u = ['B', 'KB', 'MB', 'GB']; let i = 0; while (b >= 1024 && i < 3) { b /= 1024; i++; } return (i ? b.toFixed(1) : b) + ' ' + u[i]; }; const fmtDate = (s) => new Date(s).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }); const daysLeft = (s) => (s ? Math.max(0, Math.ceil((new Date(s) - Date.now()) / 86400000)) : null); const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`; const media = (path) => cfg.publicUrl + path; // the link to paste in Discord: /v/ gives Discord a player (and a small copy for big clips) const isVideo = (name) => /\.(mp4|mov|m4v|webm)$/i.test(name); const shareLink = (name, raw) => isVideo(name) ? cfg.publicUrl + '/v/' + encodeURIComponent(name) : raw; const wait = (ms) => new Promise((r) => setTimeout(r, ms)); // the old Steam block bar: whole blocks only, added one at a time until they catch up with the real progress function sbar() { const bar = h('div', { class: 'sbar' }, h('div', { class: 'fill' })); bar.shown = 0; bar.target = 0; bar.tick = null; return bar; } function setBar(bar, frac) { const total = Math.max(1, Math.floor(bar.clientWidth / BLOCK)); bar.target = Math.floor(total * Math.min(1, Math.max(0, frac))); if (bar.target < bar.shown) bar.shown = bar.target; // a new file starts again from empty bar.firstChild.style.width = bar.shown * BLOCK + 'px'; if (!bar.tick) bar.tick = setInterval(() => { if (bar.shown < bar.target) bar.shown++; else { clearInterval(bar.tick); bar.tick = null; } bar.firstChild.style.width = bar.shown * BLOCK + 'px'; }, 30); } const barFull = (bar) => new Promise((r) => { const t = setInterval(() => { if (!bar.tick) { clearInterval(t); r(); } }, 30); }); function dialog(title, body, { clear = false } = {}) { const box = h('div', { class: 'dlg raised' }, h('div', { class: 'title' }, title), h('div', { class: 'body' }, body)); const ov = h('div', { class: 'overlay' + (clear ? ' clear' : '') }, box); dialogs.append(ov); return { box, close: () => ov.remove() }; } function shake(box) { box.classList.remove('shake'); void box.offsetWidth; box.classList.add('shake'); } function alertBox(title, msg) { return new Promise((res) => { const ok = h('button', { class: 'btn y', onclick: () => { d.close(); res(); } }, 'OK'); const d = dialog(title, [h('div', { class: 'msg' }, msg), h('div', { class: 'row' }, ok)]); ok.focus(); }); } function confirmBox(title, msg, yes) { return new Promise((res) => { const d = dialog(title, [h('div', { class: 'msg' }, msg), h('div', { class: 'row' }, h('button', { class: 'btn', onclick: () => { d.close(); res(false); } }, 'Cancel'), h('button', { class: 'btn y', onclick: () => { d.close(); res(true); } }, yes))]); }); } // check(value) resolves to an error message, or null to close the box function promptBox(title, label, value, check) { return new Promise((res) => { const input = h('input', { class: 'field', value }); const err = h('div', { class: 'err' }); const go = async () => { const e = await check(input.value.trim()); if (e) { err.textContent = e; shake(d.box); } else { d.close(); res(true); } }; input.addEventListener('keydown', (e) => { if (e.key === 'Enter') go(); if (e.key === 'Escape') { d.close(); res(false); } }); const d = dialog(title, [h('label', {}, label), input, err, h('div', { class: 'row' }, h('button', { class: 'btn', onclick: () => { d.close(); res(false); } }, 'Cancel'), h('button', { class: 'btn y', onclick: go }, 'OK'))]); input.focus(); input.select(); }); } function toast(msg) { const t = h('div', { class: 'toast raised' }, msg); document.body.append(t); setTimeout(() => t.remove(), 1600); } // Discord plays a big clip only from its small copy and caches whatever it sees first: on the clip page, a link // whose copy is still being made shows "preparing for Discord... 64%" instead, with Copy off async function holdForDiscord(name, el, show) { if (!isVideo(name)) return show(true); show(false, 'checking Discord...'); for (let i = 0; i < 3600; i++) { let s = { state: 'small' }; try { s = await api('/discord/' + encodeURIComponent(name)); } catch {} if (s.state !== 'making') return show(true, s.state === 'none' ? "Too long for Discord's player: the link opens the clip, but won't play in the chat." : ''); show(false, 'preparing for Discord... ' + discordNote(s)); await wait(1000); if (!el.isConnected) return; // left the page } show(true); } async function copy(text) { try { await navigator.clipboard.writeText(text); toast('Link copied'); } catch { await promptBox('Copy link', 'Copy this link:', text, async () => null); } } // static markup only (h() can't make SVG elements) function svg(markup) { const t = document.createElement('template'); t.innerHTML = markup; return t.content.firstChild; } // full-screen login; the window buttons are only for the look function loginDialog() { return new Promise((res) => { const user = h('input', { class: 'field', id: 'lu', autocomplete: 'username' }); const pass = h('input', { class: 'field', id: 'lp', type: 'password', autocomplete: 'current-password' }); const err = h('div', { class: 'err' }); const bar = sbar(); const conn = h('div', { style: 'display:none' }, h('div', {}, `Connecting to ${SERVER}...`), bar); const form = h('form', { onsubmit: async (e) => { e.preventDefault(); err.textContent = ''; form.style.display = 'none'; conn.style.display = ''; bar.shown = 0; setBar(bar, 0.6); try { await api('/login', { method: 'POST', body: { username: user.value.trim(), password: pass.value } }); setBar(bar, 1); await barFull(bar); screen.remove(); res(); } catch (x) { conn.style.display = 'none'; form.style.display = ''; err.textContent = x.message; shake(win); pass.select(); } } }, h('label', { for: 'lu' }, 'Account name'), user, h('label', { for: 'lp' }, 'Password'), pass, err, h('div', { class: 'row' }, h('button', { class: 'btn y', type: 'submit' }, 'Log in'))); const win = h('div', { class: 'win' }, h('div', { class: 'title' }, h('i', {}, BRAND + ' · Login'), h('u', {}, '_'), h('u', {}, '□'), h('u', {}, '×')), h('div', { class: 'body' }, h('div', { class: 'brand' }, svg(''), h('strong', {}, SITE)), form, conn)); const screen = h('div', { class: 'login' }, h('header', {}, h('b', {}, BRAND), h('div', { class: 'sp' }), h('span', {}, `Connected to ${SERVER}`), h('span', { class: 'hide' }, 'Invite only'), h('span', {}, 'v1.0')), h('div', { class: 'mark-word', 'aria-hidden': 'true' }, BRAND), svg(''), h('main', {}, win)); dialogs.append(screen); user.focus(); }); } function shell(content, sideOn) { if (!me) { // a visitor from a shared link: the clip, the look, and a way in for account holders const top = h('div', { class: 'topbar' }, h('a', { class: 'logo', href: '/' }, h('i'), SITE), h('div', { class: 'user' }, h('button', { class: 'btn', onclick: async () => { await loginDialog(); boot(); } }, 'Log in'))); app.replaceChildren(h('div', { class: 'shell' }, top, h('div', { class: 'main' }, h('div', { class: 'content' }, h('div', { class: 'wrap' }, content))), h('div', { class: 'statusbar' }, h('span', {}, 'connected · ' + SERVER), h('span', {}, 'guest')))); return; } const side = h('div', { class: 'side' }, h('div', { class: 'item' + (sideOn === 'all' ? ' on' : ''), onclick: () => go('/') }, '▣ All videos', state.total != null ? ` (${state.total})` : ''), h('div', { class: 'lbl' }, 'STORAGE'), h('div', { class: 'small' }, fmtSize(me.storageUsed || 0) + ' used')); const menu = h('div', { class: 'menu raised', style: 'display:none' }, h('div', { onclick: logout }, 'Log out')); const top = h('div', { class: 'topbar' }, h('button', { class: 'btn burger', onclick: () => side.classList.toggle('open') }, '☰'), h('a', { class: 'logo', href: '/', onclick: (e) => { e.preventDefault(); go('/'); } }, h('i'), SITE), h('div', { class: 'user' }, h('button', { class: 'btn', onclick: () => { menu.style.display = menu.style.display === 'none' ? '' : 'none'; } }, me.username + ' ▾'), menu)); app.replaceChildren(h('div', { class: 'shell' }, top, h('div', { class: 'main' }, side, h('div', { class: 'content' }, h('div', { class: 'wrap' }, content))), h('div', { class: 'statusbar' }, h('span', {}, 'connected · ' + SERVER), h('span', {}, me.username)))); } async function loadPage() { const d = await api('/files?page=' + (state.page + 1)); state.page++; state.pages = d.pages || 1; state.total = d.total; state.files.push(...d.files); } function card(f) { const left = daysLeft(f.deletesAt); return h('div', { class: 'card raised', onclick: () => go('/v/' + encodeURIComponent(f.name)) }, h('div', { class: 'thumb sunken', style: f.thumbnail ? `background-image:url("${media(f.thumbnail)}")` : '' }, f.thumbnail ? '' : '▶'), h('div', { class: 't', title: f.name }, f.name), h('div', { class: 'm' }, [fmtDate(f.createdAt), plural(f.views, 'view'), left != null ? left + 'd left' : null].filter(Boolean).join(' · '))); } function preload(files, progress) { const urls = files.map((f) => f.thumbnail).filter(Boolean).slice(0, 12); if (!urls.length) { progress(1); return Promise.resolve(); } let n = 0; const one = (u) => new Promise((r) => { const img = new Image(); img.onload = img.onerror = () => { progress(++n / urls.length); r(); }; img.src = media(u); }); return Promise.race([Promise.all(urls.map(one)), wait(4000)]); } async function libraryView(progress) { state.files = []; state.page = 0; await loadPage(); if (progress) await preload(state.files, progress); renderLibrary(); } function renderLibrary() { const picker = h('input', { type: 'file', multiple: true, style: 'display:none', onchange: () => { const f = [...picker.files]; picker.value = ''; uploadFiles(f); } }); const more = state.page < state.pages ? h('div', { class: 'more' }, h('button', { class: 'btn', onclick: async (e) => { e.target.disabled = true; try { await loadPage(); renderLibrary(); } catch (x) { handleError(x); } } }, 'Load more')) : null; const body = state.files.length ? [h('div', { class: 'grid' }, state.files.map(card)), more] : h('div', { class: 'empty raised', onclick: () => picker.click() }, h('div', { class: 'icon sunken' }, '⬆'), h('div', { class: 'big' }, 'Select videos to upload'), h('div', { class: 'dim' }, 'Or drag and drop video files')); shell([picker, h('div', { class: 'uploadbar raised' }, h('button', { class: 'btn y', onclick: () => picker.click() }, '⬆ Upload video'), h('div', { class: 'hint sunken' }, 'or drop files anywhere · max 5 GB · .mkv becomes .mp4')), h('h2', { class: 'count' }, 'All videos ', h('span', {}, `(${state.total ?? 0})`)), body], 'all'); } async function watchView(name) { let f = null; if (me) { try { f = await api('/files/' + encodeURIComponent(name)); } catch (e) { if (e instanceof LoggedOut || !/doesn't exist/.test(e.message)) throw e; } } // not logged in, or someone else's clip: everyone may watch a shared link if (!f) return publicWatchView(await api('/public/' + encodeURIComponent(name))); if (state.total == null) { try { state.total = (await api('/files?page=1')).total; } catch {} } const left = daysLeft(f.deletesAt); const link = media(f.url); const share = shareLink(f.name, link); const copyBtn = h('button', { class: 'btn y', onclick: () => copy(share) }, '⧉ Copy link'); const shown = h('div', { class: 'link sunken' }, share), note = h('div', { class: 'small' }); const player = (f.type || '').startsWith('image/') ? h('img', { class: 'sunken', src: link, alt: f.name }) : h('video', { class: 'sunken', src: link, controls: true, preload: 'metadata', poster: f.thumbnail ? media(f.thumbnail) : false }); shell([ h('button', { class: 'btn back', onclick: () => go('/') }, '◂ All videos'), h('div', { class: 'watch' }, player, h('h1', {}, f.name), h('div', { class: 'info' }, [fmtDate(f.createdAt), plural(f.views, 'view'), fmtSize(f.size), left != null ? `deletes in ${plural(left, 'day')}` : null].filter(Boolean).join(' · ')), h('div', { class: 'actions' }, copyBtn, h('a', { class: 'btn', href: link + '?download=true', download: f.name }, '⬇ Download'), h('button', { class: 'btn', onclick: () => rename(f) }, '✎ Rename'), h('button', { class: 'btn right', onclick: () => remove(f) }, '✖ Delete')), shown, note)], null); holdForDiscord(f.name, shown, (ready, msg = '') => { copyBtn.disabled = !ready; shown.textContent = ready ? share : msg; note.textContent = ready ? msg : ''; }); } function publicWatchView(f) { const link = media(f.url); const share = shareLink(f.name, link); const player = (f.type || '').startsWith('image/') ? h('img', { class: 'sunken', src: link, alt: f.name }) : h('video', { class: 'sunken', src: link, controls: true, preload: 'metadata', poster: f.thumbnail ? media(f.thumbnail) : false }); shell([ me ? h('button', { class: 'btn back', onclick: () => go('/') }, '◂ All videos') : null, h('div', { class: 'watch' }, player, h('h1', {}, f.name), h('div', { class: 'info' }, fmtSize(f.size)), h('div', { class: 'actions' }, h('button', { class: 'btn y', onclick: () => copy(share) }, '⧉ Copy link'), h('a', { class: 'btn', href: link + '?download=true', download: f.name }, '⬇ Download')), h('div', { class: 'link sunken' }, share))], null); } async function rename(f) { const ext = (f.name.match(/\.[^.]+$/) || [''])[0]; let renamed = null; const ok = await promptBox('Rename', 'New name', f.name, async (v) => { if (!v) return 'Type a name.'; if (ext && !v.toLowerCase().endsWith(ext.toLowerCase())) v += ext; if (v === f.name) return null; try { renamed = (await api('/files/' + encodeURIComponent(f.id), { method: 'PATCH', body: { name: v } })).name; return null; } catch (e) { if (e instanceof LoggedOut) { location.reload(); return null; } return e.message; } }); if (ok && renamed) go('/v/' + encodeURIComponent(renamed), true); } async function remove(f) { if (!(await confirmBox('Delete clip', `Delete ${f.name}? The link stops working.`, 'Delete'))) return; try { await api('/files/' + encodeURIComponent(f.id), { method: 'DELETE' }); toast('Deleted'); go('/'); } catch (e) { handleError(e); } } function send(file, ticket, onProgress, onSent) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); const form = new FormData(); form.append('file', file, file.name); const t0 = Date.now(); let sentAny = false; xhr.open('POST', ticket.uploadUrl + '/upload'); xhr.setRequestHeader('x-upload-ticket', ticket.ticket); xhr.upload.onprogress = (e) => { if (!e.lengthComputable) return; sentAny = sentAny || e.loaded > 0; const rate = e.loaded / Math.max((Date.now() - t0) / 1000, 0.001); const left = rate > 0 ? Math.round((e.total - e.loaded) / rate) : 0; onProgress(xhr, e.loaded / e.total, `Sending to ${SERVER} · ${fmtSize(e.loaded)} / ${fmtSize(e.total)} · ${fmtSize(rate)}/s · ${left}s left`); }; xhr.upload.onload = onSent; xhr.onload = () => { let data = {}; try { data = JSON.parse(xhr.responseText); } catch {} if (xhr.status === 200) resolve(data); else reject(new Error(data.error || `Upload failed (${xhr.status})`)); }; xhr.onerror = () => reject(new Error(sentAny ? `Lost connection to ${SERVER}.` : cfg.uploadHint)); xhr.onabort = () => reject(new Error('Cancelled')); onProgress(xhr, 0, `Connecting to ${SERVER}...`); xhr.send(form); }); } // what /discord says about a clip, worded for the dialog function discordNote(s) { if (s.state === 'small') return 'plays as is'; if (s.state === 'ready') return 'ready'; if (s.state === 'none') return "too long for Discord's player"; if (s.queued) return 'waiting for another clip...'; return s.progress >= 0.99 ? 'finishing...' : Math.round(s.progress * 100) + '%'; } const fmtLeft = (s) => s < 60 ? `~${Math.max(1, Math.round(s))} s` : `~${Math.round(s / 60)} min`; const linkName = (r) => r.name + (r.remuxed ? ' (converted to .mp4)' : /\.mkv$/i.test(r.name) ? " (couldn't convert to .mp4, kept as .mkv: Discord may not play it inline)" : ''); // the upload dialog walks through three steps, like Steam installing a game: // send each file to the server, wait for the small Discord copies, then hand out the links. // Discord caches whatever it sees first, so a link is only shown once its clip will play there. async function uploadFiles(files) { if (!files.length) return; const bar = sbar(), line = h('div', { class: 'small' }), sub = h('div', { class: 'small' }), extra = h('div'); const steps = ['Send to ' + SERVER, 'Prepare for Discord', 'Link ready'].map((label) => { const mark = h('i', {}, '·'), right = h('span', { class: 'r' }); const row = h('div', {}, mark, h('span', {}, label), right); row.set = (state, text = '') => { row.className = state; mark.textContent = { done: '✓', now: '▸' }[state] || '·'; right.textContent = text; }; return row; }); let xhr = null, cancelled = false, failure = null; const btn = h('button', { class: 'btn', onclick: () => { cancelled = true; if (xhr) xhr.abort(); } }, 'Cancel'); const d = dialog('Uploading', [h('div', { class: 'steps' }, steps), line, bar, sub, extra, h('div', { class: 'row' }, btn)]); d.box.classList.add('install'); const title = d.box.querySelector('.title'); const done = []; // step 1: send the files, one at a time (keeps the server's CPU and upload link calm) for (let i = 0; i < files.length && !cancelled; i++) { const file = files[i]; title.textContent = 'Uploading · ' + file.name; steps[0].set('now', files.length > 1 ? `${i + 1} of ${files.length}` : fmtSize(file.size)); setBar(bar, 0); sub.textContent = /\.mkv$/i.test(file.name) ? 'then: converting .mkv to .mp4' : ''; btn.disabled = false; try { const ticket = await api('/upload-ticket', { method: 'POST' }); done.push(await send(file, ticket, (x, frac, text) => { xhr = x; setBar(bar, frac); line.textContent = text; }, () => { setBar(bar, 1); line.textContent = `Processing on ${SERVER}...`; btn.disabled = true; })); } catch (e) { if (!cancelled) failure = `${file.name}: ${e instanceof LoggedOut ? 'You were logged out. Log in again.' : e.message}`; break; } } if (!done.length) { d.close(); if (failure) await alertBox('Upload failed', failure); return go(location.pathname, true); } steps[0].set('done', done.length > 1 ? plural(done.length, 'clip') : fmtSize(files[0].size)); // step 2: wait until Discord can play every clip (closing early is fine: the copies get made anyway) await barFull(bar); title.textContent = 'Preparing for Discord'; steps[1].set('now'); sub.textContent = ''; setBar(bar, 0); line.textContent = 'Discord only waits ~8 s for a video, so big clips get a small 720p copy just for it. Your original stays full quality.'; let closed = false; btn.textContent = 'Close'; btn.disabled = false; btn.onclick = () => { closed = true; d.close(); }; const perClip = done.map((r) => h('div', { class: 'clip' }, h('span', {}, r.name), h('span', { class: 'r' }))); if (done.length > 1) extra.replaceChildren(h('div', { class: 'clips sunken' }, perClip)); const status = done.map(() => ({ state: 'making', progress: 0, queued: true })); let first = null; for (;;) { await Promise.all(done.map(async (r, i) => { if (status[i].state !== 'making') return; if (!isVideo(r.name)) { status[i] = { state: 'small', progress: 1, queued: false }; return; } try { status[i] = await api('/discord/' + encodeURIComponent(r.name)); } catch { status[i] = { state: 'small', progress: 1, queued: false }; } // can't ask: don't hold the link forever })); if (closed) return go(location.pathname, true); const making = status.filter((s) => s.state === 'making'); const frac = status.reduce((a, s) => a + (s.state === 'making' ? s.progress : 1), 0) / status.length; status.forEach((s, i) => { perClip[i].lastChild.textContent = discordNote(s); }); setBar(bar, frac); if (!making.length) break; if (making.every((s) => s.queued)) steps[1].set('now', 'waiting for another clip...'); else { if (!first && frac > 0) first = { t: Date.now(), frac }; const rate = first && frac > first.frac ? (frac - first.frac) / (Date.now() - first.t) : 0; steps[1].set('now', Math.round(frac * 100) + '%' + (rate ? ' · ' + fmtLeft((1 - frac) / rate / 1000) : '')); } await wait(1000); if (closed) return go(location.pathname, true); } const allSmall = status.every((s) => s.state === 'small'); steps[1].set('done', allSmall ? 'plays as is' : '100%'); await barFull(bar); // step 3: the links, Copy lit up steps[2].set('done'); title.textContent = done.length > 1 ? `Ready · ${plural(done.length, 'clip')}` : 'Ready · ' + done[0].name; line.textContent = done.length > 1 ? 'Paste these in Discord: they play right in the chat.' : 'Paste this in Discord: it plays right in the chat.'; bar.remove(); extra.replaceChildren(...done.map((r, i) => { const share = shareLink(r.name, r.url); return h('div', { class: 'linkrow' }, done.length > 1 || r.remuxed || /\.mkv$/i.test(r.name) ? h('div', { class: 'small' }, linkName(r)) : null, h('div', { style: 'display:flex;gap:6px' }, h('input', { class: 'field', value: share, readonly: true, onclick: (e) => e.target.select() }), h('button', { class: 'btn y', onclick: () => copy(share) }, 'Copy')), status[i].state === 'none' ? h('div', { class: 'small' }, "Too long for Discord's player: the link opens the clip, but won't play in the chat.") : null); })); if (status.some((s) => s.state === 'none') && done.length === 1) line.textContent = 'Your link:'; d.box.classList.add('ready'); await new Promise((res) => { btn.onclick = () => { d.close(); res(); }; }); if (failure) await alertBox('Upload failed', failure); go(location.pathname, true); } function go(path, replace = false) { if (path !== location.pathname) history[replace ? 'replaceState' : 'pushState']({}, '', path); route().catch(handleError); } async function route(progress) { const m = location.pathname.match(/^\/v\/(.+)$/); if (m) { let name; try { name = decodeURIComponent(m[1]); } catch { throw new Error("This clip doesn't exist (or isn't yours)."); } await watchView(name); if (progress) progress(1); } else await libraryView(progress); } async function handleError(e) { if (e instanceof LoggedOut) { await loginDialog(); me = await api('/me'); return route().catch(handleError); } await alertBox('Error', e.message); if (location.pathname !== '/') go('/', true); } async function logout() { await api('/logout', { method: 'POST' }).catch(() => {}); location.reload(); } // the loading dialog moves on real steps: config+session, library list, thumbnails async function boot() { const bar = sbar(), status = h('div', { class: 'small' }, `Connecting to ${SERVER}...`); const d = dialog(SITE, [h('div', { class: 'small' }, 'Loading...'), bar, status], { clear: true }); const step = (frac, text) => { setBar(bar, frac); status.textContent = text; }; try { step(0.1, 'Checking your session...'); cfg = await api('/config'); try { me = await api('/me'); } catch (e) { if (!(e instanceof LoggedOut) || !location.pathname.startsWith('/v/')) throw e; me = null; } step(0.4, 'Loading your videos...'); await route((frac) => step(0.5 + frac * 0.5, 'Loading thumbnails...')); step(1, 'Done'); await barFull(bar); await wait(250); d.close(); } catch (e) { d.close(); if (e instanceof LoggedOut) { await loginDialog(); return boot(); } if (location.pathname !== '/') { // a clip that's gone (or someone else's): say so, then show the library await alertBox(SITE, e.message); history.replaceState({}, '', '/'); return boot(); } dialog(SITE, [h('div', { class: 'msg' }, e.message), h('div', { class: 'row' }, h('button', { class: 'btn y', onclick: () => location.reload() }, 'Retry'))], { clear: true }); } } let dragDepth = 0; const hasFiles = (e) => e.dataTransfer && [...e.dataTransfer.types].includes('Files'); document.addEventListener('dragenter', (e) => { if (!me || !hasFiles(e)) return; e.preventDefault(); dragDepth++; document.body.classList.add('dropping'); }); document.addEventListener('dragleave', () => { if (--dragDepth <= 0) { dragDepth = 0; document.body.classList.remove('dropping'); } }); document.addEventListener('dragover', (e) => { if (hasFiles(e)) e.preventDefault(); }); document.addEventListener('drop', (e) => { if (!hasFiles(e)) return; e.preventDefault(); dragDepth = 0; document.body.classList.remove('dropping'); if (me && e.dataTransfer.files.length) uploadFiles([...e.dataTransfer.files]); }); document.addEventListener('click', (e) => { document.querySelectorAll('.menu').forEach((m) => { if (!m.parentElement.contains(e.target)) m.style.display = 'none'; }); }); window.addEventListener('popstate', () => route().catch(handleError)); boot();