mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-11 02:13:48 +00:00
feat: add self-contained legacy timer viewer for old browsers
Adds timer-legacy.html — a zero-dependency static page that connects to the Ontime WebSocket and displays the live countdown for browsers that cannot parse the Vite-built React bundle (Safari < 14, Chrome < 85). Also adds a synchronous pre-React guard in index.html that redirects /timer visitors to the legacy page when Promise.any is unavailable, matching the Vite build target floor of Safari 14 / Chrome 85. https://claude.ai/code/session_013MfdwdjdDUnr3akpGSGXa4
This commit is contained in:
@@ -18,6 +18,13 @@
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script>
|
||||
if (/\/timer\/?$/.test(window.location.pathname) && typeof Promise.any !== 'function') {
|
||||
var b = document.querySelector('base');
|
||||
var base = b ? (b.getAttribute('href') || '').replace(/\/$/, '') : '';
|
||||
window.location.replace(base + '/timer-legacy' + window.location.search);
|
||||
}
|
||||
</script>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -32,7 +32,7 @@ import { runtimeService } from './services/runtime-service/runtime.service.js';
|
||||
import { timerConfig } from './setup/config.js';
|
||||
import { environment, isProduction } from './setup/environment.js';
|
||||
// import utils
|
||||
import { publicDir, srcDir } from './setup/index.js';
|
||||
import { publicDir, srcDir, srcFiles } from './setup/index.js';
|
||||
import { populateDemo } from './setup/loadDemo.js';
|
||||
import { populateStyles } from './setup/loadStyles.js';
|
||||
import { populateTranslation } from './setup/loadTranslations.js';
|
||||
@@ -113,6 +113,11 @@ app.use(`${prefix}/external`, (req, res) => {
|
||||
});
|
||||
app.use(`${prefix}/user`, express.static(publicDir.userDir, { etag: false, lastModified: true }));
|
||||
|
||||
// Serve legacy timer for old browsers that don't support modern JS
|
||||
app.get(`${prefix}/timer-legacy`, authenticateAndRedirect, (_req, res) => {
|
||||
res.sendFile(srcFiles.timerLegacy);
|
||||
});
|
||||
|
||||
// Base route for static files
|
||||
app.use(`${prefix}`, authenticateAndRedirect, compressedStatic);
|
||||
app.use(`${prefix}/*splat`, authenticateAndRedirect, compressedStatic);
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Timer view for legacy browsers" />
|
||||
<title>Ontime Timer</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #121212;
|
||||
color: #ffffff;
|
||||
font-family: sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: -webkit-flex;
|
||||
display: flex;
|
||||
-webkit-flex-direction: column;
|
||||
flex-direction: column;
|
||||
-webkit-align-items: center;
|
||||
align-items: center;
|
||||
-webkit-justify-content: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#timer {
|
||||
font-size: 20vmin;
|
||||
font-weight: bold;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.025em;
|
||||
line-height: 1;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
#timer.short {
|
||||
font-size: 35vmin;
|
||||
}
|
||||
|
||||
#timer.warning {
|
||||
color: #ffa528;
|
||||
}
|
||||
|
||||
#timer.danger {
|
||||
color: #ff7300;
|
||||
}
|
||||
|
||||
#timer.overtime {
|
||||
color: #fa5656;
|
||||
}
|
||||
|
||||
#title {
|
||||
color: #999999;
|
||||
font-size: 5vmin;
|
||||
margin-top: 3vmin;
|
||||
text-align: center;
|
||||
padding-left: 4vmin;
|
||||
padding-right: 4vmin;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#progress-container {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4vmin;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
display: none;
|
||||
}
|
||||
|
||||
#progress-fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: #ffffff;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="container">
|
||||
<div id="timer">--:--</div>
|
||||
<div id="title"></div>
|
||||
</div>
|
||||
<div id="progress-container">
|
||||
<div id="progress-fill"></div>
|
||||
</div>
|
||||
<script>
|
||||
var timerEl = document.getElementById('timer');
|
||||
var titleEl = document.getElementById('title');
|
||||
var progressContainerEl = document.getElementById('progress-container');
|
||||
var progressFillEl = document.getElementById('progress-fill');
|
||||
|
||||
var phaseColors = { warning: '#ffa528', danger: '#ff7300', overtime: '#fa5656' };
|
||||
|
||||
var state = { timerCurrent: null, timerPhase: 'none', timerDuration: null, eventTitle: null };
|
||||
|
||||
function pad(n) {
|
||||
return n < 10 ? '0' + n : String(n);
|
||||
}
|
||||
|
||||
function formatMs(ms) {
|
||||
var sign = ms < 0 ? '-' : '';
|
||||
var abs = Math.abs(ms);
|
||||
var h = Math.floor(abs / 3600000);
|
||||
var m = Math.floor((abs % 3600000) / 60000);
|
||||
var s = Math.floor((abs % 60000) / 1000);
|
||||
|
||||
// remove the hours if there is nothing to show
|
||||
if (h === 0) {
|
||||
return sign + pad(m) + ':' + pad(s);
|
||||
}
|
||||
|
||||
return sign + pad(h) + ':' + pad(m) + ':' + pad(s);
|
||||
}
|
||||
|
||||
function render() {
|
||||
var text = state.timerCurrent !== null ? formatMs(state.timerCurrent) : '--:--:--';
|
||||
var hasHours = text.lastIndexOf(':') !== text.indexOf(':');
|
||||
timerEl.textContent = text;
|
||||
var phase = state.timerPhase;
|
||||
var cls = phase === 'warning' || phase === 'danger' || phase === 'overtime' ? phase : '';
|
||||
if (!hasHours) {
|
||||
cls = cls ? cls + ' short' : 'short';
|
||||
}
|
||||
timerEl.className = cls;
|
||||
if (state.eventTitle) {
|
||||
titleEl.textContent = state.eventTitle;
|
||||
titleEl.style.display = 'block';
|
||||
} else {
|
||||
titleEl.style.display = 'none';
|
||||
}
|
||||
|
||||
if (state.timerDuration > 0 && state.timerCurrent !== null) {
|
||||
var elapsed = state.timerDuration - Math.max(0, state.timerCurrent);
|
||||
var pct = Math.min(100, Math.max(0, (elapsed / state.timerDuration) * 100));
|
||||
progressFillEl.style.width = pct + '%';
|
||||
progressFillEl.style.backgroundColor = phaseColors[phase] || '#ffffff';
|
||||
progressContainerEl.style.display = 'block';
|
||||
} else {
|
||||
progressContainerEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function applyPatch(payload) {
|
||||
if (payload.timer) {
|
||||
if (payload.timer.current !== undefined) state.timerCurrent = payload.timer.current;
|
||||
if (payload.timer.duration !== undefined) state.timerDuration = payload.timer.duration;
|
||||
if (payload.timer.phase !== undefined) state.timerPhase = payload.timer.phase;
|
||||
}
|
||||
if ('eventNow' in payload) {
|
||||
state.eventTitle = payload.eventNow ? payload.eventNow.title : null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveConnectionUrl() {
|
||||
var isSecure = window.location.protocol === 'https:';
|
||||
var stageHash = '';
|
||||
var href = window.location.href;
|
||||
|
||||
if (href.indexOf('getontime.no') !== -1) {
|
||||
var hash = href.split('/')[3];
|
||||
stageHash = '/' + hash;
|
||||
}
|
||||
|
||||
if (isSecure) {
|
||||
return 'wss://' + window.location.host + stageHash + '/ws';
|
||||
}
|
||||
return 'ws://' + window.location.host + stageHash + '/ws';
|
||||
}
|
||||
|
||||
var reconnectDelay = 1000;
|
||||
|
||||
function connect() {
|
||||
var wsUrl = resolveConnectionUrl();
|
||||
var ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = function () {
|
||||
reconnectDelay = 1000;
|
||||
};
|
||||
|
||||
ws.onmessage = function (event) {
|
||||
try {
|
||||
var msg = JSON.parse(event.data);
|
||||
if (msg.tag === 'runtime-data') {
|
||||
applyPatch(msg.payload);
|
||||
render();
|
||||
}
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
ws.onclose = function () {
|
||||
setTimeout(connect, reconnectDelay);
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, 30000);
|
||||
};
|
||||
|
||||
ws.onerror = function () {
|
||||
ws.close();
|
||||
};
|
||||
}
|
||||
|
||||
connect();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -95,6 +95,8 @@ export const srcFiles = {
|
||||
translationReadme: join(srcDir.root, config.user, config.translations.directory, 'README.md'),
|
||||
/** Path to login */
|
||||
login: join(srcDir.root, 'html/login.html'),
|
||||
/** Path to legacy timer for old browsers */
|
||||
timerLegacy: join(srcDir.root, 'html/timer-legacy.html'),
|
||||
/** Path to ontime-logo */
|
||||
logo: join(srcDir.root, config.user, config.logo, 'ontime-logo.png'),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user