v_1.7
This commit is contained in:
@@ -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) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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();
|
||||
Reference in New Issue
Block a user