mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-16 21:03:29 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e9de1ec19f |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "4.8.0",
|
||||
"version": "4.7.0",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
<link rel="icon" href="favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#101010" />
|
||||
<meta name="ontime" content="ontime - time keeping for live events" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="ontime" content="ontime - time keeping for live events" />
|
||||
<link rel="apple-touch-icon" href="ontime-logo.png" />
|
||||
<link rel="icon" type="image/png" href="ontime-logo.png" />
|
||||
<link rel="manifest" href="site.webmanifest" />
|
||||
@@ -18,13 +19,6 @@
|
||||
<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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "4.8.0",
|
||||
"version": "4.7.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
@@ -51,8 +51,7 @@
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all",
|
||||
"safari >= 14.1"
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
@@ -65,7 +64,6 @@
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@vitejs/plugin-legacy": "^8.0.1",
|
||||
"@vitejs/plugin-react": "5.2.0",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"ontime-types": "workspace:*",
|
||||
|
||||
@@ -14,6 +14,6 @@
|
||||
"scope": "./",
|
||||
"start_url": "./",
|
||||
"display": "",
|
||||
"theme_color": "#121212",
|
||||
"theme_color": "#101010",
|
||||
"background_color": "#101010"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "",
|
||||
"short_name": "",
|
||||
"icons": [{ "src": "ontime-logo.png", "sizes": "295x295", "type": "image/png" }],
|
||||
"theme_color": "#121212",
|
||||
"theme_color": "#101010",
|
||||
"background_color": "#101010",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/rele
|
||||
export const websiteUrl = 'https://www.getontime.no';
|
||||
export const discordUrl = 'https://discord.com/invite/eje3CSUEXm';
|
||||
export const subredditUrl = 'https://www.reddit.com/r/ontimeapp/';
|
||||
export const youtubeUrl = 'https://www.youtube.com/@ontimeapp';
|
||||
|
||||
export const documentationUrl = 'https://docs.getontime.no';
|
||||
export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
githubUrl,
|
||||
subredditUrl,
|
||||
websiteUrl,
|
||||
youtubeUrl,
|
||||
} from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import AppVersion from './AppVersion';
|
||||
@@ -39,7 +38,6 @@ export default function AboutPanel() {
|
||||
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
|
||||
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
|
||||
<ExternalLink href={discordUrl}>Discord server</ExternalLink>
|
||||
<ExternalLink href={youtubeUrl}>YouTube channel</ExternalLink>
|
||||
<ExternalLink href={subredditUrl}>Subreddit</ExternalLink>
|
||||
</Panel.Section>
|
||||
</>
|
||||
|
||||
+58
-16
@@ -4,6 +4,18 @@
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.section,
|
||||
.formSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: $aux-text-size;
|
||||
color: $label-gray;
|
||||
}
|
||||
|
||||
.triggerList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -13,13 +25,25 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.triggerHeader {
|
||||
display: grid;
|
||||
grid-template-columns: 8rem 1fr 2rem;
|
||||
.triggerForm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: $aux-text-size;
|
||||
color: $label-gray;
|
||||
}
|
||||
|
||||
.formFields {
|
||||
display: grid;
|
||||
grid-template-columns: 8rem 1fr;
|
||||
gap: 0.75rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.formActions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-height: 2rem;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
@@ -27,22 +51,40 @@
|
||||
display: grid;
|
||||
grid-template-columns: 8rem 1fr 2rem;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-height: 2.5rem;
|
||||
|
||||
&:not(:first-child) {
|
||||
border-top: 1px solid $white-10;
|
||||
}
|
||||
|
||||
&[data-duplicate] {
|
||||
> button:not(:last-child) {
|
||||
border-color: $orange-500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.duplicateMessage {
|
||||
padding-left: 0.75rem;
|
||||
.triggerMeta {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.metaLabel {
|
||||
color: $label-gray;
|
||||
font-size: $aux-text-size;
|
||||
color: $orange-500;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.automationTitle {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.validationError,
|
||||
.validationSuccess {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: $aux-text-size;
|
||||
}
|
||||
|
||||
.validationError {
|
||||
color: $red-500;
|
||||
}
|
||||
|
||||
.validationSuccess {
|
||||
color: $green-500;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Trigger } from 'ontime-types';
|
||||
import { NormalisedAutomation, TimerLifeCycle, Trigger, timerLifecycleValues } from 'ontime-types';
|
||||
import { generateId } from 'ontime-utils';
|
||||
import { IoAdd, IoTrash } from 'react-icons/io5';
|
||||
import { Fragment, useCallback, useMemo, useState } from 'react';
|
||||
import { IoAlertCircle, IoCheckmarkCircle, IoTrash } from 'react-icons/io5';
|
||||
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import Select from '../../../../common/components/select/Select';
|
||||
import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
|
||||
@@ -19,108 +21,174 @@ interface EventEditorTriggersProps {
|
||||
|
||||
export default function EventEditorTriggers({ triggers, eventId }: EventEditorTriggersProps) {
|
||||
const { data: automationSettings, status: automationStatus } = useAutomationSettings();
|
||||
const { updateEntry } = useEntryActionsContext();
|
||||
const automationsEnabled = automationStatus === 'pending' ? undefined : automationSettings.enabledAutomations;
|
||||
|
||||
const allAutomationOptions = Object.values(automationSettings.automations).map(({ id, title }) => ({
|
||||
value: id,
|
||||
label: title,
|
||||
}));
|
||||
const hasAutomationOptions = allAutomationOptions.length > 0;
|
||||
const triggerOptions = eventTriggerOptions.map((cycle) => ({ value: cycle, label: cycle }));
|
||||
|
||||
const duplicateIds = new Set<string>();
|
||||
const seen = new Map<string, string>();
|
||||
for (const trigger of triggers) {
|
||||
const key = `${trigger.trigger}:${trigger.automationId}`;
|
||||
if (seen.has(key)) {
|
||||
duplicateIds.add(trigger.id);
|
||||
} else {
|
||||
seen.set(key, trigger.id);
|
||||
}
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!hasAutomationOptions) return;
|
||||
updateEntry({
|
||||
id: eventId,
|
||||
triggers: [
|
||||
...triggers,
|
||||
{ id: generateId(), title: '', trigger: eventTriggerOptions[0], automationId: allAutomationOptions[0].value },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (triggerId: string) => {
|
||||
updateEntry({ id: eventId, triggers: triggers.filter((t) => t.id !== triggerId) });
|
||||
};
|
||||
|
||||
const handleChange = (triggerId: string, field: 'trigger' | 'automationId', value: string) => {
|
||||
const newTriggers = triggers.map((t) => (t.id !== triggerId ? t : { ...t, [field]: value }));
|
||||
updateEntry({ id: eventId, triggers: newTriggers });
|
||||
};
|
||||
const showTriggers = triggers.length > 0;
|
||||
|
||||
return (
|
||||
<div className={style.triggers}>
|
||||
{automationsEnabled === false && (
|
||||
<Info>Automations are disabled. Event triggers stay configured, but they will not run until enabled.</Info>
|
||||
)}
|
||||
{automationsEnabled && !hasAutomationOptions && (
|
||||
<Info>
|
||||
<Info.Title>No automations available</Info.Title>
|
||||
<Info.Body>Create an automation before attaching it to this event.</Info.Body>
|
||||
</Info>
|
||||
{showTriggers && (
|
||||
<div className={style.section}>
|
||||
<div className={style.sectionTitle}>Applied automations</div>
|
||||
<ExistingEventTriggers triggers={triggers} eventId={eventId} automations={automationSettings.automations} />
|
||||
</div>
|
||||
)}
|
||||
{triggers.length > 0 && (
|
||||
<div className={style.triggerList}>
|
||||
<div className={style.triggerHeader}>
|
||||
<span>Lifecycle</span>
|
||||
<span>Automation</span>
|
||||
</div>
|
||||
{triggers.map((trigger) => {
|
||||
const isDuplicate = duplicateIds.has(trigger.id);
|
||||
const lifecycleOptions = isDuplicate
|
||||
? triggerOptions.map((opt) => (opt.value === trigger.trigger ? { ...opt, label: `${opt.label} *` } : opt))
|
||||
: triggerOptions;
|
||||
const automationOptions = isDuplicate
|
||||
? allAutomationOptions.map((opt) =>
|
||||
opt.value === trigger.automationId ? { ...opt, label: `${opt.label} *` } : opt,
|
||||
)
|
||||
: allAutomationOptions;
|
||||
<Editor.Panel className={style.formSection}>
|
||||
<div className={style.sectionTitle}>Add automation</div>
|
||||
<EventTriggerForm triggers={triggers} eventId={eventId} automations={automationSettings.automations} />
|
||||
</Editor.Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface EventTriggerFormProps {
|
||||
eventId: string;
|
||||
triggers?: Trigger[];
|
||||
automations: NormalisedAutomation;
|
||||
}
|
||||
|
||||
function EventTriggerForm({ eventId, triggers, automations }: EventTriggerFormProps) {
|
||||
const { updateEntry } = useEntryActionsContext();
|
||||
const [automationId, setAutomationId] = useState<string | undefined>(undefined);
|
||||
const [cycleValue, setCycleValue] = useState(TimerLifeCycle.onStart);
|
||||
|
||||
const handleSubmit = (triggerLifeCycle: TimerLifeCycle, automationId: string) => {
|
||||
const newTriggers = triggers ?? new Array<Trigger>();
|
||||
const id = generateId();
|
||||
newTriggers.push({ id, title: '', trigger: triggerLifeCycle, automationId });
|
||||
updateEntry({ id: eventId, triggers: newTriggers });
|
||||
};
|
||||
|
||||
const getValidationError = (cycle: TimerLifeCycle, automationId?: string): string | undefined => {
|
||||
if (automationId === undefined) {
|
||||
return 'Select an automation';
|
||||
}
|
||||
if (!Object.keys(automations).includes(automationId)) {
|
||||
return 'This automation does not exist';
|
||||
}
|
||||
if (triggers === undefined) {
|
||||
return;
|
||||
}
|
||||
return Object.values(triggers).some((t) => t.automationId === automationId && t.trigger === cycle)
|
||||
? 'Automation can only be used once'
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const validationError = getValidationError(cycleValue, automationId);
|
||||
const validationLabel = validationError ?? 'Ready to add automation';
|
||||
|
||||
const triggerOptions = useMemo(
|
||||
() => [
|
||||
{ value: null, label: 'Select lifecycle' },
|
||||
...eventTriggerOptions.map((cycle) => ({ value: cycle, label: cycle })),
|
||||
],
|
||||
[], // eventTriggerOptions is a constant, no need for dependency
|
||||
);
|
||||
|
||||
const automationOptions = useMemo(
|
||||
() => [
|
||||
{ value: null, label: 'Select Automation' },
|
||||
...Object.values(automations).map(({ id, title }) => ({ value: id, label: title })),
|
||||
],
|
||||
[automations], // This needs to be a dependency as it can change
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={style.triggerForm}>
|
||||
<div className={style.formFields}>
|
||||
<div>
|
||||
<Editor.Label>Lifecycle</Editor.Label>
|
||||
<Select
|
||||
value={cycleValue}
|
||||
onValueChange={(value) => {
|
||||
if (value !== null) setCycleValue(value);
|
||||
}}
|
||||
options={triggerOptions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Editor.Label>Automation</Editor.Label>
|
||||
<Select
|
||||
value={automationId ?? null}
|
||||
onValueChange={(value) => {
|
||||
if (value !== null) setAutomationId(value);
|
||||
}}
|
||||
options={automationOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style.formActions}>
|
||||
<div className={validationError ? style.validationError : style.validationSuccess}>
|
||||
{validationError ? <IoAlertCircle /> : <IoCheckmarkCircle />}
|
||||
<span>{validationLabel}</span>
|
||||
</div>
|
||||
<Button
|
||||
disabled={validationError !== undefined}
|
||||
onClick={() => automationId && handleSubmit(cycleValue, automationId)}
|
||||
>
|
||||
Add automation
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExistingEventTriggersProps {
|
||||
eventId: string;
|
||||
triggers: Trigger[];
|
||||
automations: NormalisedAutomation;
|
||||
}
|
||||
|
||||
function ExistingEventTriggers({ eventId, triggers, automations }: ExistingEventTriggersProps) {
|
||||
const { updateEntry } = useEntryActionsContext();
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(triggerId: string) => {
|
||||
const newTriggers = triggers.filter((trigger) => trigger.id !== triggerId);
|
||||
updateEntry({ id: eventId, triggers: newTriggers });
|
||||
},
|
||||
[eventId, triggers, updateEntry],
|
||||
);
|
||||
|
||||
const filteredTriggers: Record<string, Trigger[]> = {};
|
||||
|
||||
// sort triggers out into groups by the Lifecycle they are on
|
||||
timerLifecycleValues.forEach((triggerType) => {
|
||||
const thisTriggerType = triggers.filter((trigger) => trigger.trigger === triggerType);
|
||||
if (thisTriggerType.length) {
|
||||
Object.assign(filteredTriggers, { [triggerType]: thisTriggerType });
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={style.triggerList}>
|
||||
{Object.entries(filteredTriggers).map(([triggerLifeCycle, triggerGroup]) => (
|
||||
<Fragment key={triggerLifeCycle}>
|
||||
{triggerGroup.map((trigger) => {
|
||||
const { id, automationId } = trigger;
|
||||
const automationTitle = automations[automationId]?.title ?? '<MISSING AUTOMATION>';
|
||||
return (
|
||||
<div key={trigger.id} className={style.trigger} data-duplicate={isDuplicate || undefined}>
|
||||
<Select
|
||||
value={trigger.trigger}
|
||||
onValueChange={(value) => {
|
||||
if (value !== null) handleChange(trigger.id, 'trigger', value);
|
||||
}}
|
||||
options={lifecycleOptions}
|
||||
/>
|
||||
<Select
|
||||
value={trigger.automationId}
|
||||
onValueChange={(value) => {
|
||||
if (value !== null) handleChange(trigger.id, 'automationId', value);
|
||||
}}
|
||||
options={automationOptions}
|
||||
/>
|
||||
<IconButton variant='ghosted-destructive' onClick={() => handleDelete(trigger.id)}>
|
||||
<div key={id} className={style.trigger}>
|
||||
<div className={style.triggerMeta}>
|
||||
<div className={style.metaLabel}>Lifecycle</div>
|
||||
<div>{triggerLifeCycle}</div>
|
||||
</div>
|
||||
<div className={style.triggerMeta}>
|
||||
<div className={style.metaLabel}>Automation</div>
|
||||
<div className={style.automationTitle}>{automationTitle}</div>
|
||||
</div>
|
||||
<IconButton variant='ghosted-destructive' onClick={() => handleDelete(id)}>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{duplicateIds.size > 0 && (
|
||||
<span className={style.duplicateMessage}>
|
||||
* Duplicate combinations will only fire once per lifecycle event.
|
||||
</span>
|
||||
)}
|
||||
{hasAutomationOptions && (
|
||||
<Button variant='ghosted' onClick={handleAdd}>
|
||||
<IoAdd /> Add automation
|
||||
</Button>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,7 +81,8 @@ html,
|
||||
user-select: none;
|
||||
-webkit-app-region: drag;
|
||||
isolation: isolate;
|
||||
background-color: $ui-black; // prevent the safari from showing white background
|
||||
// prevents Safari iOS from showing white in the overscroll (rubber-band) area
|
||||
background-color: $ui-black;
|
||||
}
|
||||
|
||||
// reset button styles
|
||||
|
||||
@@ -23,19 +23,21 @@ export default function ViewLoader({ children }: PropsWithChildren) {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const colourFromParams = searchParams.get('keyColour') ?? '#101010';
|
||||
|
||||
// always keep body background in sync so Safari iOS overscroll area matches the view
|
||||
const bodyBgStyle = `body { background-color: var(--background-color-override, ${colourFromParams}); }`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`body { background: var(--background-color-override, ${colourFromParams}); }`}</style>
|
||||
<Suspense
|
||||
fallback={
|
||||
<>
|
||||
<Loader />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<OverrideStyles />
|
||||
{children}
|
||||
</Suspense>
|
||||
</>
|
||||
<Suspense
|
||||
fallback={
|
||||
<>
|
||||
<style>{bodyBgStyle}</style>
|
||||
<Loader />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<style>{bodyBgStyle}</style>
|
||||
<OverrideStyles />
|
||||
{children}
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import path from 'path';
|
||||
|
||||
import { sentryVitePlugin } from '@sentry/vite-plugin';
|
||||
import legacy from '@vitejs/plugin-legacy';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
import { compression } from 'vite-plugin-compression2';
|
||||
@@ -22,9 +21,6 @@ export default defineConfig({
|
||||
'import.meta.env.IS_DOCKER': process.env.NODE_ENV === 'docker',
|
||||
},
|
||||
plugins: [
|
||||
legacy({
|
||||
targets: ['safari >= 14.1'],
|
||||
}),
|
||||
react({
|
||||
babel: {
|
||||
plugins: [['babel-plugin-react-compiler', ReactCompilerConfig]],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-electron",
|
||||
"version": "4.8.0",
|
||||
"version": "4.7.0",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/resolver",
|
||||
"version": "4.8.0",
|
||||
"version": "4.7.0",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
"types": "./dist/main.d.ts",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "ontime-server",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"version": "4.8.0",
|
||||
"version": "4.7.0",
|
||||
"exports": "./src/index.js",
|
||||
"dependencies": {
|
||||
"@googleapis/sheets": "^5.0.5",
|
||||
|
||||
@@ -47,15 +47,7 @@ export function triggerAutomations(cycle: TimerLifeCycle) {
|
||||
return;
|
||||
}
|
||||
|
||||
// deduplicate — if the same automation appears multiple times on one lifecycle, fire it once
|
||||
const seen = new Set<string>();
|
||||
const uniqueTriggers = filteredTrigger.filter((t) => {
|
||||
if (seen.has(t.automationId)) return false;
|
||||
seen.add(t.automationId);
|
||||
return true;
|
||||
});
|
||||
|
||||
uniqueTriggers.forEach((trigger) => {
|
||||
filteredTrigger.forEach((trigger) => {
|
||||
const automation = automations[trigger.automationId];
|
||||
if (!automation || automation.outputs.length === 0) {
|
||||
return;
|
||||
|
||||
@@ -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, srcFiles } from './setup/index.js';
|
||||
import { publicDir, srcDir } from './setup/index.js';
|
||||
import { populateDemo } from './setup/loadDemo.js';
|
||||
import { populateStyles } from './setup/loadStyles.js';
|
||||
import { populateTranslation } from './setup/loadTranslations.js';
|
||||
@@ -113,11 +113,6 @@ 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);
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
<!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,8 +95,6 @@ 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'),
|
||||
};
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime",
|
||||
"version": "4.8.0",
|
||||
"version": "4.7.0",
|
||||
"description": "Time keeping for live events",
|
||||
"keywords": [
|
||||
"ontime",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "4.8.0",
|
||||
"version": "4.7.0",
|
||||
"name": "ontime-types",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
|
||||
Generated
+51
-1269
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user