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();
|
||||
@@ -0,0 +1,68 @@
|
||||
import { PdfViewer } from './pdf-viewer.js';
|
||||
|
||||
const $ = (s) => document.querySelector(s);
|
||||
const parts = location.pathname.split('/').filter(Boolean);
|
||||
const sessionId = parts[parts.length - 1];
|
||||
const params = new URLSearchParams(location.search);
|
||||
const token = params.get('token') || ''; // presenter token: mirror the console even after code rotation
|
||||
const accessCode = params.get('k') || ''; // fallback for asset access
|
||||
|
||||
const socket = io();
|
||||
let kind = null, built = false, viewer = null, videoEl = null;
|
||||
|
||||
function msg(text) { $('#stage').innerHTML = `<div class="display-msg">${text}</div>`; }
|
||||
|
||||
// Join as presenter (display-only — it never sends control) so it stays in sync
|
||||
// with the console and isn't counted as an audience viewer.
|
||||
socket.on('connect', () => socket.emit('join', { sessionId, role: 'presenter', token, k: accessCode }));
|
||||
socket.on('error:join', (m) => msg(m));
|
||||
socket.on('ended', () => msg('The presentation has ended.'));
|
||||
socket.on('state', onState);
|
||||
|
||||
async function onState(s) {
|
||||
if (!s.meta) return;
|
||||
document.title = (s.meta.title || 'Presentation') + ' — MPS';
|
||||
if (s.meta.status === 'processing') { msg('Preparing…'); return; }
|
||||
if (s.meta.status === 'error') { msg('This presentation could not be prepared.'); return; }
|
||||
if (!built) { await build(s.meta); built = true; }
|
||||
if (kind === 'pdf') viewer.render(s.currentPage || 1);
|
||||
else if (kind === 'video') applyVideo(s.video);
|
||||
}
|
||||
|
||||
async function build(meta) {
|
||||
kind = meta.kind;
|
||||
const stage = $('#stage');
|
||||
stage.innerHTML = '';
|
||||
const src = `/s/${meta.id}/${meta.viewerFile}${accessCode ? `?k=${encodeURIComponent(accessCode)}` : ''}`;
|
||||
if (kind === 'pdf') {
|
||||
viewer = new PdfViewer(stage);
|
||||
await viewer.load(src);
|
||||
} else if (kind === 'video') {
|
||||
videoEl = document.createElement('video');
|
||||
videoEl.src = src;
|
||||
videoEl.playsInline = true;
|
||||
videoEl.preload = 'auto';
|
||||
videoEl.setAttribute('webkit-playsinline', '');
|
||||
stage.appendChild(videoEl);
|
||||
} else if (kind === 'image') {
|
||||
const img = document.createElement('img');
|
||||
img.src = src; img.alt = meta.title || '';
|
||||
stage.appendChild(img);
|
||||
}
|
||||
}
|
||||
|
||||
function applyVideo(v) {
|
||||
if (!videoEl || !v) return;
|
||||
const rate = v.rate || 1;
|
||||
if (v.playing) {
|
||||
const elapsed = (Date.now() - (v.updatedAt || Date.now())) / 1000;
|
||||
const expected = (v.time || 0) + elapsed * rate;
|
||||
if (Math.abs(videoEl.currentTime - expected) > 0.8) videoEl.currentTime = expected;
|
||||
videoEl.playbackRate = rate;
|
||||
// Play with sound where allowed; fall back to muted so the image still tracks.
|
||||
videoEl.play().catch(() => { videoEl.muted = true; videoEl.play().catch(() => {}); });
|
||||
} else {
|
||||
videoEl.pause();
|
||||
if (typeof v.time === 'number' && Math.abs(videoEl.currentTime - v.time) > 0.4) videoEl.currentTime = v.time;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
const $ = (s) => document.querySelector(s);
|
||||
|
||||
// Decide whether to show the sign-in form or the first-run setup form.
|
||||
fetch('/api/me').then((r) => r.json()).then((m) => {
|
||||
if (m.authed) { location.href = '/'; return; }
|
||||
if (m.needsSetup) {
|
||||
$('#loginView').style.display = 'none';
|
||||
$('#setupView').style.display = 'block';
|
||||
$('#s_username').focus();
|
||||
} else {
|
||||
$('#username').focus();
|
||||
}
|
||||
}).catch(() => {});
|
||||
|
||||
// --- sign in ---------------------------------------------------------------
|
||||
async function signIn() {
|
||||
const username = $('#username').value.trim();
|
||||
const password = $('#password').value;
|
||||
$('#err').textContent = '';
|
||||
if (!username || !password) { $('#err').textContent = 'Enter your username and password.'; return; }
|
||||
$('#signin').disabled = true;
|
||||
try {
|
||||
const res = await fetch('/api/login', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || 'Sign in failed.');
|
||||
location.href = '/';
|
||||
} catch (e) { $('#err').textContent = e.message; $('#signin').disabled = false; }
|
||||
}
|
||||
$('#signin').addEventListener('click', signIn);
|
||||
|
||||
// --- first-run setup -------------------------------------------------------
|
||||
async function createAdmin() {
|
||||
const username = $('#s_username').value.trim();
|
||||
const email = $('#s_email').value.trim();
|
||||
const password = $('#s_password').value;
|
||||
const password2 = $('#s_password2').value;
|
||||
$('#s_err').textContent = '';
|
||||
if (!username || !password) { $('#s_err').textContent = 'Choose a username and password.'; return; }
|
||||
if (password !== password2) { $('#s_err').textContent = 'The passwords do not match.'; return; }
|
||||
$('#createAdmin').disabled = true;
|
||||
try {
|
||||
const res = await fetch('/api/setup', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, email, password })
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || 'Could not create the account.');
|
||||
location.href = '/';
|
||||
} catch (e) { $('#s_err').textContent = e.message; $('#createAdmin').disabled = false; }
|
||||
}
|
||||
$('#createAdmin').addEventListener('click', createAdmin);
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
if ($('#setupView').style.display !== 'none') createAdmin();
|
||||
else signIn();
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
// Loads PDF.js (vendored first, CDN fallback) and renders one page at a time,
|
||||
// scaled to fit its container and sharp on high-DPI screens.
|
||||
|
||||
let readyPromise = null;
|
||||
|
||||
function loadScript(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const s = document.createElement('script');
|
||||
s.src = src;
|
||||
s.onload = resolve;
|
||||
s.onerror = () => reject(new Error('Failed to load ' + src));
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
export function ensurePdfjs() {
|
||||
if (window.pdfjsLib) return Promise.resolve();
|
||||
if (readyPromise) return readyPromise;
|
||||
|
||||
const CDN = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174';
|
||||
readyPromise = loadScript('/vendor/pdfjs/pdf.min.js')
|
||||
.then(() => {
|
||||
if (!window.pdfjsLib) throw new Error('vendor missing');
|
||||
window.__pdfWorker = '/vendor/pdfjs/pdf.worker.min.js';
|
||||
})
|
||||
.catch(() => loadScript(`${CDN}/pdf.min.js`).then(() => {
|
||||
window.__pdfWorker = `${CDN}/pdf.worker.min.js`;
|
||||
}))
|
||||
.then(() => {
|
||||
window.pdfjsLib.GlobalWorkerOptions.workerSrc = window.__pdfWorker;
|
||||
});
|
||||
return readyPromise;
|
||||
}
|
||||
|
||||
export class PdfViewer {
|
||||
constructor(container) {
|
||||
this.container = container;
|
||||
this.canvas = document.createElement('canvas');
|
||||
this.canvas.className = 'pdf-canvas';
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
container.appendChild(this.canvas);
|
||||
this.pdf = null;
|
||||
this.numPages = 0;
|
||||
this.page = 1;
|
||||
this._busy = false;
|
||||
this._queued = null;
|
||||
this._onResize = () => this.refit();
|
||||
window.addEventListener('resize', this._onResize);
|
||||
}
|
||||
|
||||
async load(url) {
|
||||
await ensurePdfjs();
|
||||
this.pdf = await window.pdfjsLib.getDocument(url).promise;
|
||||
this.numPages = this.pdf.numPages;
|
||||
return this.numPages;
|
||||
}
|
||||
|
||||
async render(n) {
|
||||
if (!this.pdf) return;
|
||||
n = Math.max(1, Math.min(this.numPages, Math.trunc(n) || 1));
|
||||
this.page = n;
|
||||
if (this._busy) { this._queued = n; return; }
|
||||
this._busy = true;
|
||||
try {
|
||||
const page = await this.pdf.getPage(n);
|
||||
const base = page.getViewport({ scale: 1 });
|
||||
const cw = this.container.clientWidth || 800;
|
||||
const ch = this.container.clientHeight || 600;
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
const fit = Math.min(cw / base.width, ch / base.height);
|
||||
const vp = page.getViewport({ scale: fit * dpr });
|
||||
this.canvas.width = Math.floor(vp.width);
|
||||
this.canvas.height = Math.floor(vp.height);
|
||||
this.canvas.style.width = Math.floor(vp.width / dpr) + 'px';
|
||||
this.canvas.style.height = Math.floor(vp.height / dpr) + 'px';
|
||||
await page.render({ canvasContext: this.ctx, viewport: vp }).promise;
|
||||
} finally {
|
||||
this._busy = false;
|
||||
if (this._queued !== null) {
|
||||
const q = this._queued;
|
||||
this._queued = null;
|
||||
if (q !== n) this.render(q);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
refit() { if (this.pdf) this.render(this.page); }
|
||||
|
||||
destroy() { window.removeEventListener('resize', this._onResize); }
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { PdfViewer } from './pdf-viewer.js';
|
||||
|
||||
const $ = (s) => document.querySelector(s);
|
||||
const sessionId = location.pathname.split('/').filter(Boolean).pop();
|
||||
const token = new URLSearchParams(location.search).get('token') || '';
|
||||
|
||||
const socket = io();
|
||||
let kind = null, built = false, mode = 'synced';
|
||||
let viewer = null, videoEl = null, page = 1, numPages = 1;
|
||||
|
||||
function toast(msg) {
|
||||
const t = $('#toast'); t.textContent = msg; t.classList.add('show');
|
||||
clearTimeout(toast._t); toast._t = setTimeout(() => t.classList.remove('show'), 2000);
|
||||
}
|
||||
function stageMsg(html) { $('#stage').innerHTML = `<div class="msg">${html}</div>`; }
|
||||
|
||||
$('#qrPageBtn').href = `/qr/${sessionId}`;
|
||||
|
||||
socket.on('connect', () => socket.emit('join', { sessionId, role: 'presenter', token }));
|
||||
socket.on('error:join', (m) => stageMsg(m));
|
||||
socket.on('ended', () => showEnded());
|
||||
socket.on('joined', ({ isPresenter }) => {
|
||||
if (!isPresenter) stageMsg('Your presenter link is invalid. Reopen it from the host console.');
|
||||
});
|
||||
socket.on('state', onState);
|
||||
|
||||
async function onState(s) {
|
||||
if (!s.meta) return;
|
||||
$('#deckTitle').textContent = s.meta.title || 'Presenting';
|
||||
$('#viewerCount').textContent = s.counts.viewers;
|
||||
mode = s.mode; syncModeButtons();
|
||||
if (s.meta.status === 'processing') { stageMsg('<div class="spinner"></div>Still converting your file…'); return; }
|
||||
if (s.meta.status === 'error') { stageMsg('Conversion failed: ' + (s.meta.error || '')); return; }
|
||||
if (!built) { await build(s.meta); built = true; }
|
||||
if (kind === 'pdf') { page = s.currentPage || 1; viewer.render(page); updatePageInd(); }
|
||||
}
|
||||
|
||||
async function build(meta) {
|
||||
kind = meta.kind;
|
||||
const stage = $('#stage'); stage.innerHTML = '';
|
||||
$('#controls').style.display = 'flex';
|
||||
const src = `/s/${meta.id}/${meta.viewerFile}`;
|
||||
if (kind === 'pdf') {
|
||||
viewer = new PdfViewer(stage);
|
||||
numPages = await viewer.load(src);
|
||||
$('#pdfControls').style.display = 'flex';
|
||||
updatePageInd(); viewer.render(page);
|
||||
} else if (kind === 'video') {
|
||||
videoEl = document.createElement('video');
|
||||
videoEl.src = src; videoEl.controls = true; videoEl.playsInline = true; videoEl.preload = 'auto';
|
||||
stage.appendChild(videoEl);
|
||||
$('#videoHint').style.display = 'block';
|
||||
wireVideo();
|
||||
} else if (kind === 'image') {
|
||||
const img = document.createElement('img'); img.src = src; img.alt = meta.title || 'Image';
|
||||
stage.appendChild(img);
|
||||
}
|
||||
}
|
||||
|
||||
// --- PDF paging (with first / last) ---------------------------------------
|
||||
function updatePageInd() { $('#pageInd').textContent = `${page} / ${numPages}`; }
|
||||
function goto(n) {
|
||||
if (kind !== 'pdf') return;
|
||||
page = Math.max(1, Math.min(numPages, n));
|
||||
viewer.render(page); updatePageInd();
|
||||
socket.emit('control', { currentPage: page });
|
||||
}
|
||||
$('#first').addEventListener('click', () => goto(1));
|
||||
$('#prev').addEventListener('click', () => goto(page - 1));
|
||||
$('#next').addEventListener('click', () => goto(page + 1));
|
||||
$('#last').addEventListener('click', () => goto(numPages));
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (kind !== 'pdf') return;
|
||||
if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') { e.preventDefault(); goto(page + 1); }
|
||||
if (e.key === 'ArrowLeft' || e.key === 'PageUp') { e.preventDefault(); goto(page - 1); }
|
||||
if (e.key === 'Home') { e.preventDefault(); goto(1); }
|
||||
if (e.key === 'End') { e.preventDefault(); goto(numPages); }
|
||||
});
|
||||
|
||||
// --- video sync ------------------------------------------------------------
|
||||
function pushVideo() {
|
||||
if (!videoEl) return;
|
||||
socket.emit('control', { video: { playing: !videoEl.paused, time: videoEl.currentTime, rate: videoEl.playbackRate } });
|
||||
}
|
||||
function wireVideo() {
|
||||
['play', 'pause', 'seeked', 'ratechange'].forEach((ev) => videoEl.addEventListener(ev, pushVideo));
|
||||
setInterval(() => { if (videoEl && !videoEl.paused) pushVideo(); }, 4000);
|
||||
}
|
||||
|
||||
// --- mode toggle -----------------------------------------------------------
|
||||
$('#modeSeg').addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('button'); if (!btn) return;
|
||||
mode = btn.dataset.mode; syncModeButtons();
|
||||
socket.emit('control', { mode });
|
||||
toast(mode === 'synced' ? 'Audience now follows you' : 'Audience can browse freely');
|
||||
});
|
||||
function syncModeButtons() {
|
||||
document.querySelectorAll('#modeSeg button').forEach((b) => b.classList.toggle('active', b.dataset.mode === mode));
|
||||
}
|
||||
|
||||
// --- end session (invalidate QR + kick everyone) ---------------------------
|
||||
let ended = false;
|
||||
$('#endBtn').addEventListener('click', async () => {
|
||||
if (ended) return;
|
||||
if (!confirm('End the presentation for everyone? The QR code will stop working and all viewers will be disconnected.')) return;
|
||||
$('#endBtn').disabled = true;
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${sessionId}/terminate`, { method: 'POST' });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || 'Could not end the session.');
|
||||
ended = true;
|
||||
showEnded();
|
||||
} catch (e) {
|
||||
$('#endBtn').disabled = false;
|
||||
toast(e.message);
|
||||
}
|
||||
});
|
||||
|
||||
function showEnded() {
|
||||
ended = true;
|
||||
document.querySelector('.lamp')?.classList.remove('on');
|
||||
$('#controls').style.display = 'none';
|
||||
$('#endBtn').textContent = 'Session ended';
|
||||
stageMsg('This presentation has ended. Its QR code is no longer valid.<br><br><a class="btn ghost small" href="/">Back to dashboard</a>');
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
const $ = (s) => document.querySelector(s);
|
||||
const id = location.pathname.split('/').filter(Boolean).pop();
|
||||
let joinUrl = '';
|
||||
|
||||
function toast(msg) {
|
||||
const t = $('#toast'); t.textContent = msg; t.classList.add('show');
|
||||
clearTimeout(toast._t); toast._t = setTimeout(() => t.classList.remove('show'), 1800);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const meta = await (await fetch(`/api/sessions/${id}`)).json();
|
||||
if (meta && meta.title) $('#qrTitle').textContent = meta.title;
|
||||
} catch {}
|
||||
try {
|
||||
const q = await (await fetch(`/api/sessions/${id}/qr`)).json();
|
||||
joinUrl = q.url;
|
||||
const img = new Image();
|
||||
img.alt = 'QR code'; img.src = q.dataUrl;
|
||||
$('#qrBig').replaceChildren(img);
|
||||
const link = $('#qrLink'); link.href = q.url; link.textContent = q.url;
|
||||
} catch {
|
||||
$('#qrBig').innerHTML = '<div class="msg">This presentation is no longer available.</div>';
|
||||
$('#eyebrow').textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
$('#copyBtn').addEventListener('click', () => {
|
||||
if (joinUrl) navigator.clipboard.writeText(joinUrl).then(() => toast('Link copied'));
|
||||
});
|
||||
$('#fsBtn').addEventListener('click', () => {
|
||||
const el = document.documentElement;
|
||||
if (document.fullscreenElement) document.exitFullscreen();
|
||||
else el.requestFullscreen?.();
|
||||
});
|
||||
$('#printBtn').addEventListener('click', () => window.print());
|
||||
|
||||
load();
|
||||
@@ -0,0 +1,138 @@
|
||||
const $ = (s) => document.querySelector(s);
|
||||
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('auth'); }
|
||||
if (res.status === 403) { location.href = '/'; throw new Error('forbidden'); }
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`);
|
||||
return data;
|
||||
}
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
|
||||
$('#logoutBtn').addEventListener('click', async () => {
|
||||
await fetch('/api/logout', { method: 'POST' }); location.href = '/login';
|
||||
});
|
||||
|
||||
// --- tabs ------------------------------------------------------------------
|
||||
$('#tabs').addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('button'); if (!btn) return;
|
||||
document.querySelectorAll('#tabs button').forEach((b) => b.classList.toggle('active', b === btn));
|
||||
document.querySelectorAll('.tab-panel').forEach((p) => p.classList.toggle('active', p.id === `tab-${btn.dataset.tab}`));
|
||||
if (btn.dataset.tab === 'logs') loadLogs();
|
||||
if (btn.dataset.tab === 'mail') loadMail();
|
||||
});
|
||||
|
||||
// --- users -----------------------------------------------------------------
|
||||
async function loadUsers() {
|
||||
const users = await api('/api/users');
|
||||
const tbody = $('#userTable tbody');
|
||||
tbody.innerHTML = '';
|
||||
for (const u of users) {
|
||||
const tr = document.createElement('tr');
|
||||
const isMe = me && u.id === me.user.id;
|
||||
tr.innerHTML = `
|
||||
<td>${esc(u.username)}${isMe ? ' <span class="hint">(you)</span>' : ''}</td>
|
||||
<td class="mono">${esc(u.email) || '—'}</td>
|
||||
<td><span class="role-badge ${u.role}">${u.role}</span></td>
|
||||
<td class="mono">${new Date(u.createdAt).toLocaleDateString()}</td>
|
||||
<td><div class="row">
|
||||
<button class="btn ghost small" data-role="${u.id}" data-current="${u.role}">${u.role === 'admin' ? 'Make user' : 'Make admin'}</button>
|
||||
<button class="btn ghost small" data-pw="${u.id}">Reset password</button>
|
||||
<button class="btn danger small" data-del="${u.id}" ${isMe ? 'disabled' : ''}>Delete</button>
|
||||
</div></td>`;
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
tbody.querySelectorAll('[data-role]').forEach((b) => b.addEventListener('click', async () => {
|
||||
const next = b.dataset.current === 'admin' ? 'user' : 'admin';
|
||||
try { await api(`/api/users/${b.dataset.role}/role`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role: next }) }); loadUsers(); }
|
||||
catch (e) { toast(e.message); }
|
||||
}));
|
||||
tbody.querySelectorAll('[data-pw]').forEach((b) => b.addEventListener('click', async () => {
|
||||
const pw = prompt('New password for this user:');
|
||||
if (!pw) return;
|
||||
try { await api(`/api/users/${b.dataset.pw}/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }) }); toast('Password updated'); }
|
||||
catch (e) { toast(e.message); }
|
||||
}));
|
||||
tbody.querySelectorAll('[data-del]').forEach((b) => b.addEventListener('click', async () => {
|
||||
if (!confirm('Delete this user?')) return;
|
||||
try { await api(`/api/users/${b.dataset.del}`, { method: 'DELETE' }); loadUsers(); }
|
||||
catch (e) { toast(e.message); }
|
||||
}));
|
||||
}
|
||||
$('#nu_add').addEventListener('click', async () => {
|
||||
$('#nu_err').textContent = '';
|
||||
try {
|
||||
await api('/api/users', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: $('#nu_name').value, email: $('#nu_email').value, password: $('#nu_pass').value, role: $('#nu_role').value })
|
||||
});
|
||||
$('#nu_name').value = $('#nu_email').value = $('#nu_pass').value = '';
|
||||
toast('User created'); loadUsers();
|
||||
} catch (e) { $('#nu_err').textContent = e.message; }
|
||||
});
|
||||
|
||||
// --- mail ------------------------------------------------------------------
|
||||
async function loadMail() {
|
||||
const c = await api('/api/mail');
|
||||
$('#m_enabled').checked = c.enabled;
|
||||
$('#m_username').value = c.username || '';
|
||||
$('#m_password').value = '';
|
||||
$('#m_password').placeholder = c.hasPassword ? '•••••••• (unchanged)' : 'password';
|
||||
$('#m_fromName').value = c.fromName || '';
|
||||
$('#m_smtpHost').value = c.smtpHost; $('#m_smtpPort').value = c.smtpPort;
|
||||
$('#m_imapHost').value = c.imapHost; $('#m_imapPort').value = c.imapPort;
|
||||
$('#m_sentFolder').value = c.sentFolder; $('#m_saveToSent').checked = c.saveToSent;
|
||||
}
|
||||
$('#m_save').addEventListener('click', async () => {
|
||||
$('#m_err').textContent = '';
|
||||
const body = {
|
||||
enabled: $('#m_enabled').checked, username: $('#m_username').value.trim(),
|
||||
password: $('#m_password').value, fromName: $('#m_fromName').value,
|
||||
smtpHost: $('#m_smtpHost').value.trim(), smtpPort: $('#m_smtpPort').value,
|
||||
imapHost: $('#m_imapHost').value.trim(), imapPort: $('#m_imapPort').value,
|
||||
sentFolder: $('#m_sentFolder').value.trim(), saveToSent: $('#m_saveToSent').checked
|
||||
};
|
||||
try { await api('/api/mail', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); toast('Mail settings saved'); loadMail(); }
|
||||
catch (e) { $('#m_err').textContent = e.message; }
|
||||
});
|
||||
$('#m_test').addEventListener('click', async () => {
|
||||
$('#m_err').textContent = '';
|
||||
const to = $('#m_testTo').value.trim();
|
||||
try { await api('/api/mail/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ to }) }); toast('Test email sent'); }
|
||||
catch (e) { $('#m_err').textContent = e.message; }
|
||||
});
|
||||
|
||||
// --- logs ------------------------------------------------------------------
|
||||
async function loadLogs() {
|
||||
const logs = await api('/api/logs');
|
||||
const box = $('#logList');
|
||||
if (!logs.length) { box.innerHTML = '<div class="empty">No activity logged yet.</div>'; return; }
|
||||
box.innerHTML = logs.map((l) => `
|
||||
<div class="log-line">
|
||||
<span class="mono">${new Date(l.ts).toLocaleString()}</span>
|
||||
<span class="lv ${l.level || 'info'}">${l.level || 'info'}</span>
|
||||
<span class="actor">${esc(l.actor || '')}</span>
|
||||
<span>${esc(l.message || '')}</span>
|
||||
</div>`).join('');
|
||||
}
|
||||
$('#logRefresh').addEventListener('click', loadLogs);
|
||||
$('#logClear').addEventListener('click', async () => {
|
||||
if (!confirm('Clear the activity log?')) return;
|
||||
await api('/api/logs', { method: 'DELETE' }); loadLogs();
|
||||
});
|
||||
|
||||
// --- boot ------------------------------------------------------------------
|
||||
(async () => {
|
||||
try { me = await api('/api/me'); } catch { return; }
|
||||
if (!me.authed) { location.href = '/login'; return; }
|
||||
if (me.user.role !== 'admin') { location.href = '/'; return; }
|
||||
loadUsers();
|
||||
})();
|
||||
@@ -0,0 +1,111 @@
|
||||
import { PdfViewer } from './pdf-viewer.js';
|
||||
|
||||
const $ = (s) => document.querySelector(s);
|
||||
const sessionId = location.pathname.split('/').filter(Boolean).pop();
|
||||
const accessCode = new URLSearchParams(location.search).get('k') || '';
|
||||
|
||||
const socket = io();
|
||||
let kind = null, built = false, mode = 'synced', following = true;
|
||||
let viewer = null, videoEl = null, joinedAudio = false;
|
||||
let localPage = 1, numPages = 1, lastVideo = null;
|
||||
|
||||
function toast(msg) {
|
||||
const t = $('#toast'); t.textContent = msg; t.classList.add('show');
|
||||
clearTimeout(toast._t); toast._t = setTimeout(() => t.classList.remove('show'), 2000);
|
||||
}
|
||||
function stageMsg(html) {
|
||||
$('#stage').querySelector('.msg')?.remove();
|
||||
const d = document.createElement('div'); d.className = 'msg'; d.innerHTML = html;
|
||||
$('#stage').prepend(d);
|
||||
}
|
||||
function clearMsg() { $('#stage').querySelector('.msg')?.remove(); }
|
||||
function setStatus(text, live) { $('#statusText').textContent = text; $('#lamp').classList.toggle('on', !!live); }
|
||||
|
||||
socket.on('connect', () => { setStatus('Connecting…', false); socket.emit('join', { sessionId, role: 'viewer', k: accessCode }); });
|
||||
socket.on('disconnect', () => setStatus('Reconnecting…', false));
|
||||
socket.on('error:join', (m) => stageMsg(m));
|
||||
socket.on('ended', () => { stageMsg('The presenter ended this session.'); setStatus('Ended', false); });
|
||||
socket.on('state', onState);
|
||||
|
||||
async function onState(s) {
|
||||
if (!s.meta) return;
|
||||
$('#deckTitle').textContent = s.meta.title || 'Presentation';
|
||||
if (s.meta.status === 'processing') { stageMsg('<div class="spinner"></div>The presenter is preparing this. Hang tight…'); setStatus('Preparing', false); return; }
|
||||
if (s.meta.status === 'error') { stageMsg('This presentation could not be prepared.'); setStatus('Unavailable', false); return; }
|
||||
if (!built) { await build(s.meta); built = true; }
|
||||
mode = s.mode;
|
||||
setStatus(mode === 'free' ? 'Browse freely' : (following ? 'Live · in sync' : 'Live · you moved away'), true);
|
||||
if (kind === 'pdf') {
|
||||
updateFollowBar();
|
||||
if (mode === 'synced' && following) { localPage = s.currentPage || 1; viewer.render(localPage); updatePageInd(); }
|
||||
$('#controls').style.display = 'flex';
|
||||
} else if (kind === 'video') {
|
||||
lastVideo = s.video; applyVideo(s.video);
|
||||
}
|
||||
}
|
||||
|
||||
async function build(meta) {
|
||||
kind = meta.kind; clearMsg();
|
||||
const src = `/s/${meta.id}/${meta.viewerFile}?k=${encodeURIComponent(accessCode)}`;
|
||||
if (kind === 'pdf') {
|
||||
viewer = new PdfViewer($('#stage'));
|
||||
numPages = await viewer.load(src); updatePageInd();
|
||||
} else if (kind === 'video') {
|
||||
videoEl = document.createElement('video');
|
||||
videoEl.src = src; videoEl.playsInline = true; videoEl.preload = 'auto'; videoEl.muted = true;
|
||||
videoEl.setAttribute('webkit-playsinline', '');
|
||||
$('#stage').appendChild(videoEl);
|
||||
$('#tapjoin').classList.add('show');
|
||||
} else if (kind === 'image') {
|
||||
const img = document.createElement('img'); img.src = src; img.alt = meta.title || 'Image';
|
||||
$('#stage').appendChild(img);
|
||||
}
|
||||
}
|
||||
|
||||
// --- PDF: audience self-navigation (with first / last) --------------------
|
||||
function updatePageInd() { $('#pageInd').textContent = `${localPage} / ${numPages}`; }
|
||||
function updateFollowBar() { $('#followbar').classList.toggle('show', mode === 'synced' && !following); }
|
||||
function localGoto(n) {
|
||||
if (kind !== 'pdf') return;
|
||||
localPage = Math.max(1, Math.min(numPages, n));
|
||||
viewer.render(localPage); updatePageInd();
|
||||
if (mode === 'synced') { following = false; updateFollowBar(); setStatus('Live · you moved away', true); }
|
||||
}
|
||||
$('#first').addEventListener('click', () => localGoto(1));
|
||||
$('#prev').addEventListener('click', () => localGoto(localPage - 1));
|
||||
$('#next').addEventListener('click', () => localGoto(localPage + 1));
|
||||
$('#last').addEventListener('click', () => localGoto(numPages));
|
||||
$('#followBtn').addEventListener('click', () => {
|
||||
following = true; updateFollowBar();
|
||||
socket.emit('join', { sessionId, role: 'viewer', k: accessCode });
|
||||
setStatus('Live · in sync', true);
|
||||
});
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (kind !== 'pdf') return;
|
||||
if (e.key === 'ArrowRight') localGoto(localPage + 1);
|
||||
if (e.key === 'ArrowLeft') localGoto(localPage - 1);
|
||||
if (e.key === 'Home') localGoto(1);
|
||||
if (e.key === 'End') localGoto(numPages);
|
||||
});
|
||||
|
||||
// --- video following -------------------------------------------------------
|
||||
$('#tapBtn').addEventListener('click', () => {
|
||||
joinedAudio = true;
|
||||
if (videoEl) videoEl.muted = false;
|
||||
$('#tapjoin').classList.remove('show');
|
||||
if (lastVideo) applyVideo(lastVideo);
|
||||
});
|
||||
function applyVideo(v) {
|
||||
if (!videoEl || !v) return;
|
||||
const rate = v.rate || 1;
|
||||
if (v.playing) {
|
||||
const elapsed = (Date.now() - (v.updatedAt || Date.now())) / 1000;
|
||||
const expected = (v.time || 0) + elapsed * rate;
|
||||
if (Math.abs(videoEl.currentTime - expected) > 0.8) videoEl.currentTime = expected;
|
||||
videoEl.playbackRate = rate;
|
||||
videoEl.play().catch(() => { if (!joinedAudio) $('#tapjoin').classList.add('show'); });
|
||||
} else {
|
||||
videoEl.pause();
|
||||
if (typeof v.time === 'number' && Math.abs(videoEl.currentTime - v.time) > 0.4) videoEl.currentTime = v.time;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user