This commit is contained in:
jpmvaz
2026-09-13 20:23:05 +01:00
commit 34900bf069
45 changed files with 4879 additions and 0 deletions
+204
View File
@@ -0,0 +1,204 @@
const $ = (s) => document.querySelector(s);
let config = { supportedHint: '', maxUploadMb: 500, mailAvailable: false };
let me = null;
function toast(msg) {
const t = $('#toast');
t.textContent = msg; t.classList.add('show');
clearTimeout(toast._t); toast._t = setTimeout(() => t.classList.remove('show'), 2200);
}
async function api(path, opts = {}) {
const res = await fetch(path, opts);
if (res.status === 401) { location.href = '/login'; throw new Error('Not signed in'); }
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`);
return data;
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
// --- session guard + user menu --------------------------------------------
async function boot() {
me = await api('/api/me');
if (!me.authed) { location.href = '/login'; return; }
try { config = await api('/config.json'); } catch {}
$('#usermenu .who').innerHTML =
`Signed in as <b>${escapeHtml(me.user.username)}</b> <span class="role-badge ${me.user.role}">${me.user.role}</span>`;
if (me.user.role === 'admin') $('#manageBtn').style.display = '';
$('#dropSub').textContent = `or click to browse — ${config.supportedHint}`;
hostWarning();
load();
}
$('#logoutBtn').addEventListener('click', async () => {
await fetch('/api/logout', { method: 'POST' });
location.href = '/login';
});
function hostWarning() {
const h = location.hostname;
if (h === 'localhost' || h === '127.0.0.1' || h === '0.0.0.0') {
$('#hostWarn').innerHTML =
'<span class="warn">Heads up:</span> you\'re on localhost, so QR codes will point at this machine only. ' +
'Open the app via this computer\'s LAN address (or set PUBLIC_BASE_URL) so phones can reach it.';
}
}
// --- upload ----------------------------------------------------------------
const drop = $('#drop');
const fileInput = $('#file');
drop.addEventListener('click', () => fileInput.click());
['dragenter', 'dragover'].forEach((ev) =>
drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.add('drag'); }));
['dragleave', 'drop'].forEach((ev) =>
drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.remove('drag'); }));
drop.addEventListener('drop', (e) => { if (e.dataTransfer.files[0]) startUpload(e.dataTransfer.files[0]); });
fileInput.addEventListener('change', () => { if (fileInput.files[0]) startUpload(fileInput.files[0]); });
function startUpload(file) {
const maxBytes = config.maxUploadMb * 1024 * 1024;
if (file.size > maxBytes) { toast(`That file is over the ${config.maxUploadMb} MB limit.`); return; }
const form = new FormData();
form.append('file', file);
form.append('title', $('#title').value);
const bar = $('#progress');
const fill = bar.querySelector('i');
bar.style.display = 'block'; fill.style.width = '0%';
$('#uploadNote').textContent = `Uploading ${file.name}`;
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/sessions');
xhr.upload.onprogress = (e) => { if (e.lengthComputable) fill.style.width = `${Math.round((e.loaded / e.total) * 100)}%`; };
xhr.onload = async () => {
bar.style.display = 'none';
if (xhr.status === 401) { location.href = '/login'; return; }
let data = {}; try { data = JSON.parse(xhr.responseText); } catch {}
if (xhr.status >= 400) { $('#uploadNote').textContent = data.error || 'Upload failed.'; return; }
$('#uploadNote').textContent = 'Converting… this can take a moment for large files.';
$('#title').value = '';
await pollUntilReady(data.id);
load();
};
xhr.onerror = () => { bar.style.display = 'none'; $('#uploadNote').textContent = 'Upload failed.'; };
xhr.send(form);
}
async function pollUntilReady(id) {
for (let i = 0; i < 240; i++) {
try {
const m = await api(`/api/sessions/${id}`);
if (m.status === 'ready') { $('#uploadNote').textContent = 'Ready.'; return; }
if (m.status === 'error') { $('#uploadNote').textContent = `Conversion failed: ${m.error}`; return; }
} catch {}
load();
await new Promise((r) => setTimeout(r, 1500));
}
}
// --- session list ----------------------------------------------------------
$('#refresh').addEventListener('click', async () => {
// Rotate every presentation's QR code so links from finished sessions stop working.
try { await api('/api/sessions/rotate-codes', { method: 'POST' }); toast('New QR codes generated'); }
catch {}
load();
});
async function load() {
let items;
try { items = await api('/api/sessions'); } catch { return; }
const list = $('#list');
if (!items.length) {
list.innerHTML = '<div class="empty">No presentations yet. Upload a file above to get your first QR code.</div>';
return;
}
const grid = document.createElement('div');
grid.className = 'grid';
for (const s of items) grid.appendChild(tile(s));
list.replaceChildren(grid);
}
function tile(s) {
const el = document.createElement('div');
el.className = 'card tile';
const when = new Date(s.createdAt).toLocaleString();
const presentUrl = `/present/${s.id}?token=${encodeURIComponent(s.presenterToken)}`;
const displayUrl = `/display/${s.id}?token=${encodeURIComponent(s.presenterToken)}&k=${encodeURIComponent(s.accessToken || '')}`;
const viewUrl = `${location.origin}/view/${s.id}`;
const owner = (me.user.role === 'admin' && s.ownerName) ? ` · ${escapeHtml(s.ownerName)}` : '';
el.innerHTML = `
<div class="qr"><img alt="QR code" src="/api/sessions/${s.id}/qr.svg?v=${encodeURIComponent(s.accessToken || '')}" /></div>
<div class="meta">
<div class="title" title="${escapeHtml(s.title)}">${escapeHtml(s.title)}</div>
<div class="row" style="margin:6px 0 0;">
<span class="tag ${s.status}"><span class="dot"></span>${s.status}</span>
<span class="tag"><span class="dot"></span>${s.kind || '…'}</span>
${s.viewers ? `<span class="tag"><span class="dot"></span>${s.viewers} watching</span>` : ''}
</div>
<div class="note" style="margin-top:6px;">${when}${owner}</div>
${s.status === 'error' ? `<div class="note" style="color:var(--danger)">${escapeHtml(s.error || '')}</div>` : ''}
<div class="row">
<a class="btn primary small" href="${presentUrl}" target="_blank" rel="noopener" ${s.status !== 'ready' ? 'aria-disabled="true" style="pointer-events:none;opacity:.45"' : ''}>Present</a>
${s.status === 'ready' ? `<a class="btn small" href="${presentUrl}" target="_blank" rel="noopener" data-display="${displayUrl}">Present Local Only</a>` : ''}
${s.status === 'ready' ? `<a class="btn ghost small" href="/qr/${s.id}" target="_blank" rel="noopener">QR Code</a>` : ''}
${config.mailAvailable ? `<button class="btn ghost small" data-email="${s.id}" data-title="${escapeHtml(s.title)}">Email</button>` : ''}
<button class="btn ghost small" data-copy="${viewUrl}">Copy link</button>
<a class="btn ghost small" href="/view/${s.id}" target="_blank" rel="noopener">Open viewer</a>
<button class="btn danger small" data-del="${s.id}">Delete</button>
</div>
</div>`;
el.querySelector('[data-copy]').addEventListener('click', (e) =>
navigator.clipboard.writeText(e.target.dataset.copy).then(() => toast('Audience link copied')));
const emailBtn = el.querySelector('[data-email]');
if (emailBtn) emailBtn.addEventListener('click', (e) => openEmail(e.target.dataset.email, e.target.dataset.title));
const localBtn = el.querySelector('[data-display]');
if (localBtn) localBtn.addEventListener('click', (e) => {
// The anchor itself opens the presenter console (tab 1). Open the clean,
// controls-free display as a second browser tab (no features string, so it's
// a tab — not a popup window — and isn't blocked like the old version).
const w = window.open(e.currentTarget.dataset.display, '_blank');
if (w) w.opener = null; else toast('Allow pop-ups to open the display tab');
});
el.querySelector('[data-del]').addEventListener('click', async (e) => {
if (!confirm('Delete this presentation for everyone?')) return;
await api(`/api/sessions/${e.target.dataset.del}`, { method: 'DELETE' });
load();
});
return el;
}
// --- email share modal -----------------------------------------------------
let emailSessionId = null;
function openEmail(id, title) {
emailSessionId = id;
$('#emailTitle').textContent = `Send "${title}"`;
$('#emailTo').value = ''; $('#emailMsg').value = ''; $('#emailErr').textContent = '';
$('#emailOverlay').classList.add('show');
$('#emailTo').focus();
}
function closeEmail() { $('#emailOverlay').classList.remove('show'); }
$('#emailCancel').addEventListener('click', closeEmail);
$('#emailOverlay').addEventListener('click', (e) => { if (e.target.id === 'emailOverlay') closeEmail(); });
$('#emailSend').addEventListener('click', async () => {
$('#emailErr').textContent = '';
$('#emailSend').disabled = true;
try {
const r = await api(`/api/sessions/${emailSessionId}/email`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ to: $('#emailTo').value, message: $('#emailMsg').value })
});
closeEmail();
toast(`Sent to ${r.sentTo.join(', ')}`);
} catch (e) {
$('#emailErr').textContent = e.message;
} finally {
$('#emailSend').disabled = false;
}
});
boot();