mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-05 07:28:01 +00:00
Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 35347e8d63 | |||
| e37d2ce50f | |||
| 49e923026e | |||
| f1a8db9649 | |||
| dddeefd544 | |||
| b4aaa8966f | |||
| ad9db4edd3 | |||
| fef9ed4fcc | |||
| 9ec47eda6e | |||
| 32edf3404c | |||
| bcf80f9d77 | |||
| df137402cf | |||
| daddb7f7b7 | |||
| 82e8b9d66a | |||
| 765d5d97cf | |||
| ed4d3ba265 | |||
| a358ec0f17 | |||
| 524721002a | |||
| 9ab54edbf5 | |||
| 8a7f8c8ebc | |||
| 3ce539af2a | |||
| 3158311aa1 | |||
| 6b4d96dddd | |||
| dc3a32d2bc | |||
| bda01c886e | |||
| 93916653be | |||
| 40f8976350 | |||
| 78e905a735 | |||
| 126ff61738 | |||
| 1e63238cac | |||
| 6ffe34a642 | |||
| 69f276765b | |||
| 75554071a7 | |||
| a1fab10bfd | |||
| 5bc286145d | |||
| 44508ce8b4 | |||
| 90105a199a | |||
| 9618bec847 | |||
| 66a4e27fdd | |||
| ef906f43d7 | |||
| 93b2a1aece | |||
| 050629d281 | |||
| abe9012f20 |
@@ -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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "4.0.0-alpha.0",
|
||||
"version": "4.0.0-alpha.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
+6
-1
@@ -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;
|
||||
|
||||
+4
-2
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
-1
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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¶m2=value2',
|
||||
{
|
||||
alias: 'test',
|
||||
target: 'timer',
|
||||
search: 'param1=value1¶m2=value2',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
'cloud URL',
|
||||
'test',
|
||||
'https://cloud.getontime.no/timer?param1=value1¶m2=value2',
|
||||
{
|
||||
alias: 'test',
|
||||
target: 'timer',
|
||||
search: 'param1=value1¶m2=value2',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
'local URL',
|
||||
'test',
|
||||
'http://localhost:4001/timer?param1=value1¶m2=value2',
|
||||
{
|
||||
alias: 'test',
|
||||
target: 'timer',
|
||||
search: 'param1=value1¶m2=value2',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
'IP-based URL',
|
||||
'test',
|
||||
'http://192.168.0.1:4001/timer?param1=value1¶m2=value2',
|
||||
{
|
||||
alias: 'test',
|
||||
target: 'timer',
|
||||
search: 'param1=value1¶m2=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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
+1
-1
@@ -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>
|
||||
);
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
.expand {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
+154
@@ -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>
|
||||
|
||||
+2
-3
@@ -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';
|
||||
}
|
||||
|
||||
+4
-4
@@ -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>
|
||||
);
|
||||
|
||||
+2
-3
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
+5
-2
@@ -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>;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user