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
+312
View File
@@ -0,0 +1,312 @@
:root {
--bg: #0d0f22;
--bg-2: #151935;
--panel: #1b2044;
--ink: #eef0ff;
--muted: #9aa1d6;
--faint: #6a6fa0;
--line: rgba(255, 255, 255, 0.10);
--line-strong: rgba(255, 255, 255, 0.18);
--accent: #ffb020; /* "on air" signal amber — the one bold note */
--accent-ink: #2a1c00;
--indigo: #7b86ff;
--ok: #47d6a0;
--danger: #ff6b6b;
--radius: 14px;
--shadow: 0 24px 60px -24px rgba(0, 0, 0, 0.7);
--display: "Space Grotesk", ui-sans-serif, system-ui, sans-serif;
--body: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: var(--body);
color: var(--ink);
background:
radial-gradient(1200px 600px at 82% -8%, rgba(123, 134, 255, 0.16), transparent 60%),
radial-gradient(900px 500px at 6% 108%, rgba(255, 176, 32, 0.10), transparent 55%),
var(--bg);
-webkit-font-smoothing: antialiased;
line-height: 1.5;
}
a { color: inherit; }
h1, h2, h3 { font-family: var(--display); font-weight: 600; letter-spacing: -0.02em; margin: 0; }
.wrap { max-width: 1120px; margin: 0 auto; padding: 28px 22px 72px; }
/* --- top bar --- */
.topbar {
display: flex; align-items: center; justify-content: space-between;
gap: 16px; padding-bottom: 26px;
}
.brand { display: flex; align-items: center; gap: 12px; font-family: var(--display); font-weight: 600; }
.lamp {
width: 12px; height: 12px; border-radius: 50%;
background: var(--faint); box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.04);
}
.lamp.on { background: var(--accent); box-shadow: 0 0 14px 2px rgba(255, 176, 32, 0.7); animation: pulse 1.8s ease-in-out infinite; }
@keyframes pulse { 50% { opacity: 0.55; } }
.brand small { color: var(--muted); font-weight: 400; letter-spacing: 0.02em; }
.eyebrow {
font-family: var(--display); text-transform: uppercase; letter-spacing: 0.22em;
font-size: 11px; color: var(--accent); font-weight: 600;
}
/* --- buttons --- */
.btn {
font: inherit; font-weight: 600; cursor: pointer; border: 1px solid var(--line-strong);
background: var(--panel); color: var(--ink); padding: 10px 16px; border-radius: 10px;
display: inline-flex; align-items: center; gap: 8px; transition: transform .06s ease, background .15s ease, border-color .15s ease;
}
.btn:hover { border-color: var(--indigo); }
.btn:active { transform: translateY(1px); }
.btn.primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
.btn.primary:hover { filter: brightness(1.05); }
.btn.ghost { background: transparent; }
.btn.danger { color: var(--danger); border-color: rgba(255, 107, 107, 0.35); background: transparent; }
.btn.small { padding: 7px 11px; font-size: 13px; }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn:focus-visible, a:focus-visible, input:focus-visible { outline: 2px solid var(--indigo); outline-offset: 2px; }
/* --- cards --- */
.card {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent), var(--bg-2);
border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow);
}
/* --- upload dropzone --- */
.hero { display: grid; grid-template-columns: 1.1fr 0.9fr; gap: 26px; align-items: stretch; }
.hero h1 { font-size: clamp(30px, 5vw, 48px); line-height: 1.02; }
.hero p.lede { color: var(--muted); font-size: 17px; margin: 16px 0 0; max-width: 42ch; }
.drop {
margin-top: 24px; border: 1.5px dashed var(--line-strong); border-radius: var(--radius);
padding: 30px; text-align: center; background: rgba(255, 255, 255, 0.015);
transition: border-color .15s ease, background .15s ease;
}
.drop.drag { border-color: var(--accent); background: rgba(255, 176, 32, 0.06); }
.drop .big { font-family: var(--display); font-size: 18px; }
.drop .sub { color: var(--faint); font-size: 13px; margin-top: 6px; }
.field { margin-top: 14px; }
.field input[type="text"] {
width: 100%; font: inherit; color: var(--ink); background: var(--bg); border: 1px solid var(--line-strong);
padding: 11px 13px; border-radius: 10px;
}
.progress { height: 8px; border-radius: 99px; background: rgba(255,255,255,0.08); overflow: hidden; margin-top: 14px; display: none; }
.progress > i { display: block; height: 100%; width: 0; background: var(--accent); transition: width .2s ease; }
/* --- session list --- */
.section-head { display: flex; align-items: baseline; justify-content: space-between; margin: 44px 0 16px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
.tile { padding: 16px; display: flex; gap: 14px; }
.tile .qr { width: 84px; height: 84px; border-radius: 10px; background: #fff; padding: 6px; flex: none; }
.tile .qr img { width: 100%; height: 100%; display: block; }
.tile .meta { min-width: 0; flex: 1; }
.tile .title { font-family: var(--display); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tag { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; padding: 3px 8px; border-radius: 99px; border: 1px solid var(--line); color: var(--muted); text-transform: capitalize; }
.tag .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--muted); }
.tag.ready .dot { background: var(--ok); } .tag.ready { color: var(--ok); }
.tag.processing .dot { background: var(--accent); }
.tag.error .dot { background: var(--danger); } .tag.error { color: var(--danger); }
.tile .row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
.empty { color: var(--faint); border: 1px dashed var(--line); border-radius: var(--radius); padding: 34px; text-align: center; }
.note { font-size: 13px; color: var(--faint); margin-top: 10px; }
.warn { color: var(--accent); }
/* --- stage (present + view) --- */
.stage-page { display: flex; flex-direction: column; height: 100dvh; }
.stage-bar {
display: flex; align-items: center; gap: 12px; padding: 10px 16px;
border-bottom: 1px solid var(--line); background: rgba(9, 10, 26, 0.6); backdrop-filter: blur(8px);
}
.stage-bar .spacer { flex: 1; }
.pill { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; color: var(--muted);
border: 1px solid var(--line); border-radius: 99px; padding: 5px 11px; }
.pill b { color: var(--ink); }
.stage {
flex: 1; position: relative; display: flex; align-items: center; justify-content: center;
overflow: hidden; padding: 14px; min-height: 0;
}
.pdf-canvas, .stage video, .stage img { max-width: 100%; max-height: 100%; border-radius: 10px; box-shadow: var(--shadow); }
.stage video { background: #000; }
.stage .msg { color: var(--muted); text-align: center; max-width: 34ch; }
.stage .msg .spinner {
width: 34px; height: 34px; margin: 0 auto 16px; border-radius: 50%;
border: 3px solid var(--line-strong); border-top-color: var(--accent); animation: spin 0.9s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* presenter control bar */
.controls { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-top: 1px solid var(--line); flex-wrap: wrap; }
.pageind { font-family: var(--display); min-width: 74px; text-align: center; }
.seg { display: inline-flex; border: 1px solid var(--line-strong); border-radius: 10px; overflow: hidden; }
.seg button { font: inherit; font-weight: 600; background: transparent; color: var(--muted); border: 0; padding: 8px 12px; cursor: pointer; }
.seg button.active { background: var(--accent); color: var(--accent-ink); }
/* audience follow banner */
.followbar {
position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%);
display: none; align-items: center; gap: 10px;
background: var(--panel); border: 1px solid var(--line-strong); border-radius: 99px;
padding: 8px 8px 8px 16px; box-shadow: var(--shadow); font-size: 14px;
}
.followbar.show { display: flex; }
/* tap-to-join overlay for synced video (autoplay policy) */
.tapjoin {
position: absolute; inset: 0; display: none; flex-direction: column; align-items: center; justify-content: center;
gap: 14px; background: rgba(8, 9, 22, 0.82); backdrop-filter: blur(4px); text-align: center; padding: 24px;
}
.tapjoin.show { display: flex; }
.tapjoin .big { font-family: var(--display); font-size: 22px; }
/* --- present: QR sidebar --- */
.share { display: grid; grid-template-columns: 260px 1fr; gap: 20px; align-items: center; }
.ticket {
background: #fff; color: #111; border-radius: 16px; padding: 16px; text-align: center; box-shadow: var(--shadow);
}
.ticket img { width: 100%; display: block; border-radius: 8px; }
.ticket .cap { font-family: var(--display); font-weight: 600; margin-top: 10px; font-size: 14px; color: #333; }
.linkbox { display: flex; gap: 8px; margin-top: 12px; }
.linkbox input { flex: 1; font: inherit; background: var(--bg); color: var(--ink); border: 1px solid var(--line-strong); border-radius: 10px; padding: 10px 12px; }
.toast { position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%) translateY(20px);
background: var(--ink); color: #111; padding: 10px 16px; border-radius: 10px; font-weight: 600;
opacity: 0; pointer-events: none; transition: all .25s ease; z-index: 50; }
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
/* modal (login + share) */
.overlay { position: fixed; inset: 0; background: rgba(6, 7, 18, 0.7); display: none; align-items: center; justify-content: center; padding: 20px; z-index: 40; }
.overlay.show { display: flex; }
.modal { width: 100%; max-width: 420px; padding: 26px; }
.modal h2 { font-size: 22px; }
.modal p { color: var(--muted); margin: 8px 0 18px; }
@media (max-width: 820px) {
.hero { grid-template-columns: 1fr; }
.share { grid-template-columns: 1fr; }
.ticket { max-width: 300px; margin: 0 auto; }
}
/* ===================== v1.2 additions ===================== */
/* footer (every page except the login page) */
.app-footer {
text-align: center; color: var(--faint); font-size: 12.5px; letter-spacing: 0.01em;
padding: 22px 16px; border-top: 1px solid var(--line); margin-top: 40px;
}
.stage-page .app-footer { margin-top: 0; padding: 7px 16px; font-size: 11.5px; flex: none; }
/* login page */
.login-shell { min-height: 100dvh; display: grid; place-items: center; padding: 22px; }
.login-card { width: 100%; max-width: 400px; padding: 30px; }
.login-card .brand { justify-content: center; margin-bottom: 4px; font-size: 20px; }
.login-card h1 { font-size: 24px; text-align: center; margin-top: 14px; }
.login-card p.sub { color: var(--muted); text-align: center; margin: 6px 0 22px; }
.form-row { margin-bottom: 12px; }
.form-row label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 6px; }
.input {
width: 100%; font: inherit; color: var(--ink); background: var(--bg);
border: 1px solid var(--line-strong); padding: 11px 13px; border-radius: 10px;
}
.input:focus-visible { outline: 2px solid var(--indigo); outline-offset: 1px; }
.err { color: var(--danger); font-size: 13px; min-height: 18px; margin-top: 4px; }
/* user menu in the top bar */
.usermenu { display: flex; align-items: center; gap: 12px; }
.usermenu .who { font-size: 13px; color: var(--muted); }
.usermenu .who b { color: var(--ink); }
.role-badge { font-size: 10px; text-transform: uppercase; letter-spacing: 0.12em; padding: 2px 7px; border-radius: 99px; border: 1px solid var(--line-strong); color: var(--muted); }
.role-badge.admin { color: var(--accent); border-color: rgba(255,176,32,0.4); }
/* tabs (management page) */
.tabs { display: flex; gap: 6px; border-bottom: 1px solid var(--line); margin: 18px 0 22px; }
.tabs button {
font: inherit; font-weight: 600; background: transparent; border: 0; color: var(--muted);
padding: 12px 14px; cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px;
}
.tabs button.active { color: var(--ink); border-bottom-color: var(--accent); }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* tables */
.table { width: 100%; border-collapse: collapse; }
.table th, .table td { text-align: left; padding: 11px 12px; border-bottom: 1px solid var(--line); font-size: 14px; }
.table th { color: var(--faint); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; }
.table td .row { display: flex; gap: 6px; flex-wrap: wrap; }
.mono { font-variant-numeric: tabular-nums; color: var(--muted); font-size: 13px; }
/* forms grid */
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.grid2 .full { grid-column: 1 / -1; }
.check { display: flex; align-items: center; gap: 9px; color: var(--muted); font-size: 14px; }
.check input { width: 17px; height: 17px; accent-color: var(--accent); }
.panel-pad { padding: 22px; }
.hint { color: var(--faint); font-size: 13px; margin: 4px 0 0; }
/* log stream */
.logs { font-size: 13px; }
.log-line { display: grid; grid-template-columns: 150px 80px 130px 1fr; gap: 12px; padding: 8px 10px; border-bottom: 1px solid var(--line); align-items: baseline; }
.log-line .lv { text-transform: uppercase; font-size: 10px; letter-spacing: 0.1em; }
.log-line .lv.warn { color: var(--accent); } .log-line .lv.error { color: var(--danger); } .log-line .lv.info { color: var(--faint); }
.log-line .actor { color: var(--muted); }
@media (max-width: 720px) {
.grid2 { grid-template-columns: 1fr; }
.log-line { grid-template-columns: 1fr; gap: 2px; }
}
/* ===================== v1.3 additions ===================== */
/* brand: logo image + full product name */
.brand-logo { height: 30px; width: auto; display: block; flex: none; }
.brand-name { font-family: var(--display); font-weight: 600; letter-spacing: -0.01em; }
.brand-name small { color: var(--muted); font-weight: 400; }
.login-logo { display: block; width: 190px; max-width: 72%; height: auto; margin: 2px auto 8px; }
/* presentation cards: twice as wide and 50% taller */
.grid { grid-template-columns: repeat(auto-fill, minmax(min(600px, 100%), 1fr)); }
.tile { min-height: 200px; padding: 22px; gap: 20px; align-items: center; }
.tile .qr { width: 132px; height: 132px; }
.tile .title { font-size: 19px; }
/* large-format QR page */
.qr-page { min-height: 100dvh; display: flex; flex-direction: column; }
.qr-head { padding: 14px 20px; border-bottom: 1px solid var(--line); }
.qr-stage { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 26px; gap: 14px; }
.qr-stage h1 { font-size: clamp(24px, 4vw, 40px); }
.qr-big { background: #fff; border-radius: 18px; padding: 22px; box-shadow: var(--shadow); }
.qr-big img { display: block; width: min(58vh, 78vw); height: auto; image-rendering: pixelated; }
.qr-big .msg { width: min(58vh, 78vw); aspect-ratio: 1; display: flex; align-items: center; justify-content: center; color: var(--faint); }
.qr-link { color: var(--indigo); word-break: break-all; font-size: 15px; }
.qr-actions { display: flex; gap: 10px; margin-top: 6px; flex-wrap: wrap; justify-content: center; }
@media print {
.qr-head, .qr-actions, .qr-link, .app-footer, .eyebrow { display: none !important; }
body { background: #fff; }
.qr-big { box-shadow: none; }
.qr-stage h1 { color: #111; }
}
/* ===================== v1.5 additions ===================== */
/* chrome-free projection surface (Present Local Only) */
.display-stage {
position: fixed; inset: 0; background: #000;
display: flex; align-items: center; justify-content: center; overflow: hidden;
}
.display-stage .pdf-canvas,
.display-stage img,
.display-stage video {
max-width: 100vw; max-height: 100vh; width: auto; height: auto;
border-radius: 0; box-shadow: none; display: block;
}
.display-stage video { background: #000; }
.display-msg {
color: var(--muted); font-family: var(--body); font-size: 18px; text-align: center; padding: 24px;
}
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Presentation — MPS</title>
<link rel="stylesheet" href="/css/styles.css" />
</head>
<body>
<!-- Chrome-free display: the presentation only, no header text and no controls.
It mirrors whatever the presenter console (the other tab) is showing. -->
<div class="display-stage" id="stage"></div>
<script src="/socket.io/socket.io.js"></script>
<script type="module" src="/js/display.js"></script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

+79
View File
@@ -0,0 +1,79 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>QR Presentation — Host</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/styles.css" />
</head>
<body>
<div class="wrap">
<header class="topbar">
<div class="brand"><img class="brand-logo" src="/img/mps-icon.png" alt="" /> <span class="brand-name">MPS Martinhal Presentation System</span></div>
<div class="usermenu" id="usermenu">
<span class="who"></span>
<a class="btn ghost small" id="manageBtn" href="/users" style="display:none;">Manage users</a>
<button class="btn ghost small" id="logoutBtn">Sign out</button>
</div>
</header>
<section class="hero">
<div>
<div class="eyebrow">Host console</div>
<h1>Put your deck on every phone in the room.</h1>
<p class="lede">Drop in a video, PowerPoint, Word doc, Excel sheet, PDF, or image. We turn it into one shareable link and a QR code. Whoever scans it watches along — in sync with you, or at their own pace.</p>
<p class="note" id="hostWarn"></p>
</div>
<div class="card" style="padding:20px;">
<div class="eyebrow">New presentation</div>
<div class="field">
<input type="text" id="title" placeholder="Title (optional)" autocomplete="off" />
</div>
<div class="drop" id="drop">
<div class="big">Drop a file here</div>
<div class="sub" id="dropSub">or click to browse</div>
<input type="file" id="file" hidden />
</div>
<div class="progress" id="progress"><i></i></div>
<p class="note" id="uploadNote"></p>
</div>
</section>
<div class="section-head">
<h2>Your presentations</h2>
<button class="btn ghost small" id="refresh">Refresh</button>
</div>
<div id="list"><div class="empty">Loading…</div></div>
</div>
<!-- email share modal -->
<div class="overlay" id="emailOverlay">
<div class="card modal">
<div class="eyebrow">Share by email</div>
<h2 id="emailTitle">Send the join link</h2>
<p>The recipient gets the link and a QR code to open the presentation.</p>
<div class="form-row">
<label>Recipients (comma or space separated)</label>
<input class="input" id="emailTo" placeholder="alex@example.com, sam@example.com" />
</div>
<div class="form-row">
<label>Message (optional)</label>
<input class="input" id="emailMsg" placeholder="Join my talk at 3pm" />
</div>
<div class="err" id="emailErr"></div>
<div style="display:flex; gap:8px; justify-content:flex-end; margin-top:8px;">
<button class="btn ghost" id="emailCancel">Cancel</button>
<button class="btn primary" id="emailSend">Send</button>
</div>
</div>
</div>
<footer class="app-footer">© 2026 Martinhal IT - Joao Vaz - Version 1.7</footer>
<div class="toast" id="toast"></div>
<script type="module" src="/js/app.js"></script>
</body>
</html>
+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();
+68
View File
@@ -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;
}
}
+60
View File
@@ -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();
});
+90
View File
@@ -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); }
}
+125
View File
@@ -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>');
}
+38
View File
@@ -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();
+138
View File
@@ -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) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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();
})();
+111
View File
@@ -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;
}
}
+57
View File
@@ -0,0 +1,57 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Sign in — MPS</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/styles.css" />
</head>
<body>
<div class="login-shell">
<div class="card login-card">
<!-- SIGN IN -->
<div id="loginView">
<div class="form-row">
<label for="username">Username</label>
<input class="input" id="username" autocomplete="username" />
</div>
<div class="form-row">
<label for="password">Password</label>
<input class="input" id="password" type="password" autocomplete="current-password" />
</div>
<div class="err" id="err"></div>
<button class="btn primary" id="signin" style="width:100%; justify-content:center;">Sign in</button>
</div>
<!-- FIRST-RUN SETUP -->
<div id="setupView" style="display:none;">
<h1>Welcome</h1>
<p class="sub">This is the first run. Create the administrator account to get started.</p>
<div class="form-row">
<label for="s_username">Admin username</label>
<input class="input" id="s_username" autocomplete="username" />
</div>
<div class="form-row">
<label for="s_email">Email <span class="hint">(optional, used for mail tests)</span></label>
<input class="input" id="s_email" autocomplete="email" />
</div>
<div class="form-row">
<label for="s_password">Password</label>
<input class="input" id="s_password" type="password" autocomplete="new-password" />
</div>
<div class="form-row">
<label for="s_password2">Confirm password</label>
<input class="input" id="s_password2" type="password" autocomplete="new-password" />
</div>
<div class="err" id="s_err"></div>
<button class="btn primary" id="createAdmin" style="width:100%; justify-content:center;">Create admin account</button>
</div>
</div>
</div>
<script type="module" src="/js/login.js"></script>
</body>
</html>
+50
View File
@@ -0,0 +1,50 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Presenting — QR Presentation</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/styles.css" />
</head>
<body>
<div class="stage-page">
<div class="stage-bar">
<div class="brand"><img class="brand-logo" src="/img/mps-icon.png" alt="" /> <span class="lamp on"></span> <span id="deckTitle">Presenting</span></div>
<div class="spacer"></div>
<span class="pill"><b id="viewerCount">0</b> watching</span>
<a class="btn small" id="qrPageBtn" target="_blank" rel="noopener">QR Code</a>
<button class="btn danger small" id="endBtn" title="Invalidate the QR code and end for everyone">End session</button>
<a class="btn ghost small" href="/">Exit</a>
</div>
<div class="stage" id="stage">
<div class="msg"><div class="spinner"></div><div id="stageMsg">Connecting…</div></div>
</div>
<div class="controls" id="controls" style="display:none;">
<div class="seg" id="modeSeg" title="Synced: everyone follows you. Free: audience browses on their own.">
<button data-mode="synced" class="active">Synced</button>
<button data-mode="free">Free browse</button>
</div>
<div class="spacer" style="flex:1"></div>
<div id="pdfControls" style="display:none; align-items:center; gap:8px;">
<button class="btn" id="first" title="First slide (Home)">« First</button>
<button class="btn" id="prev" title="Previous (←)"> Prev</button>
<span class="pageind" id="pageInd"> / </span>
<button class="btn" id="next" title="Next (→)">Next </button>
<button class="btn" id="last" title="Last slide (End)">Last »</button>
</div>
<div id="videoHint" class="note" style="display:none;">Use the video controls — play, pause, and seeking sync to the audience.</div>
</div>
<footer class="app-footer">© 2026 Martinhal IT - Joao Vaz - Version 1.7</footer>
</div>
<div class="toast" id="toast"></div>
<script src="/socket.io/socket.io.js"></script>
<script type="module" src="/js/present.js"></script>
</body>
</html>
+35
View File
@@ -0,0 +1,35 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>QR Code — MPS</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/styles.css" />
</head>
<body>
<div class="qr-page">
<div class="qr-head">
<div class="brand"><img class="brand-logo" src="/img/mps-icon.png" alt="" /> <span class="brand-name">MPS Martinhal Presentation System</span></div>
</div>
<div class="qr-stage">
<div class="eyebrow" id="eyebrow">Scan to watch along</div>
<h1 id="qrTitle">Presentation</h1>
<div class="qr-big" id="qrBig"><div class="msg"><div class="spinner"></div>Loading QR code…</div></div>
<a class="qr-link" id="qrLink" href="#" target="_blank" rel="noopener"></a>
<div class="qr-actions">
<button class="btn" id="copyBtn">Copy link</button>
<button class="btn ghost" id="fsBtn">Fullscreen</button>
<button class="btn ghost" id="printBtn">Print</button>
</div>
</div>
<footer class="app-footer">© 2026 Martinhal IT - Joao Vaz - Version 1.7</footer>
</div>
<div class="toast" id="toast"></div>
<script type="module" src="/js/qr.js"></script>
</body>
</html>
+96
View File
@@ -0,0 +1,96 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>User management — QR Presentation</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/styles.css" />
</head>
<body>
<div class="wrap">
<header class="topbar">
<div class="brand"><img class="brand-logo" src="/img/mps-icon.png" alt="" /> <span class="brand-name">MPS <small>/ management</small></span></div>
<div class="usermenu">
<a class="btn ghost small" href="/">← Presentations</a>
<button class="btn ghost small" id="logoutBtn">Sign out</button>
</div>
</header>
<div class="eyebrow">Admin</div>
<h1 style="font-size:30px;margin-top:6px;">Management</h1>
<div class="tabs" id="tabs">
<button data-tab="users" class="active">Users</button>
<button data-tab="mail">Mail</button>
<button data-tab="logs">Logs</button>
</div>
<!-- USERS -->
<section class="tab-panel active" id="tab-users">
<div class="card panel-pad" style="margin-bottom:18px;">
<div class="eyebrow">Add a user</div>
<div class="grid2" style="margin-top:12px;">
<div><label class="hint">Username</label><input class="input" id="nu_name" /></div>
<div><label class="hint">Email</label><input class="input" id="nu_email" placeholder="optional" /></div>
<div><label class="hint">Password</label><input class="input" id="nu_pass" type="password" /></div>
<div><label class="hint">Role</label>
<select class="input" id="nu_role"><option value="user">User</option><option value="admin">Admin</option></select>
</div>
</div>
<div class="err" id="nu_err"></div>
<button class="btn primary" id="nu_add" style="margin-top:6px;">Create user</button>
</div>
<div class="card">
<table class="table" id="userTable">
<thead><tr><th>Username</th><th>Email</th><th>Role</th><th>Created</th><th></th></tr></thead>
<tbody></tbody>
</table>
</div>
</section>
<!-- MAIL -->
<section class="tab-panel" id="tab-mail">
<div class="card panel-pad">
<div class="eyebrow">Mail account (PurelyMail)</div>
<p class="hint">Enter the full email address and password of a PurelyMail account. Servers are prefilled; leave the password blank to keep the stored one.</p>
<label class="check" style="margin:14px 0;"><input type="checkbox" id="m_enabled" /> Enable email sending</label>
<div class="grid2">
<div class="full"><label class="hint">Account email (SMTP/IMAP username)</label><input class="input" id="m_username" placeholder="you@yourdomain.com" /></div>
<div class="full"><label class="hint">Password</label><input class="input" id="m_password" type="password" placeholder="•••••••• (unchanged)" /></div>
<div class="full"><label class="hint">From name</label><input class="input" id="m_fromName" /></div>
<div><label class="hint">SMTP host</label><input class="input" id="m_smtpHost" /></div>
<div><label class="hint">SMTP port</label><input class="input" id="m_smtpPort" /></div>
<div><label class="hint">IMAP host</label><input class="input" id="m_imapHost" /></div>
<div><label class="hint">IMAP port</label><input class="input" id="m_imapPort" /></div>
<div><label class="hint">Sent folder</label><input class="input" id="m_sentFolder" /></div>
<div style="display:flex;align-items:flex-end;">
<label class="check"><input type="checkbox" id="m_saveToSent" /> Save a copy to Sent</label>
</div>
</div>
<div class="err" id="m_err"></div>
<div class="row" style="display:flex;gap:8px;margin-top:6px;">
<button class="btn primary" id="m_save">Save settings</button>
<input class="input" id="m_testTo" placeholder="test recipient" style="max-width:240px;" />
<button class="btn ghost" id="m_test">Send test</button>
</div>
</div>
</section>
<!-- LOGS -->
<section class="tab-panel" id="tab-logs">
<div style="display:flex;gap:8px;justify-content:flex-end;margin-bottom:10px;">
<button class="btn ghost small" id="logRefresh">Refresh</button>
<button class="btn danger small" id="logClear">Clear</button>
</div>
<div class="card logs" id="logList"></div>
</section>
</div>
<footer class="app-footer">© 2026 Martinhal IT - Joao Vaz - Version 1.7</footer>
<div class="toast" id="toast"></div>
<script type="module" src="/js/users.js"></script>
</body>
</html>
+22
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+51
View File
@@ -0,0 +1,51 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Watching — QR Presentation</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/styles.css" />
</head>
<body>
<div class="stage-page">
<div class="stage-bar">
<div class="brand"><img class="brand-logo" src="/img/mps-icon.png" alt="" /> <span class="lamp on" id="lamp"></span> <span id="deckTitle">Presentation</span></div>
<div class="spacer"></div>
<span class="pill" id="statusPill"><b id="statusText">Connecting…</b></span>
</div>
<div class="stage" id="stage">
<div class="msg"><div class="spinner"></div><div id="stageMsg">Connecting…</div></div>
<div class="tapjoin" id="tapjoin">
<div class="big">Tap to join with sound</div>
<button class="btn primary" id="tapBtn">Join presentation</button>
</div>
<div class="followbar" id="followbar">
<span>You're browsing on your own</span>
<button class="btn primary small" id="followBtn">Rejoin presenter</button>
</div>
</div>
<div class="controls" id="controls" style="display:none;">
<div class="spacer" style="flex:1"></div>
<button class="btn" id="first" title="First slide">«</button>
<button class="btn" id="prev" title="Previous"></button>
<span class="pageind" id="pageInd"> / </span>
<button class="btn" id="next" title="Next"></button>
<button class="btn" id="last" title="Last slide">»</button>
<div class="spacer" style="flex:1"></div>
</div>
<footer class="app-footer">© 2026 Martinhal IT - Joao Vaz - Version 1.7</footer>
</div>
<div class="toast" id="toast"></div>
<script src="/socket.io/socket.io.js"></script>
<script type="module" src="/js/view.js"></script>
</body>
</html>