feat: UI access for custom views (#2020)

* style: consistent casing on menu

* refactor: bundle demo from code

* feat: allow uploading custom views
This commit is contained in:
Carlos Valente
2026-03-24 09:19:45 +01:00
committed by GitHub
parent 95536902f5
commit 7e3aaa8c30
39 changed files with 1799 additions and 817 deletions
+67
View File
@@ -0,0 +1,67 @@
export const defaultCss = `
/**
* This CSS file allows user customisation of the UI
* We expose some CSS properties to facilitate this (see below in :root)
* In the cases where this is missing, you can add your selectors here
*/
:root {
/** Background colour for the views */
--background-color-override: #ececec;
/** Main text colour for the views */
--color-override: #101010;
/** Text colour for the views */
--secondary-color-override: #404040;
/** Accent text colour, used on active elements */
--accent-color-override: #fa5656;
/** Label text colour, used on active elements */
--label-color-override: #6c6c6c;
/** Timer text colour */
--timer-color-override: #202020;
--timer-warning-color-override: #ffbc56;
--timer-danger-color-override: #e69000;
--timer-overtime-color-override: #fa5656;
--timer-pending-color-override: #578AF4;
/** Background for card elements on background */
--card-background-color-override: #fff;
/** Font used for all text in views */
--font-family-override: 'Open Sans';
/** Colour used for external message and aux timer in /timer */
--external-color-override: #161616;
/** View specific features: /backstage */
/** ---- Background highlight for blink behaviour */
--card-background-color-blink-override: #339e4e;
/** ---- Colour used for progress bar background */
--timer-progress-bg-override: #fff;
/** ---- Colour used for progress bar progress */
--timer-progress-override: #202020;
/** View specific features: /op */
--operator-customfield-font-size-override: 1.25rem;
--operator-running-bg-override: #339e4e;
/** View specific features: /studio */
--studio-active: #101010;
--studio-idle: #cfcfcf;
--studio-active-label: #101010;
--studio-idle-label: #595959;
--studio-overtime: #101010;
}
/**
* You can inspect the page in your browser and add the selectors here.
* In the below example, we change the colour of the overlay message in the stage-timer view.
*/
.stage-timer > .message-overlay--active > div {
color: red;
}
`;
+305
View File
@@ -0,0 +1,305 @@
export const defaultDemoHtml = `<!doctype html>
<html lang="en">
<head>
<!-- For detailed explanations and examples, refer to the README.md file in this directory -->
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>ontime demo</title>
<style>
body {
margin: 0;
padding: 0;
max-width: 100vw;
overflow-x: hidden;
font-family: 'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
font-size: 14px;
line-height: 1.4;
background: #f6f6f6;
color: #222;
}
.container {
display: flex;
flex-direction: row;
gap: 12px;
padding: 10px 12px;
}
.container .column {
flex: 1;
display: flex;
flex-direction: column;
gap: 10px;
}
.title-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: #eaeaea;
}
.logo-title {
display: flex;
align-items: center;
gap: 7px;
}
.logo-title img {
width: 28px;
height: 28px;
object-fit: contain;
}
.card {
padding: 10px 12px;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
}
.card summary.title {
border-bottom: 1px solid #ccc;
padding-bottom: 2px;
margin-bottom: 2px;
}
h1.title,
summary.title {
font-size: 0.95em;
font-weight: 600;
margin: 0;
user-select: none;
}
summary.title {
cursor: pointer;
}
code {
font-size: 0.75em;
font-family: monospace;
background: #f4f4f4;
border-radius: 4px;
padding: 1.5px 3px;
display: inline-block;
white-space: pre;
width: 100%;
}
figure {
margin: 0;
padding: 0;
}
figcaption.description {
color: #555;
font-size: 0.75em;
font-style: italic;
}
</style>
</head>
<body>
<header class="title-card">
<div class="logo-title">
<img
src="https://www.getontime.no/images/icons/ontime-logo.png"
alt="Ontime logo"
onerror="this.style.display = 'none'"
/>
<h1 class="title">Ontime demo</h1>
</div>
<div>
<span>Last message received at</span>
<span id="clock">-</span>
</div>
<nav>
<a href="https://docs.getontime.no/api/data/runtime-data" target="_blank">Help? See docs</a>
</nav>
</header>
<main class="container">
<section class="column">
<details class="card" open>
<summary class="title">Timer</summary>
<figure>
<figcaption class="description">Current timer values</figcaption>
<code id="timer">-</code>
</figure>
</details>
<details class="card" open>
<summary class="title">Rundown</summary>
<figure>
<figcaption class="description">Progress of the current rundown</figcaption>
<code id="rundown">-</code>
</figure>
</details>
<details class="card" open>
<summary class="title">Offset</summary>
<figure>
<figcaption class="description">Runtime offset and timings for upcoming targets</figcaption>
<code id="offset">-</code>
</figure>
</details>
</section>
<section class="column">
<details class="card" open>
<summary class="title">Event now</summary>
<figure>
<figcaption class="description">Currently loaded event</figcaption>
<code id="eventNow">-</code>
</figure>
</details>
<details class="card" open>
<summary class="title">Event next</summary>
<figure>
<figcaption class="description">Next scheduled event</figcaption>
<code id="eventNext">-</code>
</figure>
</details>
</section>
<section class="column">
<details class="card" open>
<summary class="title">Group now</summary>
<figure>
<figcaption class="description">Currently active group</figcaption>
<code id="groupNow">-</code>
</figure>
</details>
<details class="card" open>
<summary class="title">Event flag</summary>
<figure>
<figcaption class="description">Currently targeted flag</figcaption>
<code id="eventFlag">-</code>
</figure>
</details>
</section>
<section class="column">
<details class="card" open>
<summary class="title">Message</summary>
<figure>
<figcaption class="description">Messaging feature</figcaption>
<code id="message">-</code>
</figure>
</details>
<details class="card" open>
<summary class="title">Aux timers</summary>
<figure>
<figcaption class="description">Auxiliary Timer 1</figcaption>
<code id="auxtimer1">-</code>
</figure>
<figure>
<figcaption class="description">Auxiliary Timer 2</figcaption>
<code id="auxtimer2">-</code>
</figure>
<figure>
<figcaption class="description">Auxiliary Timer 3</figcaption>
<code id="auxtimer3">-</code>
</figure>
</details>
</section>
</main>
<script>
const isSecure = window.location.protocol === 'https:';
const userProvidedSocketUrl = \`\${isSecure ? 'wss' : 'ws'}://\${window.location.host}\${getStageHash()}/ws\`;
connectSocket();
let reconnectTimeout;
const reconnectInterval = 1000;
let reconnectAttempts = 0;
function connectSocket(socketUrl = userProvidedSocketUrl) {
const websocket = new WebSocket(socketUrl);
websocket.onopen = () => {
clearTimeout(reconnectTimeout);
reconnectAttempts = 0;
console.warn('WebSocket connected');
};
websocket.onclose = () => {
console.warn('WebSocket disconnected');
reconnectTimeout = setTimeout(() => {
console.warn(\`WebSocket: attempting reconnect \${reconnectAttempts}\`);
if (websocket && websocket.readyState === WebSocket.CLOSED) {
reconnectAttempts += 1;
connectSocket();
}
}, reconnectInterval);
};
websocket.onerror = (error) => {
console.error('WebSocket error:', error);
};
websocket.onmessage = (event) => {
const { tag, payload } = JSON.parse(event.data);
if (tag === 'runtime-data') {
handleOntimePayload(payload);
}
};
}
let localData = {};
function handleOntimePayload(payload) {
localData = { ...localData, ...payload };
if ('clock' in payload) updateDOM('clock', formatTimer(payload.clock));
if ('timer' in payload) updateDOM('timer', formatObject(payload.timer));
if ('rundown' in payload) updateDOM('rundown', formatObject(payload.rundown));
if ('offset' in payload) updateDOM('offset', formatObject(payload.offset));
if ('eventNow' in payload) updateDOM('eventNow', formatObject(payload.eventNow));
if ('eventNext' in payload) updateDOM('eventNext', formatObject(payload.eventNext));
if ('eventFlag' in payload) updateDOM('eventFlag', formatObject(payload.eventFlag));
if ('groupNow' in payload) updateDOM('groupNow', formatObject(payload.groupNow));
if ('message' in payload) updateDOM('message', formatObject(payload.message));
if ('auxtimer1' in payload) updateDOM('auxtimer1', formatObject(payload.auxtimer1));
if ('auxtimer2' in payload) updateDOM('auxtimer2', formatObject(payload.auxtimer2));
if ('auxtimer3' in payload) updateDOM('auxtimer3', formatObject(payload.auxtimer3));
}
function updateDOM(field, payload) {
const domElement = document.getElementById(field);
if (domElement) {
domElement.innerText = payload;
}
}
const millisToSeconds = 1000;
const millisToMinutes = 1000 * 60;
const millisToHours = 1000 * 60 * 60;
function formatTimer(number) {
if (number == null) {
return '--:--:--';
}
const millis = Math.abs(number);
const isNegative = number < 0;
return \`\${isNegative ? '-' : ''}\${leftPad(millis / millisToHours)}:\${leftPad(
(millis % millisToHours) / millisToMinutes,
)}:\${leftPad((millis % millisToMinutes) / millisToSeconds)}\`;
function leftPad(val) {
return Math.floor(val).toString().padStart(2, '0');
}
}
function formatObject(data) {
return JSON.stringify(data, null, 2);
}
function getStageHash() {
const href = window.location.href;
if (!href.includes('getontime.no')) {
return '';
}
const hash = href.split('/');
const stageHash = hash.at(3);
return stageHash ? \`/\${stageHash}\` : '';
}
</script>
</body>
</html>`;
@@ -0,0 +1,3 @@
import { langEn } from 'ontime-types';
export const defaultTranslation = JSON.stringify(langEn, null, 2);