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 ${escapeHtml(me.user.username)} ${me.user.role}`; 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 = 'Heads up: 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 = '
No presentations yet. Upload a file above to get your first QR code.
'; 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 = `
QR code
${escapeHtml(s.title)}
${s.status} ${s.kind || '…'} ${s.viewers ? `${s.viewers} watching` : ''}
${when}${owner}
${s.status === 'error' ? `
${escapeHtml(s.error || '')}
` : ''}
Present ${s.status === 'ready' ? `Present Local Only` : ''} ${s.status === 'ready' ? `QR Code` : ''} ${config.mailAvailable ? `` : ''} Open viewer
`; 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();