Files
kaledonya-clips/intake/site/app.js
T

527 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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/<name> 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('<svg viewBox="0 0 34 34" aria-hidden="true"><rect width="34" height="34" fill="#c4b550"/><path d="M12 8 L27 17 L12 26Z" fill="#2b3125"/></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('<svg class="mark" viewBox="0 0 100 100" aria-hidden="true"><rect x="4" y="4" width="92" height="92"/><path d="M38 28 L74 50 L38 72Z"/></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();