205 lines
9.3 KiB
JavaScript
205 lines
9.3 KiB
JavaScript
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) =>
|
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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();
|