mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-07 16:33:51 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 767b567bba | |||
| 9ae49a7175 | |||
| d30d9cfd4e | |||
| a0e7b9b665 | |||
| 1dcb7fb08d | |||
| a7e84b9903 | |||
| 3bebbf9ce9 | |||
| ab8da07518 | |||
| c8bae76121 | |||
| 2873698959 | |||
| 741ebef90e | |||
| 0741f46aca | |||
| 5a33ffad92 | |||
| 562f00dc7e | |||
| e50ab0150e | |||
| fe44b33da2 | |||
| 22e392ba9d | |||
| 4997602e9b |
@@ -20,13 +20,13 @@ jobs:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Get pnpm store directory
|
||||
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ env.PNPM_STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
@@ -15,13 +15,13 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Get pnpm store directory
|
||||
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ env.PNPM_STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
|
||||
jobs:
|
||||
build_macos:
|
||||
runs-on: macOS-latest
|
||||
runs-on: macOS-26
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -17,13 +17,13 @@ jobs:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Get pnpm store directory
|
||||
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ env.PNPM_STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -18,13 +18,13 @@ jobs:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Get pnpm store directory
|
||||
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ env.PNPM_STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
@@ -16,13 +16,13 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Get pnpm store directory
|
||||
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ env.PNPM_STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
@@ -55,13 +55,13 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Get pnpm store directory
|
||||
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ env.PNPM_STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
- name: Get installed Playwright version
|
||||
run: echo "PLAYWRIGHT_VERSION=$(pnpm ls @playwright/test --parseable | cut -s -d '@' -f3 | cut -d '/' -f1)" >> $GITHUB_ENV
|
||||
- name: Cache playwright binaries
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cache/ms-playwright
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
ARG NODE_VERSION=22.15.1
|
||||
ARG NODE_VERSION=22.22.2
|
||||
FROM node:${NODE_VERSION}-alpine
|
||||
|
||||
# Set environment variables
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "4.7.0",
|
||||
"version": "4.8.0",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
<meta charset="utf-8" />
|
||||
<base href="/" />
|
||||
<link rel="icon" href="favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<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-status-bar-style" content="black-translucent" />
|
||||
<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" />
|
||||
@@ -17,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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "4.7.0",
|
||||
"version": "4.8.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
@@ -32,7 +32,6 @@
|
||||
"react-icons": "5.5.0",
|
||||
"react-router": "^7.11.0",
|
||||
"react-virtuoso": "^4.17.0",
|
||||
"web-vitals": "^5.1.0",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"scripts": {
|
||||
@@ -51,7 +50,8 @@
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
"not op_mini all",
|
||||
"safari >= 14.1"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
@@ -64,8 +64,10 @@
|
||||
"@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",
|
||||
"happy-dom": "^20.9.0",
|
||||
"ontime-types": "workspace:*",
|
||||
"ontime-utils": "workspace:*",
|
||||
"sass": "^1.57.1",
|
||||
@@ -73,7 +75,6 @@
|
||||
"vite": "8.0.1",
|
||||
"vite-plugin-compression2": "2.5.1",
|
||||
"vite-plugin-svgr": "4.5.0",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
"start_url": "./",
|
||||
"display": "",
|
||||
"theme_color": "#121212",
|
||||
"background_color": "#ffffff"
|
||||
"background_color": "#101010"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "",
|
||||
"short_name": "",
|
||||
"icons": [{ "src": "ontime-logo.png", "sizes": "295x295", "type": "image/png" }],
|
||||
"theme_color": "#2B5ABC",
|
||||
"theme_color": "#121212",
|
||||
"background_color": "#101010",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ export default function useRundown() {
|
||||
queryClient.setQueryData(getRundownQueryKey(data.id), data);
|
||||
}, [data, loadedRundownId, queryClient]);
|
||||
|
||||
// Once we have the ID, drop the temporary current cache
|
||||
useEffect(() => {
|
||||
if (!loadedRundownId) return;
|
||||
queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export default function useScrollIntoView<T extends HTMLElement>(name: string, location?: string) {
|
||||
export default function useScrollIntoView<T extends HTMLElement>(
|
||||
name: string,
|
||||
location?: string,
|
||||
onVisible?: (name: string) => void,
|
||||
) {
|
||||
const ref = useRef<T>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -11,5 +15,17 @@ export default function useScrollIntoView<T extends HTMLElement>(name: string, l
|
||||
}
|
||||
}, [location, name]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current || !onVisible) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) onVisible(name);
|
||||
},
|
||||
{ threshold: 0.3, rootMargin: '0px 0px -40% 0px' },
|
||||
);
|
||||
observer.observe(ref.current);
|
||||
return () => observer.disconnect();
|
||||
}, [name, onVisible]);
|
||||
|
||||
return ref;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,11 @@ describe('getRouteFromPreset()', () => {
|
||||
expect(getRouteFromPreset(location, presets)).toStrictEqual('timer?user=guest&alias=demopage');
|
||||
});
|
||||
|
||||
it('returns null when already on a preset path', () => {
|
||||
const location = resolvePath('/preset/demopage');
|
||||
expect(getRouteFromPreset(location, presets)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null if the current location is the exact match of an unwrapped alias', () => {
|
||||
// we make the current location be the alias
|
||||
const location = resolvePath('/timer?user=guest&alias=demopage');
|
||||
|
||||
@@ -3,9 +3,7 @@ import {
|
||||
Log,
|
||||
MaybeNumber,
|
||||
MessageTag,
|
||||
ProjectRundownsList,
|
||||
RefetchKey,
|
||||
Rundown,
|
||||
RuntimeStore,
|
||||
WsPacketToClient,
|
||||
WsPacketToServer,
|
||||
@@ -17,15 +15,16 @@ import {
|
||||
CLIENT_LIST,
|
||||
CSS_OVERRIDE,
|
||||
CUSTOM_FIELDS,
|
||||
PROJECT_DATA,
|
||||
CURRENT_RUNDOWN_QUERY_KEY,
|
||||
PROJECT_RUNDOWNS,
|
||||
PROJECT_DATA,
|
||||
REPORT,
|
||||
RUNDOWN,
|
||||
RUNTIME,
|
||||
TRANSLATION,
|
||||
URL_PRESETS,
|
||||
VIEW_SETTINGS,
|
||||
getRundownQueryKey,
|
||||
PROJECT_RUNDOWNS,
|
||||
} from '../api/constants';
|
||||
import { invalidateAllCaches } from '../api/utils';
|
||||
import { ontimeQueryClient } from '../queryClient';
|
||||
@@ -181,7 +180,7 @@ export const connectSocket = () => {
|
||||
}
|
||||
case MessageTag.Refetch: {
|
||||
// the refetch message signals that the rundown has changed in the server side
|
||||
const { target, revision } = payload;
|
||||
const { target, revision, rundownId } = payload;
|
||||
switch (target) {
|
||||
case RefetchKey.All:
|
||||
invalidateAllCaches();
|
||||
@@ -196,7 +195,7 @@ export const connectSocket = () => {
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
|
||||
break;
|
||||
case RefetchKey.Rundown: {
|
||||
maybeInvalidateRundownCache(revision);
|
||||
maybeInvalidateRundownCache(revision, rundownId);
|
||||
break;
|
||||
}
|
||||
case RefetchKey.UrlPresets:
|
||||
@@ -232,34 +231,28 @@ export const connectSocket = () => {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* When we receive a refetch message for the rundown
|
||||
* check which rundown needs to be invalidated
|
||||
*/
|
||||
export function maybeInvalidateRundownCache(revision: MaybeNumber) {
|
||||
const loadedRundownId: string | undefined = (ontimeQueryClient.getQueryData(PROJECT_RUNDOWNS) as ProjectRundownsList)
|
||||
?.loaded;
|
||||
|
||||
const activeRundownQueryKey = loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY;
|
||||
const cachedRundown = ontimeQueryClient.getQueryData<Rundown>(activeRundownQueryKey);
|
||||
if (revision === cachedRundown?.revision) {
|
||||
export function maybeInvalidateRundownCache(revision: MaybeNumber, rundownId?: string) {
|
||||
if (!rundownId) {
|
||||
// we omit rundownId to signify invalidate all rundowns
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
|
||||
return;
|
||||
}
|
||||
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: activeRundownQueryKey, exact: true });
|
||||
|
||||
if (loadedRundownId) {
|
||||
// Keep bootstrap alias in sync with the ID-based cache
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
|
||||
} else {
|
||||
// During bootstrap, loadedRundownId is not yet known.
|
||||
// Invalidate any ID-based rundown caches that may have been seeded early.
|
||||
ontimeQueryClient.invalidateQueries({
|
||||
predicate: (query) => query.queryKey[0] === 'rundown' && query.queryKey[1] !== 'current',
|
||||
});
|
||||
// skip if we dont recognise the ID the revision is lower
|
||||
const queryKey = getRundownQueryKey(rundownId);
|
||||
const cachedRundown = ontimeQueryClient.getQueryData<{ revision: number }>(queryKey);
|
||||
if (revision !== null && revision === cachedRundown?.revision) {
|
||||
return;
|
||||
}
|
||||
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS });
|
||||
ontimeQueryClient.invalidateQueries({ queryKey, exact: true });
|
||||
|
||||
// keep current alias in sync with the ID-based cache
|
||||
const loadedRundownId = ontimeQueryClient.getQueryData<{ loaded: string }>(PROJECT_RUNDOWNS)?.loaded;
|
||||
if (!loadedRundownId || loadedRundownId === rundownId) {
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function sendSocket<T extends MessageTag | ApiActionTag>(
|
||||
|
||||
@@ -204,7 +204,7 @@ function isPresettableView(view: string): view is OntimeViewPresettable {
|
||||
/**
|
||||
* Check if current location is a preset path
|
||||
*/
|
||||
export function isPresetPath(location: Path): boolean {
|
||||
const segments = location.pathname.split('/').filter(Boolean);
|
||||
return segments[0] === 'preset';
|
||||
function isPresetPath(location: Path): boolean {
|
||||
const firstSegment = location.pathname.split('/').find(Boolean);
|
||||
return firstSegment === 'preset';
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ 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/';
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
gap: 0;
|
||||
|
||||
overflow: hidden;
|
||||
background-color: $ui-black;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ErrorBoundary } from '@sentry/react';
|
||||
|
||||
import { useKeyDown } from '../../common/hooks/useKeyDown';
|
||||
import { AppSettingsScrollContext, useScrollContextState } from './AppSettingsScrollContext';
|
||||
import PanelContent from './panel-content/PanelContent';
|
||||
import PanelList from './panel-list/PanelList';
|
||||
import AboutPanel from './panel/about-panel/AboutPanel';
|
||||
@@ -17,13 +18,15 @@ import style from './AppSettings.module.scss';
|
||||
|
||||
export default function AppSettings() {
|
||||
const { close, panel, location, setLocation } = useAppSettingsNavigation();
|
||||
const scrollContext = useScrollContextState(panel);
|
||||
useKeyDown(close, 'Escape');
|
||||
|
||||
return (
|
||||
<div className={style.container}>
|
||||
<ErrorBoundary>
|
||||
<AppSettingsScrollContext.Provider value={scrollContext}>
|
||||
<PanelList selectedPanel={panel} location={location} />
|
||||
<PanelContent onClose={close}>
|
||||
<PanelContent onClose={close} panel={panel} location={location}>
|
||||
{panel === 'settings' && <SettingsPanel location={location} />}
|
||||
{panel === 'project' && <ProjectPanel location={location} setLocation={setLocation} />}
|
||||
{panel === 'manage' && <ManagePanel location={location} />}
|
||||
@@ -33,6 +36,7 @@ export default function AppSettings() {
|
||||
{panel === 'about' && <AboutPanel />}
|
||||
{panel === 'shutdown' && <ShutdownPanel />}
|
||||
</PanelContent>
|
||||
</AppSettingsScrollContext.Provider>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { createContext, useCallback, useContext, useState } from 'react';
|
||||
|
||||
interface ScrollContextValue {
|
||||
activeSection: string | undefined;
|
||||
setActiveSection: (name: string) => void;
|
||||
}
|
||||
|
||||
export const AppSettingsScrollContext = createContext<ScrollContextValue>({
|
||||
activeSection: undefined,
|
||||
setActiveSection: () => {},
|
||||
});
|
||||
|
||||
export function useAppSettingsScroll() {
|
||||
return useContext(AppSettingsScrollContext);
|
||||
}
|
||||
|
||||
export function useScrollContextState(panel: string) {
|
||||
const [activeSection, setActiveSectionRaw] = useState<string | undefined>(undefined);
|
||||
const [trackedPanel, setTrackedPanel] = useState(panel);
|
||||
|
||||
if (panel !== trackedPanel) {
|
||||
setTrackedPanel(panel);
|
||||
setActiveSectionRaw(undefined);
|
||||
}
|
||||
|
||||
const setActiveSection = useCallback((name: string) => {
|
||||
setActiveSectionRaw(name);
|
||||
}, []);
|
||||
|
||||
return { activeSection, setActiveSection };
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
let _isDirty = false;
|
||||
|
||||
export const markDirty = () => {
|
||||
_isDirty = true;
|
||||
};
|
||||
|
||||
export const markClean = () => {
|
||||
_isDirty = false;
|
||||
};
|
||||
|
||||
export const getIsDirty = () => _isDirty;
|
||||
@@ -1,8 +1,19 @@
|
||||
.corner {
|
||||
position: fixed;
|
||||
top: 6rem;
|
||||
right: 4rem;
|
||||
z-index: $zindex-floating;
|
||||
.stickyHeader {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: $zindex-nav;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: $gray-1250;
|
||||
border-bottom: 1px solid $white-10;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
font-size: calc(1rem - 1px);
|
||||
color: $gray-400;
|
||||
}
|
||||
|
||||
.contentWrapper {
|
||||
@@ -12,6 +23,7 @@
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content {
|
||||
|
||||
@@ -2,22 +2,31 @@ import { PropsWithChildren } from 'react';
|
||||
import { IoClose } from 'react-icons/io5';
|
||||
|
||||
import Button from '../../../common/components/buttons/Button';
|
||||
import { getPanelLabel } from '../useAppSettingsMenu';
|
||||
|
||||
import style from './PanelContent.module.scss';
|
||||
|
||||
interface PanelContentProps {
|
||||
onClose: () => void;
|
||||
panel: string;
|
||||
location?: string;
|
||||
}
|
||||
|
||||
export default function PanelContent({ onClose, children }: PropsWithChildren<PanelContentProps>) {
|
||||
export default function PanelContent({ onClose, panel, location, children }: PropsWithChildren<PanelContentProps>) {
|
||||
const { panelLabel, sectionLabel } = getPanelLabel(panel, location);
|
||||
|
||||
return (
|
||||
<div className={style.contentWrapper}>
|
||||
<div className={style.content}>{children}</div>
|
||||
<div className={style.corner}>
|
||||
<div className={style.stickyHeader}>
|
||||
<span className={style.breadcrumb}>
|
||||
{panelLabel}
|
||||
{sectionLabel ? <> › {sectionLabel}</> : null}
|
||||
</span>
|
||||
<Button size='large' onClick={onClose}>
|
||||
Close settings <IoClose />
|
||||
</Button>
|
||||
</div>
|
||||
<div className={style.content}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
background-color: $gray-1250;
|
||||
border-right: 1px solid $white-10;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.primary,
|
||||
@@ -32,14 +35,16 @@ ul {
|
||||
|
||||
.primary {
|
||||
font-size: 1rem;
|
||||
border-radius: 2px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
&.active {
|
||||
color: $blue-400;
|
||||
background-color: $gray-1100;
|
||||
border-left: 3px solid $blue-400;
|
||||
padding-left: calc(1rem - 3px);
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
|
||||
&.highlight {
|
||||
@@ -53,10 +58,6 @@ ul {
|
||||
background-color: $red-400;
|
||||
}
|
||||
}
|
||||
|
||||
&.split {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.secondary {
|
||||
@@ -67,5 +68,7 @@ ul {
|
||||
|
||||
&.active {
|
||||
color: $blue-400;
|
||||
border-left-color: $blue-400;
|
||||
border-left-width: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Fragment } from 'react';
|
||||
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
||||
import { isKeyEnter } from '../../../common/utils/keyEvent';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
import { getIsDirty, markClean } from '../appSettingsDirtyState';
|
||||
import { useAppSettingsScroll } from '../AppSettingsScrollContext';
|
||||
import { SettingsOption, SettingsOptionId, useAppSettingsMenu } from '../useAppSettingsMenu';
|
||||
import useAppSettingsNavigation from '../useAppSettingsNavigation';
|
||||
|
||||
@@ -42,25 +44,27 @@ interface PanelListItemProps {
|
||||
location?: string;
|
||||
}
|
||||
|
||||
function PanelListItem(props: PanelListItemProps) {
|
||||
const { panel, isSelected, location } = props;
|
||||
const { setLocation } = useAppSettingsNavigation();
|
||||
function navigateWithGuard(navigate: (id: SettingsOptionId) => void, id: SettingsOptionId) {
|
||||
if (getIsDirty()) {
|
||||
if (!window.confirm('You have unsaved changes. Leave without saving?')) return;
|
||||
markClean();
|
||||
}
|
||||
navigate(id);
|
||||
}
|
||||
|
||||
const classes = cx([
|
||||
style.primary,
|
||||
isSelected && style.active,
|
||||
panel.split && style.split,
|
||||
panel.highlight && style.highlight,
|
||||
]);
|
||||
function PanelListItem({ panel, isSelected, location }: PanelListItemProps) {
|
||||
const { setLocation } = useAppSettingsNavigation();
|
||||
const { activeSection } = useAppSettingsScroll();
|
||||
const classes = cx([style.primary, isSelected && style.active, panel.highlight && style.highlight]);
|
||||
|
||||
return (
|
||||
<Fragment key={panel.id}>
|
||||
<li
|
||||
key={panel.id}
|
||||
onClick={() => setLocation(panel.id as SettingsOptionId)}
|
||||
onClick={() => navigateWithGuard(setLocation, panel.id as SettingsOptionId)}
|
||||
onKeyDown={(event) => {
|
||||
if (isKeyEnter(event)) {
|
||||
setLocation(panel.id as SettingsOptionId);
|
||||
navigateWithGuard(setLocation, panel.id as SettingsOptionId);
|
||||
}
|
||||
}}
|
||||
className={classes}
|
||||
@@ -71,14 +75,15 @@ function PanelListItem(props: PanelListItemProps) {
|
||||
</li>
|
||||
{panel.secondary?.map((secondary, index) => {
|
||||
const id = secondary.id.split('__')[1];
|
||||
const secondaryClasses = cx([style.secondary, isSelected && location === id ? style.active : null]);
|
||||
const effectiveSection = isSelected ? (activeSection ?? location) : undefined;
|
||||
const secondaryClasses = cx([style.secondary, effectiveSection === id ? style.active : null]);
|
||||
return (
|
||||
<li
|
||||
key={secondary.id + index}
|
||||
onClick={() => setLocation(secondary.id as SettingsOptionId)}
|
||||
onClick={() => navigateWithGuard(setLocation, secondary.id as SettingsOptionId)}
|
||||
onKeyDown={(event) => {
|
||||
if (isKeyEnter(event)) {
|
||||
setLocation(secondary.id as SettingsOptionId);
|
||||
navigateWithGuard(setLocation, secondary.id as SettingsOptionId);
|
||||
}
|
||||
}}
|
||||
className={secondaryClasses}
|
||||
|
||||
@@ -53,9 +53,10 @@ $inner-padding: 1rem;
|
||||
.card {
|
||||
position: relative;
|
||||
padding: 2rem;
|
||||
background-color: $white-3;
|
||||
background-color: $white-5;
|
||||
border: 1px solid $gray-1100;
|
||||
border-radius: 3px;
|
||||
border-radius: 8px;
|
||||
box-shadow: $box-shadow-l1;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
githubUrl,
|
||||
subredditUrl,
|
||||
websiteUrl,
|
||||
youtubeUrl,
|
||||
} from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import AppVersion from './AppVersion';
|
||||
@@ -38,6 +39,7 @@ 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>
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||
import { useAppSettingsScroll } from '../../AppSettingsScrollContext';
|
||||
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import AutomationSettingsForm from './AutomationSettingsForm';
|
||||
@@ -8,9 +9,10 @@ import TriggersList from './TriggersList';
|
||||
|
||||
export default function AutomationPanel({ location }: PanelBaseProps) {
|
||||
const { data, status } = useAutomationSettings();
|
||||
const settingsRef = useScrollIntoView<HTMLDivElement>('settings', location);
|
||||
const triggersRef = useScrollIntoView<HTMLDivElement>('triggers', location);
|
||||
const automationsRef = useScrollIntoView<HTMLDivElement>('automations', location);
|
||||
const { setActiveSection } = useAppSettingsScroll();
|
||||
const settingsRef = useScrollIntoView<HTMLDivElement>('settings', location, setActiveSection);
|
||||
const triggersRef = useScrollIntoView<HTMLDivElement>('triggers', location, setActiveSection);
|
||||
const automationsRef = useScrollIntoView<HTMLDivElement>('automations', location, setActiveSection);
|
||||
|
||||
const isLoading = status === 'pending';
|
||||
const automationState = isLoading ? undefined : data.enabledAutomations;
|
||||
|
||||
+5
-4
@@ -12,6 +12,7 @@ import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import { isOnlyNumbers } from '../../../../common/utils/regex';
|
||||
import { isOntimeCloud } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import { useSettingsDirty } from '../../useSettingsDirty';
|
||||
|
||||
const oscApiDocsUrl = 'https://docs.getontime.no/api/protocols/osc/';
|
||||
|
||||
@@ -45,6 +46,7 @@ export default function AutomationSettingsForm({
|
||||
keepDirtyValues: false,
|
||||
},
|
||||
});
|
||||
useSettingsDirty(isDirty);
|
||||
|
||||
const onSubmit = async (formData: AutomationSettingsProps) => {
|
||||
try {
|
||||
@@ -89,10 +91,9 @@ export default function AutomationSettingsForm({
|
||||
|
||||
<Panel.Section>
|
||||
<Info>
|
||||
<p>Control Ontime and share its data with external systems in your workflow.</p>
|
||||
<p>- Automations allow Ontime to send its data on lifecycle triggers.</p>
|
||||
<p>- OSC Input tells Ontime to listen to messages on the specific port.</p>
|
||||
<br />
|
||||
<span>Control Ontime and share its data with external systems in your workflow.</span>
|
||||
<span>- Automations allow Ontime to send its data on lifecycle triggers.</span>
|
||||
<span>- OSC Input tells Ontime to listen to messages on the specific port.</span>
|
||||
<ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
|
||||
</Info>
|
||||
</Panel.Section>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||
import { isOntimeCloud } from '../../../../externals';
|
||||
import { useAppSettingsScroll } from '../../AppSettingsScrollContext';
|
||||
import GenerateLinkFormExport from '../../../sharing/GenerateLinkFormExport';
|
||||
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
@@ -8,9 +9,10 @@ import ReportSettings from './ReportSettings';
|
||||
import URLPresets from './URLPresets';
|
||||
|
||||
export default function FeaturePanel({ location }: PanelBaseProps) {
|
||||
const presetsRef = useScrollIntoView<HTMLDivElement>('presets', location);
|
||||
const linkRef = useScrollIntoView<HTMLDivElement>('link', location);
|
||||
const reportRef = useScrollIntoView<HTMLDivElement>('report', location);
|
||||
const { setActiveSection } = useAppSettingsScroll();
|
||||
const presetsRef = useScrollIntoView<HTMLDivElement>('presets', location, setActiveSection);
|
||||
const linkRef = useScrollIntoView<HTMLDivElement>('link', location, setActiveSection);
|
||||
const reportRef = useScrollIntoView<HTMLDivElement>('report', location, setActiveSection);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -57,11 +57,11 @@ export default function CustomFieldSettings() {
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
<Info>
|
||||
Custom fields allow for additional information to be added to an event.
|
||||
<br />
|
||||
<br />
|
||||
To use custom fields as a data source in an{' '}
|
||||
<AppLink search='settings=automation__automations'>Automation</AppLink>, please note the generated key.
|
||||
<span>Custom fields allow for additional information to be added to an event.</span>
|
||||
<span>
|
||||
To use custom fields as a data source in an{' '}
|
||||
<AppLink search='settings=automation__automations'>Automation</AppLink>, please note the generated key.
|
||||
</span>
|
||||
<ExternalLink href={customFieldsDocsUrl}>See the docs</ExternalLink>
|
||||
</Info>
|
||||
</Panel.Section>
|
||||
|
||||
@@ -52,8 +52,9 @@ export default function CustomViews() {
|
||||
|
||||
<Panel.Section>
|
||||
<Info>
|
||||
Upload one <strong>index.html</strong> per view to <strong>/external/<name>/</strong>.
|
||||
<br />
|
||||
<span>
|
||||
Upload one <strong>index.html</strong> per view to <strong>/external/<name>/</strong>.
|
||||
</span>
|
||||
External imports are not allowed, include all assets inside the html file.
|
||||
<ExternalLink href={customViewsDocs}>See the docs</ExternalLink>
|
||||
</Info>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||
import { useAppSettingsScroll } from '../../AppSettingsScrollContext';
|
||||
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import CustomFieldSettings from './CustomFields';
|
||||
@@ -7,10 +8,11 @@ import RundownDefaultSettings from './RundownDefaultSettings';
|
||||
import SourcesPanel from './sources-panel/SourcesPanel';
|
||||
|
||||
export default function ManagePanel({ location }: PanelBaseProps) {
|
||||
const defaultsRef = useScrollIntoView<HTMLDivElement>('defaults', location);
|
||||
const customRef = useScrollIntoView<HTMLDivElement>('custom', location);
|
||||
const rundownsRef = useScrollIntoView<HTMLDivElement>('rundowns', location);
|
||||
const sheetsRef = useScrollIntoView<HTMLDivElement>('sheets', location);
|
||||
const { setActiveSection } = useAppSettingsScroll();
|
||||
const defaultsRef = useScrollIntoView<HTMLDivElement>('defaults', location, setActiveSection);
|
||||
const customRef = useScrollIntoView<HTMLDivElement>('custom', location, setActiveSection);
|
||||
const rundownsRef = useScrollIntoView<HTMLDivElement>('rundowns', location, setActiveSection);
|
||||
const sheetsRef = useScrollIntoView<HTMLDivElement>('sheets', location, setActiveSection);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -5,14 +5,16 @@ import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||
import { usePing } from '../../../../common/hooks/useSocket';
|
||||
import { sendSocket } from '../../../../common/utils/socket';
|
||||
import { isDocker } from '../../../../externals';
|
||||
import { useAppSettingsScroll } from '../../AppSettingsScrollContext';
|
||||
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import ClientControlPanel from './client-control/ClientControlPanel';
|
||||
import LogExport from './NetworkLogExport';
|
||||
|
||||
export default function NetworkLogPanel({ location }: PanelBaseProps) {
|
||||
const clientsRef = useScrollIntoView<HTMLDivElement>('clients', location);
|
||||
const logRef = useScrollIntoView<HTMLDivElement>('log', location);
|
||||
const { setActiveSection } = useAppSettingsScroll();
|
||||
const clientsRef = useScrollIntoView<HTMLDivElement>('clients', location, setActiveSection);
|
||||
const logRef = useScrollIntoView<HTMLDivElement>('log', location, setActiveSection);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
+2
@@ -68,6 +68,7 @@ export default function ClientList() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ontimeClients.length === 0 && <Panel.TableEmpty label='No clients connected' />}
|
||||
{ontimeClients.map(([key, client]) => {
|
||||
const { identify, name, path } = client;
|
||||
const isCurrent = id === key;
|
||||
@@ -125,6 +126,7 @@ export default function ClientList() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{otherClients.length === 0 && <Panel.TableEmpty label='No other clients' />}
|
||||
{otherClients.map(([key, client]) => {
|
||||
const { name, type } = client;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||
import { useAppSettingsScroll } from '../../AppSettingsScrollContext';
|
||||
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import QuickStart from '../../quick-start/QuickStart';
|
||||
@@ -10,7 +11,8 @@ interface ProjectPanelProps extends PanelBaseProps {
|
||||
}
|
||||
|
||||
export default function ProjectPanel({ location, setLocation }: ProjectPanelProps) {
|
||||
const manageProjectsRef = useScrollIntoView<HTMLDivElement>('list', location);
|
||||
const { setActiveSection } = useAppSettingsScroll();
|
||||
const manageProjectsRef = useScrollIntoView<HTMLDivElement>('list', location, setActiveSection);
|
||||
|
||||
const handleQuickClose = () => {
|
||||
setLocation('project');
|
||||
|
||||
@@ -11,6 +11,7 @@ import Select from '../../../../common/components/select/Select';
|
||||
import useSettings from '../../../../common/hooks-query/useSettings';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import { useSettingsDirty } from '../../useSettingsDirty';
|
||||
import GeneralPinInput from './composite/GeneralPinInput';
|
||||
|
||||
const TranslationModal = lazy(() => import('./composite/CustomTranslationModal'));
|
||||
@@ -32,6 +33,7 @@ export default function GeneralSettings() {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
});
|
||||
useSettingsDirty(isDirty);
|
||||
|
||||
const [isOpen, handler] = useDisclosure();
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import { validateLogo } from '../../../../common/utils/uploadUtils';
|
||||
import { documentationUrl } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import { useSettingsDirty } from '../../useSettingsDirty';
|
||||
|
||||
import style from './SettingsPanel.module.scss';
|
||||
|
||||
@@ -38,6 +39,7 @@ export default function ProjectData() {
|
||||
},
|
||||
mode: 'onChange',
|
||||
});
|
||||
useSettingsDirty(isDirty);
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
|
||||
@@ -9,6 +9,7 @@ import useServerPort from '../../../../common/hooks-query/useServerPort';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import { isOnlyNumbers } from '../../../../common/utils/regex';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import { useSettingsDirty } from '../../useSettingsDirty';
|
||||
|
||||
interface ServerPortForm {
|
||||
serverPort: number;
|
||||
@@ -27,6 +28,7 @@ export default function ServerPortSettings() {
|
||||
mode: 'onChange',
|
||||
defaultValues: { serverPort: 4001 },
|
||||
});
|
||||
useSettingsDirty(isDirty);
|
||||
|
||||
useEffect(() => {
|
||||
reset({ serverPort: data.port });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||
import { isDocker } from '../../../../externals';
|
||||
import { useAppSettingsScroll } from '../../AppSettingsScrollContext';
|
||||
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import CustomViews from '../manage-panel/CustomViews';
|
||||
@@ -9,11 +10,12 @@ import ServerPortSettings from './ServerPortSettings';
|
||||
import ViewSettings from './ViewSettings';
|
||||
|
||||
export default function SettingsPanel({ location }: PanelBaseProps) {
|
||||
const dataRef = useScrollIntoView<HTMLDivElement>('data', location);
|
||||
const generalRef = useScrollIntoView<HTMLDivElement>('general', location);
|
||||
const viewRef = useScrollIntoView<HTMLDivElement>('view', location);
|
||||
const customViewsRef = useScrollIntoView<HTMLDivElement>('custom-views', location);
|
||||
const portRef = useScrollIntoView<HTMLDivElement>('port', location);
|
||||
const { setActiveSection } = useAppSettingsScroll();
|
||||
const dataRef = useScrollIntoView<HTMLDivElement>('data', location, setActiveSection);
|
||||
const generalRef = useScrollIntoView<HTMLDivElement>('general', location, setActiveSection);
|
||||
const viewRef = useScrollIntoView<HTMLDivElement>('view', location, setActiveSection);
|
||||
const customViewsRef = useScrollIntoView<HTMLDivElement>('custom-views', location, setActiveSection);
|
||||
const portRef = useScrollIntoView<HTMLDivElement>('port', location, setActiveSection);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -13,6 +13,7 @@ import Tag from '../../../../common/components/tag/Tag';
|
||||
import useViewSettings from '../../../../common/hooks-query/useViewSettings';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import { useSettingsDirty } from '../../useSettingsDirty';
|
||||
import CodeEditorModal from './composite/StyleEditorModal';
|
||||
|
||||
const cssOverrideDocsUrl = 'https://docs.getontime.no/features/custom-styling/';
|
||||
@@ -35,6 +36,7 @@ export default function ViewSettings() {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
});
|
||||
useSettingsDirty(isDirty);
|
||||
|
||||
// update form if we get new data from server
|
||||
useEffect(() => {
|
||||
|
||||
+1
@@ -20,4 +20,5 @@
|
||||
border: 1px solid $gray-1100;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ export type SettingsOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
secondary?: Readonly<SettingsOption[]>;
|
||||
split?: boolean;
|
||||
highlight?: string;
|
||||
};
|
||||
|
||||
@@ -26,7 +25,6 @@ const staticOptions = [
|
||||
{
|
||||
id: 'project',
|
||||
label: 'Project',
|
||||
split: true,
|
||||
secondary: [
|
||||
{ id: 'project__create', label: 'Create...' },
|
||||
{ id: 'project__list', label: 'Manage projects' },
|
||||
@@ -46,7 +44,6 @@ const staticOptions = [
|
||||
{
|
||||
id: 'automation',
|
||||
label: 'Automation',
|
||||
split: true,
|
||||
secondary: [
|
||||
{ id: 'automation__settings', label: 'Automation settings' },
|
||||
{ id: 'automation__automations', label: 'Manage automations' },
|
||||
@@ -56,7 +53,6 @@ const staticOptions = [
|
||||
{
|
||||
id: 'sharing',
|
||||
label: 'Sharing and reporting',
|
||||
split: true,
|
||||
secondary: [
|
||||
{ id: 'sharing__presets', label: 'URL Presets' },
|
||||
{
|
||||
@@ -69,7 +65,6 @@ const staticOptions = [
|
||||
{
|
||||
id: 'network',
|
||||
label: 'Network',
|
||||
split: true,
|
||||
secondary: [
|
||||
{
|
||||
id: 'network__log',
|
||||
@@ -84,15 +79,22 @@ const staticOptions = [
|
||||
{
|
||||
id: 'about',
|
||||
label: 'About',
|
||||
split: true,
|
||||
},
|
||||
{
|
||||
id: 'shutdown',
|
||||
label: 'Shutdown',
|
||||
split: true,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function getPanelLabel(panel: string, location?: string): { panelLabel: string; sectionLabel?: string } {
|
||||
const panelOption = staticOptions.find((o) => o.id === panel);
|
||||
if (!panelOption) return { panelLabel: panel };
|
||||
const panelLabel = panelOption.label;
|
||||
if (!location || !('secondary' in panelOption)) return { panelLabel };
|
||||
const match = panelOption.secondary.find((s) => s.id.split('__')[1] === location);
|
||||
return { panelLabel, sectionLabel: match?.label };
|
||||
}
|
||||
|
||||
// a child of navigation or a child of secondary navigation
|
||||
export type SettingsOptionId =
|
||||
| (typeof staticOptions)[number]['id']
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { markClean, markDirty } from './appSettingsDirtyState';
|
||||
|
||||
export function useSettingsDirty(isDirty: boolean) {
|
||||
useEffect(() => {
|
||||
if (isDirty) {
|
||||
markDirty();
|
||||
} else {
|
||||
markClean();
|
||||
}
|
||||
return () => markClean();
|
||||
}, [isDirty]);
|
||||
}
|
||||
+16
-58
@@ -4,18 +4,6 @@
|
||||
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;
|
||||
@@ -25,25 +13,13 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.triggerForm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.formFields {
|
||||
.triggerHeader {
|
||||
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;
|
||||
grid-template-columns: 8rem 1fr 2rem;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: $aux-text-size;
|
||||
color: $label-gray;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
@@ -51,40 +27,22 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.triggerMeta {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.metaLabel {
|
||||
color: $label-gray;
|
||||
.duplicateMessage {
|
||||
padding-left: 0.75rem;
|
||||
font-size: $aux-text-size;
|
||||
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;
|
||||
color: $orange-500;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { NormalisedAutomation, TimerLifeCycle, Trigger, timerLifecycleValues } from 'ontime-types';
|
||||
import { Trigger } from 'ontime-types';
|
||||
import { generateId } from 'ontime-utils';
|
||||
import { Fragment, useCallback, useMemo, useState } from 'react';
|
||||
import { IoAlertCircle, IoCheckmarkCircle, IoTrash } from 'react-icons/io5';
|
||||
import { IoAdd, 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';
|
||||
@@ -21,174 +19,108 @@ 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 showTriggers = triggers.length > 0;
|
||||
|
||||
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 });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={style.triggers}>
|
||||
{automationsEnabled === false && (
|
||||
<Info>Automations are disabled. Event triggers stay configured, but they will not run until enabled.</Info>
|
||||
)}
|
||||
{showTriggers && (
|
||||
<div className={style.section}>
|
||||
<div className={style.sectionTitle}>Applied automations</div>
|
||||
<ExistingEventTriggers triggers={triggers} eventId={eventId} automations={automationSettings.automations} />
|
||||
</div>
|
||||
{automationsEnabled && !hasAutomationOptions && (
|
||||
<Info>
|
||||
<Info.Title>No automations available</Info.Title>
|
||||
<Info.Body>Create an automation before attaching it to this event.</Info.Body>
|
||||
</Info>
|
||||
)}
|
||||
<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>';
|
||||
{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;
|
||||
return (
|
||||
<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)}>
|
||||
<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)}>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ html,
|
||||
user-select: none;
|
||||
-webkit-app-region: drag;
|
||||
isolation: isolate;
|
||||
background-color: $ui-black; // prevent the safari from showing white background
|
||||
}
|
||||
|
||||
// reset button styles
|
||||
|
||||
@@ -24,16 +24,18 @@ export default function ViewLoader({ children }: PropsWithChildren) {
|
||||
const colourFromParams = searchParams.get('keyColour') ?? '#101010';
|
||||
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<>
|
||||
<style>{`body { background: var(--background-color-override, ${colourFromParams}); }`}</style>
|
||||
<Loader />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<OverrideStyles />
|
||||
{children}
|
||||
</Suspense>
|
||||
<>
|
||||
<style>{`body { background: var(--background-color-override, ${colourFromParams}); }`}</style>
|
||||
<Suspense
|
||||
fallback={
|
||||
<>
|
||||
<Loader />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<OverrideStyles />
|
||||
{children}
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+17
-15
@@ -1,7 +1,7 @@
|
||||
import { Table, flexRender } from '@tanstack/react-table';
|
||||
import { EntryId, OntimeEntry, RGBColour, SupportedEntry } from 'ontime-types';
|
||||
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
|
||||
import { CSSProperties, useMemo } from 'react';
|
||||
import { CSSProperties, memo, useMemo } from 'react';
|
||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
@@ -30,7 +30,8 @@ interface EventRowProps {
|
||||
hasCursor?: boolean;
|
||||
}
|
||||
|
||||
export default function EventRow({
|
||||
export default memo(EventRow);
|
||||
function EventRow({
|
||||
rowId,
|
||||
id,
|
||||
eventIndex,
|
||||
@@ -55,24 +56,25 @@ export default function EventRow({
|
||||
|
||||
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
|
||||
|
||||
const { color, backgroundColor } = getAccessibleColour(colour);
|
||||
const tmpColour = cssOrHexToColour(color) as RGBColour; // we know this to be a correct colour
|
||||
const mutedText = colourToHex({ ...tmpColour, alpha: tmpColour.alpha * 0.8 });
|
||||
const { rowBgColour, backgroundColor, mutedText } = useMemo(() => {
|
||||
const accessible = getAccessibleColour(colour);
|
||||
const tmpColour = cssOrHexToColour(accessible.color) as RGBColour;
|
||||
|
||||
const rowBgColour: string | undefined = useMemo(() => {
|
||||
let rowBgColour: string | undefined;
|
||||
if (isLoaded) {
|
||||
return '#087A27'; // $active-green
|
||||
rowBgColour = '#087A27'; // $active-green
|
||||
} else if (colour) {
|
||||
// the colour is user defined and might be invalid
|
||||
const accessibleBackgroundColor = cssOrHexToColour(getAccessibleColour(colour).backgroundColor);
|
||||
if (accessibleBackgroundColor !== null) {
|
||||
return colourToHex({
|
||||
...accessibleBackgroundColor,
|
||||
alpha: accessibleBackgroundColor.alpha * 0.25,
|
||||
});
|
||||
const accessibleBg = cssOrHexToColour(accessible.backgroundColor);
|
||||
if (accessibleBg !== null) {
|
||||
rowBgColour = colourToHex({ ...accessibleBg, alpha: accessibleBg.alpha * 0.25 });
|
||||
}
|
||||
}
|
||||
return;
|
||||
|
||||
return {
|
||||
rowBgColour,
|
||||
backgroundColor: accessible.backgroundColor,
|
||||
mutedText: colourToHex({ ...tmpColour, alpha: tmpColour.alpha * 0.8 }),
|
||||
};
|
||||
}, [colour, isLoaded]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Table, flexRender } from '@tanstack/react-table';
|
||||
import { EntryId, SupportedEntry } from 'ontime-types';
|
||||
import { CSSProperties } from 'react';
|
||||
import { CSSProperties, memo } from 'react';
|
||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
@@ -20,7 +20,8 @@ interface GroupRowProps {
|
||||
hasCursor?: boolean;
|
||||
}
|
||||
|
||||
export default function GroupRow({
|
||||
export default memo(GroupRow);
|
||||
function GroupRow({
|
||||
groupId,
|
||||
colour,
|
||||
rowId,
|
||||
|
||||
+9
-13
@@ -1,7 +1,7 @@
|
||||
import { Table, flexRender } from '@tanstack/react-table';
|
||||
import { EntryId, SupportedEntry } from 'ontime-types';
|
||||
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
|
||||
import { CSSProperties } from 'react';
|
||||
import { CSSProperties, memo, useMemo } from 'react';
|
||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
@@ -25,7 +25,8 @@ interface MilestoneRowProps {
|
||||
hasCursor?: boolean;
|
||||
}
|
||||
|
||||
export default function MilestoneRow({
|
||||
export default memo(MilestoneRow);
|
||||
function MilestoneRow({
|
||||
entryId,
|
||||
isPast,
|
||||
parentBgColour,
|
||||
@@ -45,17 +46,12 @@ export default function MilestoneRow({
|
||||
|
||||
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
|
||||
|
||||
let rowBgColour: string | undefined;
|
||||
if (colour) {
|
||||
// the colour is user defined and might be invalid
|
||||
const accessibleBackgroundColor = cssOrHexToColour(getAccessibleColour(colour).backgroundColor);
|
||||
if (accessibleBackgroundColor !== null) {
|
||||
rowBgColour = colourToHex({
|
||||
...accessibleBackgroundColor,
|
||||
alpha: accessibleBackgroundColor.alpha * 0.25,
|
||||
});
|
||||
}
|
||||
}
|
||||
const rowBgColour = useMemo(() => {
|
||||
if (!colour) return undefined;
|
||||
const accessibleBg = cssOrHexToColour(getAccessibleColour(colour).backgroundColor);
|
||||
if (accessibleBg === null) return undefined;
|
||||
return colourToHex({ ...accessibleBg, alpha: accessibleBg.alpha * 0.25 });
|
||||
}, [colour]);
|
||||
|
||||
return (
|
||||
<tr
|
||||
|
||||
@@ -45,6 +45,9 @@ $studio-idle: $red-1300;
|
||||
top: 0;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
|
||||
margin-top: -0.5rem;
|
||||
margin-left: -0.5rem;
|
||||
}
|
||||
|
||||
.tick--active {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
@@ -21,6 +22,9 @@ 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]],
|
||||
@@ -83,7 +87,7 @@ export default defineConfig({
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
dom: true,
|
||||
},
|
||||
build: {
|
||||
outDir: './build',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-electron",
|
||||
"version": "4.7.0",
|
||||
"version": "4.8.0",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
@@ -29,8 +29,7 @@
|
||||
"appId": "no.lightdev.ontime",
|
||||
"asar": true,
|
||||
"dmg": {
|
||||
"artifactName": "ontime-macOS-${arch}.dmg",
|
||||
"icon": "icon.icns"
|
||||
"artifactName": "ontime-macOS-${arch}.dmg"
|
||||
},
|
||||
"mac": {
|
||||
"notarize": true,
|
||||
@@ -46,7 +45,7 @@
|
||||
]
|
||||
},
|
||||
"category": "public.app-category.productivity",
|
||||
"icon": "icon.icns"
|
||||
"icon": "ontime.icon"
|
||||
},
|
||||
"win": {
|
||||
"target": "nsis"
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 170 KiB |
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"fill-specializations" : [
|
||||
{
|
||||
"value" : "system-dark"
|
||||
},
|
||||
{
|
||||
"appearance" : "dark",
|
||||
"value" : "system-dark"
|
||||
}
|
||||
],
|
||||
"groups" : [
|
||||
{
|
||||
"layers" : [
|
||||
{
|
||||
"blend-mode-specializations" : [
|
||||
{
|
||||
"appearance" : "dark",
|
||||
"value" : "normal"
|
||||
}
|
||||
],
|
||||
"glass-specializations" : [
|
||||
{
|
||||
"value" : false
|
||||
},
|
||||
{
|
||||
"appearance" : "dark",
|
||||
"value" : false
|
||||
},
|
||||
{
|
||||
"appearance" : "tinted",
|
||||
"value" : false
|
||||
}
|
||||
],
|
||||
"hidden" : false,
|
||||
"image-name" : "ontime-icon.png",
|
||||
"name" : "ontime-icon",
|
||||
"position" : {
|
||||
"scale" : 1,
|
||||
"translation-in-points" : [
|
||||
-0.5,
|
||||
-113
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"shadow" : {
|
||||
"kind" : "neutral",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"translucency" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms" : {
|
||||
"circles" : [
|
||||
"watchOS"
|
||||
],
|
||||
"squares" : "shared"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/resolver",
|
||||
"version": "4.7.0",
|
||||
"version": "4.8.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.7.0",
|
||||
"version": "4.8.0",
|
||||
"exports": "./src/index.js",
|
||||
"dependencies": {
|
||||
"@googleapis/sheets": "^5.0.5",
|
||||
@@ -29,12 +29,10 @@
|
||||
"@types/express": "5.0.3",
|
||||
"@types/multer": "2.1.0",
|
||||
"@types/node": "catalog:",
|
||||
"@types/websocket": "^1.0.5",
|
||||
"@types/ws": "^8.5.10",
|
||||
"esbuild": "^0.24.0",
|
||||
"ontime-types": "workspace:*",
|
||||
"server-timing": "^3.3.3",
|
||||
"shx": "^0.3.4",
|
||||
"ts-essentials": "catalog:",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "catalog:",
|
||||
|
||||
@@ -262,6 +262,6 @@ export const socket = new SocketServer();
|
||||
/**
|
||||
* Utility function to notify clients that the REST data is stale
|
||||
*/
|
||||
export function sendRefetch(target: RefetchKey, revision: MaybeNumber = null) {
|
||||
socket.sendAsJson(MessageTag.Refetch, { target, revision });
|
||||
export function sendRefetch(target: RefetchKey, revision: MaybeNumber = null, rundownId?: string) {
|
||||
socket.sendAsJson(MessageTag.Refetch, { target, revision, rundownId });
|
||||
}
|
||||
|
||||
@@ -47,7 +47,15 @@ export function triggerAutomations(cycle: TimerLifeCycle) {
|
||||
return;
|
||||
}
|
||||
|
||||
filteredTrigger.forEach((trigger) => {
|
||||
// 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) => {
|
||||
const automation = automations[trigger.automationId];
|
||||
if (!automation || automation.outputs.length === 0) {
|
||||
return;
|
||||
|
||||
@@ -74,6 +74,7 @@ let rundownMetadata: RundownMetadata = {
|
||||
let projectCustomFields: CustomFields = {};
|
||||
|
||||
export const getCurrentRundown = (): Readonly<Rundown> => cachedRundown;
|
||||
export const getCurrentRundownId = (): string => cachedRundown.id;
|
||||
export const getRundownMetadata = (): Readonly<RundownMetadata> => rundownMetadata;
|
||||
export const getProjectCustomFields = (): Readonly<CustomFields> => projectCustomFields;
|
||||
export const getEntryWithId = (entryId: EntryId): OntimeEntry | undefined => cachedRundown.entries[entryId];
|
||||
|
||||
@@ -27,7 +27,7 @@ import { updateRundownData } from '../../stores/runtimeState.js';
|
||||
import {
|
||||
createTransaction,
|
||||
customFieldMutation,
|
||||
getCurrentRundown,
|
||||
getCurrentRundownId,
|
||||
rundownCache,
|
||||
rundownMutation,
|
||||
updateBackgroundRundown,
|
||||
@@ -74,7 +74,7 @@ export async function addEntry(eventData: EventPostPayload): Promise<OntimeEntry
|
||||
updateRuntimeOnChange(rundownMetadata);
|
||||
|
||||
// notify timer and external services of change
|
||||
notifyChanges(rundownMetadata, revision, { timer: [newEntry.id], external: true });
|
||||
notifyChanges(rundown.id, rundownMetadata, revision, { timer: [newEntry.id], external: true });
|
||||
});
|
||||
|
||||
return newEntry;
|
||||
@@ -118,7 +118,10 @@ export async function editEntry(patch: PatchWithId): Promise<OntimeEntry> {
|
||||
updateRuntimeOnChange(rundownMetadata);
|
||||
|
||||
// notify timer and external services of change
|
||||
notifyChanges(rundownMetadata, revision, { timer: didInvalidate ? true : [entry.id], external: true });
|
||||
notifyChanges(rundown.id, rundownMetadata, revision, {
|
||||
timer: didInvalidate ? true : [entry.id],
|
||||
external: true,
|
||||
});
|
||||
});
|
||||
|
||||
return entry;
|
||||
@@ -181,7 +184,10 @@ export async function batchEditEntries(ids: EntryId[], patch: Partial<OntimeEntr
|
||||
updateRuntimeOnChange(rundownMetadata);
|
||||
|
||||
// notify timer and external services of change
|
||||
notifyChanges(rundownMetadata, revision, { timer: batchDidInvalidate ? true : changedIds, external: true });
|
||||
notifyChanges(rundown.id, rundownMetadata, revision, {
|
||||
timer: batchDidInvalidate ? true : changedIds,
|
||||
external: true,
|
||||
});
|
||||
});
|
||||
|
||||
return rundownResult;
|
||||
@@ -209,7 +215,7 @@ export async function deleteEntries(entryIds: EntryId[]): Promise<Rundown> {
|
||||
updateRuntimeOnChange(rundownMetadata);
|
||||
|
||||
// notify timer and external services of change
|
||||
notifyChanges(rundownMetadata, revision, { timer: entryIds, external: true });
|
||||
notifyChanges(rundown.id, rundownMetadata, revision, { timer: entryIds, external: true });
|
||||
});
|
||||
|
||||
return rundownResult;
|
||||
@@ -231,7 +237,7 @@ export async function deleteAllEntries(): Promise<Rundown> {
|
||||
updateRuntimeOnChange(rundownMetadata);
|
||||
|
||||
// notify timer and external services of change
|
||||
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
|
||||
notifyChanges(rundown.id, rundownMetadata, revision, { timer: true, external: true });
|
||||
});
|
||||
|
||||
return rundownResult;
|
||||
@@ -263,7 +269,7 @@ export async function reorderEntry(entryId: EntryId, destinationId: EntryId, ord
|
||||
updateRuntimeOnChange(rundownMetadata);
|
||||
|
||||
// notify timer and external services of change
|
||||
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
|
||||
notifyChanges(rundown.id, rundownMetadata, revision, { timer: true, external: true });
|
||||
});
|
||||
|
||||
return rundownResult;
|
||||
@@ -287,7 +293,7 @@ export function renumberEntries(ids: EntryId[], prefix: string, start: string, i
|
||||
|
||||
setImmediate(() => {
|
||||
updateRuntimeOnChange(rundownMetadata);
|
||||
notifyChanges(rundownMetadata, revision, { timer: ids, external: true });
|
||||
notifyChanges(rundown.id, rundownMetadata, revision, { timer: ids, external: true });
|
||||
});
|
||||
|
||||
return rundownResult;
|
||||
@@ -318,7 +324,7 @@ export async function applyDelay(delayId: EntryId): Promise<Rundown> {
|
||||
updateRuntimeOnChange(rundownMetadata);
|
||||
|
||||
// notify timer and external services of change
|
||||
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
|
||||
notifyChanges(rundown.id, rundownMetadata, revision, { timer: true, external: true });
|
||||
});
|
||||
|
||||
return rundownResult;
|
||||
@@ -351,7 +357,7 @@ export async function swapEvents(fromId: EntryId, toId: EntryId): Promise<Rundow
|
||||
updateRuntimeOnChange(rundownMetadata);
|
||||
|
||||
// notify timer and external services of change
|
||||
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
|
||||
notifyChanges(rundown.id, rundownMetadata, revision, { timer: true, external: true });
|
||||
});
|
||||
|
||||
return rundownResult;
|
||||
@@ -380,11 +386,11 @@ export async function cloneEntry(entryId: EntryId, options: InsertOptions): Prom
|
||||
|
||||
// notify timer and external services of change
|
||||
if (isOntimeGroup(newEntry)) {
|
||||
notifyChanges(rundownMetadata, revision, { timer: newEntry.entries, external: true });
|
||||
notifyChanges(rundown.id, rundownMetadata, revision, { timer: newEntry.entries, external: true });
|
||||
} else if (isOntimeEvent(newEntry)) {
|
||||
notifyChanges(rundownMetadata, revision, { timer: [newEntry.id], external: true });
|
||||
notifyChanges(rundown.id, rundownMetadata, revision, { timer: [newEntry.id], external: true });
|
||||
} else if (isOntimeDelay(newEntry)) {
|
||||
notifyChanges(rundownMetadata, revision, { external: true });
|
||||
notifyChanges(rundown.id, rundownMetadata, revision, { external: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -406,7 +412,7 @@ export async function groupEntries(entryIds: EntryId[]): Promise<Rundown> {
|
||||
updateRuntimeOnChange(rundownMetadata);
|
||||
|
||||
// we need to notify the timer since we might be grouping a running event
|
||||
notifyChanges(rundownMetadata, revision, { external: true, timer: true });
|
||||
notifyChanges(rundown.id, rundownMetadata, revision, { external: true, timer: true });
|
||||
});
|
||||
|
||||
return rundownResult;
|
||||
@@ -432,7 +438,7 @@ export async function ungroupEntries(groupId: EntryId): Promise<Rundown> {
|
||||
updateRuntimeOnChange(rundownMetadata);
|
||||
|
||||
// we dont need to notify the timer since the grouping does not affect the runtime
|
||||
notifyChanges(rundownMetadata, revision, { external: true });
|
||||
notifyChanges(rundown.id, rundownMetadata, revision, { external: true });
|
||||
});
|
||||
|
||||
return rundownResult;
|
||||
@@ -522,7 +528,7 @@ export async function editCustomField(
|
||||
// schedule the side effects
|
||||
setImmediate(() => {
|
||||
sendRefetch(RefetchKey.CustomFields);
|
||||
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
|
||||
notifyChanges(undefined, rundownMetadata, revision, { timer: true, external: true });
|
||||
});
|
||||
|
||||
return resultCustomFields;
|
||||
@@ -561,7 +567,7 @@ export async function deleteCustomField(key: CustomFieldKey, projectRundowns: Pr
|
||||
// schedule the side effects
|
||||
setImmediate(() => {
|
||||
sendRefetch(RefetchKey.CustomFields);
|
||||
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
|
||||
notifyChanges(undefined, rundownMetadata, revision, { timer: true, external: true });
|
||||
});
|
||||
|
||||
return resultCustomFields;
|
||||
@@ -592,10 +598,17 @@ type NotifyChangesOptions = {
|
||||
|
||||
/**
|
||||
* Notify services of changes in the rundown
|
||||
* TODO: we could receive a runtime flag to call updateRuntimeOnChange
|
||||
* instead of having it in every consumer
|
||||
*/
|
||||
function notifyChanges(rundownMetadata: RundownMetadata, revision: number, options: NotifyChangesOptions) {
|
||||
// notify timer service of changed events
|
||||
if (options.timer) {
|
||||
function notifyChanges(
|
||||
rundownId: string | undefined,
|
||||
rundownMetadata: RundownMetadata,
|
||||
revision: number,
|
||||
options: NotifyChangesOptions,
|
||||
) {
|
||||
// notify timer service of changed event
|
||||
if (options.timer && rundownId && isCurrentRundown(rundownId)) {
|
||||
runtimeService.notifyOfChangedEvents(rundownMetadata);
|
||||
}
|
||||
|
||||
@@ -603,18 +616,23 @@ function notifyChanges(rundownMetadata: RundownMetadata, revision: number, optio
|
||||
if (options.reload) {
|
||||
sendRefetch(RefetchKey.All);
|
||||
} else if (options.external) {
|
||||
sendRefetch(RefetchKey.Rundown, revision);
|
||||
sendRefetch(RefetchKey.Rundown, revision, rundownId);
|
||||
}
|
||||
}
|
||||
|
||||
export function isCurrentRundown(id: string) {
|
||||
return id === getCurrentRundownId();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws if the provided id does not exist
|
||||
*/
|
||||
export async function loadRundown(id: string) {
|
||||
const dataProvider = getDataProvider();
|
||||
if (id === getCurrentRundown().id) {
|
||||
if (isCurrentRundown(id)) {
|
||||
return dataProvider.getProjectRundowns();
|
||||
}
|
||||
|
||||
const rundown = dataProvider.getRundown(id);
|
||||
const customField = dataProvider.getCustomFields();
|
||||
await initRundown(rundown, customField);
|
||||
@@ -637,7 +655,7 @@ export async function initRundown(
|
||||
updateRuntimeOnChange(rundownMetadata);
|
||||
|
||||
setImmediate(() => {
|
||||
notifyChanges(rundownMetadata, revision, { timer: true, external: true, reload });
|
||||
notifyChanges(rundown.id, rundownMetadata, revision, { timer: true, external: true, reload });
|
||||
setLastLoadedRundown(rundown.id).catch((error) => {
|
||||
logger.error(LogOrigin.Server, `Failed to persist last loaded rundown: ${error}`);
|
||||
});
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
|
||||
+9
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime",
|
||||
"version": "4.7.0",
|
||||
"version": "4.8.0",
|
||||
"description": "Time keeping for live events",
|
||||
"keywords": [
|
||||
"ontime",
|
||||
@@ -17,7 +17,7 @@
|
||||
"license": "AGPL-3.0-only",
|
||||
"engines": {
|
||||
"node": "~22",
|
||||
"pnpm": "~10"
|
||||
"pnpm": "~11"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -51,5 +51,11 @@
|
||||
"turbo": "2.8.20",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319"
|
||||
"packageManager": "pnpm@11.1.2+sha512.415a1cc25974731e75455c1468371be74c5aa5fb7621b50d4056d222451609f11412f23fd602e6169f1e060466641f798597e1be961a10688836a67b16569499",
|
||||
"devEngines": {
|
||||
"runtime": {
|
||||
"name": "node",
|
||||
"version": "22.22.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "4.7.0",
|
||||
"version": "4.8.0",
|
||||
"name": "ontime-types",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
|
||||
@@ -41,6 +41,7 @@ type RefetchPacket = {
|
||||
payload: {
|
||||
target: RefetchKey;
|
||||
revision: MaybeNumber;
|
||||
rundownId?: string; // undefined means refetch all rundowns
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Generated
+1307
-514
File diff suppressed because it is too large
Load Diff
+7
-5
@@ -9,8 +9,10 @@ catalog:
|
||||
typescript: 6.0.2
|
||||
vitest: 4.0.17
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- electron
|
||||
- esbuild
|
||||
|
||||
useNodeVersion: 22.22.2
|
||||
allowBuilds:
|
||||
'@parcel/watcher': true
|
||||
'@sentry/cli': false
|
||||
core-js: false
|
||||
electron: true
|
||||
electron-winstaller: false
|
||||
esbuild: true
|
||||
|
||||
Reference in New Issue
Block a user