Compare commits

...

43 Commits

Author SHA1 Message Date
Alex Christoffer Rasmussen 35347e8d63 Feat: Time until group and flag (#1708)
* refactor: split event data and state data

* refactor: calculate the start time insted of until

* cleanup

* send next flag expected start from server

* refactor: render

* fix test

* use same icon

* refactor: group duration

* Optimize (#1711)

* refactor: consolidate block, flag and end loading and expected times

* work on test

* cal end value

* lint

* fix test

* remove todo
2025-08-06 21:55:28 +02:00
Carlos Valente e37d2ce50f refactor: tweaks to timeline styles 2025-08-06 21:40:19 +02:00
Carlos Valente 49e923026e feat: expose planned start in operator 2025-08-06 21:40:19 +02:00
Carlos Valente f1a8db9649 feat: timer overview shows waiting timer 2025-08-06 21:40:19 +02:00
Carlos Valente dddeefd544 chore: rename projected > expected 2025-08-06 21:40:19 +02:00
Carlos Valente b4aaa8966f fix: insert into group 2025-08-06 21:40:19 +02:00
Carlos Valente ad9db4edd3 chore: add demo project 2025-08-06 21:40:19 +02:00
Carlos Valente fef9ed4fcc chore: update icon assets 2025-08-06 21:40:19 +02:00
Carlos Valente 9ec47eda6e feat: expose block target duration in header 2025-08-06 21:40:19 +02:00
Alex Christoffer Rasmussen 32edf3404c Fix tag ws api (#1709)
* fix: ws api should also reten tag and not type

* fix: when starting by cue try finding the next first then check before
2025-08-06 21:40:19 +02:00
Alex Christoffer Rasmussen bcf80f9d77 chore: rename offset to offsetAbs and offsetRel (#1706) 2025-08-06 21:40:19 +02:00
Carlos Valente df137402cf bump version to 4.0.0-alpha.2 2025-08-06 21:40:19 +02:00
Carlos Valente daddb7f7b7 fix: open group when selecting from finder 2025-08-06 21:40:19 +02:00
Carlos Valente 82e8b9d66a fix: cleanup behaviour for rundown add buttons 2025-08-06 21:40:19 +02:00
Carlos Valente 765d5d97cf refactor: readonly inputs in cuesheet 2025-08-06 21:40:19 +02:00
Carlos Valente ed4d3ba265 refactor: remove toggle link context menu action 2025-08-06 21:40:19 +02:00
Carlos Valente a358ec0f17 refactor: small style tweaks
refactor: consistent font size on inputs

refactor: style tweaks for block spacing

refactor: improve skipped styles

fix: improve responsiveness in extracted rundown

refactor: style tweaks to overview

refactor: simplify group target duration
2025-08-06 21:40:19 +02:00
Carlos Valente 524721002a fix: prevent nesting groups 2025-08-06 21:40:19 +02:00
Carlos Valente 9ab54edbf5 refactor: small code cleanups
- remove compiler directives
2025-08-06 21:40:19 +02:00
Carlos Valente 8a7f8c8ebc refactor: small style tweaks
- cursor styles on dnd
2025-08-06 21:40:19 +02:00
Carlos Valente 3ce539af2a fix: rendering if countdown in safari 2025-08-06 21:40:19 +02:00
Carlos Valente 3158311aa1 docs: align readme with documentation 2025-07-26 10:51:00 +02:00
Carlos Valente 6b4d96dddd feat: extra info in backstage 2025-07-26 10:51:00 +02:00
Carlos Valente dc3a32d2bc fix: custom project data 2025-07-26 10:51:00 +02:00
Carlos Valente bda01c886e fix: mirroring 2025-07-26 10:51:00 +02:00
Carlos Valente 93916653be refactor: tweaks to styles
- cuesheet action is ghosted
- mute index in cuesheet milestones
- clarify mirrored state in navigation menu
- align overview contents
2025-07-26 10:51:00 +02:00
Carlos Valente 40f8976350 fix: custom fields are last in order 2025-07-26 10:51:00 +02:00
Carlos Valente 78e905a735 fix: flag cannot be the current event 2025-07-26 10:51:00 +02:00
Carlos Valente 126ff61738 refactor: milestone actions in context menu 2025-07-26 10:51:00 +02:00
Carlos Valente 1e63238cac fix: allow empty string as a cue value 2025-07-26 10:51:00 +02:00
Carlos Valente 6ffe34a642 bump version to 4.0.0-alpha.1 2025-07-26 10:51:00 +02:00
Carlos Valente 69f276765b fix: delay indicator styling 2025-07-26 10:51:00 +02:00
Carlos Valente 75554071a7 refactor: improve params performance 2025-07-26 10:51:00 +02:00
Carlos Valente a1fab10bfd feat: improve preset building 2025-07-26 10:51:00 +02:00
Carlos Valente 5bc286145d refactor: extract regex utilities 2025-07-26 10:51:00 +02:00
Carlos Valente 44508ce8b4 refactor: block writing to base fields in run mode 2025-07-26 10:51:00 +02:00
Carlos Valente 90105a199a refactor: empty state for cuesheet 2025-07-26 10:51:00 +02:00
Carlos Valente 9618bec847 refactor: prevent reflow on visible rows 2025-07-26 10:51:00 +02:00
Carlos Valente 66a4e27fdd refactor: remove unused data 2025-07-26 10:51:00 +02:00
Carlos Valente ef906f43d7 docs: improve feature description 2025-07-26 10:51:00 +02:00
Carlos Valente 93b2a1aece refactor: index test alignment 2025-07-26 10:51:00 +02:00
Carlos Valente 050629d281 fix: stale column data 2025-07-26 10:51:00 +02:00
Carlos Valente abe9012f20 fix: keyboard shortcuts in rundown 2025-07-26 10:51:00 +02:00
175 changed files with 2880 additions and 2266 deletions
+14 -59
View File
@@ -43,12 +43,12 @@ Ontime is made by entertainment and broadcast engineers and used by
- Houses of worship
## Main features
- [x] **Multiplatform**: Available for Windows / MacOS, Linux. You can also self host with the docker image
- [x] **In any device**: Ontime is available in the local network to any device with a browser, eg: tablets, mobile phones, laptops, signage, media servers...
- [x] **Made for teams**: Ontime caters to different roles in your production team: directors, operators, backstage and front of house signage...
- [x] **Delay workflows**: Manage and communicate runtime delays in real-time to your team
- [x] **Automatable**: Ontime can be fully or partially controlled by an operator, or run standalone with the system clock
- [x] **Focus on integrations**: Use one of the APIs provided (OSC, HTTP, Websocket) or the available [Companion module](https://bitfocus.io/connections/getontime-ontime) to integrate into your workflow (vMix, disguise, Qlab, OBS)
- **Multiplatform**: Available as a Cloud service and for Windows, macOS, Linux, or self-hosted via Docker.
- **In any device**: Ontime is available to any device with a browser, eg: tablets, mobile phones, laptops, signage, media servers...
- **Team Collaboration**: Dedicated views for directors, operators, backstage, and signage.
- **Real-Time Updates**: Manage and communicate runtime delays effortlessly.
- **Automatable**: Ontime can be fully or partially controlled by an operator, or run standalone with the system clock
- **Flexible Integrations**: Use one of the APIs provided (OSC, HTTP, Websocket) or the available [Companion module](https://bitfocus.io/connections/getontime-ontime) to integrate into your workflow (vMix, disguise, Qlab, OBS)
... and a lot more ...
@@ -79,75 +79,29 @@ Ontime is made by video engineers and entertainment technicians.
## Using Ontime
Ontime can be started by downloading the latest release for your platform. \
Alternatively you can also use the docker image, available at [Docker Hub](https://hub.docker.com/r/getontime/ontime)
### Getting started
Once installed and running, any device in the network has access to Ontime.
The easiest way to start with Ontime is by leveraging our [Cloud service](https://getontime.no). \
This will give you immediate access to running instances of Ontime which are available to share with anyone with an internet connection.
Ontime provides different screens which allow for different types of interactions with the data. These are called
views. \
Each view in Ontime focuses on empowering a specific role or achieving a particular task.
Alternatively, you can run Ontime locally for free by downloading the latest release for your platform or using the docker image, available at [Docker Hub](https://hub.docker.com/r/getontime/ontime)
You can access the different views by reaching the ontime server, in your browser, at (_default port
4001_) `http://localhost:4001` or `http://192.168.1.3:4001`
```
For the backstage views
-------------------------------------------------------------
IP.ADDRESS:4001/timer > Presenter / Stage timer view
IP.ADDRESS:4001/backstage > Stage Manager / Backstage view
IP.ADDRESS:4001/countdown > Countdown to anything
IP.ADDRESS:4001/studio > Studio Clock
IP.ADDRESS:4001/timeline > Timeline
```
```
For production views
-------------------------------------------------------------
IP.ADDRESS:4001/editor > the control interface, same as the app
IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
IP.ADDRESS:4001/op > automated views for operators
```
Once installed and running, any device that shares the same network as Ontime will have access to Ontime.
More information is available [in our docs](https://docs.getontime.no)
## Roadmap
### Continued development
## Continued development
Ontime is under active development. We continue adding and improving features in collaboration with users.
Have an idea? Reach out via [email](mail@getontime.no)
or [open an issue](https://github.com/cpvalente/ontime/issues/new)
### Issues
## Issues
We use Github's issue tracking for bug reporting and feature requests. \
Found a bug? [Open an issue](https://github.com/cpvalente/ontime/issues/new).
#### Unsigned App
When installing the app you would see warning screens from the Operating System like:
in Windows
`Microsoft Defender SmartScreen prevented an unrecognised app from starting. Running this app might put your PC at risk.`
or in Linux
`Could Not Display "ontime-linux.AppImage`
We currently only sign MacOS releases. \
Purchasing the certificates for both Mac and Windows would mean a recurrent expense which we are not able to cover.
You can circumvent this by allowing the execution of the app manually.
- In Windows: click `more` -> `Run Anyway`
- In Linux: right-click the AppImage file: `Properties` -> `Permissions` -> `Allow Executing File as a Program`
If you have tips on how to improve this or would like to sponsor the code signing,
please [open an issue](https://github.com/cpvalente/ontime/issues/new)
## Contributing
Looking to contribute? All types of help are appreciated, from coding to testing and feature specification.
@@ -159,6 +113,7 @@ Information about the project setup can be found in the [development documentati
## Links
- [Ontime website](https://getontime.no)
- [Documentation](https://docs.getontime.no)
- [Video tutorials](https://www.youtube.com/@ontimeapp)
- [Ontime discord server](https://discord.com/invite/eje3CSUEXm)
## License
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "4.0.0-alpha.0",
"version": "4.0.0-alpha.2",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "4.0.0-alpha.0",
"version": "4.0.0-alpha.2",
"private": true,
"type": "module",
"dependencies": {
+18 -4
View File
@@ -6,7 +6,7 @@ import { apiEntryUrl } from './constants';
const urlPresetsPath = `${apiEntryUrl}/url-presets`;
/**
* HTTP request to retrieve aliases
* HTTP request to retrieve all presets
*/
export async function getUrlPresets(): Promise<URLPreset[]> {
const res = await axios.get(urlPresetsPath);
@@ -14,8 +14,22 @@ export async function getUrlPresets(): Promise<URLPreset[]> {
}
/**
* HTTP request to mutate aliases
* HTTP request to add a preset
*/
export async function postUrlPresets(data: URLPreset[]): Promise<URLPreset[]> {
return axios.post(urlPresetsPath, data);
export async function postUrlPreset(data: URLPreset): Promise<URLPreset[]> {
return (await axios.post(urlPresetsPath, data)).data;
}
/**
* HTTP request to edit a preset
*/
export async function putUrlPreset(alias: string, data: URLPreset): Promise<URLPreset[]> {
return (await axios.put(`${urlPresetsPath}/${alias}`, data)).data;
}
/**
* HTTP request to delete a preset
*/
export async function deleteUrlPreset(alias: string): Promise<URLPreset[]> {
return (await axios.delete(`${urlPresetsPath}/${alias}`)).data;
}
+14
View File
@@ -33,6 +33,20 @@ export function maybeAxiosError(error: unknown) {
}
}
/**
* Utility unwrap a an instance of Error
*/
export function unwrapError(error: unknown) {
if (error instanceof Error) {
return error.message;
} else {
if (typeof error !== 'string') {
return JSON.stringify(error);
}
return error;
}
}
/**
* Utility unwraps a potential axios error and sends to logger
* @param prepend
@@ -45,8 +45,8 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
label: view.label,
})),
...enabledPresets.map((preset) => ({
value: preset.pathAndParams,
label: `Preset: ${preset.alias}`,
value: preset.search,
label: `URL Preset: ${preset.alias}`,
})),
];
@@ -1,5 +1,7 @@
.delaySymbol {
svg {
display: inline;
vertical-align: middle;
font-size: 1.5rem;
color: $ontime-delay;
margin: 0 auto;
@@ -10,13 +10,19 @@
padding-inline: 0.5em;
outline: none;
&:hover:not(:disabled) {
&::placeholder {
color: $gray-500;
letter-spacing: 0;
}
&:hover:not(:disabled):not(:read-only) {
background-color: $gray-1100;
}
&:focus:not(:read-only) {
background-color: $gray-1000;
border: 1px solid $blue-500;
outline: 2px solid $blue-500;
outline-offset: 2px;
}
&:disabled {
@@ -24,9 +30,8 @@
cursor: not-allowed;
}
&::placeholder {
color: $gray-500;
letter-spacing: 0;
&:read-only::placeholder {
opacity: 0;
}
}
@@ -9,9 +9,13 @@
border-radius: $component-border-radius-md;
border: 1px solid transparent;
padding-inline: 0.5em;
outline: none;
&::placeholder {
color: $gray-500;
letter-spacing: 0;
}
&:hover:not(:disabled) {
background-color: $gray-1100;
}
@@ -26,21 +30,21 @@
cursor: not-allowed;
}
&::placeholder {
color: $gray-500;
letter-spacing: 0;
&:read-only::placeholder {
opacity: 0;
}
}
.subtle {
background-color: $gray-1200;
padding-top: 0.5em;
padding-inline: 0.5em;
padding-top: 0.25em;
}
.ghosted {
background-color: transparent;
padding: 0;
padding-top: 0.5em;
padding-top: 0.25em;
}
.fluid {
@@ -9,7 +9,7 @@ import style from './TimeInput.module.scss';
interface NullableTimeInputProps<T extends string> {
id?: T;
name: T;
submitHandler: (field: T, value: string) => void;
submitHandler: (field: T, value: number) => void;
time?: number | null;
emptyDisplay: string;
placeholder?: string;
@@ -71,7 +71,7 @@ export default function NullableTimeInput<T extends string>({
return false;
}
submitHandler(name, newValue);
submitHandler(name, valueInMillis);
return true;
},
[name, submitHandler, time],
@@ -64,3 +64,8 @@
flex: 1;
overflow-y: auto;
}
.note {
margin-left: auto;
color: $white-20;
}
@@ -57,9 +57,10 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
Toggle Fullscreen
{fullscreen ? <IoContract /> : <IoExpand />}
</NavigationMenuItem>
<NavigationMenuItem active={mirror} onClick={toggleMirror}>
<NavigationMenuItem active={mirror} onClick={() => toggleMirror()}>
Flip Screen
<IoSwapVertical />
{mirror && <span className={style.note}>Active</span>}
</NavigationMenuItem>
<NavigationMenuItem onClick={handlers.open}>Rename Client</NavigationMenuItem>
@@ -5,12 +5,17 @@
}
.interfaces {
padding: 0.5rem 1rem;
padding: 0.5rem 0.5rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.interfaceCopy {
display: flex;
align-items: center;
}
.goIcon {
@include rotate-fourty-five;
margin-left: 0.25rem;
@@ -30,8 +30,10 @@ export default function OtherAddresses({ currentLocation }: OtherAddressesProps)
const address = linkToOtherHost(nif.address, currentLocation);
return (
<CopyTag key={nif.name} copyValue={address} onClick={() => openLink(address)}>
{nif.address} <IoArrowUp className={style.goIcon} />
<CopyTag key={nif.name} copyValue={address} onClick={() => openLink(address)} size='small'>
<span className={style.interfaceCopy}>
{nif.address} <IoArrowUp className={style.goIcon} />
</span>
</CopyTag>
);
})}
@@ -14,7 +14,6 @@
border: 1px solid transparent;
padding-inline: 0.5rem;
font-size: calc(1rem - 2px);
white-space: nowrap;
&:hover:not([data-disabled]) {
@@ -57,7 +56,7 @@
}
.popup {
font-size: calc(1rem - 2px);
font-size: 1rem;
background-color: $gray-1200;
box-sizing: border-box;
padding: 2px;
@@ -11,8 +11,7 @@ interface EmptyProps {
className?: string;
}
export default function Empty(props: EmptyProps) {
const { text, className, ...rest } = props;
export default function Empty({ text, className, ...rest }: EmptyProps) {
return (
<div className={cx([style.emptyContainer, className])} {...rest}>
<EmptyImage className={style.empty} />
@@ -0,0 +1,19 @@
.emptyContainer {
width: 100%;
color: $white-10;
.emptyCell {
margin-inline: auto;
text-align: center;
}
.empty {
width: 100%;
opacity: 0.8;
}
.text {
font-weight: 600;
font-size: 2em;
}
}
@@ -0,0 +1,20 @@
import EmptyImage from '../../../assets/images/empty.svg?react';
import style from './EmptyTableBody.module.scss';
interface EmptyTableBodyProps {
text: string;
}
export default function EmptyTableBody({ text }: EmptyTableBodyProps) {
return (
<tbody className={style.emptyContainer}>
<tr>
<td colSpan={99} className={style.emptyCell}>
<EmptyImage className={style.empty} />
{text && <span className={style.text}>{text}</span>}
</td>
</tr>
</tbody>
);
}
@@ -22,6 +22,11 @@
outline: 2px solid $blue-500;
outline-offset: 2px;
}
&[data-disabled] {
opacity: $opacity-disabled;
cursor: not-allowed;
}
}
.medium {
@@ -50,4 +55,5 @@
&[data-checked] {
translate: calc(var(--width) - var(--height)) 0;
}
}
@@ -232,7 +232,6 @@ describe('getURLSearchParamsFromObj()', () => {
bool2: 'on',
};
const result = getURLSearchParamsFromObj(params, mockOptionsWithBooleans);
console.log('Result:', result.toString());
expect(result.get('bool1')).toBe('false');
expect(result.get('bool2')).toBe('true');
});
@@ -1,4 +1,4 @@
import type { CustomFields } from 'ontime-types';
import type { CustomFields, ProjectData } from 'ontime-types';
import type { SelectOption } from '../select/Select';
@@ -53,6 +53,18 @@ export function makeCustomFieldSelectOptions(customFields: CustomFields, filterI
return options;
}
/**
* Creates data for a select element that displays project custom data
*/
export function makeProjectDataOptions(projectData: ProjectData): SelectOption[] {
return projectData.custom.map((entry, index) => {
return {
value: `${index}-${entry.title}`,
label: entry.title,
};
});
}
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
/**
@@ -1,8 +1,8 @@
import { useQuery } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_DATA } from '../api/constants';
import { getProjectData } from '../api/project';
import { getProjectData, postProjectData } from '../api/project';
import { projectDataPlaceholder } from '../models/ProjectData';
export default function useProjectData() {
@@ -10,11 +10,25 @@ export default function useProjectData() {
queryKey: PROJECT_DATA,
queryFn: getProjectData,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? projectDataPlaceholder, status, isFetching, isError, refetch };
}
export function useUpdateProjectData() {
const queryClient = useQueryClient();
const updateFn = useMutation({
mutationFn: postProjectData,
onSuccess: (newProjectData) => {
queryClient.setQueryData(PROJECT_DATA, newProjectData);
},
});
return {
updateProjectData: updateFn.mutateAsync,
isMutating: updateFn.isPending,
isMutatingError: updateFn.isError,
};
}
@@ -56,6 +56,7 @@ export function useFlatRundown() {
}, [data.entries, data.flatOrder, data.revision, prevRevision]);
// TODO: should we have a project id field?
// TODO(v4): cleanup as part of load multiple rundowns
// invalidate current version if project changes
useEffect(() => {
if (projectData?.title !== loadedProject.current) {
@@ -64,7 +65,7 @@ export function useFlatRundown() {
}
}, [projectData]);
return { data: flatRundown, status };
return { data: flatRundown, rundownId: data.id, status };
}
/**
@@ -1,8 +1,9 @@
import { useQuery } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { URLPreset } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { URL_PRESETS } from '../api/constants';
import { getUrlPresets } from '../api/urlPresets';
import { deleteUrlPreset, getUrlPresets, postUrlPreset, putUrlPreset } from '../api/urlPresets';
interface FetchProps {
skip?: boolean;
@@ -13,12 +14,42 @@ export default function useUrlPresets({ skip = false }: FetchProps = {}) {
queryKey: URL_PRESETS,
queryFn: getUrlPresets,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
enabled: !skip,
});
return { data: data ?? [], status, isError, refetch };
}
export function useUpdateUrlPreset() {
const queryClient = useQueryClient();
const addFn = useMutation({
mutationFn: postUrlPreset,
onSuccess: (newPresets) => {
queryClient.setQueryData(URL_PRESETS, newPresets);
},
});
const updateFn = useMutation({
mutationFn: ({ alias, data }: { alias: string; data: URLPreset }) => putUrlPreset(alias, data),
onSuccess: (newPresets) => {
queryClient.setQueryData(URL_PRESETS, newPresets);
},
});
const deleteFn = useMutation({
mutationFn: deleteUrlPreset,
onSuccess: (newPresets) => {
queryClient.setQueryData(URL_PRESETS, newPresets);
},
});
return {
addPreset: addFn.mutateAsync,
updatePreset: (alias: string, data: URLPreset) => updateFn.mutateAsync({ alias, data }),
deletePreset: deleteFn.mutateAsync,
isMutating: addFn.isPending || updateFn.isPending || deleteFn.isPending,
isMutationError: addFn.isError || updateFn.isError || deleteFn.isError,
};
}
+4 -11
View File
@@ -89,25 +89,17 @@ export const useEntryActions = () => {
// ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEntry)) {
// merge creation time options with event settings
const applicationOptions = {
after: options?.after,
before: options?.before,
lastEventId: options?.lastEventId,
linkPrevious: options?.linkPrevious ?? linkPrevious,
};
if (applicationOptions?.lastEventId) {
if (options?.lastEventId) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
const rundownData = queryClient.getQueryData<Rundown>(RUNDOWN)!;
const previousEvent = rundownData.entries[applicationOptions.lastEventId];
const previousEvent = rundownData.entries[options?.lastEventId];
if (isOntimeEvent(previousEvent)) {
newEntry.timeStart = previousEvent.timeEnd;
}
}
// Override event with options from editor settings
newEntry.linkStart = applicationOptions.linkPrevious;
newEntry.linkStart = options?.linkPrevious ?? linkPrevious;
if (newEntry.duration === undefined && newEntry.timeEnd === undefined) {
newEntry.duration = parseUserTime(defaultDuration);
@@ -701,6 +693,7 @@ export const useEntryActions = () => {
await reorderEntryMutation(reorderObject);
} catch (error) {
logAxiosError('Error re-ordering event', error);
throw error; // rethrow to handle in the component
}
},
[reorderEntryMutation],
+8 -8
View File
@@ -151,7 +151,8 @@ export const useClock = createSelector((state: RuntimeStore) => ({
}));
export const useNextFlag = createSelector((state: RuntimeStore) => ({
nextFlag: state.nextFlag,
id: state.nextFlag?.id ?? null,
expectedStart: state.nextFlag?.expectedStart ?? null,
}));
/** Used by the progress bar components */
@@ -175,24 +176,23 @@ export const useRuntimePlaybackOverview = createSelector((state: RuntimeStore) =
numEvents: state.runtime.numEvents,
selectedEventIndex: state.runtime.selectedEventIndex,
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offsetAbs : state.runtime.offsetRel,
blockStartedAt: state.blockNow?.startedAt ?? null,
blockExpectedEnd: state.blockNow?.expectedEnd ?? null,
}));
export const useTimelineStatus = createSelector((state: RuntimeStore) => ({
clock: state.clock,
offset: state.runtime.offset,
offset: state.runtime.offsetAbs,
}));
export const useTimeUntilData = createSelector((state: RuntimeStore) => ({
clock: state.clock,
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
export const useExpectedStartData = createSelector((state: RuntimeStore) => ({
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offsetAbs : state.runtime.offsetRel,
offsetMode: state.runtime.offsetMode,
currentDay: state.eventNow?.dayOffset ?? 0,
actualStart: state.runtime.actualStart,
plannedStart: state.runtime.plannedStart,
clock: state.clock,
}));
export const useCurrentDay = createSelector((state: RuntimeStore) => ({
@@ -200,7 +200,7 @@ export const useCurrentDay = createSelector((state: RuntimeStore) => ({
}));
export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({
offset: state.runtime.offset,
offset: state.runtime.offsetAbs,
}));
export const usePing = createSelector((state: RuntimeStore) => ({
@@ -1,6 +1,12 @@
import { resolvePath } from 'react-router-dom';
import { arePathsEquivalent, generatePathFromPreset, getRouteFromPreset, validateUrlPresetPath } from '../urlPresets';
import {
arePathsEquivalent,
generatePathFromPreset,
generateUrlPresetOptions,
getRouteFromPreset,
validateUrlPresetPath,
} from '../urlPresets';
describe('validateUrlPresetPaths()', () => {
test.each([
@@ -27,7 +33,8 @@ describe('getRouteFromPreset()', () => {
{
enabled: true,
alias: 'demopage',
pathAndParams: '/timer?user=guest',
target: 'timer',
search: 'user=guest',
},
];
@@ -76,14 +83,14 @@ describe('getRouteFromPreset()', () => {
describe('generatePathFromPreset()', () => {
test.each([
['timer?user=guest', 'demopage', 'timer?user=guest&alias=demopage'],
['timer?user=admin', 'demopage', 'timer?user=admin&alias=demopage'],
])('generates a path from a preset: %s', (path, alias, expected) => {
expect(generatePathFromPreset(path, alias, null, null)).toEqual(expected);
['timer', 'user=guest', 'demopage', 'timer?user=guest&alias=demopage'],
['timer', 'user=admin', 'demopage', 'timer?user=admin&alias=demopage'],
])('generates a path from a preset: %s', (target, path, alias, expected) => {
expect(generatePathFromPreset(target, path, alias, null, null)).toEqual(expected);
});
test('appends the feature params to the alias', () => {
expect(generatePathFromPreset('timer?user=guest', 'demopage', 'true', '123')).toBe(
expect(generatePathFromPreset('timer', 'user=guest', 'demopage', 'true', '123')).toBe(
'timer?user=guest&alias=demopage&locked=true&token=123',
);
});
@@ -106,3 +113,62 @@ describe('arePathsEquivalent()', () => {
expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=a')).toBeTruthy();
});
});
describe('generateUrlPresetOptions', () => {
it.each([
[
'cloud URL without protocol',
'test',
'www.getontime.no/timer?param1=value1&param2=value2',
{
alias: 'test',
target: 'timer',
search: 'param1=value1&param2=value2',
enabled: true,
},
],
[
'cloud URL',
'test',
'https://cloud.getontime.no/timer?param1=value1&param2=value2',
{
alias: 'test',
target: 'timer',
search: 'param1=value1&param2=value2',
enabled: true,
},
],
[
'local URL',
'test',
'http://localhost:4001/timer?param1=value1&param2=value2',
{
alias: 'test',
target: 'timer',
search: 'param1=value1&param2=value2',
enabled: true,
},
],
[
'IP-based URL',
'test',
'http://192.168.0.1:4001/timer?param1=value1&param2=value2',
{
alias: 'test',
target: 'timer',
search: 'param1=value1&param2=value2',
enabled: true,
},
],
])('should generate URL preset options for %s', (_description, alias, url, expected) => {
expect(generateUrlPresetOptions(alias, url)).toStrictEqual(expected);
});
it('throws on invalid URL', () => {
expect(() => generateUrlPresetOptions('test', 'invalid-url')).toThrow();
});
it('throws on on invalid route', () => {
expect(() => generateUrlPresetOptions('test', 'www.getontime.no/somethingelse/')).toThrow();
});
});
+2 -2
View File
@@ -19,7 +19,7 @@ export function getOffsetText(offset: MaybeNumber): string {
}
export function getOffsetState(offset: MaybeNumber): 'over' | 'under' | 'muted' | null {
if (offset === null) return null;
if (offset === 0) return 'muted';
if (offset === null) return 'muted';
if (offset === 0) return null;
return offset < 0 ? 'over' : 'under';
}
+14 -9
View File
@@ -1,7 +1,7 @@
import { MaybeNumber, OntimeEvent, Settings, TimeFormat } from 'ontime-types';
import {
calculateTimeUntilStart,
formatFromMillis,
getExpectedStart,
MILLIS_PER_HOUR,
MILLIS_PER_MINUTE,
MILLIS_PER_SECOND,
@@ -9,7 +9,7 @@ import {
import { FORMAT_12, FORMAT_24 } from '../../viewerConfig';
import { APP_SETTINGS } from '../api/constants';
import { useTimeUntilData } from '../hooks/useSocket';
import { useExpectedStartData } from '../hooks/useSocket';
import { ontimeQueryClient } from '../queryClient';
/**
@@ -105,8 +105,7 @@ export const formatTime = (
/**
* Handles case for formatting a duration time
* @param duration
* @returns
* eg: "1h 0m 0s" or "0h 30m"
*/
export function formatDuration(duration: number, hideSeconds = true): string {
// durations should never be negative, we handle it here to flag if there is an issue in future
@@ -134,18 +133,24 @@ export function formatDuration(duration: number, hideSeconds = true): string {
}
/**
*
* @param totalGap accumulated gap from the current event
* @param isLinkedToLoaded is this event part of a chain linking back to the current loaded event
* @returns
*/
export function useTimeUntilStart(
export function useTimeUntilExpectedStart(
// typed like this to make it very clear what the data is
data: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay'> & {
event: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay'> | null,
state: {
totalGap: number;
isLinkedToLoaded: boolean;
},
): number {
const { offset, clock, currentDay, offsetMode, actualStart, plannedStart } = useTimeUntilData();
return calculateTimeUntilStart({ ...data, currentDay, clock, offset, offsetMode, actualStart, plannedStart });
const { offset, currentDay, offsetMode, actualStart, plannedStart, clock } = useExpectedStartData();
if (event === null) return 0;
const expectedStart = getExpectedStart(
{ ...event },
{ ...state, currentDay, offset, offsetMode, actualStart, plannedStart },
);
return expectedStart - clock;
}
+50 -8
View File
@@ -1,5 +1,6 @@
import { Path, resolvePath } from 'react-router-dom';
import { URLPreset } from 'ontime-types';
import { OntimeView, URLPreset } from 'ontime-types';
import { checkRegex } from 'ontime-utils';
/**
* Validates a preset against defined parameters
@@ -49,7 +50,7 @@ export function getRouteFromPreset(location: Path, urlPresets: URLPreset[]): str
const foundPreset = urlPresets.find((preset) => preset.alias === removeTrailingSlash(currentURL) && preset.enabled);
if (foundPreset) {
// if so, we can redirect to the preset path
return generatePathFromPreset(foundPreset.pathAndParams, foundPreset.alias, locked, token);
return generatePathFromPreset(foundPreset.target, foundPreset.search, foundPreset.alias, locked, token);
}
// if the current url is not an alias, we check if the alias is in the search parameters
@@ -63,7 +64,7 @@ export function getRouteFromPreset(location: Path, urlPresets: URLPreset[]): str
for (const preset of urlPresets) {
// if the page has a known enabled alias, we check if we need to redirect
if (preset.alias === presetOnPage && preset.enabled) {
const newPath = generatePathFromPreset(preset.pathAndParams, preset.alias, locked, token);
const newPath = generatePathFromPreset(preset.target, preset.search, preset.alias, locked, token);
if (!arePathsEquivalent(currentPath, newPath)) {
// if current path is out of date
// return new path so we can redirect
@@ -77,8 +78,14 @@ export function getRouteFromPreset(location: Path, urlPresets: URLPreset[]): str
/**
* Handles generating a path and search parameters from a preset
*/
export function generatePathFromPreset(pathAndParams: string, alias: string, locked: string | null, token: string | null ): string {
const path = resolvePath(pathAndParams);
export function generatePathFromPreset(
target: Omit<OntimeView, 'editor'>,
search: string,
alias: string,
locked: string | null,
token: string | null,
): string {
const path = resolvePath(`${target}?${search}`);
const searchParams = new URLSearchParams(path.search);
// save the alias so we have a reference to it being a preset and can update if necessary
@@ -106,16 +113,16 @@ export function generatePathFromPreset(pathAndParams: string, alias: string, loc
export function arePathsEquivalent(currentPath: string, newPath: string): boolean {
const currentUrl = new URL(currentPath, document.location.origin);
const newUrl = new URL(newPath, document.location.origin);
// check path
if (currentUrl.pathname !== newUrl.pathname) {
return false
return false;
}
// check search params
// if the params match, we dont need further checks
if (currentUrl.searchParams.toString() === newUrl.searchParams.toString()) {
return true
return true;
}
// if there is no match, we check the edge cases for the url sharing feature
@@ -124,3 +131,38 @@ export function arePathsEquivalent(currentPath: string, newPath: string): boolea
return currentUrl.searchParams.toString() === newUrl.searchParams.toString();
}
/**
* Generates a URL preset from a user given alias and URL.
*/
export function generateUrlPresetOptions(alias: string, userUrl: string): URLPreset {
let sanitisedUrl = userUrl.toLowerCase();
// we need to ensure the URL has a protocol, but it doesnt matter which
if (!checkRegex.startsWithHttp(sanitisedUrl)) {
sanitisedUrl = `http://${sanitisedUrl}`;
}
const url = new URL(sanitisedUrl);
const target = extractLastSegment(url.pathname);
if (target === 'editor' || !Object.values(OntimeView).includes(target as OntimeView)) {
throw new Error(`Invalid target view: ${target}`);
}
return {
alias,
target,
search: url.searchParams.toString(),
enabled: true,
};
}
/**
* the path can contain the stage hash
* "/team-hash/timer" or "/timer"
* we need to extract ontime view it targets
*/
function extractLastSegment(pathname: string): string {
const segments = pathname.split('/').filter(Boolean);
return segments[segments.length - 1] || '';
}
+15 -1
View File
@@ -21,7 +21,15 @@ declare global {
}
/**
* We pass a custom property to the table meta to allow field update
* Declare custom data we pass to the table
* - `handleUpdate` callback to update the entry when the user edits a cell
* - `handleUpdateTimer` callback to update the timer for a specific event
* - `options-showDelayedTimes` whether to show or hide delayed times
* - `options-hideTableSeconds` whether to hide seconds in the table
*
* And metadata specific for each column
* - `canWrite` whether the user can write to this column
* - `colour` background colour associated with a custom field
*/
declare module '@tanstack/react-table' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -33,6 +41,12 @@ declare module '@tanstack/react-table' {
hideTableSeconds: boolean;
};
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface ColumnMeta<TData extends RowData, TValue> {
canWrite: boolean;
colour?: string;
}
}
/**
@@ -106,6 +106,7 @@ $inner-padding: 1rem;
th,
td {
padding: 0.5rem;
vertical-align: top;
}
tr:nth-child(even) {
@@ -28,7 +28,7 @@ export default function AboutPanel() {
<ExternalLink href={buyMeACoffeeUrl}>Buy Me a Coffee</ExternalLink>
</Panel.Paragraph>
<Panel.Paragraph>
And trying out our cloud service
If you are looking for an online version of Ontime, consider trying out our cloud service
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
</Panel.Paragraph>
</Panel.Card>
@@ -1,6 +1,6 @@
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import useAppVersion from '../../../../common/hooks-query/useAppVersion';
import { appVersion } from '../../../../externals';
import { appVersion, isOntimeCloud } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
export default function AppVersion() {
@@ -17,13 +17,17 @@ export default function AppVersion() {
if (data.hasUpdates) {
return (
<>
<Panel.Paragraph>{`You are currently using Ontime version ${appVersion}.`}</Panel.Paragraph>
<Panel.Paragraph>
{`A new version ${data.version} is available.`}
<ExternalLink href={data.url}> Please visit the release page to download</ExternalLink>
</Panel.Paragraph>
</>
<Panel.Paragraph>
{`You are currently using Ontime version ${appVersion}.`}
<br />
<br />
{`A new version ${data.version} is available.`} <br />
{isOntimeCloud ? (
'You can restart your stage to get the latest available version.'
) : (
<ExternalLink href={data.url}>Please visit the release page to download</ExternalLink>
)}
</Panel.Paragraph>
);
}
@@ -31,7 +31,6 @@ export default function TriggerForm({
onCancel,
postSubmit,
}: TriggerFormProps) {
'use no memo'; // RHF and react-compiler don't seem to get along
const {
handleSubmit,
register,
@@ -6,7 +6,7 @@ import InfoNif from '../network-panel/NetworkInterfaces';
import GenerateLinkFormExport from './GenerateLinkFormExport';
import ReportSettings from './ReportSettings';
import UrlPresetsForm from './UrlPresetsForm';
import URLPresets from './URLPresets';
export default function FeaturePanel({ location }: PanelBaseProps) {
const presetsRef = useScrollIntoView<HTMLDivElement>('presets', location);
@@ -17,7 +17,7 @@ export default function FeaturePanel({ location }: PanelBaseProps) {
<>
<Panel.Header>Sharing and reporting</Panel.Header>
<div ref={presetsRef}>
<UrlPresetsForm />
<URLPresets />
</div>
<div ref={linkRef}>
<Panel.Section>
@@ -1,5 +1,3 @@
'use no memo'; // RHF and react-compiler don't seem to get along
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import QRCode from 'react-qr-code';
@@ -34,7 +32,6 @@ interface GenerateLinkFormOptions {
type GenerateLinkState = 'pending' | 'loading' | 'success' | 'error';
export default function GenerateLinkForm({ hostOptions, pathOptions, isLockedToView }: GenerateLinkFormProps) {
'use no memo'; // RHF and react-compiler don't seem to get along
const [formState, setFormState] = useState<GenerateLinkState>('pending');
const [url, setUrl] = useState(serverURL);
@@ -33,7 +33,7 @@ export default function GenerateLinkFormExport({ lockedPath }: GenerateLinkFormE
{ value: '', label: 'Companion' },
...urlPresetData.map((preset) => ({
value: preset.alias,
label: `Preset: ${preset.alias}`,
label: `URL Preset: ${preset.alias}`,
})),
];
}, [lockedPath, urlPresetData]);
@@ -0,0 +1,113 @@
import { useState } from 'react';
import { IoAdd, IoOpenOutline, IoPencil, IoTrash } from 'react-icons/io5';
import { URLPreset } from 'ontime-types';
import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton';
import Info from '../../../../common/components/info/Info';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import Switch from '../../../../common/components/switch/Switch';
import useUrlPresets, { useUpdateUrlPreset } from '../../../../common/hooks-query/useUrlPresets';
import { handleLinks } from '../../../../common/utils/linkUtils';
import * as Panel from '../../panel-utils/PanelUtils';
import URLPresetForm from './composite/URLPresetForm';
type FormState = {
isOpen: boolean;
preset?: URLPreset;
};
const urlPresetsDocs = 'https://docs.getontime.no/features/url-presets/';
export default function URLPresets() {
const [formState, setFormState] = useState<FormState>({ isOpen: false, preset: undefined });
const { data, status } = useUrlPresets();
const { deletePreset, isMutating } = useUpdateUrlPreset();
const openNewForm = () => setFormState({ isOpen: true });
const openEditForm = (preset: URLPreset) => setFormState({ isOpen: true, preset });
const closeForm = () => setFormState({ isOpen: false, preset: undefined });
return (
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>
URL presets
<Button onClick={openNewForm}>
New <IoAdd />
</Button>
</Panel.SubHeader>
<Panel.Divider />
<Panel.Section>
<Info>
URL presets are user pre-defined aliases to Ontime URLs.
<br />
This URL can contain full configuration including parameters, or simply route to a specific view.
<br />
<br />
The easiest way to get started is to copy an URL from your browser and paste it into the form.
<ExternalLink href={urlPresetsDocs}>See the docs</ExternalLink>
</Info>
</Panel.Section>
<Panel.Section>
<Panel.Loader isLoading={status === 'pending'} />
{formState.isOpen && <URLPresetForm urlPreset={formState.preset} onClose={closeForm} />}
<Panel.Table>
<thead>
<tr>
<th>Enabled</th>
<th>Alias</th>
<th>Target view</th>
<th>URL parameters</th>
<th />
</tr>
</thead>
<tbody>
{data.length === 0 && <Panel.TableEmpty handleClick={openNewForm} />}
{data.map((preset, index) => {
return (
<tr key={preset.alias}>
<td>
<Switch defaultChecked={preset.enabled} onCheckedChange={() => {}} />
</td>
<td>{preset.alias}</td>
<td>{preset.target}</td>
<td>{preset.search}</td>
<Panel.InlineElements relation='inner' as='td'>
<IconButton
variant='ghosted-white'
onClick={(event) => handleLinks(preset.alias, event)}
disabled={isMutating}
>
<IoOpenOutline />
</IconButton>
<IconButton
onClick={() => openEditForm(preset)}
variant='ghosted-white'
aria-label='Edit entry'
data-testid={`field__edit_${index}`}
disabled={isMutating}
>
<IoPencil />
</IconButton>
<IconButton
onClick={() => deletePreset(preset.alias)}
variant='ghosted-destructive'
aria-label='Delete entry'
data-testid={`field__delete_${index}`}
disabled={isMutating}
>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</tr>
);
})}
</tbody>
</Panel.Table>
</Panel.Section>
</Panel.Card>
</Panel.Section>
);
}
@@ -1,230 +0,0 @@
import { useEffect } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, IoOpenOutline, IoTrash } from 'react-icons/io5';
import { URLPreset } from 'ontime-types';
import { postUrlPresets } from '../../../../common/api/urlPresets';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton';
import Info from '../../../../common/components/info/Info';
import Input from '../../../../common/components/input/input/Input';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import Switch from '../../../../common/components/switch/Switch';
import Tooltip from '../../../../common/components/tooltip/Tooltip';
import useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
import { preventEscape } from '../../../../common/utils/keyEvent';
import { handleLinks } from '../../../../common/utils/linkUtils';
import { validateUrlPresetPath } from '../../../../common/utils/urlPresets';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './FeaturePanel.module.scss';
const urlPresetsDocs = 'https://docs.getontime.no/features/url-presets/';
type FormData = {
data: URLPreset[];
};
export default function UrlPresetsForm() {
'use no memo'; // RHF and react-compiler don't seem to get along
const { data, status, refetch } = useUrlPresets();
const {
control,
handleSubmit,
register,
reset,
setError,
watch,
setValue,
formState: { isSubmitting, isDirty, isValid, errors },
} = useForm<FormData>({
mode: 'onChange',
defaultValues: { data },
values: { data },
resetOptions: {
keepDirtyValues: true,
},
});
const { fields, prepend, remove } = useFieldArray({
name: 'data',
control,
});
// reset form if we get new data from backend
useEffect(() => {
if (data) {
reset({ data });
}
}, [data, reset]);
const onSubmit = async (formData: FormData) => {
for (let i = 0; i < formData.data.length; i++) {
const preset = formData.data[i];
const { isValid, message } = validateUrlPresetPath(preset.pathAndParams);
if (!isValid) {
setError(`data.${i}.pathAndParams`, { message });
return;
}
}
try {
await postUrlPresets(formData.data);
} catch (error) {
const message = maybeAxiosError(error);
setError('root', { message });
} finally {
await refetch();
}
};
const onReset = () => {
reset({ data });
};
const addNew = () => {
prepend({
enabled: true,
alias: '',
pathAndParams: '',
});
};
const isLoading = status === 'pending';
const canSubmit = !isSubmitting && isDirty && isValid;
return (
<Panel.Section
as='form'
onSubmit={handleSubmit(onSubmit)}
onKeyDown={(event) => preventEscape(event, onReset)}
data-testid='url-preset-form'
>
<Panel.Card>
<Panel.SubHeader>
URL presets
<Panel.InlineElements>
<Button variant='ghosted' onClick={onReset} disabled={!canSubmit}>
Revert to saved
</Button>
<Button variant='primary' type='submit' disabled={!canSubmit} loading={isSubmitting}>
Save
</Button>
</Panel.InlineElements>
</Panel.SubHeader>
<Panel.Divider />
<Info>
URL presets are user defined aliases to Ontime URLs
<br />
<br />
<b>Preset Name</b> <br />
The alias for the URL. This will be the URL you will be calling. eg: <br />
<Panel.BlockQuote>
Preset name <Panel.Highlight>cam3</Panel.Highlight> called as{' '}
<Panel.Highlight>http://localhost:4001/cam3</Panel.Highlight>
</Panel.BlockQuote>
<br />
<b>URL Segment</b> <br />
The corresponding alias path and configuration parameters. eg: <br />
<Panel.BlockQuote>
URL segment <Panel.Highlight>backstage?hidePast=true&stopCycle=true</Panel.Highlight> corresponds to
complete URL
<Panel.Highlight>http://localhost:4001/backstage?hidePast=true&stopCycle=true</Panel.Highlight>
</Panel.BlockQuote>
<br />
You will need to save the changes before the presets are functional.
<br />
<ExternalLink href={urlPresetsDocs}>See the docs</ExternalLink>
</Info>
<Panel.Section>
<Panel.Loader isLoading={isLoading} />
<Panel.Title>
Manage presets
<Button onClick={addNew}>
New <IoAdd />
</Button>
</Panel.Title>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
{errors?.data && <Panel.Error>{errors.data.message}</Panel.Error>}
<Panel.Table>
<thead>
<tr>
<th className={style.fit}>Active</th>
<th className={style.aliasConstrain}>Preset name</th>
<th className={style.fullWidth}>URL segment</th>
<th />
</tr>
</thead>
<tbody>
{fields.length === 0 && <Panel.TableEmpty handleClick={addNew} />}
{fields.map((preset, index) => {
const maybeAliasError = errors.data?.[index]?.alias?.message;
const maybeUrlError = errors.data?.[index]?.pathAndParams?.message;
// only saved and enabled URLs can be tested
const canTest =
preset.alias && preset.enabled && preset.pathAndParams && !maybeAliasError && !maybeUrlError;
return (
<tr key={preset.id}>
<td className={style.fit}>
<Switch
size='large'
checked={watch(`data.${index}.enabled`)}
onCheckedChange={(value: boolean) =>
setValue(`data.${index}.enabled`, value, { shouldDirty: true })
}
data-testid={`field__enable_${index}`}
/>
</td>
<td className={style.aliasConstrain}>
<Input
{...register(`data.${index}.alias`, {
required: { value: true, message: 'Required field' },
})}
fluid
placeholder='URL Preset'
data-testid={`field__alias_${index}`}
/>
<Panel.Error>{maybeAliasError}</Panel.Error>
</td>
<td className={style.fullWidth}>
<Input
{...register(`data.${index}.pathAndParams`, {
required: { value: true, message: 'Required field' },
})}
fluid
placeholder='URL (portion after ontime Port)'
data-testid={`field__url_${index}`}
/>
<Panel.Error>{maybeUrlError}</Panel.Error>
</td>
<Panel.InlineElements relation='inner' as='td'>
<Tooltip
text='Test preset'
render={<IconButton variant='ghosted-white' />}
data-testid={`field__test_${index}`}
onClick={(event) => handleLinks(preset.alias, event)}
disabled={!canTest}
>
<IoOpenOutline />
</Tooltip>
<IconButton
onClick={() => remove(index)}
variant='ghosted-destructive'
aria-label='Delete entry'
data-testid={`field__delete_${index}`}
>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</tr>
);
})}
</tbody>
</Panel.Table>
</Panel.Section>
</Panel.Card>
</Panel.Section>
);
}
@@ -0,0 +1,9 @@
.expand {
flex: 1;
}
.column {
display: flex;
flex-direction: column;
gap: 1rem;
}
@@ -0,0 +1,154 @@
import { useEffect, useRef } from 'react';
import { useForm } from 'react-hook-form';
import { OntimeView, URLPreset } from 'ontime-types';
import { maybeAxiosError, unwrapError } from '../../../../../common/api/utils';
import Button from '../../../../../common/components/buttons/Button';
import Input from '../../../../../common/components/input/input/Input';
import Select, { SelectOption } from '../../../../../common/components/select/Select';
import { useUpdateUrlPreset } from '../../../../../common/hooks-query/useUrlPresets';
import { preventEscape } from '../../../../../common/utils/keyEvent';
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
import * as Panel from '../../../panel-utils/PanelUtils';
import style from './URLPresetForm.module.scss';
const targetOptions: SelectOption[] = [
{ value: OntimeView.Cuesheet, label: 'Cuesheet' },
{ value: OntimeView.Operator, label: 'Operator' },
{ value: OntimeView.Timer, label: 'Timer' },
{ value: OntimeView.Backstage, label: 'Backstage' },
{ value: OntimeView.Timeline, label: 'Timeline' },
{ value: OntimeView.StudioClock, label: 'Studio Clock' },
{ value: OntimeView.Countdown, label: 'Countdown' },
{ value: OntimeView.ProjectInfo, label: 'Project Info' },
];
const defaultValues: URLPreset = {
alias: '',
target: OntimeView.Timer,
search: '',
enabled: true,
};
interface URLPresetFormProps {
urlPreset?: URLPreset;
onClose: () => void;
}
export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps) {
const { addPreset, updatePreset, isMutating } = useUpdateUrlPreset();
const {
handleSubmit,
register,
setFocus,
setError,
clearErrors,
setValue,
getValues,
watch,
formState: { errors, isSubmitting, isValid, isDirty },
} = useForm<URLPreset>({
defaultValues: urlPreset ?? defaultValues,
resetOptions: {
keepDirtyValues: true,
},
});
const urlRef = useRef<HTMLInputElement>(null);
const setupSubmit = async (data: URLPreset) => {
try {
if (urlPreset) {
await updatePreset(urlPreset.alias, data);
} else {
await addPreset(data);
}
onClose();
} catch (error) {
setError('root', { message: maybeAxiosError(error) });
}
};
useEffect(() => {
setFocus('alias');
}, [setFocus]);
const generateOptions = () => {
clearErrors();
try {
const preset = generateUrlPresetOptions(getValues('alias'), urlRef.current?.value.trim() ?? '');
setValue('target', preset.target, { shouldDirty: true, shouldValidate: true });
setValue('search', preset.search, { shouldDirty: true, shouldValidate: true });
} catch (error) {
setError('root', { message: unwrapError(error) });
return;
}
};
const validateParams = (value: string) => {
try {
new URLSearchParams(value);
return true;
} catch (error) {
return unwrapError(error) || 'Invalid URL parameters';
}
};
return (
<Panel.Indent
as='form'
onSubmit={handleSubmit(setupSubmit)}
onKeyDown={(event) => preventEscape(event, onClose)}
className={style.column}
>
<input hidden name='enabled' value='true' />
<div>1. Enter URL and let Ontime generate the preset options</div>
<Panel.InlineElements>
<div>
<Panel.Description>Alias</Panel.Description>
<Input {...register('alias', { required: 'Alias is required' })} />
</div>
<div className={style.expand}>
<Panel.Description>Generate options (paste URL to generate options)</Panel.Description>
<Panel.InlineElements>
<Input placeholder='Paste URL' fluid ref={urlRef} required />
<Button onClick={generateOptions}>Generate</Button>
</Panel.InlineElements>
</div>
</Panel.InlineElements>
<div> - or -</div>
<div>2. Choose a view and its parameters</div>
<div>
<Panel.Description>Target</Panel.Description>
<Select
options={targetOptions}
{...register('target', { required: 'Target is required' })}
value={watch('target') as OntimeView}
onValueChange={(value) => setValue('target', value)}
/>
</div>
<div>
<Panel.Description>Parameters</Panel.Description>
<Input
fluid
{...register('search', {
validate: validateParams,
})}
/>
<Panel.Error>{errors.search?.message}</Panel.Error>
</div>
<div>
<Panel.Error>{errors.root?.message}</Panel.Error>
<Panel.InlineElements align='end'>
<Button onClick={onClose}>Cancel</Button>
<Button variant='primary' type='submit' disabled={!isValid || !isDirty} loading={isSubmitting || isMutating}>
Save
</Button>
</Panel.InlineElements>
</div>
</Panel.Indent>
);
}
@@ -5,6 +5,7 @@ import { CustomField, CustomFieldKey } from 'ontime-types';
import { deleteCustomField, editCustomField, postCustomField } from '../../../../common/api/customFields';
import Button from '../../../../common/components/buttons/Button';
import Info from '../../../../common/components/info/Info';
import AppLink from '../../../../common/components/link/app-link/AppLink';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import useCustomFields from '../../../../common/hooks-query/useCustomFields';
import { customFieldsDocsUrl } from '../../../../externals';
@@ -60,7 +61,8 @@ export default function CustomFieldSettings() {
Custom fields allow for additional information to be added to an event.
<br />
<br />
This data can be used in the Automation feature by using the generated key.
To use custom fields as a data source in an{' '}
<AppLink search='settings=automation__automations'>Automation</AppLink>, please note the generated key.
<ExternalLink href={customFieldsDocsUrl}>See the docs</ExternalLink>
</Info>
</Panel.Section>
@@ -72,7 +74,7 @@ export default function CustomFieldSettings() {
<th>Colour</th>
<th>Type</th>
<th>Name</th>
<th>Key (used in Integrations)</th>
<th>Key (used in Automations)</th>
<th />
</tr>
</thead>
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { CustomField } from 'ontime-types';
import { customFieldLabelToKey, isAlphanumericWithSpace } from 'ontime-utils';
import { checkRegex, customFieldLabelToKey } from 'ontime-utils';
import { maybeAxiosError } from '../../../../../common/api/utils';
import Button from '../../../../../common/components/buttons/Button';
@@ -32,7 +32,6 @@ export default function CustomFieldForm({
initialLabel,
initialKey,
}: CustomFieldsFormProps) {
'use no memo'; // RHF and react-compiler don't seem to get along
const { data } = useCustomFields();
// we use this to force an update
@@ -117,7 +116,7 @@ export default function CustomFieldForm({
onChange: () => setValue('key', customFieldLabelToKey(getValues('label')) ?? 'N/A'),
validate: (value) => {
if (value.trim().length === 0) return 'Required field';
if (!isAlphanumericWithSpace(value)) return 'Only alphanumeric characters and space are allowed';
if (!checkRegex.isAlphanumericWithSpace(value)) return 'Only alphanumeric characters and space are allowed';
if (!isEditMode) {
if (isEditMode && Object.keys(data).includes(value)) return 'Custom fields must be unique';
}
@@ -6,12 +6,12 @@ const googleSheetDocsUrl = 'https://docs.getontime.no/features/import-spreadshee
export default function GSheetInfo() {
return (
<Info>
Ontime allows you to synchronize your rundown with a Google Sheet.
Ontime can import data from spreadsheets by: <br />
- importing the spreadsheet file in Ontime <br />
- synchronising your project with a Google Sheet
<br />
<br />
To enable this feature, you will need to generate tokens in your Google account and provide them to Ontime.
<br />
Once set up, you will be able to synchronize data between Ontime and your Google Sheet. <br />
To synchronise with a Google Sheet, you will need to allow Ontime to authenticate with your Google account.
<ExternalLink href={googleSheetDocsUrl}>See the docs</ExternalLink>
</Info>
);
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, IoTrash } from 'react-icons/io5';
import { ImportMap, isAlphanumericWithSpace } from 'ontime-utils';
import { checkRegex, ImportMap } from 'ontime-utils';
import Button from '../../../../../../common/components/buttons/Button';
import IconButton from '../../../../../../common/components/buttons/IconButton';
@@ -31,7 +31,6 @@ export default function ImportMapForm({
onSubmitExport,
onSubmitImport,
}: ImportMapFormProps) {
'use no memo'; // RHF and react-compiler don't seem to get along
const namedImportMap = getPersistedOptions();
const { revoke } = useGoogleSheet();
const {
@@ -200,7 +199,7 @@ export default function ImportMapForm({
placeholder='Name of the field as shown in Ontime'
{...register(`custom.${index}.ontimeName`, {
validate: (value) => {
if (!isAlphanumericWithSpace(value))
if (!checkRegex.isAlphanumericWithSpace(value))
return 'Only alphanumeric characters and space are allowed';
return true;
},
@@ -23,7 +23,6 @@ type ProjectCreateFormValues = {
};
export default function ProjectCreateForm({ onClose }: ProjectCreateFromProps) {
'use no memo'; // RHF and react-compiler don't seem to get along
const [error, setError] = useState<string | null>(null);
const {
@@ -20,7 +20,6 @@ interface ProjectFormProps {
}
export default function ProjectForm({ action, filename, onSubmit, onCancel }: ProjectFormProps) {
'use no memo'; // RHF and react-compiler don't seem to get along
const {
handleSubmit,
register,
@@ -28,7 +28,6 @@ type ProjectMergeFormValues = {
};
export default function ProjectMergeForm({ onClose, fileName }: ProjectMergeFromProps) {
'use no memo'; // RHF and react-compiler don't seem to get along
const [error, setError] = useState<string | null>(null);
const queryClient = useQueryClient();
@@ -16,7 +16,6 @@ import * as Panel from '../../panel-utils/PanelUtils';
import GeneralPinInput from './composite/GeneralPinInput';
export default function GeneralSettings() {
'use no memo'; // RHF and react-compiler don't seem to get along
const { data, status, refetch } = useSettings();
const {
handleSubmit,
@@ -4,12 +4,12 @@ import { IoAdd, IoDownloadOutline, IoTrash } from 'react-icons/io5';
import { type ProjectData } from 'ontime-types';
import { projectLogoPath } from '../../../../common/api/constants';
import { postProjectData, uploadProjectLogo } from '../../../../common/api/project';
import { uploadProjectLogo } from '../../../../common/api/project';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input';
import Textarea from '../../../../common/components/input/textarea/Textarea';
import useProjectData from '../../../../common/hooks-query/useProjectData';
import useProjectData, { useUpdateProjectData } from '../../../../common/hooks-query/useProjectData';
import { preventEscape } from '../../../../common/utils/keyEvent';
import { validateLogo } from '../../../../common/utils/uploadUtils';
import { documentationUrl } from '../../../../externals';
@@ -18,8 +18,8 @@ import * as Panel from '../../panel-utils/PanelUtils';
import style from './SettingsPanel.module.scss';
export default function ProjectData() {
'use no memo'; // RHF and react-compiler don't seem to get along
const { data, status, refetch } = useProjectData();
const { data, status } = useProjectData();
const { updateProjectData } = useUpdateProjectData();
const {
handleSubmit,
@@ -93,16 +93,14 @@ export default function ProjectData() {
const onSubmit = async (formData: ProjectData) => {
try {
await postProjectData(formData);
await updateProjectData(formData);
} catch (error) {
const message = maybeAxiosError(error);
setError('root', { message });
} finally {
await refetch();
}
};
// populate with new data if we get an update
// reset data to the initial state
const onReset = () => {
reset(data);
};
@@ -18,7 +18,6 @@ import CodeEditorModal from './composite/StyleEditorModal';
const cssOverrideDocsUrl = 'https://docs.getontime.no/features/custom-styling/';
export default function ViewSettings() {
'use no memo'; // RHF and react-compiler don't seem to get along
const { data, isPending, mutateAsync } = useViewSettings();
const [isCodeEditorOpen, codeEditorHandler] = useDisclosure();
@@ -20,7 +20,6 @@ interface QuickStartProps {
}
export default function QuickStart({ isOpen, onClose }: QuickStartProps) {
'use no memo'; // RHF and react-compiler don't seem to get along
const { defaultWarnTime, defaultDangerTime, setDangerTime, setWarnTime } = useEditorSettings();
const {
@@ -1,5 +1,3 @@
import { Playback } from 'ontime-types';
import { usePlaybackControl } from '../../../common/hooks/useSocket';
import AddTime from './add-time/AddTime';
@@ -14,7 +12,7 @@ export default function PlaybackControl() {
return (
<div className={style.mainContainer}>
<PlaybackTimer playback={data.playback as Playback}>
<PlaybackTimer>
<AddTime playback={data.playback} />
</PlaybackTimer>
<PlaybackButtons
@@ -11,10 +11,6 @@ import TimerDisplay from '../timer-display/TimerDisplay';
import style from './PlaybackTimer.module.scss';
interface PlaybackTimerProps {
playback: Playback;
}
function resolveAddedTimeLabel(addedTime: number) {
if (addedTime > 0) {
return `Added ${formatDuration(addedTime, false)}`;
@@ -27,11 +23,10 @@ function resolveAddedTimeLabel(addedTime: number) {
return '';
}
export default function PlaybackTimer(props: PropsWithChildren<PlaybackTimerProps>) {
const { playback, children } = props;
export default function PlaybackTimer({ children }: PropsWithChildren) {
const timer = useTimer();
const isRolling = playback === Playback.Roll;
const isRolling = timer.playback === Playback.Roll;
const isWaiting = timer.phase === TimerPhase.Pending;
const isOvertime = timer.phase === TimerPhase.Overtime;
const hasAddedTime = Boolean(timer.addedTime);
@@ -52,7 +47,7 @@ export default function PlaybackTimer(props: PropsWithChildren<PlaybackTimerProp
{isWaiting ? (
<span className={style.rolltag}>Roll: Countdown to start</span>
) : (
<RunningStatus startedAt={timer.startedAt} expectedFinish={timer.expectedFinish} playback={playback} />
<RunningStatus startedAt={timer.startedAt} expectedFinish={timer.expectedFinish} playback={timer.playback} />
)}
</div>
{children}
@@ -65,9 +60,7 @@ interface RunningStatusProps {
expectedFinish: MaybeNumber;
playback: Playback;
}
function RunningStatus(props: RunningStatusProps) {
const { startedAt, expectedFinish, playback } = props;
function RunningStatus({ startedAt, expectedFinish, playback }: RunningStatusProps) {
if (playback === Playback.Stop) {
return <StoppedStatus />;
}
@@ -35,7 +35,7 @@ export default function Operator() {
const timeoutId = useRef<NodeJS.Timeout | null>(null);
const { selectedEventId } = useSelectedEventId();
const { subscribe, mainSource, secondarySource, shouldEdit, hidePast } = useOperatorOptions();
const { subscribe, mainSource, secondarySource, shouldEdit, hidePast, showStart } = useOperatorOptions();
const { data: settings } = useSettings();
const [showEditPrompt, setShowEditPrompt] = useState(false);
@@ -160,6 +160,7 @@ export default function Operator() {
isSelected={isSelected}
isPast={isPast}
selectedRef={isSelected ? selectedRef : undefined}
showStart={showStart}
subscribed={subscribedData}
totalGap={totalGap}
onLongPress={canEdit ? handleEdit : () => undefined}
@@ -208,6 +209,7 @@ export default function Operator() {
isSelected={isSelected}
isPast={isPast}
selectedRef={isSelected ? selectedRef : undefined}
showStart={showStart}
subscribed={subscribedData}
totalGap={totalGap}
onLongPress={canEdit ? handleEdit : () => undefined}
@@ -1,3 +1,4 @@
@use '@/theme/viewerDefs' as *;
@import '../Operator.module.scss';
.event {
@@ -5,13 +6,14 @@
border-top: 1px solid $white-1;
padding-right: 0.5rem;
color: $white-90;
background-color: $gray-1300;
background-color: $viewer-card-bg-color;
display: grid;
align-items: center;
grid-template-columns: 1.25rem 1fr auto;
grid-template-rows: auto auto auto;
column-gap: 0.5rem;
row-gap: 0.5rem;
grid-template-rows: auto auto auto;
grid-template-columns: 1.25rem 1fr auto;
grid-template-areas:
'binder main schedule'
'binder secondary running'
@@ -69,14 +71,23 @@
white-space: pre-line;
}
.plannedStart, .timeUntil {
line-height: 1em;
background-color: $gray-1000;
padding: 0.25rem 0.5rem;
border-radius: 1px;
}
.plannedStart {
font-size: 1.5rem;
margin-right: 0.5rem;
}
.timeUntil {
font-size: calc(1rem - 2px);
line-height: 1em;
grid-area: schedule;
justify-self: end;
background-color: $gray-1000;
padding: 0.25rem 0.5rem;
border-radius: 1px;
}
.runningTime {
@@ -1,10 +1,10 @@
import { memo, RefObject, SyntheticEvent } from 'react';
import { useLongPress } from '@mantine/hooks';
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND, millisToString } from 'ontime-utils';
import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatDuration, useTimeUntilStart } from '../../../common/utils/time';
import { formatDuration, useTimeUntilExpectedStart } from '../../../common/utils/time';
import RunningTime from '../../viewers/common/running-time/RunningTime';
import type { EditEvent, Subscribed } from '../operator.types';
@@ -24,6 +24,7 @@ interface OperatorEventProps {
isSelected: boolean;
isPast: boolean;
selectedRef?: RefObject<HTMLDivElement | null>;
showStart: boolean;
subscribed: Subscribed;
totalGap: number;
onLongPress: (event: EditEvent) => void;
@@ -44,6 +45,7 @@ function OperatorEvent({
isSelected,
isPast,
selectedRef,
showStart,
subscribed,
totalGap,
onLongPress,
@@ -64,9 +66,8 @@ function OperatorEvent({
const operatorClasses = cx([
style.event,
isSelected ? style.running : null,
subscribed ? style.subscribed : null,
isPast ? style.past : null,
isSelected && style.running,
isPast && style.past,
]);
return (
@@ -75,9 +76,11 @@ function OperatorEvent({
<span className={style.cue}>{cue}</span>
</div>
<span className={style.mainField}>{main}</span>
<span className={style.mainField}>
{showStart && <span className={style.plannedStart}>{millisToString(timeStart)}</span>}
{main}
</span>
<span className={style.secondaryField}>{secondary}</span>
<OperatorEventSchedule
timeStart={timeStart}
isPast={isPast}
@@ -159,7 +162,7 @@ interface TimeUntilProps {
}
function TimeUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }: TimeUntilProps) {
// we isolate this to avoid unnecessary re-renders
const timeUntil = useTimeUntilStart({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded });
const timeUntil = useTimeUntilExpectedStart({ timeStart, delay, dayOffset }, { totalGap, isLinkedToLoaded });
const isDue = timeUntil < MILLIS_PER_SECOND;
const timeUntilString = isDue ? 'DUE' : `${formatDuration(Math.abs(timeUntil), timeUntil > 2 * MILLIS_PER_MINUTE)}`;
@@ -67,6 +67,13 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin
type: 'boolean',
defaultValue: false,
},
{
id: 'showStart',
title: 'Show planned start',
description: 'Whether to prepend the planned start to the items',
type: 'boolean',
defaultValue: false,
},
],
},
];
@@ -78,6 +85,7 @@ type OperatorOptions = {
subscribe: string[];
shouldEdit: boolean;
hidePast: boolean;
showStart: boolean;
};
/**
@@ -92,6 +100,7 @@ function getOptionsFromParams(searchParams: URLSearchParams): OperatorOptions {
subscribe: searchParams.getAll('subscribe'),
shouldEdit: isStringBoolean(searchParams.get('shouldEdit')),
hidePast: isStringBoolean(searchParams.get('hidePast')),
showStart: isStringBoolean(searchParams.get('showStart')),
};
}
@@ -3,15 +3,12 @@
top: 0;
width: 100%;
z-index: $zindex-floating;
background-color: $gray-1350;
border-bottom: 1px solid $white-10;
box-shadow: $large-top-drawer-shadow;
}
.timers {
padding-block: 0.75rem 0.5rem;
padding-inline: 1rem;
padding-inline: 1rem 0;
display: grid;
grid-template-columns: 1fr 1fr 1fr;
@@ -20,35 +17,14 @@
.runningTimer {
grid-area: timers;
display: flex;
flex-direction: column;
justify-self: center;
color: $muted-gray;
}
.timeNow {
grid-area: clock;
display: flex;
flex-direction: column;
justify-self: right;
}
.label {
font-size: calc(1rem - 2px);
color: $gray-700;
line-height: 0.9em;
}
.timer {
font-size: 1.5rem;
letter-spacing: 0.5px;
line-height: 1.5;
}
.active {
color: $ui-white;
}
.progressOverride {
grid-area: bar;
height: 1rem;
@@ -1,33 +1,12 @@
import { isPlaybackActive } from 'ontime-utils';
import { ClockOverview, TimerOverview } from '../../overview/composite/TimeElements';
import { useClock, useTimer } from '../../../common/hooks/useSocket';
import { cx } from '../../../common/utils/styleUtils';
import ClockTime from '../../viewers/common/clock-time/ClockTime';
import RunningTime from '../../viewers/common/running-time/RunningTime';
import styles from './StatusBar.module.scss';
import style from './StatusBar.module.scss';
export default function StatusBarTimers() {
const timer = useTimer();
const { clock } = useClock();
const playbackActive = isPlaybackActive(timer.playback);
return (
<div className={styles.timers}>
<div className={styles.runningTimer}>
<span className={styles.label}>Running timer</span>
<RunningTime
className={cx([styles.timer, playbackActive && styles.active])}
value={timer.current}
hideLeadingZero
/>
</div>
<div className={styles.timeNow}>
<span className={styles.label}>Time now</span>
<ClockTime className={styles.timer} value={clock} />
</div>
<div className={style.timers}>
<TimerOverview className={style.runningTimer} />
<ClockOverview className={style.timeNow} />
</div>
);
}
@@ -1,7 +1,10 @@
.column {
height: 100%;
justify-content: center;
display: flex;
flex-direction: column;
align-items: end;
gap: 2px;
}
.row {
@@ -63,4 +66,4 @@
color: $active-indicator;
font-size: calc(1rem - 2px);
text-align: right;
}
}
@@ -1,6 +1,14 @@
import { useMemo } from 'react';
import { TbCalendar, TbCalendarClock, TbCalendarDown, TbCalendarStar, TbFlagDown, TbFlagStar } from 'react-icons/tb';
import { isOntimeBlock, OntimeBlock, OntimeEvent, TimerType } from 'ontime-types';
import {
TbCalendarClock,
TbCalendarPin,
TbCalendarStar,
TbFlagPin,
TbFlagStar,
TbFolderPin,
TbFolderStar,
} from 'react-icons/tb';
import { OntimeBlock, OntimeEvent, TimerPhase, TimerType } from 'ontime-types';
import { isPlaybackActive, millisToString } from 'ontime-utils';
import Tooltip from '../../../common/components/tooltip/Tooltip';
@@ -15,7 +23,7 @@ import {
import { useEntry } from '../../../common/hooks-query/useRundown';
import { getOffsetState, getOffsetText } from '../../../common/utils/offset';
import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils';
import { formatTime, useTimeUntilStart } from '../../../common/utils/time';
import { formatTime } from '../../../common/utils/time';
import { calculateEndAndDaySpan, formattedTime } from '../overview.utils';
import { OverUnder, TimeColumn } from './TimeLayout';
@@ -36,7 +44,7 @@ export function StartTimes() {
<div className={style.row}>
<span className={style.label}>Start</span>
<div className={style.labelledElement}>
<Tooltip text='Planned start time' render={<TbCalendar className={style.icon} />} />
<Tooltip text='Planned start time' render={<TbCalendarPin className={style.icon} />} />
<span className={cx([style.time, plannedStart === null && style.muted])}>{plannedStartText}</span>
</div>
<div className={style.labelledElement}>
@@ -47,7 +55,7 @@ export function StartTimes() {
<div className={style.row}>
<span className={style.label}>End</span>
<div className={style.labelledElement}>
<Tooltip text='Planned end time' render={<TbCalendar className={style.icon} />} />
<Tooltip text='Planned end time' render={<TbCalendarPin className={style.icon} />} />
{maybePlannedDaySpan > 0 ? (
<Tooltip
text={`Event spans over ${maybePlannedDaySpan + 1} days`}
@@ -88,50 +96,31 @@ export function MetadataTimes() {
);
}
//TODO: there a some things here we still need to think about, mainly what to do whit the planed group duration in relation to the events
function GroupTimes() {
const { blockStartedAt, clock, blockExpectedEnd } = useRuntimePlaybackOverview();
const { clock, blockExpectedEnd } = useRuntimePlaybackOverview();
const { currentBlockId } = useCurrentBlockId();
const entry = useEntry(currentBlockId);
const group = useEntry(currentBlockId) as OntimeBlock | null;
if (!currentBlockId) {
return (
<div className={style.metadataRow}>
<span className={style.label}>Group</span>
<div className={style.labelledElement}>
<Tooltip text='Time to scheduled group end' render={<TbCalendarDown className={style.icon} />} />
<span className={cx([style.time, style.muted])}>{timerPlaceholder}</span>
</div>
<div className={style.labelledElement}>
<Tooltip text='Time to expected group end' render={<TbCalendarStar className={style.icon} />} />
<span className={cx([style.time, style.muted])}>{timerPlaceholder}</span>
</div>
</div>
);
}
// the group end time dose not encode any day offsets
const plannedGroupEnd = group && group.timeStart !== null ? group.timeStart + group.duration - clock : null;
const plannedTimeUntilGroupEnd = formattedTime(plannedGroupEnd, 3, TimerType.CountDown);
const remainingBlockDuration = (() => {
if (blockStartedAt === null || !entry) return timerPlaceholder;
if (!isOntimeBlock(entry)) return timerPlaceholder;
return formattedTime(blockStartedAt + entry.duration - clock, 3, TimerType.CountDown);
})();
const expectedGroupEnd = blockExpectedEnd !== null ? blockExpectedEnd - clock : null;
const expectedTimeUntilGroupEnd = formattedTime(expectedGroupEnd, 3, TimerType.CountDown);
const timeUntilBlockEnd = (() => {
if (blockExpectedEnd === null) return timerPlaceholder;
return formattedTime(blockExpectedEnd - clock, 3, TimerType.CountDown);
})();
const groupTitle = (entry as OntimeBlock | null)?.title || 'Group';
const groupTitle = group?.title ?? null;
return (
<div className={style.metadataRow}>
<span className={style.labelTitle}>{groupTitle}</span>
<span className={groupTitle ? style.labelTitle : style.label}>{`${groupTitle ? groupTitle : 'Group'} `}</span>
<div className={style.labelledElement}>
<Tooltip text='Time to scheduled group end' render={<TbCalendarDown className={style.icon} />} />
<span className={cx([style.time, blockStartedAt === null && style.muted])}>{remainingBlockDuration}</span>
<Tooltip text='Time to planned group end' render={<TbFolderPin className={style.icon} />} />
<span className={cx([style.time, !group && style.muted])}>{plannedTimeUntilGroupEnd}</span>
</div>
<div className={style.labelledElement}>
<Tooltip text='Time to expected group end' render={<TbCalendarStar className={style.icon} />} />
<span className={cx([style.time, blockExpectedEnd === null && style.muted])}>{timeUntilBlockEnd}</span>
<Tooltip text='Time to expected group end' render={<TbFolderStar className={style.icon} />} />
<span className={cx([style.time, blockExpectedEnd === null && style.muted])}>{expectedTimeUntilGroupEnd}</span>
</div>
</div>
);
@@ -139,50 +128,27 @@ function GroupTimes() {
function FlagTimes() {
const { clock } = useClock();
const { nextFlag } = useNextFlag();
const entry = useEntry(nextFlag?.id ?? null);
const { id, expectedStart } = useNextFlag();
const entry = useEntry(id) as OntimeEvent | null;
// TODO(v4): can we make a good approximation of time until next flag?
const timeUntil = useTimeUntilStart({
timeStart: nextFlag?.start ?? 0,
delay: 0,
dayOffset: 0,
totalGap: 0,
isLinkedToLoaded: true,
});
const plannedFlagStart = entry ? entry.timeStart - clock : null;
const plannedTimeUntilDisplay = formattedTime(plannedFlagStart, 3, TimerType.CountDown);
if (!nextFlag) {
return (
<div className={style.metadataRow}>
<span className={style.label}>Flag</span>
<div className={style.labelledElement}>
<Tooltip text='Time to next flag scheduled start' render={<TbFlagDown className={style.icon} />} />
<span className={cx([style.time, style.muted])}>{timerPlaceholder}</span>
</div>
<div className={style.labelledElement}>
<Tooltip text='Time to next flag expected start' render={<TbFlagStar className={style.icon} />} />
<span className={cx([style.time, style.muted])}>{timerPlaceholder}</span>
</div>
</div>
);
}
const expectedTimeUntil = expectedStart !== null ? expectedStart - clock : null;
const expectedTimeUntilDisplay = formattedTime(expectedTimeUntil, 3, TimerType.CountDown);
const muted = nextFlag === null;
const flagTitle = (entry as OntimeEvent | null)?.title || 'Flag';
const timeToNextFlag = nextFlag.start - clock;
const display = millisToString(timeToNextFlag, { fallback: timerPlaceholder });
const timeUntilDisplay = millisToString(timeUntil, { fallback: timerPlaceholder });
const title = entry?.title ?? null;
return (
<div className={style.metadataRow}>
<span className={cx([style.labelTitle])}>{flagTitle}</span>
<span className={title ? style.labelTitle : style.label}>{`${title ? title : 'Flag'} `}</span>
<div className={style.labelledElement}>
<Tooltip text='Time to next flag scheduled start' render={<TbFlagDown className={style.icon} />} />
<span className={cx([style.time])}>{display}</span>
<Tooltip text='Time to next flag planned start' render={<TbFlagPin className={style.icon} />} />
<span className={cx([style.time, !entry && style.muted])}>{plannedTimeUntilDisplay}</span>
</div>
<div className={style.labelledElement}>
<Tooltip text='Time to next flag expected start' render={<TbFlagStar className={style.icon} />} />
<span className={cx([style.time, muted && style.muted])}>{timeUntilDisplay}</span>
<span className={cx([style.time, expectedTimeUntil === null && style.muted])}>{expectedTimeUntilDisplay}</span>
</div>
</div>
);
@@ -194,7 +160,7 @@ export function ProgressOverview() {
const current = selectedEventIndex !== null ? selectedEventIndex + 1 : enDash;
const progressText = numEvents ? `${current} of ${numEvents || enDash}` : enDash;
return <TimeColumn label='Progress' value={progressText} />;
return <TimeColumn label='Progress' value={progressText} state={selectedEventIndex === null ? 'muted' : 'active'} />;
}
export function OffsetOverview() {
@@ -202,22 +168,29 @@ export function OffsetOverview() {
const isPlaying = isPlaybackActive(playback);
const correctedOffset = offset * -1;
const offsetState = getOffsetState(offset);
const offsetState = getOffsetState(isPlaying ? offset : null);
const offsetText = getOffsetText(isPlaying ? correctedOffset : null);
return <OverUnder state={offsetState} value={offsetText} testId='offset' />;
}
export function ClockOverview() {
export function ClockOverview({ className }: { className?: string }) {
const { clock } = useClock();
return <TimeColumn label='Time now' value={formattedTime(clock)} />;
return <TimeColumn label='Time now' value={formattedTime(clock)} className={className} />;
}
export function TimerOverview() {
const { current } = useTimer();
export function TimerOverview({ className }: { className?: string }) {
const timer = useTimer();
const display = millisToString(current, { fallback: timerPlaceholder });
const isWaiting = timer.phase === TimerPhase.Pending;
const title = isWaiting ? 'Count to start' : 'Running timer';
const display = millisToString(isWaiting ? timer.secondaryTimer : timer.current, { fallback: timerPlaceholder });
const timerState = (() => {
if (isWaiting) return 'waiting';
if (timer.current === null) return 'muted';
return 'active';
})();
return <TimeColumn label='Running timer' value={display} muted={current === null} />;
return <TimeColumn label={title} value={display} state={timerState} className={className} />;
}
@@ -9,6 +9,7 @@
letter-spacing: 0.5px;
min-width: 5em;
font-variant-numeric: tabular-nums;
color: $ui-white;
&::after {
content: '\200b';
@@ -18,6 +19,8 @@
.column {
display: flex;
flex-direction: column;
height: 100%;
justify-content: end;
.label {
line-height: 0.9em;
@@ -26,7 +29,12 @@
gap: 0.25rem;
}
&[data-state="over"] {
&[data-state='waiting'] {
.label {
color: $ontime-roll;
}
}
&[data-state='over'] {
.label .over {
color: $playback-over;
}
@@ -35,7 +43,7 @@
}
}
&[data-state="under"] {
&[data-state='under'] {
.label .under {
color: $playback-under;
}
@@ -44,7 +52,7 @@
}
}
&[data-state="muted"] {
&[data-state='muted'] {
.clock {
color: $muted-gray;
}
@@ -5,17 +5,17 @@ import style from './TimeLayout.module.scss';
interface TimeLayoutProps {
label: string;
value: string;
muted?: boolean;
state?: 'muted' | 'waiting' | 'active';
daySpan?: number;
className?: string;
testId?: string;
}
export function TimeColumn({ label, value, muted, className, testId }: TimeLayoutProps) {
export function TimeColumn({ label, value, state = 'active', className, testId }: TimeLayoutProps) {
return (
<div className={style.column}>
<div className={cx([style.column, className])} data-state={state}>
<span className={style.label}>{label}</span>
<span className={cx([style.clock, muted && style.muted, className])} data-testid={testId}>
<span className={style.clock} data-testid={testId}>
{value}
</span>
</div>
@@ -56,6 +56,7 @@
.index {
grid-row: 2;
align-content: center;
}
.entry {
+107 -58
View File
@@ -18,7 +18,6 @@ import {
type Rundown,
isOntimeBlock,
isOntimeEvent,
OntimeBlock,
OntimeEntry,
Playback,
SupportedEntry,
@@ -33,7 +32,7 @@ import {
reorderArray,
} from 'ontime-utils';
import { type EventOptions, useEntryActions } from '../../common/hooks/useEntryAction';
import { useEntryActions } from '../../common/hooks/useEntryAction';
import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useRundownEditor } from '../../common/hooks/useSocket';
import { useEntryCopy } from '../../common/stores/entryCopyStore';
@@ -42,9 +41,9 @@ import { AppMode, sessionKeys } from '../../ontimeConfig';
import QuickAddButtons from './entry-editor/quick-add-buttons/QuickAddButtons';
import QuickAddInline from './entry-editor/quick-add-cursor/QuickAddInline';
import BlockEnd from './rundown-block/BlockEnd';
import RundownBlock from './rundown-block/RundownBlock';
import { makeRundownMetadata, makeSortableList } from './rundown.utils';
import RundownBlockEnd from './rundown-block/RundownBlockEnd';
import { canDrop, makeRundownMetadata, makeSortableList } from './rundown.utils';
import RundownEmpty from './RundownEmpty';
import { useEventSelection } from './useEventSelection';
@@ -114,20 +113,16 @@ export default function Rundown({ data }: RundownProps) {
[addEntry, order, entries],
);
/**
* Add a new item referring to an existing one
*/
const insertAtId = useCallback(
(patch: Partial<OntimeEntry> & { type: SupportedEntry }, id: MaybeString, above = false) => {
const options: EventOptions =
id === null
? {}
: {
after: above ? undefined : id,
before: above ? id : undefined,
};
if (!above && id) {
options.lastEventId = id;
}
addEntry(patch, options);
addEntry(patch, {
after: id && !above ? id : undefined,
before: id && above ? id : undefined,
lastEventId: !above && id ? id : undefined,
});
},
[addEntry],
);
@@ -239,52 +234,72 @@ export default function Rundown({ data }: RundownProps) {
// shortcuts
useHotkeys([
['alt + ArrowDown', () => selectEntry(cursor, 'down'), { preventDefault: true }],
['alt + ArrowUp', () => selectEntry(cursor, 'up'), { preventDefault: true }],
['alt + ArrowDown', () => selectEntry(cursor, 'down'), { preventDefault: true, usePhysicalKeys: true }],
['alt + ArrowUp', () => selectEntry(cursor, 'up'), { preventDefault: true, usePhysicalKeys: true }],
['alt + shift + ArrowDown', () => selectBlock(cursor, 'down'), { preventDefault: true }],
['alt + shift + ArrowUp', () => selectBlock(cursor, 'up'), { preventDefault: true }],
['alt + shift + ArrowDown', () => selectBlock(cursor, 'down'), { preventDefault: true, usePhysicalKeys: true }],
['alt + shift + ArrowUp', () => selectBlock(cursor, 'up'), { preventDefault: true, usePhysicalKeys: true }],
['alt + mod + ArrowDown', () => moveEntry(cursor, 'down'), { preventDefault: true }],
['alt + mod + ArrowUp', () => moveEntry(cursor, 'up'), { preventDefault: true }],
['alt + mod + ArrowDown', () => moveEntry(cursor, 'down'), { preventDefault: true, usePhysicalKeys: true }],
['alt + mod + ArrowUp', () => moveEntry(cursor, 'up'), { preventDefault: true, usePhysicalKeys: true }],
['Escape', () => clearSelectedEvents(), { preventDefault: true }],
['Escape', () => clearSelectedEvents(), { preventDefault: true, usePhysicalKeys: true }],
['mod + Backspace', () => deleteAtCursor(cursor), { preventDefault: true }],
['mod + Backspace', () => deleteAtCursor(cursor), { preventDefault: true, usePhysicalKeys: true }],
[
'alt + E',
() => insertAtId({ type: SupportedEntry.Event }, cursor),
{ preventDefault: true, usePhysicalKeys: true },
],
['alt + shift + E', () => insertAtId({ type: SupportedEntry.Event }, cursor, true), { preventDefault: true }],
[
'alt + shift + E',
() => insertAtId({ type: SupportedEntry.Event }, cursor, true),
{ preventDefault: true, usePhysicalKeys: true },
],
[
'alt + G',
() => insertAtId({ type: SupportedEntry.Block }, cursor),
{ preventDefault: true, usePhysicalKeys: true },
],
['alt + shift + G', () => insertAtId({ type: SupportedEntry.Block }, cursor, true), { preventDefault: true }],
[
'alt + shift + G',
() => insertAtId({ type: SupportedEntry.Block }, cursor, true),
{ preventDefault: true, usePhysicalKeys: true },
],
[
'alt + D',
() => insertAtId({ type: SupportedEntry.Delay }, cursor),
{ preventDefault: true, usePhysicalKeys: true },
],
['alt + shift + D', () => insertAtId({ type: SupportedEntry.Delay }, cursor, true), { preventDefault: true }],
[
'alt + shift + D',
() => insertAtId({ type: SupportedEntry.Delay }, cursor, true),
{ preventDefault: true, usePhysicalKeys: true },
],
[
'alt + M',
() => insertAtId({ type: SupportedEntry.Milestone }, cursor),
{ preventDefault: true, usePhysicalKeys: true },
],
['alt + shift + M', () => insertAtId({ type: SupportedEntry.Milestone }, cursor, true), { preventDefault: true }],
[
'alt + shift + M',
() => insertAtId({ type: SupportedEntry.Milestone }, cursor, true),
{ preventDefault: true, usePhysicalKeys: true },
],
['mod + C', () => setEntryCopyId(cursor)],
['mod + V', () => insertCopyAtId(cursor, entryCopyId)],
['mod + shift + V', () => insertCopyAtId(cursor, entryCopyId, true), { preventDefault: true }],
[
'mod + shift + V',
() => insertCopyAtId(cursor, entryCopyId, true),
{ preventDefault: true, usePhysicalKeys: true },
],
['alt + backspace', () => deleteAtCursor(cursor), { preventDefault: true }],
['alt + backspace', () => deleteAtCursor(cursor), { preventDefault: true, usePhysicalKeys: true }],
]);
// we copy the state from the store here
@@ -319,14 +334,14 @@ export default function Rundown({ data }: RundownProps) {
return;
}
// prevent dropping a group inside another
if (active.data.current?.type === 'block' && !canDrop(over.data.current?.type, over.data.current?.parent)) {
return;
}
const fromIndex = active.data.current?.sortable.index;
const toIndex = over.data.current?.sortable.index;
// we keep a copy of the state as a hack to handle inconsistencies between dnd-kit and async store updates
setSortableData((currentEntries) => {
return reorderArray(currentEntries, fromIndex, toIndex);
});
let destinationId = over.id as EntryId;
let order: 'before' | 'after' | 'insert' = fromIndex < toIndex ? 'after' : 'before';
@@ -352,7 +367,16 @@ export default function Rundown({ data }: RundownProps) {
}
}
}
reorderEntry(active.id as EntryId, destinationId, order);
// keep copy of the current state in case we need to revert
const currentEntries = structuredClone(sortableData);
// we keep a copy of the state as a hack to handle inconsistencies between dnd-kit and async store updates
setSortableData((currentEntries) => {
return reorderArray(currentEntries, fromIndex, toIndex);
});
reorderEntry(active.id as EntryId, destinationId, order).catch((_) => {
setSortableData(currentEntries);
});
};
/**
@@ -409,9 +433,6 @@ export default function Rundown({ data }: RundownProps) {
<div className={style.list}>
{isEditMode && <QuickAddButtons previousEventId={null} parentBlock={null} />}
{sortableData.map((entryId, index) => {
const isFirst = index === 0;
const isLast = index === sortableData.length - 1;
// the entry might be a pseudo block-end which does not generate metadata and should not be processed
if (entryId.startsWith('end-')) {
const parentId = entryId.split('end-')[1];
@@ -419,20 +440,23 @@ export default function Rundown({ data }: RundownProps) {
if (isBlockCollapsed) {
return null;
} else {
const parentColour = (entries[parentId] as OntimeBlock | undefined)?.colour;
// if the previous element is selected, it will have its own QuickAddInline
// we use thisId instead of previousEntryId because the block end does not process
// and it does not cause the reassignment of the iteration id to the previous entry
return (
<Fragment key={entryId}>
{isEditMode && rundownMetadata.groupEntries === 0 && (
<QuickAddButtons previousEventId={null} parentBlock={parentId} backgroundColor={parentColour} />
)}
<BlockEnd key={entryId} id={entryId} colour={parentColour} />
</Fragment>
);
}
// if the previous element is selected, it will have its own QuickAddInline
// we use thisId instead of previousEntryId because the block end does not process
// and it does not cause the reassignment of the iteration id to the previous entry
return (
<Fragment key={entryId}>
{isEditMode && rundownMetadata.groupEntries === 0 && (
<QuickAddButtons
previousEventId={null}
parentBlock={parentId}
backgroundColor={rundownMetadata.groupColour}
/>
)}
<RundownBlockEnd key={entryId} id={entryId} colour={rundownMetadata.groupColour} />
</Fragment>
);
}
// we iterate through a stateful copy of order to make the dnd operations smoother
@@ -440,6 +464,7 @@ export default function Rundown({ data }: RundownProps) {
// instead of writing all the logic guards, we simply short circuit rendering here
const entry = entries[entryId];
if (!entry) return null;
rundownMetadata = process(entry);
// if the entry has a parent, and it is collapsed, render nothing
@@ -462,13 +487,30 @@ export default function Rundown({ data }: RundownProps) {
*/
const blockColour = rundownMetadata.groupColour === '' ? '#9d9d9d' : rundownMetadata.groupColour;
const isFirst = index === 0;
const isLast = entryId === order.at(-1);
/**
* We need to provide the parent ID for the QuickAdd components
* This should be different depending on whether we are adding before or after an element
* - when adding before, we need to avoid a group referencing itself as the parent
* - when adding after, we can use the group ID directly to insert at the top of the group
*/
const parentIdForBefore =
rundownMetadata.thisId !== rundownMetadata.groupId ? rundownMetadata.groupId : null;
const parentIdForAfter = rundownMetadata.groupId;
return (
<Fragment key={entry.id}>
{/**
* Before the entry
* - edit mode only
* - if there is a cursor
* - if it is not the first entry (the buttons would be there)
*/}
{isEditMode && hasCursor && !isFirst && (
<QuickAddInline
previousEventId={rundownMetadata.previousEntryId}
parentBlock={rundownMetadata.thisId !== rundownMetadata.groupId ? rundownMetadata.groupId : null}
/>
<QuickAddInline previousEventId={rundownMetadata.previousEntryId} parentBlock={parentIdForBefore} />
)}
{isOntimeBlock(entry) ? (
<RundownBlock
@@ -509,8 +551,15 @@ export default function Rundown({ data }: RundownProps) {
</div>
</div>
)}
{isEditMode && hasCursor && rundownMetadata.groupEntries !== 0 && !isLast && (
<QuickAddInline previousEventId={entry.id} parentBlock={rundownMetadata.groupId} />
{/**
* After the entry
* - edit mode only
* - if there is a cursor
* - if it is not the last entry (the buttons would be there)
* - if the entry is not the block header
*/}
{isEditMode && hasCursor && !isLast && (
<QuickAddInline previousEventId={entry.id} parentBlock={parentIdForAfter} />
)}
</Fragment>
);
@@ -37,7 +37,7 @@ function RundownExport() {
>
<FinderPlacement />
<ViewNavigationMenu suppressSettings />
<div className={style.content}>
<div className={style.rundown}>
<ErrorBoundary>
<RundownContextMenu>
<RundownWrapper isSmallDevice />
@@ -1,25 +1,25 @@
import { useCallback } from 'react';
import { OntimeBlock } from 'ontime-types';
import { millisToString, parseUserTime } from 'ontime-utils';
import { MaybeNumber, OntimeBlock } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
import NullableTimeInput from '../../../common/components/input/time-input/NullableTimeInput';
import AppLink from '../../../common/components/link/app-link/AppLink';
import { useEntryActions } from '../../../common/hooks/useEntryAction';
import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { getOffsetState } from '../../../common/utils/offset';
import { enDash, timerPlaceholder } from '../../../common/utils/styleUtils';
import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils';
import TextLikeInput from '../../../views/cuesheet/cuesheet-table/cuesheet-table-elements/TextLikeInput';
import EntryEditorCustomFields from './composite/EventEditorCustomFields';
import EventTextArea from './composite/EventTextArea';
import EntryEditorTextInput from './composite/EventTextInput';
import TargetDurationInput from './composite/TargetDurationInput';
import style from './EntryEditor.module.scss';
// title + colour + custom field labels
export type BlockEditorUpdateTextFields = 'targetDuration' | 'title' | 'colour' | string;
export type BlockEditorUpdateTextFields = 'title' | 'colour' | string;
export type BlockEditorUpdateMaybeNumberFields = 'targetDuration';
interface BlockEditorProps {
@@ -31,7 +31,7 @@ export default function BlockEditor({ block }: BlockEditorProps) {
const { updateEntry } = useEntryActions();
const handleSubmit = useCallback(
(field: BlockEditorUpdateTextFields | BlockEditorUpdateMaybeNumberFields, value: string | boolean) => {
(field: BlockEditorUpdateTextFields | BlockEditorUpdateMaybeNumberFields, value: string | MaybeNumber) => {
// Handle custom fields
if (typeof field === 'string' && field.startsWith('custom-')) {
const fieldLabel = field.split('custom-')[1];
@@ -40,11 +40,7 @@ export default function BlockEditor({ block }: BlockEditorProps) {
}
if (field === 'targetDuration') {
if (value === '') {
return updateEntry({ id: block.id, targetDuration: null });
}
return updateEntry({ id: block.id, targetDuration: parseUserTime(value as string) });
return updateEntry({ id: block.id, targetDuration: value as MaybeNumber });
}
// all other strings are text fields
@@ -85,22 +81,22 @@ export default function BlockEditor({ block }: BlockEditorProps) {
</div>
</div>
<div className={style.inline}>
<div>
<Editor.Label htmlFor='targetDuration'>Target duration</Editor.Label>
<NullableTimeInput
name='targetDuration'
time={block.targetDuration}
submitHandler={handleSubmit}
emptyDisplay={enDash}
/>
</div>
<div>
<Editor.Label htmlFor='eventId'>Plan offset</Editor.Label>
<TextLikeInput offset={planOffsetLabel} className={style.textLikeInput} tabIndex={-1}>
<TextLikeInput
offset={planOffsetLabel}
className={cx([style.textLikeInput, planOffset === null && style.inactive])}
tabIndex={-1}
>
{planOffset !== null && planOffset > 0 ? '+' : ''}
{millisToString(planOffset, { fallback: enDash })}
</TextLikeInput>
</div>
<TargetDurationInput
duration={block.duration}
targetDuration={block.targetDuration}
submitHandler={handleSubmit}
/>
</div>
</div>
@@ -94,16 +94,21 @@
/* approximating the style of a disabled input */
.textLikeInput {
background-color: rgba($gray-1200, 0.4);
font-weight: 400;
color: $gray-200;
border: 1px solid transparent;
justify-content: center;
background: transparent;
justify-content: start;
width: 7.5em;
border: 1px solid transparent;
border-bottom: 1px solid $gray-1100;
&:hover {
background-color: rgba($gray-1200, 0.4);
background: transparent;
}
}
.inactive {
color: $muted-gray;
}
.active {
color: $active-indicator;
}
@@ -0,0 +1,45 @@
import { IoLockClosed, IoLockOpenOutline } from 'react-icons/io5';
import { MaybeNumber } from 'ontime-types';
import IconButton from '../../../../common/components/buttons/IconButton';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import NullableTimeInput from '../../../../common/components/input/time-input/NullableTimeInput';
import Tooltip from '../../../../common/components/tooltip/Tooltip';
import { cx, enDash } from '../../../../common/utils/styleUtils';
import TimeInputGroup from '../../time-input-flow/TimeInputGroup';
import style from '../EntryEditor.module.scss';
interface TargetDurationInputProps {
duration: MaybeNumber;
targetDuration: MaybeNumber;
submitHandler: (field: 'targetDuration', value: MaybeNumber) => void;
}
export default function TargetDurationInput({ duration, targetDuration, submitHandler }: TargetDurationInputProps) {
const isBlocked = targetDuration !== null;
return (
<div>
<Editor.Label htmlFor='targetDuration'>Target duration</Editor.Label>
<TimeInputGroup hasDelay={isBlocked && targetDuration !== duration}>
<NullableTimeInput
name='targetDuration'
time={targetDuration}
submitHandler={submitHandler}
emptyDisplay={enDash}
className={isBlocked ? '' : style.inactive}
/>
<Tooltip
text='Lock to target duration'
className={cx([style.timeAction, isBlocked && style.active])}
onClick={() => submitHandler('targetDuration', isBlocked ? null : duration)}
data-testid='lock__duration'
render={<IconButton variant='subtle-white' className={isBlocked ? style.active : style.inactive} />}
>
{isBlocked ? <IoLockClosed /> : <IoLockOpenOutline />}
</Tooltip>
</TimeInputGroup>
</div>
);
}
@@ -3,7 +3,7 @@
.block {
@include block-styling;
margin-block: 1rem 0.25rem;
margin-block: 0.5rem;
display: grid;
grid-template-columns: 2rem 1fr;
grid-template-areas: 'binder header';
@@ -14,7 +14,7 @@
}
&.expanded {
margin-block: 1rem 0;
margin-block: 0.5rem 0;
border-radius: $block-border-radius $block-border-radius 0 0;
border-bottom: 0.25rem solid color-mix(in srgb, transparent 90%, var(--user-bg, transparent) 10%);
}
@@ -32,7 +32,7 @@
&:focus {
outline: 1px solid $blue-500;
outline-offset: -1px;
outline-offset: -1px;
}
}
@@ -68,6 +68,16 @@
}
}
.strike {
text-decoration: line-through;
}
.over {
color: $playback-over;
}
.under {
color: $playback-under;
}
.drag {
@include drag-style;
position: absolute;
@@ -14,6 +14,7 @@ import { EntryId, OntimeBlock } from 'ontime-types';
import IconButton from '../../../common/components/buttons/IconButton';
import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryActions } from '../../../common/hooks/useEntryAction';
import { getOffsetState } from '../../../common/utils/offset';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../common/utils/time';
import EditableBlockTitle from '../common/EditableBlockTitle';
@@ -29,6 +30,7 @@ interface RundownBlockProps {
onCollapse: (collapsed: boolean, groupId: EntryId) => void;
}
//TODO: the block should maybe include a multiple day indicator
export default function RundownBlock({ data, hasCursor, collapsed, onCollapse }: RundownBlockProps) {
const handleRef = useRef<null | HTMLSpanElement>(null);
const { clone, ungroup, deleteEntry } = useEntryActions();
@@ -91,11 +93,24 @@ export default function RundownBlock({ data, hasCursor, collapsed, onCollapse }:
const binderColours = data.colour && getAccessibleColour(data.colour);
const isValidDrop = over?.id && canDrop(over.data.current?.type, over.data.current?.parent);
const [planOffset, planOffsetLabel] = (() => {
if (data.targetDuration === null) {
return [null, null];
}
const offset = data.duration - data.targetDuration;
if (offset === 0) {
return [null, 'under'];
}
return [offset < 0 ? `-${formatDuration(offset * -1)}` : `+${formatDuration(offset)}`, getOffsetState(offset * -1)];
})();
const dragStyle = {
zIndex: isDragging ? 2 : 'inherit',
transform: CSS.Translate.toString(transform),
transition,
cursor: isOver ? (isValidDrop ? 'grabbing' : 'no-drop') : 'default',
cursor: isOver ? (isValidDrop ? 'grabbing' : 'no-drop') : 'grab',
};
return (
@@ -139,7 +154,16 @@ export default function RundownBlock({ data, hasCursor, collapsed, onCollapse }:
</div>
<div className={style.metaEntry}>
<div>Duration</div>
<div>{formatDuration(data.duration)}</div>
{planOffset === null ? (
<div className={cx([planOffsetLabel !== null && style[planOffsetLabel]])}>
{formatDuration(data.duration)}
</div>
) : (
<div>
<span className={style.strike}>{formatDuration(data.duration)}</span>
<span className={cx([planOffsetLabel !== null && style[planOffsetLabel]])}>{planOffset}</span>
</div>
)}
</div>
<div className={style.metaEntry}>
<div>Entries</div>
@@ -6,5 +6,5 @@
background-color: var(--user-bg, $gray-1050);
border-radius: 0 0 $block-border-radius $block-border-radius;
margin-bottom: 0.25rem;
margin-bottom: 0.5rem;
}
@@ -1,14 +1,14 @@
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import style from './BlockEnd.module.scss';
import style from './RundownBlockEnd.module.scss';
interface BlockEndProps {
id: string;
colour?: string;
}
export default function BlockEnd({ id, colour }: BlockEndProps) {
export default function RundownBlockEnd({ id, colour }: BlockEndProps) {
const {
attributes: dragAttributes,
listeners: dragListeners,
@@ -17,6 +17,9 @@ export default function BlockEnd({ id, colour }: BlockEndProps) {
transition,
} = useSortable({
id,
data: {
type: 'end-block',
},
animateLayoutChanges: () => false,
disabled: true, // we do not want to drag end blocks
});
@@ -37,6 +37,7 @@ export default function RundownDelay({ data, hasCursor }: RundownDelayProps) {
const dragStyle = {
zIndex: isDragging ? 2 : 'inherit',
cursor: isDragging ? 'grabbing' : 'grab',
transform: CSS.Translate.toString(transform),
transition,
};
@@ -135,16 +135,6 @@ export default function RundownEvent({
{ type: 'item', label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') },
]
: [
{
type: 'item',
label: 'Toggle link to previous',
icon: IoLink,
onClick: () =>
actionHandler('update', {
field: 'linkStart',
value: linkStart,
}),
},
{
type: 'item',
label: flag ? 'Remove flag' : 'Add flag',
@@ -201,6 +191,7 @@ export default function RundownEvent({
const dragStyle = {
zIndex: isDragging ? 2 : 'inherit',
cursor: isDragging ? 'grabbing' : 'grab',
transform: CSS.Translate.toString(transform),
transition,
};
@@ -6,7 +6,7 @@ import Tooltip from '../../../../common/components/tooltip/Tooltip';
import { usePlayback } from '../../../../common/hooks/useSocket';
import useReport from '../../../../common/hooks-query/useReport';
import { cx } from '../../../../common/utils/styleUtils';
import { formatDuration, useTimeUntilStart } from '../../../../common/utils/time';
import { formatDuration, useTimeUntilExpectedStart } from '../../../../common/utils/time';
import style from './RundownEventChip.module.scss';
@@ -76,7 +76,7 @@ interface EventUntilProps {
function EventUntil(props: EventUntilProps) {
const { timeStart, delay, dayOffset, totalGap, isLinkedToLoaded } = props;
const timeUntil = useTimeUntilStart({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded });
const timeUntil = useTimeUntilExpectedStart({ timeStart, delay, dayOffset }, { totalGap, isLinkedToLoaded });
const isDue = timeUntil < MILLIS_PER_SECOND;
const timeUntilString = isDue ? 'DUE' : `${formatDuration(Math.abs(timeUntil), timeUntil > 2 * MILLIS_PER_MINUTE)}`;
@@ -37,10 +37,10 @@ function RundownHeader() {
return (
<Toolbar.Root className={style.header}>
<ToggleGroup value={[editorMode]} onValueChange={toggleAppMode} className={style.group}>
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.button}>
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}>
Run
</Toolbar.Button>
<Toolbar.Button render={<Toggle />} value={AppMode.Edit} className={style.button}>
<Toolbar.Button render={<Toggle />} value={AppMode.Edit} className={style.radioButton}>
Edit
</Toolbar.Button>
</ToggleGroup>
@@ -48,10 +48,10 @@ function RundownHeader() {
<Editor.Separator className={style.separator} />
<ToggleGroup value={[offsetMode]} onValueChange={toggleOffsetMode} className={style.group}>
<Toolbar.Button render={<Toggle />} value={OffsetMode.Absolute} className={style.button}>
<Toolbar.Button render={<Toggle />} value={OffsetMode.Absolute} className={style.radioButton}>
Absolute
</Toolbar.Button>
<Toolbar.Button render={<Toggle />} value={OffsetMode.Relative} className={style.button}>
<Toolbar.Button render={<Toggle />} value={OffsetMode.Relative} className={style.radioButton}>
Relative
</Toolbar.Button>
</ToggleGroup>
@@ -10,7 +10,7 @@
color: $section-white; // to override inline
display: grid;
grid-template-columns: 2rem 8rem 1fr auto;
grid-template-columns: 2rem 1fr 3fr;
align-items: center;
height: $secondary-block-height;
gap: 0.5rem;
@@ -1,12 +1,12 @@
import { MouseEvent, useCallback, useRef } from 'react';
import { IoClose, IoReorderTwo } from 'react-icons/io5';
import { IoReorderTwo, IoTrash } from 'react-icons/io5';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { EntryId } from 'ontime-types';
import Button from '../../../common/components/buttons/Button';
import Input from '../../../common/components/input/input/Input';
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryActions } from '../../../common/hooks/useEntryAction';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { useEventSelection } from '../useEventSelection';
@@ -26,6 +26,15 @@ export default function RundownMilestone({ colour, cue, entryId, hasCursor, titl
const { updateEntry, deleteEntry } = useEntryActions();
const { selectedEvents, setSelectedBlock } = useEventSelection();
const [onContextMenu] = useContextMenu<HTMLDivElement>([
{
type: 'item',
label: 'Delete',
icon: IoTrash,
onClick: () => deleteEntry([entryId]),
},
]);
const {
attributes: dragAttributes,
listeners: dragListeners,
@@ -59,13 +68,9 @@ export default function RundownMilestone({ colour, cue, entryId, hasCursor, titl
updateEntry({ id: entryId, [field]: value });
};
const handleDelete = (event: MouseEvent) => {
event.stopPropagation();
deleteEntry([entryId]);
};
const dragStyle = {
zIndex: isDragging ? 2 : 'inherit',
cursor: isDragging ? 'grabbing' : 'grab',
transform: CSS.Translate.toString(transform),
transition,
};
@@ -77,6 +82,7 @@ export default function RundownMilestone({ colour, cue, entryId, hasCursor, titl
className={cx([style.milestone, hasCursor ? style.hasCursor : null])}
ref={setNodeRef}
onClick={handleFocusClick}
onContextMenu={onContextMenu}
style={dragStyle}
data-testid='rundown-milestone'
>
@@ -87,9 +93,6 @@ export default function RundownMilestone({ colour, cue, entryId, hasCursor, titl
</div>
<MilestoneTextInput field='cue' initialValue={cue} placeholder='Cue' submitHandler={handleUpdate} />
<MilestoneTextInput field='title' initialValue={title} placeholder='Title' submitHandler={handleUpdate} />
<Button variant='ghosted-destructive' onClick={handleDelete}>
<IoClose /> Cancel
</Button>
</div>
);
}
@@ -158,15 +158,21 @@ export function makeSortableList(order: EntryId[], entries: RundownEntries): Ent
/**
* Checks whether a drop operation is valid
* Currently only used for validating dropping blocks
* Currently only used for validating dropping groups
*/
export function canDrop(targetType?: SupportedEntry, targetParent?: EntryId | null): boolean {
if (targetType === 'event' || targetType === 'delay') {
return targetParent === null;
export function canDrop(targetType?: SupportedEntry & 'end-block', targetParent?: EntryId | null): boolean {
// this would mean inserting a group inside another
if (targetType === 'end-block') {
return false;
}
// remaining events will be block or end-block
// we can swap places with other blocks
return targetType == 'block';
// this means swapping places with another group
if (targetType === 'block') {
return true;
}
// for all other cases, we just need to check if we are inside a group
return targetParent === null;
}
/**
@@ -1,31 +1,3 @@
.timeAction {
background: $gray-1050;
color: $gray-500;
cursor: pointer;
height: 2rem;
width: 2rem;
display: grid;
place-content: center;
aspect-ratio: 1;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
}
&:active:not(:disabled) {
background: $gray-1300;
}
&:focus {
outline: 1px solid $blue-500;
outline-offset: -1px;
}
&.active {
color: var(--status-color-active-override, $active-indicator);
}
}
.fourtyfive {
transform: rotate(-45deg);
}
@@ -36,28 +8,10 @@
font-size: 1.5em;
}
.inputGroup {
border: 1px solid transparent;
width: fit-content;
display: flex;
align-items: center;
border-radius: $component-border-radius-md;
&.delayed {
border: 1px solid $ontime-delay-text;
}
input {
max-width: 6.5em;
border-radius: $component-border-radius-md 0 0 $component-border-radius-md;
border: none;
padding-right: 0;
}
button {
width: 1.5rem;
height: 2rem;
border-radius: 0 $component-border-radius-md $component-border-radius-md 0;
border: none;
}
.inactive {
color: $muted-gray;
}
.active {
color: $active-indicator;
}
@@ -3,11 +3,13 @@ import { IoAlertCircleOutline, IoLink, IoLockClosed, IoLockOpenOutline, IoUnlink
import { TimeField, TimeStrategy } from 'ontime-types';
import { dayInMs } from 'ontime-utils';
import IconButton from '../../../common/components/buttons/IconButton';
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import TimeInput from '../../../common/components/input/time-input/TimeInput';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useEntryActions } from '../../../common/hooks/useEntryAction';
import { cx } from '../../../common/utils/styleUtils';
import TimeInputGroup from './TimeInputGroup';
import style from './TimeInputFlow.module.scss';
@@ -67,7 +69,7 @@ function TimeInputFlow({
<>
<div>
{showLabels && <Editor.Label className={style.sectionTitle}>Start time</Editor.Label>}
<div className={cx([style.inputGroup, hasDelay && style.delayed])}>
<TimeInputGroup hasDelay={hasDelay}>
<TimeInput
name='timeStart'
submitHandler={handleSubmit}
@@ -78,17 +80,17 @@ function TimeInputFlow({
/>
<Tooltip
text='Link start to previous end'
className={cx([style.timeAction, linkStart && style.active])}
onClick={() => handleLink(!linkStart)}
render={<IconButton variant='subtle-white' className={linkStart ? style.active : style.inactive} />}
>
<span className={style.fourtyfive}>{linkStart ? <IoLink /> : <IoUnlink />}</span>
</Tooltip>
</div>
</TimeInputGroup>
</div>
<div>
{showLabels && <Editor.Label>End time</Editor.Label>}
<div className={cx([style.inputGroup, hasDelay && style.delayed])}>
<TimeInputGroup hasDelay={hasDelay}>
<TimeInput
name='timeEnd'
submitHandler={handleSubmit}
@@ -99,18 +101,18 @@ function TimeInputFlow({
/>
<Tooltip
text='Lock end'
className={cx([style.timeAction, isLockedEnd && style.active])}
render={<IconButton variant='subtle-white' className={isLockedEnd ? style.active : style.inactive} />}
onClick={() => handleChangeStrategy(TimeStrategy.LockEnd)}
data-testid='lock__end'
>
{isLockedEnd ? <IoLockClosed /> : <IoLockOpenOutline />}
</Tooltip>
</div>
</TimeInputGroup>
</div>
<div>
{showLabels && <Editor.Label>Duration</Editor.Label>}
<div className={cx([style.inputGroup, hasDelay && style.delayed])}>
<TimeInputGroup hasDelay={hasDelay}>
<TimeInput
name='duration'
submitHandler={handleSubmit}
@@ -121,13 +123,13 @@ function TimeInputFlow({
/>
<Tooltip
text='Lock duration'
className={cx([style.timeAction, isLockedDuration && style.active])}
render={<IconButton variant='subtle-white' className={isLockedDuration ? style.active : style.inactive} />}
onClick={() => handleChangeStrategy(TimeStrategy.LockDuration)}
data-testid='lock__duration'
>
{isLockedDuration ? <IoLockClosed /> : <IoLockOpenOutline />}
</Tooltip>
</div>
</TimeInputGroup>
</div>
{warnings.length > 0 && (
@@ -0,0 +1,25 @@
.inputGroup {
border: 1px solid transparent;
width: fit-content;
display: flex;
align-items: center;
border-radius: $component-border-radius-md;
&.delayed {
border: 1px solid $ontime-delay-text;
}
input {
max-width: 6.5em;
border-radius: $component-border-radius-md 0 0 $component-border-radius-md;
border: none;
padding-right: 0;
}
button {
width: 1.5rem;
height: 2rem;
border-radius: 0 $component-border-radius-md $component-border-radius-md 0;
border: none;
}
}
@@ -0,0 +1,13 @@
import { PropsWithChildren } from 'react';
import { cx } from '../../../common/utils/styleUtils';
import style from './TimeInputGroup.module.scss';
interface TimeInputGroupProps {
hasDelay?: boolean;
}
export default function TimeInputGroup({ hasDelay, children }: PropsWithChildren<TimeInputGroupProps>) {
return <div className={cx([style.inputGroup, hasDelay && style.delayed])}>{children}</div>;
}
+13
View File
@@ -114,6 +114,19 @@ table {
word-break: break-word;
}
// reset list styles
ul,
ol {
list-style: none;
margin: 0;
padding: 0;
}
li {
margin: 0;
padding: 0;
}
// remove buttons in number inputs
/* WebKit and Blink */
input::-webkit-outer-spin-button,
+1 -1
View File
@@ -28,7 +28,7 @@ $active-green: $green-700;
// playback colours
$playback-start: $green-600;
$ontime-roll: #0274B6;
$ontime-roll: #22a0e9;
$ontime-delay: #F57C13;
$ontime-delay-text: #E69056;
$ontime-paused: #c05621;
+2 -2
View File
@@ -7,8 +7,8 @@ export const langDe: TranslationObject = {
'common.next': 'Nächste',
'common.scheduled_start': 'Geplanter beginn',
'common.scheduled_end': 'Geplantes ende',
'common.projected_start': 'Erwartetes beginn',
'common.projected_end': 'Erwartetes ende',
'common.expected_start': 'Erwartetes beginn',
'common.expected_end': 'Erwartetes ende',
'common.stage_timer': 'Bühnen-Timer',
'common.started_at': 'Gestartet am',
'common.time_now': 'Aktuelle Zeit',
+2 -2
View File
@@ -5,8 +5,8 @@ export const langEn = {
'common.next': 'Next',
'common.scheduled_start': 'Scheduled start',
'common.scheduled_end': 'Scheduled end',
'common.projected_start': 'Projected start',
'common.projected_end': 'Projected end',
'common.expected_start': 'Expected start',
'common.expected_end': 'Expected end',
'common.stage_timer': 'Stage Timer',
'common.started_at': 'Started At',
'common.time_now': 'Time now',
+2 -2
View File
@@ -7,8 +7,8 @@ export const langEs: TranslationObject = {
'common.next': 'Siguiente',
'common.scheduled_start': 'Inicio programado',
'common.scheduled_end': 'Fin programado',
'common.projected_start': 'Inicio previsto',
'common.projected_end': 'Fin previsto',
'common.expected_start': 'Inicio previsto',
'common.expected_end': 'Fin previsto',
'common.stage_timer': 'Temporizador de presentador',
'common.started_at': 'Iniciado en',
'common.time_now': 'Ahora',
+2 -2
View File
@@ -7,8 +7,8 @@ export const langFr: TranslationObject = {
'common.next': 'A suivre',
'common.scheduled_start': 'Début prévu',
'common.scheduled_end': 'Fin prévue',
'common.projected_start': 'Début projeté',
'common.projected_end': 'Fin projetée',
'common.expected_start': 'Début projeté',
'common.expected_end': 'Fin projetée',
'common.stage_timer': 'Minuteur de scène',
'common.started_at': 'Commencé à',
'common.time_now': 'Heure',
+2 -2
View File
@@ -7,8 +7,8 @@ export const langIt: TranslationObject = {
'common.next': 'Prossimo',
'common.scheduled_start': 'Inizio programmato',
'common.scheduled_end': 'Fine programmata',
'common.projected_start': 'Inizio previsto',
'common.projected_end': 'Fine prevista',
'common.expected_start': 'Inizio previsto',
'common.expected_end': 'Fine prevista',
'common.stage_timer': 'Orologio Palco',
'common.started_at': 'Iniziato Alle',
'common.time_now': 'Ora attuale',
+2 -2
View File
@@ -7,8 +7,8 @@ export const langPt: TranslationObject = {
'common.next': 'Próximo',
'common.scheduled_start': 'Início programado',
'common.scheduled_end': 'Fim programado',
'common.projected_start': 'Início previsto',
'common.projected_end': 'Fim previsto',
'common.expected_start': 'Início previsto',
'common.expected_end': 'Fim previsto',
'common.stage_timer': 'Temporizador do presentador',
'common.started_at': 'Iniciado em',
'common.time_now': 'Hora atual',
+26 -4
View File
@@ -149,10 +149,27 @@
.info {
grid-area: info;
display: flex;
gap: max(1vw, 16px);
align-self: flex-end;
flex-direction: column;
gap: $view-element-gap;
align-self: end;
overflow: hidden;
align-items: end;
}
.info__column {
display: flex;
flex-direction: column;
}
.info-card {
display: flex;
gap: $view-element-gap;
&__label {
font-size: $timer-label-size;
color: var(--label-color-override, $viewer-label-color);
font-weight: 600;
text-transform: uppercase;
}
&__message {
font-size: $base-font-size;
@@ -162,11 +179,16 @@
flex: 1;
}
.qr {
&__qr {
padding: 0.5rem;
background-color: $ui-white;
border-radius: 2px;
}
&__img {
padding: 0.5rem;
border-radius: 2px;
}
}
}
+41 -5
View File
@@ -48,7 +48,7 @@ export default function Backstage({
settings,
}: BackstageProps) {
const { getLocalizedString } = useTranslation();
const { secondarySource } = useBackstageOptions();
const { secondarySource, extraInfo } = useBackstageOptions();
const [blinkClass, setBlinkClass] = useState(false);
const { height: screenHeight } = useViewportSize();
@@ -104,8 +104,8 @@ export default function Backstage({
// gather option data
const defaultFormat = getDefaultFormat(settings?.timeFormat);
const backstageOptions = useMemo(
() => getBackstageOptions(defaultFormat, customFields),
[defaultFormat, customFields],
() => getBackstageOptions(defaultFormat, customFields, general),
[defaultFormat, customFields, general],
);
return (
@@ -180,8 +180,44 @@ export default function Backstage({
{showSchedule && <ScheduleExport selectedId={selectedId} />}
<div className={cx(['info', !showSchedule && 'info--stretch'])}>
{general.url && <QRCode value={general.url} size={qrSize} level='L' className='qr' />}
{general.info && <div className='info__message'>{general.info}</div>}
{extraInfo && <ExtraInfo projectData={general} size={qrSize} source={extraInfo} />}
<div className='info-card'>
{general.url && <QRCode value={general.url} size={qrSize} level='L' className='info-card__qr' />}
{general.info && <div className='info-card__message'>{general.info}</div>}
</div>
</div>
</div>
);
}
interface ExtraInfoProps {
projectData: ProjectData;
size: number;
source: string;
}
function ExtraInfo({ projectData, size, source }: ExtraInfoProps) {
const info = projectData.custom.find((entry, index) => {
const label = `${index}-${entry.title}`;
return label === source;
});
if (!info) {
return null;
}
return (
<div className='info-card'>
{info.url && (
<img
className='info-card__img'
width={size}
src={info.url}
onError={(event) => (event.currentTarget.style.display = 'none')}
/>
)}
<div className='info__column'>
{info.title && <div className='info-card__label'>{info.title}</div>}
{info.value && <div className='info-card__message'>{info.value}</div>}
</div>
</div>
);
@@ -1,15 +1,23 @@
import { useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { CustomFields, OntimeEvent } from 'ontime-types';
import { CustomFields, OntimeEvent, ProjectData } from 'ontime-types';
import { getTimeOption } from '../../common/components/view-params-editor/common.options';
import { OptionTitle } from '../../common/components/view-params-editor/constants';
import { ViewOption } from '../../common/components/view-params-editor/viewParams.types';
import { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils';
import {
makeOptionsFromCustomFields,
makeProjectDataOptions,
} from '../../common/components/view-params-editor/viewParams.utils';
import { scheduleOptions } from '../common/schedule/schedule.options';
export const getBackstageOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
export const getBackstageOptions = (
timeFormat: string,
customFields: CustomFields,
projectData: ProjectData,
): ViewOption[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields, [{ value: 'note', label: 'Note' }]);
const projectDataOptions = makeProjectDataOptions(projectData);
return [
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
@@ -28,11 +36,26 @@ export const getBackstageOptions = (timeFormat: string, customFields: CustomFiel
],
},
scheduleOptions,
{
title: OptionTitle.ElementVisibility,
collapsible: true,
options: [
{
id: 'extra-info',
title: 'Extra info',
description: 'Select a project data source to show in the view',
type: 'option',
values: projectDataOptions,
defaultValue: '',
},
],
},
];
};
type BackstageOptions = {
secondarySource: keyof OntimeEvent | null;
extraInfo: string | null;
};
/**
@@ -43,6 +66,7 @@ function getOptionsFromParams(searchParams: URLSearchParams): BackstageOptions {
// we manually make an object that matches the key above
return {
secondarySource: searchParams.get('secondary-src') as keyof OntimeEvent | null,
extraInfo: searchParams.get('extra-info'),
};
}
@@ -24,11 +24,11 @@ interface ScheduleItemProps {
export default function ScheduleItem(props: ScheduleItemProps) {
const { timeStart, timeEnd, title, colour, skip, delay } = props;
const { showProjected } = useScheduleOptions();
const { showExpected } = useScheduleOptions();
if (showProjected) {
if (showExpected) {
return (
<ProjectedScheduleItem
<ExpectedScheduleItem
timeStart={timeStart}
timeEnd={timeEnd}
title={title}
@@ -95,34 +95,34 @@ function DelayedScheduleItem(props: ScheduleItemProps) {
);
}
function ProjectedScheduleItem(props: ScheduleItemProps) {
function ExpectedScheduleItem(props: ScheduleItemProps) {
const { timeStart, timeEnd, title, colour, skip, delay } = props;
return (
<li className={cx(['entry', skip && 'entry--skip'])}>
<div className='entry-times'>
<span className='entry-colour' style={{ backgroundColor: colour }} />
<ProjectedTime time={timeStart} delay={delay} />
<ExpectedTime time={timeStart} delay={delay} />
<ProjectedTime time={timeEnd} delay={delay} />
<ExpectedTime time={timeEnd} delay={delay} />
</div>
<div className='entry-title'>{title}</div>
</li>
);
}
interface OffsetTimeProps {
interface ExpectedTimeProps {
time: number;
delay: number;
}
function ProjectedTime(props: OffsetTimeProps) {
function ExpectedTime(props: ExpectedTimeProps) {
const { time, delay } = props;
const { offset } = useRuntimeOffset();
const projectedOffset = offset - delay;
const projectedTime = formatTime(time - offset, formatOptions);
const projectedState = getOffsetState(projectedOffset);
const expectedOffset = offset - delay;
const expectedTime = formatTime(time - offset, formatOptions);
const expectedState = getOffsetState(expectedOffset);
return <SuperscriptTime className={`entry-times--${projectedState}`} time={projectedTime} />;
return <SuperscriptTime className={`entry-times--${expectedState}`} time={expectedTime} />;
}
@@ -24,9 +24,9 @@ export const scheduleOptions: ViewOption = {
defaultValue: 10,
},
{
id: 'showProjected',
title: 'Show projected time',
description: 'Whether scheduled times should account for runtime offset.',
id: 'showExpected',
title: 'Show expected time',
description: 'Whether the times shown should account for the runtime offset.',
type: 'boolean',
defaultValue: false,
},
@@ -36,14 +36,14 @@ export const scheduleOptions: ViewOption = {
type ScheduleOptions = {
cycleInterval: number;
stopCycle: boolean;
showProjected: boolean;
showExpected: boolean;
};
function getScheduleOptionsFromParams(searchParams: URLSearchParams): ScheduleOptions {
return {
cycleInterval: Number(searchParams.get('cycleInterval')) || 10,
stopCycle: isStringBoolean(searchParams.get('stopCycle')),
showProjected: isStringBoolean(searchParams.get('showProjected')),
showExpected: isStringBoolean(searchParams.get('showExpected')),
};
}
@@ -105,7 +105,6 @@ $item-height: 3.5rem;
background-color: var(--card-background-color-override, $viewer-card-bg-color);
padding-right: 1rem;
border-radius: $element-border-radius;
overflow: clip;
&:hover {
.sub__label {

Some files were not shown because too many files have changed in this diff Show More