kaledonya clips: a Discord-friendly clip site on top of Zipline

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
esefandClaude Opus 5.5 committed 2026-10-07 20:46:48 +03:00
commit e6f8905377
34 files changed
+2917

No files matched your search

+526
View File
@@ -0,0 +1,526 @@
'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();