Compare commits

..

8 Commits

Author SHA1 Message Date
Claude 767b567bba Improve editor settings navigation, UX, and visual design
- Unsaved changes guard: confirm dialog when navigating away from dirty forms (GeneralSettings, ProjectData, ViewSettings, ServerPortSettings, AutomationSettingsForm)
- Sticky breadcrumb header: replaces floating close button with a sticky top bar showing current panel › section context
- Scroll-to-sidebar sync: IntersectionObserver in useScrollIntoView feeds active scroll section back into sidebar secondary item highlights
- Rich empty states: ClientList now shows friendly messages for empty Ontime/Other client tables
- Sidebar styling: distinct background, right border, left-accent active indicator (VS Code style)
- Card elevation: raised opacity + box-shadow for visible depth hierarchy
- Border radius: 8px cards and 6px sidebar items using existing theme variable

https://claude.ai/code/session_017jeJF78B4eXcpXAj7KGmnF
2026-05-28 06:51:24 +00:00
Alex Christoffer Rasmussen 9ae49a7175 chore: pnpm 11 + dependency cleanup (#2089)
* core: upgrade to pnpm 11 and nodejs to 22.22.2

* chore: allow builds

* chore: remove unused

* chore: update actions

* chore: add stub for vitest

* reafactor: small optimization
2026-05-17 21:01:16 +02:00
Carlos Valente d30d9cfd4e refactor: account for tick size on translate 2026-05-13 20:26:15 +02:00
Carlos Valente a0e7b9b665 refactor: simplify spacing between help menu items 2026-05-13 20:26:15 +02:00
Carlos Valente 1dcb7fb08d refactor: fix Info text flow 2026-05-13 20:26:15 +02:00
Carlos Valente a7e84b9903 refactor: fixed css editor size 2026-05-13 20:26:15 +02:00
Carlos Valente 3bebbf9ce9 refactor: invalidate changed rundown 2026-05-11 21:25:15 +02:00
Carlos Valente ab8da07518 refactor: improve memoisation on table rows 2026-05-11 21:25:15 +02:00
52 changed files with 406 additions and 672 deletions
+2 -2
View File
@@ -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') }}
+2 -2
View File
@@ -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') }}
+4 -4
View File
@@ -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
+2 -2
View File
@@ -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') }}
+5 -5
View File
@@ -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
View File
@@ -1 +1 @@
v22.15.1
v22.22.2
+1 -1
View File
@@ -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 -2
View File
@@ -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": {
@@ -68,6 +67,7 @@
"@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",
@@ -75,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:"
}
}
@@ -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');
+22 -29
View File
@@ -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>(
+3 -3
View File
@@ -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';
}
@@ -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 {
@@ -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;
@@ -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/&lt;name&gt;/</strong>.
<br />
<span>
Upload one <strong>index.html</strong> per view to <strong>/external/&lt;name&gt;/</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 (
<>
@@ -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(() => {
@@ -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]);
}
@@ -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,
@@ -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 -1
View File
@@ -87,7 +87,7 @@ export default defineConfig({
},
test: {
globals: true,
environment: 'jsdom',
dom: true,
},
build: {
outDir: './build',
-2
View File
@@ -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:",
+2 -2
View File
@@ -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 });
}
@@ -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}`);
});
+8 -2
View File
@@ -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"
}
}
}
@@ -41,6 +41,7 @@ type RefetchPacket = {
payload: {
target: RefetchKey;
revision: MaybeNumber;
rundownId?: string; // undefined means refetch all rundowns
};
};
+62 -487
View File
File diff suppressed because it is too large Load Diff
+7 -5
View File
@@ -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