Compare commits

...

35 Commits

Author SHA1 Message Date
Carlos Valente 98bd320cbd style: coloured row background (#571)
* style: coloured row background
2023-11-05 09:47:55 +01:00
Alex Christoffer Rasmussen f57faf1837 remove unnecessary import 2023-11-04 08:46:35 +01:00
arc-alex 44f025b359 update node-osc to v9 2023-11-04 08:46:35 +01:00
Carlos Valente 0555d0f400 feat: expand user options for views with schedule (#565)
* feat: expand user options for views with schedule

* fix: selected public id on load

* fix: load events on roll

* style: remove empty container
2023-11-03 13:47:38 +01:00
Carlos Valente 1e90ce4c1d style: sticky index column (#566)
* style: sticky index column
2023-11-02 20:59:30 +01:00
Carlos Valente 2f09d27813 fix: nullcheck for nextPublicEventId in findRoll (#567)
Co-authored-by: Alex Christoffer Rasmussen <alex.christoffer@gmail.com>
2023-11-02 10:31:05 +01:00
Alex Christoffer Rasmussen a01046b479 Feat/change event api (#553)
* feat: change attribute for given event 

---------

Co-authored-by: Ary <arylmoraesn@gmail.com>
Co-authored-by: arc-alex <ac@omnivox.dk>
2023-10-31 19:24:22 +01:00
Enubia 78d5d442cf feat: added export options (#529)
* feat: added export options

* chore: eslint console proposal

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
2023-10-30 11:41:15 +01:00
Brian Teeman 7c234466f7 typos (#562)
quick fix for some typos/spelling

Small way of saying thanks for the software
2023-10-30 11:36:54 +01:00
Carlos Valente 29e5c96d44 fix: prevent error when port is unchanged (#551)
* fix: prevent error when port is unchanged
2023-10-28 19:01:58 +02:00
Kye Lewis 36b1effaaf Clarify build before E2E steps (#556)
Co-authored-by: Kye <kye@klewis.me>
2023-10-26 14:57:11 +02:00
Alex Christoffer Rasmussen a84c0654e2 Feat resumability (#547)
* add resumability functionality

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2023-10-24 08:33:17 +02:00
Carlos Valente b34fe71995 chore: add line end to prettier (#548) 2023-10-14 19:12:14 +02:00
Carlos Valente 2f74293505 chore: update docs (#537)
- Avoid duplicate instructions on docker and refer to documentation
- Add link to macOS docs on running an unsigned app
2023-10-08 11:47:45 +02:00
Carlos Valente e2aabe3646 chore: version bump (#528) 2023-09-17 15:04:55 +02:00
Carlos Valente 7545f6f7d0 refactor: clarify description import (#526) 2023-09-16 15:24:23 +02:00
Carlos Valente 958c68b6f6 style hover (#525)
* refactor: render if in view

* style: add hover indicator on row
2023-09-16 13:49:12 +02:00
Carlos Valente a0c5375376 refactor: project data (#523) 2023-09-15 17:33:30 +02:00
Carlos Valente 9708f0bfc6 refactor: pass events instead of derived data to titles (#493)
* refactor: pass events instead of derived data to titles
2023-09-14 22:30:23 +02:00
Carlos Valente 13eca98133 refactor: recompose and memoise (#522) 2023-09-14 21:06:48 +02:00
Carlos Valente 126e31403e style tweaks (#524)
* style: allow 2 rows in block title

* fix: accidental change to next and progress bar
2023-09-13 21:51:24 +02:00
Carlos Valente 53beea2768 refactor: prevent calling undefined (#521) 2023-09-13 21:24:49 +02:00
Carlos Valente 0bb09dd039 fix: sanitise cue import (#519)
* fix: sanitise cue import
2023-09-11 21:06:32 +02:00
Carlos Valente 93fb48ea1c feat: operator view (#440)
* feat: operator view

* chore: smoke test operator

* refactor: small improvements from deepsource

---------

Co-authored-by: arihanv <arihanvaranasi@gmail.com>
Co-authored-by: arihanv <63890951+arihanv@users.noreply.github.com>
2023-09-11 21:03:11 +02:00
Carlos Valente 1de3e01216 style: localise labels to user device (#509) 2023-09-03 09:13:56 +02:00
Carlos Valente 8d427a6bbe fix: allow spaces in title input (#504) 2023-09-01 23:26:59 +02:00
Carlos Valente 23dcca5527 fix: issue with resolving dev environment (#501)
* fix: issue with resolving dev environment

* fix: revert accidental commtit

* chore: version bump
2023-08-31 22:08:18 +02:00
Carlos Valente a3bbd74db7 chore: reduce sentry error logging (#498)
* chore: reduce sentry error logging

* chore: version bump

* chore: improve test selectors
2023-08-27 22:00:18 +02:00
Carlos Valente 5323e627fb feat: count to end (#500) 2023-08-27 19:25:40 +02:00
Carlos Valente cd9dbcdc68 fix: add event (#499)
* refactor: remove guards on event creation

* style: minimum safe block size

* fix: insert after finds previous valid cue
2023-08-26 21:45:51 +02:00
Carlos Valente af99882919 release test fixes (#496)
* refactor: handle missing type

* fix: prevent stale data on cancel delay
2023-08-24 22:53:23 +02:00
Carlos Valente 1d4dc3a26f feat: unlock changing port in UI (#476)
* refactor: unlock changing port in UI

* Change server port (#464)

* Ability to save custom port

* Launch server on custom port & pass that port to electron

* Add serverPort validation

* Prevent changing port in docker

---------

Co-authored-by: Snehanshu Phukon <snehanshu@mamboemm.com>

---------

Co-authored-by: স্নেহাংশু ফুকন <hello@snehanshu.com>
Co-authored-by: Snehanshu Phukon <snehanshu@mamboemm.com>
2023-08-23 22:31:57 +02:00
Carlos Valente e5fdf27f6c fix: apply delay (#494)
* fix: bug with applying delays

* fix: show delay data only in production screens

* chore: cover delay with feature test

* refactor: simplify type assertion
2023-08-23 22:31:15 +02:00
Carlos Valente 657cc22b44 feat: event cue (#473)
* feat: parse cue

* refactor: get delay from backend

* feat: add cue to UI

* refactor: remove deprecated delay logic

* refactor: extract studio clock specific logic

* refactor: extract utilities

* refactor: fix issue with missing key

* style: prevent cue overflow

* style: prevent whitespace wrap

* feat: add support for cues in integrations
2023-08-17 21:43:11 +02:00
asharonbaltazar 51c31adaf1 Swap Event Data (#446)
* use zustand store in `ContextMenuContext`

* add `withDivider` prop to context menu `Option`

* add `isDisabled` prop to `Option`

* create `eventIdSwapping` store

* create swapping context menu options

* fix `key` in ContextMenu

* address `tanstack-eslint` errors

* create initial `requestEventSwap` event

* create initial `swapEvents` action

* use `swapEvents` in `EventBlock`

* move from `emitError` to `logAxiosError`

* remove extra curly brace from Copy ID

* add `clearEventId` func

* finalize context menu swapping logic

* write optimistic swapping logic

* remove unused import and type out handler in `rundownController`

* create `rundownSwapValidator`

* move index increase to `EventBlock`

* move swapping logic from `id` to `index`

* create `swapEvents` endpoint

* move `useEventIdSwapping` hook into its own file

* revert to using event `id`

* logic now uses indexes to swap events

* add `todo` to `swapEvents`

* revert index increment

* create `swapOntimeEvents` and export in `index.ts`

* use `swapOntimeEvents` in frontend & server

* update import path

* remove extra `setCached`
2023-08-15 16:04:53 -04:00
220 changed files with 5450 additions and 3006 deletions
+1 -1
View File
@@ -21,6 +21,6 @@
}
],
"rules": {
"no-console": "warn"
"no-console": ["warn", { "allow": ["warn", "error"]}]
}
}
BIN
View File
Binary file not shown.
+10
View File
@@ -0,0 +1,10 @@
build
coverage
dist
node_modules
playwright-report
**/*.toml
**/*.yml
**/*.json
+1
View File
@@ -1,4 +1,5 @@
{
"endOfLine": "lf",
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
+31 -8
View File
@@ -1,38 +1,48 @@
# GETTING STARTED
Ontime consists of 3 distinct parts
- __client__: A React app for Ontime's UI and web clients
- __client__: A React app for Ontime's UI and web clients
- __electron__: An electron app which facilitates the cross-platform distribution of Ontime
- __server__: A node application which handles the domains services and integrations
The steps below will assume you have locally installed the necessary dependencies.
The steps below will assume you have locally installed the necessary dependencies.
Other dependencies will be installed as part of the setup
- __node__ (>=16.16)
- __pnpm__ (>=7)
- __docker__ (only necessary to run and build docker images)
## LOCAL DEVELOPMENT
The electron app is only necessary to distribute an installable version of the app and is not required for local development.
The electron app is only necessary to distribute an installable version of the app and is not required for local
development.
Locally, we would need to run both the React client and the node.js server in development mode
From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i`
- __Run dev mode__ by running `turbo dev`
### Debugging backend
To debug backend code in Node.js:
- Open two separate terminals and navigate to the `apps/client` and `apps/server` directories.
- In each terminal, run the command `pnpm dev` to start the development servers for both the client and server applications.
- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by running `pnpm dev:inspect`.
- In each terminal, run the command `pnpm dev` to start the development servers for both the client and server
applications.
- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by
running `pnpm dev:inspect`.
## TESTING
Generally we have 2 types of tests.
Generally we have 2 types of tests.
- Unit tests for functions that contain business logic
- End-to-end tests for core features
### Unit tests
Unit tests are contained in mostly all the apps and packages (client, server and utils)
You can run unit tests by running turbo `turbo test:pipeline` from the project root.
@@ -41,12 +51,22 @@ This will run all tests and close test runner.
Alternatively you can navigate to an app or project and run `pnpm test` to run those tests in watch mode
### E2E tests
E2E tests are in a separate package. On running, [playwright](https://playwright.dev/) will spin up an instance of the webserver to test against
E2E tests are in a separate package. On running, [playwright](https://playwright.dev/) will spin up an instance of the
webserver to test against
These tests also run against a separate version of the DB (test-db)
Before running the E2E, you should first build the project with `pnpm build:local`.
You can run playwright tests from project root with `pnpm e2e`
When writing tests, it can be handy to run playwright in interactive mode with `pnpm e2e:i`. You would need to manually start the webserver with `pnpm dev:server`
When writing tests, it can be handy to run playwright in interactive mode with `pnpm e2e:i`. You would need to manually
start the webserver with `pnpm dev:server`
Some other useful commands
- `pnpm e2e --ui` open playwright UI
- `pnpm e2e --headed` run tests with a visible browser window
## CREATE AN INSTALLABLE FILE (Windows | MacOS | Linux)
@@ -54,6 +74,7 @@ Ontime uses Electron to distribute the application.
You can generate a distribution for your OS by running the following steps.
From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i`
- __Build the UI and server__ by running `turbo build:local`
- __Create the package__ by running `turbo dist-win`, `turbo dist-mac` or `turbo dist-linux`
@@ -66,10 +87,12 @@ Ontime provides a docker-compose file to aid with building and running docker im
While it should allow for a generic setup, it might need to be modified to fit your infrastructure.
From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i`
- __Build docker image from__ by running `docker build -t getontime/ontime`
- __Run docker image from compose__ by running `docker-compose up -d`
Other useful commands
- __List running processes__ by running `docker ps`
- __Kill running process__ by running `docker kill <process-id>`
+36 -44
View File
@@ -14,10 +14,11 @@
Ontime is an application for creating and managing event running order and timers.
The user inputs a list of events along with scheduling and event information.
The user inputs a list of events along with scheduling and event information.
This will then populate a series of screens which are available to be rendered by any device in the Network.
This makes for a simple and cheap way to distribute over a venue using a network infrastructure instead of video outputs.
This makes for a simple and cheap way to distribute over a venue using a network infrastructure instead of video
outputs.
![App Window](https://github.com/cpvalente/ontime/blob/master/.github/aux-images/app.png)
@@ -28,7 +29,7 @@ This makes for a simple and cheap way to distribute over a venue using a network
Once installed and running, Ontime starts a background server that is the heart of all processes.
From the app, you can add / edit your running order and control the timer playback.
Any device with a browser in the same network can choose one of the supported views to render the available data.
Any device with a browser in the same network can choose one of the supported views to render the available data.
This is done by reaching the ontime server at the _default port 4001_ eg: `localhost:4001`
or `192.168.1.3:4001`
<br />
@@ -50,8 +51,9 @@ IP.ADDRESS:4001/public > Public / Foyer view
IP.ADDRESS:4001/lower > Lower Thirds
IP.ADDRESS:4001/studio > Studio Clock
```
```
For management views
For management views
-------------------------------------------------------------
IP.ADDRESS:4001/editor > the control interface, same as the app
IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
@@ -63,14 +65,14 @@ More documentation is available [in our docs](https://ontime.gitbook.io)
- [x] Distribute data over network and render it in the browser
- [x] Different screen types
- Stage Timer
- Minimal Timer
- Clock
- Backstage Info
- Public Info
- Studio Clock
- Countdown
- [Make your own?](#make-your-own-viewer)
- Stage Timer
- Minimal Timer
- Clock
- Backstage Info
- Public Info
- Studio Clock
- Countdown
- [Make your own?](#make-your-own-viewer)
- [x] Configurable Lower Thirds
- [x] Collaborative editing with the cuesheet view
- [x] Send live messages to different screen types
@@ -83,17 +85,19 @@ More documentation is available [in our docs](https://ontime.gitbook.io)
- [x] Roll mode: run standalone using the system clock
- [x] [Headless run](#headless-run): run server in a separate machine, configure from a browser locally
- [x] [Countdown to anything!](https://ontime.gitbook.io/v2/views/countdown): have
a countdown to any scheduled event
- [x] Multi-platform (available on Windows, MacOS and Linux)
a countdown to any scheduled event
- [x] Multi-platform (available on Windows, MacOS and Linux)
- [x] [Companion integration](https://bitfocus.io/connections/getontime-ontime)
## Unopinionated
We want Ontime to be unique by targeting freelancers instead of roles.
We want Ontime to be unique by targeting freelancers instead of roles.
We believe most freelancers work in different fields and we want to give you a tool that you can leverage across your many environments and workflows.
We believe most freelancers work in different fields and we want to give you a tool that you can leverage across your
many environments and workflows.
We are not interested in forcing workflows and have made Ontime so, it is flexible to whichever way you would like to work.
We are not interested in forcing workflows and have made Ontime so, it is flexible to whichever way you would like to
work.
## Rich APIs for workflow integrations
@@ -113,7 +117,8 @@ Taking advantage of the integrations, we currently use Ontime with:
Ontime broadcasts its data over WebSockets. This allows you to consume its data outside the application.
Writing a new view for the browser can be done with basic knowledge of HTML + CSS + Javascript (or any other language that can run in the browser).
Writing a new view for the browser can be done with basic knowledge of HTML + CSS + Javascript (or any other language
that can run in the browser).
<br />
See [this repository](https://github.com/cpvalente/ontime-viewer-template-v2) with a small template on
how to get you started and read the docs about
@@ -121,26 +126,11 @@ the [Websocket API](https://ontime.gitbook.io/v2/control-and-feedback/ontime-api
### Headless run
You can self-host and run Ontime in a docker image. The run command will:
You can self-host and run Ontime in a docker image.
- expose the necessary ports (listed in the Dockerfile)
- mount a local file to persist your data (in the example: ````$(pwd)/local-data````)
- the image name __getontime/ontime__
The docker image along with documentation is [available Docker Hub at getontime/ontime](https://hub.docker.com/r/getontime/ontime)
The docker image is
in [available Docker Hub at getontime/ontime](https://hub.docker.com/r/getontime/ontime)
```bash
docker pull getontime/ontime
```
and use the included docker compose to get started
```bash
docker-compose up
```
Related information available [in the docs](https://ontime.gitbook.io/v2/additional-notes/use-in-raspberry-pi)
If you want to run this image in a Raspberry Pi, please see [the docs](https://ontime.gitbook.io/v2/additional-notes/use-in-raspberry-pi)
## Roadmap
@@ -148,32 +138,32 @@ Related information available [in the docs](https://ontime.gitbook.io/v2/additio
Several features are planned in the roadmap, and we continuously adjust this to match how users interact with the app.
<br />
Have an idea? Reach out via [email](mail@getontime.no) or [open an issue](https://github.com/cpvalente/ontime/issues/new)
Have an idea? Reach out via [email](mail@getontime.no)
or [open an issue](https://github.com/cpvalente/ontime/issues/new)
### Issues
We use Github's issue tracking for bug reporting and feature requests. <br />
Found a bug? [Open an issue](https://github.com/cpvalente/ontime/issues/new).
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:
```Microsoft Defender SmartScreen prevented an unrecognised app from starting. Running this app might put your PC at risk.```
`Microsoft Defender SmartScreen prevented an unrecognised app from starting. Running this app might put your PC at risk.`
or
```Ontime can't be opened because it is from an unidentified developer```
`Ontime can't be opened because it is from an unidentified developer`
or in Linux
```Could Not Display "ontime-linux.AppImage```
`Could Not Display "ontime-linux.AppImage`
You can circumvent this by allowing the execution of the app manually.
- In Windows: click more and select "Run Anyway"
- in macOS: after attempting to run the installer, navigate to System Preferences -> Security &
Privacy and allow the execution of the app
- in macOS: the solution in macOS is different across versions, please refer to the [apple documentation](https://support.apple.com/en-gb/guide/mac-help/mh40616/mac)
- In Linux: right-click the AppImage file -> Properties -> Permissions -> select Allow Executing
File as a Program
@@ -185,6 +175,7 @@ please [open an issue](https://github.com/cpvalente/ontime/issues/new)
#### Safari
There are known issues with Safari versions lower than 13:
- Spacing and text styles might have small inconsistencies
- Table view does not work
@@ -194,7 +185,8 @@ There is no plan for any further work on this.
Looking to contribute? All types of help are appreciated, from coding to testing and feature specification.
<br /><br />
If you are a developer and would like to contribute with some code, please open an issue to discuss before opening a Pull Request.
If you are a developer and would like to contribute with some code, please open an issue to discuss before opening a
Pull Request.
<br />
Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md)
+1
View File
@@ -1,4 +1,5 @@
{
"endOfLine": "lf",
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "2.3.9",
"version": "2.9.0",
"private": true,
"dependencies": {
"@chakra-ui/react": "^2.7.0",
+12 -12
View File
@@ -4,9 +4,9 @@ import { ChakraProvider } from '@chakra-ui/react';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { ContextMenu } from './common/components/context-menu/ContextMenu';
import ErrorBoundary from './common/components/error-boundary/ErrorBoundary';
import { AppContextProvider } from './common/context/AppContext';
import { ContextMenuProvider } from './common/context/ContextMenuContext';
import useElectronEvent from './common/hooks/useElectronEvent';
import { ontimeQueryClient } from './common/queryClient';
import { socketClientName } from './common/stores/connectionName';
@@ -52,18 +52,18 @@ function App() {
<ChakraProvider resetCSS theme={theme}>
<QueryClientProvider client={ontimeQueryClient}>
<AppContextProvider>
<ContextMenuProvider>
<BrowserRouter>
<div className='App'>
<ErrorBoundary>
<TranslationProvider>
<BrowserRouter>
<div className='App'>
<ErrorBoundary>
<TranslationProvider>
<ContextMenu>
<AppRouter />
</TranslationProvider>
</ErrorBoundary>
<ReactQueryDevtools initialIsOpen={false} />
</div>
</BrowserRouter>
</ContextMenuProvider>
</ContextMenu>
</TranslationProvider>
</ErrorBoundary>
<ReactQueryDevtools initialIsOpen={false} />
</div>
</BrowserRouter>
</AppContextProvider>
</QueryClientProvider>
</ChakraProvider>
+6 -1
View File
@@ -1,10 +1,12 @@
import { lazy, Suspense } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import withData from './features/viewers/ViewWrapper';
import withAlias from './features/AliasWrapper';
import withData from './features/viewers/ViewWrapper';
const Editor = lazy(() => import('./features/editors/ProtectedEditor'));
const Cuesheet = lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
const Operator = lazy(() => import('./features/operator/Operator'));
const TimerView = lazy(() => import('./features/viewers/timer/Timer'));
const MinimalTimerView = lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
@@ -57,6 +59,9 @@ export default function AppRouter() {
{/*/!* Lower cannot have fallback *!/*/}
<Route path='/lower' element={<SLowerThird />} />
<Route path='/op' element={<Operator />} />
<Route path='/operator' element={<Operator />} />
{/*/!* Protected Routes *!/*/}
<Route path='/editor' element={<Editor />} />
<Route path='/cuesheet' element={<Cuesheet />} />
+6 -6
View File
@@ -1,7 +1,5 @@
export const STATIC_PORT = 4001;
// REST stuff
export const EVENT_DATA = ['eventdata'];
export const PROJECT_DATA = ['project'];
export const ALIASES = ['aliases'];
export const USERFIELDS = ['userFields'];
export const RUNDOWN_TABLE_KEY = 'rundown';
@@ -14,12 +12,14 @@ export const RUNTIME = ['runtimeStore'];
const location = window.location;
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
export const isProduction = import.meta.env.MODE === 'production';
export const serverPort = import.meta.env.DEV ? STATIC_PORT : location.port;
export const serverURL = import.meta.env.DEV ? `http://${location.hostname}:${serverPort}` : location.origin;
const STATIC_PORT = 4001;
export const serverPort = isProduction ? location.port : STATIC_PORT;
export const serverURL = `${location.protocol}//${location.hostname}:${serverPort}`;
export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`;
export const eventURL = `${serverURL}/eventdata`;
export const projectDataURL = `${serverURL}/project`;
export const rundownURL = `${serverURL}/events`;
export const ontimeURL = `${serverURL}/ontime`;
+8 -3
View File
@@ -6,9 +6,14 @@ import { addLog } from '../stores/logger';
import { nowInMillis } from '../utils/time';
export function logAxiosError(prepend: string, error: unknown) {
const message = axios.isAxiosError(error)
? `${prepend} ${(error as AxiosError).response?.statusText ?? ''}: ${(error as AxiosError).response?.data ?? ''}`
: `${prepend}: ${error}`;
let message;
if (axios.isAxiosError(error)) {
const statusText = (error as AxiosError).response?.statusText ?? '';
const data = (error as AxiosError).response?.data ?? '';
message = `${prepend} ${statusText}: ${data}`;
} else {
message = `${prepend}: ${error}`;
}
addLog({
id: generateId(),
@@ -1,21 +0,0 @@
import axios from 'axios';
import { EventData } from 'ontime-types';
import { eventURL } from './apiConstants';
/**
* @description HTTP request to fetch event data
* @return {Promise}
*/
export async function fetchEventData(): Promise<EventData> {
const res = await axios.get(eventURL);
return res.data;
}
/**
* @description HTTP request to mutate event data
* @return {Promise}
*/
export async function postEventData(data: EventData) {
return axios.post(eventURL, data);
}
+13
View File
@@ -50,6 +50,19 @@ export async function requestApplyDelay(eventId: string) {
return axios.patch(`${rundownURL}/applydelay/${eventId}`);
}
export type SwapEntry = {
from: string;
to: string;
};
/**
* @description HTTP request to swap two events
* @return {Promise}
*/
export async function requestEventSwap(data: SwapEntry) {
return axios.patch(`${rundownURL}/swap`, data);
}
/**
* @description HTTP request to delete given event
* @return {Promise}
+12 -34
View File
@@ -1,16 +1,9 @@
import axios from 'axios';
import {
Alias,
EventData,
OSCSettings,
OscSubscription,
Settings,
UserFields,
ViewSettings,
} from 'ontime-types';
import { Alias, OSCSettings, OscSubscription, ProjectData, Settings, UserFields, ViewSettings } from 'ontime-types';
import { apiRepoLatest } from '../../externals';
import { InfoType } from '../models/Info';
import fileDownload from '../utils/fileDownload';
import { ontimeURL } from './apiConstants';
@@ -117,32 +110,17 @@ export async function postOscSubscriptions(data: OscSubscription) {
}
/**
* @description HTTP request to download db
* @return {Promise}
* @description HTTP request to download db in CSV format
*/
export const downloadRundown = async () => {
await axios({
url: `${ontimeURL}/db`,
method: 'GET',
responseType: 'blob', // important
}).then((response) => {
const headerLine = response.headers['Content-Disposition'];
let filename = 'rundown.json';
export const downloadCSV = () => {
return fileDownload(ontimeURL, { name: 'rundown', type: 'csv' }, { type: 'text/csv;charset=utf-8;' });
};
// try and get the filename from the response
if (headerLine != null) {
const startFileNameIndex = headerLine.indexOf('"') + 1;
const endFileNameIndex = headerLine.lastIndexOf('"');
filename = headerLine.substring(startFileNameIndex, endFileNameIndex);
}
const url = window.URL.createObjectURL(new Blob([response.data], { type: 'application/json' }));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
});
/**
* @description HTTP request to download db in JSON format
*/
export const downloadRundown = () => {
return fileDownload(ontimeURL, { name: 'rundown', type: 'json' }, { type: 'application/json;charset=utf-8;' });
};
/**
@@ -186,6 +164,6 @@ export async function getLatestVersion(): Promise<HasUpdate> {
};
}
export async function postNew(initialData: Partial<EventData>) {
export async function postNew(initialData: Partial<ProjectData>) {
return axios.post(`${ontimeURL}/new`, initialData);
}
@@ -0,0 +1,21 @@
import axios from 'axios';
import { ProjectData } from 'ontime-types';
import { projectDataURL } from './apiConstants';
/**
* @description HTTP request to fetch project data
* @return {Promise}
*/
export async function getProjectData(): Promise<ProjectData> {
const res = await axios.get(projectDataURL);
return res.data;
}
/**
* @description HTTP request to mutate project data
* @return {Promise}
*/
export async function postProjectData(data: ProjectData) {
return axios.post(projectDataURL, data);
}
@@ -0,0 +1,84 @@
// logic (with some modifications) culled from:
// https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx
import { Fragment, ReactElement } from 'react';
import { Menu, MenuButton, MenuDivider, MenuItem, MenuList } from '@chakra-ui/react';
import { IconType } from '@react-icons/all-files';
import { create } from 'zustand';
import style from './ContextMenu.module.scss';
type ContextMenuCoords = {
x: number;
y: number;
};
export type Option = {
label: string;
icon: IconType;
onClick: () => void;
withDivider?: boolean;
isDisabled?: boolean;
};
type ContextMenuStore = {
coords: ContextMenuCoords;
options: Option[];
isOpen: boolean;
setContextMenu: (coords: ContextMenuCoords, options: Option[]) => void;
setIsOpen: (newIsOpen: boolean) => void;
};
export const useContextMenuStore = create<ContextMenuStore>((set) => ({
coords: { x: 0, y: 0 },
options: [],
isOpen: false,
setContextMenu: (coords, options) => set(() => ({ coords, options, isOpen: true })),
setIsOpen: (newIsOpen) => set(() => ({ isOpen: newIsOpen })),
}));
interface ContextMenuProps {
// ReactElement type required due to early `return` (line 51) returning {children}
children: ReactElement;
}
export const ContextMenu = ({ children }: ContextMenuProps) => {
const { coords, options, isOpen, setIsOpen } = useContextMenuStore();
const onClose = () => {
return setIsOpen(false);
};
if (!isOpen) {
return children;
}
return (
<>
{children}
<div className={style.contextMenuBackdrop} />
<Menu isOpen gutter={0} onClose={onClose} isLazy lazyBehavior='unmount' variant='ontime-on-dark'>
<MenuButton
className={style.contextMenuButton}
aria-hidden
w={1}
h={1}
style={{
left: coords.x,
top: coords.y,
}}
/>
<MenuList>
{options.map(({ label, icon: Icon, onClick, withDivider, isDisabled }, i) => (
<Fragment key={label}>
{withDivider && <MenuDivider />}
<MenuItem key={i} icon={<Icon />} onClick={onClick} isDisabled={isDisabled}>
{label}
</MenuItem>
</Fragment>
))}
</MenuList>
</Menu>
</>
);
};
@@ -0,0 +1,9 @@
@use '../../../theme/v2Styles' as *;
.delaySymbol {
svg {
font-size: 1.5rem;
color: $ontime-delay;
margin: 0 auto;
}
}
@@ -0,0 +1,40 @@
import { Tooltip } from '@chakra-ui/react';
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
import { IoChevronUp } from '@react-icons/all-files/io5/IoChevronUp';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { millisToDelayString } from '../../utils/dateConfig';
import style from './DelayIndicator.module.scss';
interface DelayIndicatorProps {
delayValue?: number;
}
export default function DelayIndicator(props: DelayIndicatorProps) {
const { delayValue } = props;
if (typeof delayValue === 'number') {
if (delayValue < 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<span className={style.delaySymbol}>
<IoChevronDown />
</span>
</Tooltip>
);
}
if (delayValue > 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<span className={style.delaySymbol}>
<IoChevronUp />
</span>
</Tooltip>
);
}
}
return null;
}
@@ -1,6 +1,6 @@
import { useCallback } from 'react';
import { TitleActions } from '../../../../features/event-editor/composite/EventEditorTitles';
import { TitleActions } from '../../../../features/event-editor/composite/EventEditorDataLeft';
import Swatch from './Swatch';
@@ -37,7 +37,17 @@ function ButtonTooltip(name: TimeEntryField, warning?: string) {
}
export default function TimeInput(props: TimeInputProps) {
const { id, name, submitHandler, time = 0, delay = 0, placeholder, validationHandler, previousEnd = 0, warning } = props;
const {
id,
name,
submitHandler,
time = 0,
delay = 0,
placeholder,
validationHandler,
previousEnd = 0,
warning,
} = props;
const { emitError } = useEmitLog();
const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState<string>('');
@@ -191,7 +201,7 @@ export default function TimeInput(props: TimeInputProps) {
<Input
ref={inputRef}
id={id}
data-testid='time-input'
data-testid={`time-input-${name}`}
className={style.inputField}
type='text'
placeholder={placeholder}
@@ -58,7 +58,7 @@ function NavigationMenu() {
const handleMirror = () => toggleMirror();
const showEditFormDrawer = () => {
searchParams.append('edit', 'true');
searchParams.set('edit', 'true');
setSearchParams(searchParams);
};
@@ -116,6 +116,10 @@ function NavigationMenu() {
Cuesheet
<IoArrowUp className={style.linkIcon} />
</Link>
<Link to='/op' className={style.link} tabIndex={0}>
Operator
<IoArrowUp className={style.linkIcon} />
</Link>
<hr className={style.separator} />
{navigatorConstants.map((route) => (
<Link
@@ -8,10 +8,12 @@ import { tooltipDelayFast } from '../../../ontimeConfig';
interface PlaybackIconProps {
state: Playback;
skipTooltip?: boolean;
className?: string;
}
export default function PlaybackIcon(props: PlaybackIconProps) {
const { state } = props;
const { state, skipTooltip, className } = props;
// if timer is Pause or Armed
let label = 'Timer Paused';
@@ -28,9 +30,13 @@ export default function PlaybackIcon(props: PlaybackIconProps) {
Icon = IoStop;
}
if (skipTooltip) {
return <Icon className={className} />;
}
return (
<Tooltip openDelay={tooltipDelayFast} label={label} shouldWrapChildren>
<Icon />
<Icon className={className} />
</Tooltip>
);
}
@@ -1,19 +1,19 @@
import Empty from '../state/Empty';
import { useSchedule } from './ScheduleContext';
import ScheduleItem from './ScheduleItem';
import './Schedule.scss';
interface ScheduleProps {
isProduction?: boolean;
className?: string;
}
export default function Schedule({ className }: ScheduleProps) {
export default function Schedule({ isProduction, className }: ScheduleProps) {
const { paginatedEvents, selectedEventId, isBackstage, scheduleType } = useSchedule();
// TODO: design a nice placeholder for empty schedules
if (paginatedEvents?.length < 1) {
return <Empty text='No events to show' />;
return null;
}
let selectedState: 'past' | 'now' | 'future' = 'past';
@@ -30,12 +30,16 @@ export default function Schedule({ className }: ScheduleProps) {
selectedState = 'future';
}
}
const timeStart = isProduction ? event.timeStart + (event?.delay ?? 0) : event.timeStart;
const timeEnd = isProduction ? event.timeEnd + (event?.delay ?? 0) : event.timeEnd;
return (
<ScheduleItem
key={event.id}
selected={selectedState}
timeStart={event.timeStart}
timeEnd={event.timeEnd}
timeStart={timeStart}
timeEnd={timeEnd}
title={event.title}
colour={isBackstage ? event.colour : ''}
backstageEvent={!event.isPublic}
@@ -1,7 +1,9 @@
import { createContext, PropsWithChildren, useContext, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { OntimeEvent } from 'ontime-types';
import { useInterval } from '../../hooks/useInterval';
import { isStringBoolean } from '../../utils/viewUtils';
interface ScheduleContextState {
events: OntimeEvent[];
@@ -32,12 +34,25 @@ export const ScheduleProvider = ({
time = 10,
}: PropsWithChildren<ScheduleProviderProps>) => {
const [visiblePage, setVisiblePage] = useState(0);
const [searchParams] = useSearchParams();
const numPages = Math.ceil(events.length / eventsPerPage);
// look for overrides from views
const hidePast = isStringBoolean(searchParams.get('hidePast'));
const stopCycle = isStringBoolean(searchParams.get('stopCycle'));
let selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
const viewEvents = [...events];
if (hidePast) {
// we want to show the event after the next
viewEvents.splice(0, selectedEventIndex + 2);
selectedEventIndex = 0;
}
const numPages = Math.ceil(viewEvents.length / eventsPerPage);
const eventStart = eventsPerPage * visiblePage;
const eventEnd = eventsPerPage * (visiblePage + 1);
const paginatedEvents = events.slice(eventStart, eventEnd);
const selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
const paginatedEvents = viewEvents.slice(eventStart, eventEnd);
const resolveScheduleType = () => {
if (selectedEventIndex >= eventStart && selectedEventIndex < eventEnd) {
@@ -52,7 +67,9 @@ export const ScheduleProvider = ({
// every SCROLL_TIME go to the next array
useInterval(() => {
if (events.length > eventsPerPage) {
if (stopCycle) {
setVisiblePage(0);
} else if (events.length > eventsPerPage) {
const next = (visiblePage + 1) % numPages;
setVisiblePage(next);
}
@@ -1,4 +1,5 @@
import { CSSProperties } from 'react';
import { ReactComponent as Emptyimage } from '@/assets/images/empty.svg';
import style from './Empty.module.scss';
@@ -2,7 +2,7 @@
@use '../../../theme/ontimeColours' as *;
.drawerContent {
background-color: $gray-1200;
background-color: $gray-1250;
}
.drawerHeader {
@@ -1,3 +1,5 @@
import { UserFields } from 'ontime-types';
import { ParamField } from './types';
export const TIME_FORMAT_OPTION: ParamField = {
@@ -130,7 +132,7 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
{
id: 'hideovertime',
title: 'Hide Overtime',
description: 'Whether to supress overtime styles (red borders and red text)',
description: 'Whether to suppress overtime styles (red borders and red text)',
type: 'boolean',
},
{
@@ -192,6 +194,37 @@ export const LOWER_THIRDS_OPTIONS: ParamField[] = [
},
];
export const BACKSTAGE_OPTIONS: ParamField[] = [
TIME_FORMAT_OPTION,
{
id: 'hidePast',
title: 'Hide past events',
description: 'Scheduler will only show upcoming events',
type: 'boolean',
},
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
description: 'Schedule will not auto-cycle through events',
type: 'boolean',
},
];
export const PUBLIC_OPTIONS: ParamField[] = [
TIME_FORMAT_OPTION,
{
id: 'hidePast',
title: 'Hide past events',
description: 'Scheduler will only show upcoming events',
type: 'boolean',
},
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
description: 'Schedule will not auto-cycle through events',
type: 'boolean',
},
];
export const STUDIO_CLOCK_OPTIONS: ParamField[] = [
TIME_FORMAT_OPTION,
{
@@ -201,3 +234,61 @@ export const STUDIO_CLOCK_OPTIONS: ParamField[] = [
type: 'boolean',
},
];
export const getOperatorOptions = (userFields: UserFields): ParamField[] => {
return [
TIME_FORMAT_OPTION,
{
id: 'showseconds',
title: 'Show seconds',
description: 'Schedule shows hh:mm:ss',
type: 'boolean',
},
{
id: 'hidepast',
title: 'Hide Past Events',
description: 'Whether to events that have passed',
type: 'boolean',
},
{
id: 'main',
title: 'Main data field',
description: 'Field to be shown in the first line of text',
type: 'option',
values: {
title: 'Title',
subtitle: 'Subtitle',
presenter: 'Presenter',
},
},
{
id: 'secondary',
title: 'Secondary data field',
description: 'Field to be shown in the second line of text',
type: 'option',
values: {
title: 'Title',
subtitle: 'Subtitle',
presenter: 'Presenter',
},
},
{
id: 'subscribe',
title: 'Highlight Field',
description: 'Choose a field to highlight',
type: 'option',
values: {
user0: userFields.user0 || 'user0',
user1: userFields.user1 || 'user1',
user2: userFields.user2 || 'user2',
user3: userFields.user3 || 'user3',
user4: userFields.user4 || 'user4',
user5: userFields.user5 || 'user5',
user6: userFields.user6 || 'user6',
user7: userFields.user7 || 'user7',
user8: userFields.user8 || 'user8',
user9: userFields.user9 || 'user9',
},
},
];
};
@@ -1,76 +0,0 @@
// logic (with some modifications) culled from:
// https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx
import { createContext, ReactNode, useState } from 'react';
import { Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/react';
import { IconType } from '@react-icons/all-files';
import style from './ContextMenuContext.module.scss';
type ContextMenuCoords = {
x: number;
y: number;
};
type ContextMenuContextType = {
createContextMenu: (options: Option[], menuCoordinates: ContextMenuCoords) => void;
};
export const ContextMenuContext = createContext<ContextMenuContextType | null>(null);
export type Option = {
label: string;
icon: IconType;
onClick: () => void;
};
interface ContextMenuProviderProps {
children: ReactNode;
}
export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
const [isOpen, setIsOpen] = useState(false);
const [coords, setCoords] = useState<ContextMenuCoords>({ x: 0, y: 0 });
const [options, setOptions] = useState<Option[]>([]);
const onClose = () => {
return setIsOpen(false);
};
const createContextMenu = (options: Option[], menuCoords: ContextMenuCoords) => {
setCoords(menuCoords);
setOptions(options);
setIsOpen(true);
};
return (
<ContextMenuContext.Provider value={{ createContextMenu }}>
{children}
{isOpen && (
<>
<div className={style.contextMenuBackdrop} />
<Menu isOpen gutter={0} onClose={onClose} isLazy lazyBehavior='unmount' variant='ontime-on-dark'>
<MenuButton
className={style.contextMenuButton}
aria-hidden
w={1}
h={1}
style={{
left: coords.x,
top: coords.y,
}}
/>
<MenuList>
{options.map(({ label, icon: Icon, onClick }, i) => (
<MenuItem key={i} icon={<Icon />} onClick={onClick}>
{label}
</MenuItem>
))}
</MenuList>
</Menu>
</>
)}
</ContextMenuContext.Provider>
);
};
@@ -0,0 +1,32 @@
// roughly from https://github.com/juliencrn/usehooks-ts/blob/master/packages/usehooks-ts/src/useMediaQuery/useMediaQuery.ts
import { useCallback, useEffect, useState } from 'react';
function getMatches(query: string): boolean {
return window.matchMedia(query).matches;
}
// TODO: debounce handleChange
export default function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState<boolean>(getMatches(query));
const handleChange = useCallback(() => {
setMatches(getMatches(query));
}, [query]);
useEffect(() => {
const matchMedia = window.matchMedia(query);
// Triggered at the first client-side load and if query changes
handleChange();
// Listen matchMedia
matchMedia.addEventListener('change', handleChange);
return () => {
matchMedia.removeEventListener('change', handleChange);
};
}, [handleChange, query]);
return matches;
}
@@ -1,15 +1,15 @@
import { useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { EVENT_DATA } from '../api/apiConstants';
import { fetchEventData } from '../api/eventDataApi';
import { eventDataPlaceholder } from '../models/EventData';
import { PROJECT_DATA } from '../api/apiConstants';
import { getProjectData } from '../api/projectDataApi';
import { projectDataPlaceholder } from '../models/ProjectData';
export default function useEventData() {
export default function useProjectData() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: EVENT_DATA,
queryFn: fetchEventData,
placeholderData: eventDataPlaceholder,
queryKey: PROJECT_DATA,
queryFn: getProjectData,
placeholderData: projectDataPlaceholder,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
@@ -1,22 +1,16 @@
import { MouseEvent, useContext } from 'react';
import { MouseEvent } from 'react';
import { ContextMenuContext, Option } from '../context/ContextMenuContext';
import { Option, useContextMenuStore } from '../components/context-menu/ContextMenu';
export const useContextMenu = <T extends HTMLElement>(options: Option[]) => {
const contextMenuContext = useContext(ContextMenuContext);
if (contextMenuContext === null) {
throw new Error('useContextMenu should be wrapped by ContextMenuProvider');
}
const { createContextMenu } = contextMenuContext;
const { setContextMenu } = useContextMenuStore();
const localCreateContextMenu = (contextMenuEvent: MouseEvent<T, globalThis.MouseEvent>) => {
// prevent browser default context menu from showing up
contextMenuEvent.preventDefault();
const { pageX, pageY } = contextMenuEvent;
return createContextMenu(options, { x: pageX, y: pageY });
return setContextMenu({ x: pageX, y: pageY }, options);
};
return [localCreateContextMenu];
+86 -12
View File
@@ -1,6 +1,7 @@
import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { OntimeRundown, OntimeRundownEntry, SupportedEvent } from 'ontime-types';
import { isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
import { getCueCandidate, swapOntimeEvents } from 'ontime-utils';
import { RUNDOWN_TABLE, RUNDOWN_TABLE_KEY } from '../api/apiConstants';
import { logAxiosError } from '../api/apiUtils';
@@ -9,9 +10,11 @@ import {
requestApplyDelay,
requestDelete,
requestDeleteAll,
requestEventSwap,
requestPostEvent,
requestPutEvent,
requestReorderEvent,
SwapEntry,
} from '../api/eventsApi';
import { useEditorSettings } from '../stores/editorSettings';
@@ -28,9 +31,10 @@ export const useEventAction = () => {
* Calls mutation to add new event
* @private
*/
const _addEventMutation = useMutation(requestPostEvent, {
const _addEventMutation = useMutation({
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
mutationFn: requestPostEvent,
onSettled: () => {
queryClient.invalidateQueries(RUNDOWN_TABLE);
},
@@ -55,7 +59,7 @@ export const useEventAction = () => {
const newEvent: Partial<OntimeRundownEntry> = { ...event };
// ************* CHECK OPTIONS specific to events
if (newEvent.type === SupportedEvent.Event) {
if (isOntimeEvent(newEvent)) {
const applicationOptions = {
defaultPublic: options?.defaultPublic ?? defaultPublic,
startTimeIsLastEnd: options?.startTimeIsLastEnd ?? startTimeIsLastEnd,
@@ -63,16 +67,20 @@ export const useEventAction = () => {
after: options?.after,
};
if (newEvent?.cue === undefined) {
newEvent.cue = getCueCandidate(queryClient.getQueryData(RUNDOWN_TABLE) || [], options?.after);
}
// hard coding duration value to be as expected for now
// this until timeOptions gets implemented
if (typeof newEvent?.timeStart !== 'undefined' && typeof newEvent.timeEnd !== 'undefined') {
if (newEvent?.timeStart !== undefined && newEvent.timeEnd !== undefined) {
newEvent.duration = Math.max(0, newEvent?.timeEnd - newEvent?.timeStart) || 0;
}
if (applicationOptions.startTimeIsLastEnd && applicationOptions?.lastEventId) {
const rundown = queryClient.getQueryData(RUNDOWN_TABLE) as OntimeRundown;
const previousEvent = rundown.find((event) => event.id === applicationOptions.lastEventId);
if (typeof previousEvent !== 'undefined' && previousEvent.type === 'event') {
if (previousEvent !== undefined && previousEvent.type === 'event') {
newEvent.timeStart = previousEvent.timeEnd;
newEvent.timeEnd = previousEvent.timeEnd;
}
@@ -102,7 +110,8 @@ export const useEventAction = () => {
* Calls mutation to update existing event
* @private
*/
const _updateEventMutation = useMutation(requestPutEvent, {
const _updateEventMutation = useMutation({
mutationFn: requestPutEvent,
// we optimistically update here
onMutate: async (newEvent) => {
// cancel ongoing queries
@@ -117,7 +126,6 @@ export const useEventAction = () => {
// Return a context with the previous and new events
return { previousEvent, newEvent };
},
// Mutation fails, rollback undoes optimist update
onError: (_error, _newEvent, context) => {
queryClient.setQueryData([RUNDOWN_TABLE_KEY, context?.newEvent.id], context?.previousEvent);
@@ -148,7 +156,8 @@ export const useEventAction = () => {
* Calls mutation to delete an event
* @private
*/
const _deleteEventMutation = useMutation(requestDelete, {
const _deleteEventMutation = useMutation({
mutationFn: requestDelete,
// we optimistically update here
onMutate: async (eventId) => {
// cancel ongoing queries
@@ -196,7 +205,8 @@ export const useEventAction = () => {
* Calls mutation to delete all events
* @private
*/
const _deleteAllEventsMutation = useMutation(requestDeleteAll, {
const _deleteAllEventsMutation = useMutation({
mutationFn: requestDeleteAll,
// we optimistically update here
onMutate: async () => {
// cancel ongoing queries
@@ -239,7 +249,8 @@ export const useEventAction = () => {
* Calls mutation to apply a delay
* @private
*/
const _applyDelayMutation = useMutation(requestApplyDelay, {
const _applyDelayMutation = useMutation({
mutationFn: requestApplyDelay,
// Mutation finished, failed or successful
onSettled: () => {
queryClient.invalidateQueries(RUNDOWN_TABLE);
@@ -265,7 +276,8 @@ export const useEventAction = () => {
* Calls mutation to reorder an event
* @private
*/
const _reorderEventMutation = useMutation(requestReorderEvent, {
const _reorderEventMutation = useMutation({
mutationFn: requestReorderEvent,
// we optimistically update here
onMutate: async (data) => {
// cancel ongoing queries
@@ -316,5 +328,67 @@ export const useEventAction = () => {
[_reorderEventMutation],
);
return { addEvent, updateEvent, deleteEvent, deleteAllEvents, applyDelay, reorderEvent };
/**
* Calls mutation to swap events
* @private
*/
const _swapEvents = useMutation({
mutationFn: requestEventSwap,
// we optimistically update here
onMutate: async ({ from, to }) => {
// cancel ongoing queries
await queryClient.cancelQueries(RUNDOWN_TABLE, { exact: true });
// Snapshot the previous value
const rundown = queryClient.getQueryData(RUNDOWN_TABLE) as OntimeRundown;
const fromEventIndex = rundown.findIndex((event) => event.id === from);
const toEventIndex = rundown.findIndex((event) => event.id === to);
const previousEvents = swapOntimeEvents(rundown, fromEventIndex, toEventIndex);
// optimistically update object
queryClient.setQueryData(RUNDOWN_TABLE, previousEvents);
// Return a context with the previous events
return { previousEvents };
},
// Mutation fails, rollback undoes optimist update
onError: (_error, _eventId, context) => {
queryClient.setQueryData(RUNDOWN_TABLE, context?.previousEvents);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: () => {
queryClient.invalidateQueries(RUNDOWN_TABLE);
},
networkMode: 'always',
});
/**
* Swaps the schedule of two events
*/
const swapEvents = useCallback(
async ({ from, to }: SwapEntry) => {
// TODO: before calling `/swapEvents`,
// we should determine the events are of type `OntimeEvent`
try {
await _swapEvents.mutateAsync({ from, to });
} catch (error) {
logAxiosError('Error re-ordering event', error);
}
},
[_swapEvents],
);
return {
addEvent,
updateEvent,
deleteEvent,
deleteAllEvents,
applyDelay,
reorderEvent,
swapEvents,
};
};
@@ -0,0 +1,63 @@
import { MutableRefObject, useCallback, useEffect } from 'react';
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
componentRef: MutableRefObject<ComponentRef>,
scrollRef: MutableRefObject<ScrollRef>,
topOffset: number,
) {
if (!componentRef.current || !scrollRef.current) {
return;
}
const componentRect = componentRef.current.getBoundingClientRect();
const scrollRect = scrollRef.current.getBoundingClientRect();
const top = componentRect.top - scrollRect.top + scrollRef.current.scrollTop - topOffset;
scrollRef.current.scrollTo({ top, behavior: 'smooth' });
}
interface UseFollowComponentProps {
followRef: MutableRefObject<HTMLElement | null>;
scrollRef: MutableRefObject<HTMLElement | null>;
doFollow: boolean;
topOffset?: number;
setScrollFlag?: () => void;
}
export default function useFollowComponent(props: UseFollowComponentProps) {
const { followRef, scrollRef, doFollow, topOffset = 100, setScrollFlag } = props;
// when cursor moves, view should follow
useEffect(() => {
if (!doFollow) {
return;
}
if (followRef.current && scrollRef.current) {
// Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => {
setScrollFlag?.();
scrollToComponent(
followRef as MutableRefObject<HTMLElement>,
scrollRef as MutableRefObject<HTMLElement>,
topOffset,
);
});
}
// eslint-disable-next-line -- the prompt seems incorrect
}, [followRef?.current, scrollRef?.current]);
const scrollToRefComponent = useCallback(
(componentRef = followRef, containerRef = scrollRef, offset = topOffset) => {
if (componentRef.current && containerRef.current) {
// @ts-expect-error -- we know this are not null
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
scrollToComponent(componentRef!, scrollRef!, offset);
}
},
[followRef, scrollRef, topOffset],
);
return scrollToRefComponent;
}
@@ -42,7 +42,7 @@ export default function useFullscreen() {
});
} else if (element.webkitRequestFullscreen) {
// iOS Safari fullscreen API is supported
element.webkitRequestFullscreen().catch(() => {
element.webkitRequestFullscreen?.().catch(() => {
/* nothing to do */
});
}
@@ -55,7 +55,7 @@ export default function useFullscreen() {
});
} else if ((document as WebkitDocument).webkitExitFullscreen) {
// iOS Safari fullscreen API is supported
(document as WebkitDocument).webkitExitFullscreen().catch(() => {
(document as WebkitDocument).webkitExitFullscreen?.().catch(() => {
/* nothing to do */
});
}
+12 -2
View File
@@ -13,6 +13,15 @@ export const useRundownEditor = () => {
return useRuntimeStore(featureSelector, deepCompare);
};
export const useOperator = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.playback,
selectedEventId: state.loaded.selectedEventId,
});
return useRuntimeStore(featureSelector, deepCompare);
};
export const useMessageControl = () => {
const featureSelector = (state: RuntimeStore) => ({
timerMessage: state.timerMessage,
@@ -70,7 +79,8 @@ export const setPlayback = {
export const useInfoPanel = () => {
const featureSelector = (state: RuntimeStore) => ({
titles: state.titles,
eventNow: state.eventNow,
eventNext: state.eventNext,
playback: state.playback,
selectedEventIndex: state.loaded.selectedEventIndex,
numEvents: state.loaded.numEvents,
@@ -85,7 +95,7 @@ export const useCuesheet = () => {
selectedEventId: state.loaded.selectedEventId,
selectedEventIndex: state.loaded.selectedEventIndex,
numEvents: state.loaded.numEvents,
titleNow: state.titles.titleNow,
titleNow: state.eventNow?.title || '',
});
return useRuntimeStore(featureSelector, deepCompare);
@@ -1,6 +1,6 @@
import { EventData } from 'ontime-types';
import { ProjectData } from 'ontime-types';
export const eventDataPlaceholder: EventData = {
export const projectDataPlaceholder: ProjectData = {
title: '',
description: '',
publicUrl: '',
+4 -20
View File
@@ -42,26 +42,10 @@ export const runtimeStorePlaceholder = {
nextEventId: null,
nextPublicEventId: null,
},
titles: {
titleNow: null,
subtitleNow: null,
presenterNow: null,
noteNow: null,
titleNext: null,
subtitleNext: null,
presenterNext: null,
noteNext: null,
},
titlesPublic: {
titleNow: null,
subtitleNow: null,
presenterNow: null,
noteNow: null,
titleNext: null,
subtitleNext: null,
presenterNext: null,
noteNext: null,
},
eventNow: null,
eventNext: null,
publicEventNow: null,
publicEventNext: null,
};
export const runtime = createStore<RuntimeStore>(() => ({
@@ -1,536 +0,0 @@
import { formatEventList, getEventsWithDelay, trimRundown } from '../eventsManager';
describe('getEventsWithDelay function', () => {
test('with positive delays', () => {
const testData = [
{
title: 'Welcome to Ontime',
timeStart: 28800000,
timeEnd: 30600000,
colour: '',
type: 'event',
id: '5946',
},
{
duration: 60000,
type: 'delay',
id: '24240',
},
{
title: 'Unless recalled by the OSC address',
timeStart: 34920000,
timeEnd: 35520000,
colour: '',
type: 'event',
id: '8ee5',
},
{
title: 'Use simpler times to create a timer',
timeStart: 120000,
timeEnd: 720000,
colour: '',
type: 'event',
id: '8222',
},
{
duration: 900000,
type: 'delay',
revision: 0,
id: 'a386',
},
{
title: 'Add delay blocks to affect all events',
timeStart: 37320000,
timeEnd: 38520000,
colour: '',
type: 'event',
id: '6dce',
},
{
title: 'Add and remove events with [+] and [-]',
timeStart: 38520000,
timeEnd: 45120000,
colour: '',
type: 'event',
id: '2651',
},
{
type: 'block',
id: 'e6a1',
},
{
title: 'And control whether they are public',
timeStart: 46800000,
timeEnd: 57600000,
colour: '',
type: 'event',
id: '1358',
},
];
const expected = [
{
title: 'Welcome to Ontime',
timeStart: 28800000,
timeEnd: 30600000,
colour: '',
type: 'event',
id: '5946',
},
{
title: 'Unless recalled by the OSC address',
timeStart: 34920000 + 60000,
timeEnd: 35520000 + 60000,
colour: '',
type: 'event',
id: '8ee5',
},
{
title: 'Use simpler times to create a timer',
timeStart: 120000 + 60000,
timeEnd: 720000 + 60000,
colour: '',
type: 'event',
id: '8222',
},
{
title: 'Add delay blocks to affect all events',
timeStart: 37320000 + 60000 + 900000,
timeEnd: 38520000 + 60000 + 900000,
colour: '',
type: 'event',
id: '6dce',
},
{
title: 'Add and remove events with [+] and [-]',
timeStart: 38520000 + 60000 + 900000,
timeEnd: 45120000 + 60000 + 900000,
colour: '',
type: 'event',
id: '2651',
},
{
title: 'And control whether they are public',
timeStart: 46800000,
timeEnd: 57600000,
colour: '',
type: 'event',
id: '1358',
},
];
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
});
test('with negative delays', () => {
const testData = [
{
duration: -20,
type: 'delay',
id: '24240',
},
{
title: 'Welcome to Ontime',
timeStart: 100,
timeEnd: 200,
colour: '',
type: 'event',
id: '5946',
},
];
const expected = [
{
title: 'Welcome to Ontime',
timeStart: 80,
timeEnd: 180,
colour: '',
type: 'event',
id: '5946',
},
];
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
});
});
describe('getEventsWithDelay edge cases', () => {
it('ensures time start cannot be below 0', () => {
const testData = [
{
duration: -200,
type: 'delay',
id: '24240',
},
{
title: 'Welcome to Ontime',
timeStart: 10,
timeEnd: 20,
colour: '',
type: 'event',
id: '5946',
},
];
const expected = [
{
title: 'Welcome to Ontime',
timeStart: 0,
timeEnd: 0,
colour: '',
type: 'event',
id: '5946',
},
];
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
});
it('does not modify original array', () => {
const testData = [
{
duration: 10,
type: 'delay',
id: '24240',
},
{
title: 'Welcome to Ontime',
timeStart: 10,
timeEnd: 20,
colour: '',
type: 'event',
id: '5946',
},
];
const expected = [
{
title: 'Welcome to Ontime',
timeStart: 20,
timeEnd: 30,
colour: '',
type: 'event',
id: '5946',
},
];
const expectedSafe = [
{
title: 'Welcome to Ontime',
timeStart: 20,
timeEnd: 30,
colour: '',
type: 'event',
id: '5946',
},
];
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
expect(getEventsWithDelay(expectedSafe)).toStrictEqual(expected);
});
it('given an empty array', () => {
const emptyArray = {
test: [],
expect: [],
};
expect(getEventsWithDelay(emptyArray.test)).toStrictEqual(emptyArray.expect);
});
it('given an undefined object', () => {
const withUndefined = {
test: undefined,
expect: [],
};
expect(getEventsWithDelay(withUndefined.test)).toStrictEqual(withUndefined.expect);
});
it('given a corrupted event object', () => {
const testData = [
{
title: 'Welcome to Ontime',
timeEnd: 30600000,
colour: '',
type: 'event',
id: '5946',
},
{
duration: 60000,
type: 'delay',
id: '24240',
},
{
title: 'Unless recalled by the OSC address',
timeStart: 34920000,
timeEnd: 35520000,
colour: '',
type: 'event',
id: '8ee5',
},
];
const expected = [
{
title: 'Welcome to Ontime',
timeEnd: 30600000,
colour: '',
type: 'event',
id: '5946',
},
{
title: 'Unless recalled by the OSC address',
timeStart: 34920000 + 60000,
timeEnd: 35520000 + 60000,
colour: '',
type: 'event',
id: '8ee5',
},
];
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
});
it('given a corrupted delay object', () => {
const testData = [
{
title: 'Welcome to Ontime',
timeStart: 28800000,
timeEnd: 30600000,
colour: '',
type: 'event',
id: '5946',
},
{
type: 'delay',
id: '24240',
},
{
title: 'Unless recalled by the OSC address',
timeStart: 34920000,
timeEnd: 35520000,
colour: '',
type: 'event',
id: '8ee5',
},
];
const expected = [
{
title: 'Welcome to Ontime',
timeStart: 28800000,
timeEnd: 30600000,
colour: '',
type: 'event',
id: '5946',
},
{
title: 'Unless recalled by the OSC address',
timeStart: 34920000,
timeEnd: 35520000,
colour: '',
type: 'event',
id: '8ee5',
},
];
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
});
});
describe('test trimEventlist function', () => {
const limit = 8;
const testData = [
{ id: '1' },
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
{ id: '9' },
{ id: '10' },
{ id: '11' },
{ id: '12' },
];
it('when we use the first item', () => {
const selectedId = '1';
const expected = [
{ id: '1' },
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
];
const l = trimRundown(testData, selectedId, limit);
expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected);
});
it('when we use the third item', () => {
const selectedId = '3';
const expected = [
{ id: '1' },
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
];
const l = trimRundown(testData, selectedId, limit);
expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected);
});
it('when we use the fourth item', () => {
const selectedId = '4';
const expected = [
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
{ id: '9' },
];
const l = trimRundown(testData, selectedId, limit);
expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected);
});
it('if selected is not found', () => {
const selectedId = '15';
const expected = [
{ id: '1' },
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
];
const l = trimRundown(testData, selectedId, limit);
expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected);
});
});
describe('test formatEvents function', () => {
const testEvent = [
{
title: 'Welcome to Ontime',
subtitle: 'Subtitles are useful',
presenter: 'cpvalente',
note: 'Maybe a running note for the operator?',
timeStart: 28800000,
timeEnd: 30600000,
isPublic: false,
colour: '',
type: 'event',
revision: 0,
id: '5946',
},
{
title: 'Unless recalled by the OSC address',
subtitle: '',
presenter: '',
note: 'In green, below',
timeStart: 34800000,
timeEnd: 35400000,
isPublic: false,
colour: '',
type: 'event',
revision: 0,
id: '8ee5',
},
];
it('it parses correctly', () => {
const selectedId = 'otherEvent';
const nextId = 'notHere';
const expected = [
{
id: '5946',
time: '08:00 - 08:30',
title: 'Welcome to Ontime',
isNow: false,
isNext: false,
colour: '',
},
{
id: '8ee5',
time: '09:40 - 09:50',
title: 'Unless recalled by the OSC address',
isNow: false,
isNext: false,
colour: '',
},
];
const parsed = formatEventList(testEvent, selectedId, nextId, { showEnd: true });
expect(parsed).toStrictEqual(expected);
});
it('it handles selected correctly', () => {
const selectedId = '5946';
const nextId = '8ee5';
const expected = [
{
id: '5946',
time: '08:00 - 08:30',
title: 'Welcome to Ontime',
isNow: true,
isNext: false,
colour: '',
},
{
id: '8ee5',
time: '09:40 - 09:50',
title: 'Unless recalled by the OSC address',
isNow: false,
isNext: true,
colour: '',
},
];
const parsed = formatEventList(testEvent, selectedId, nextId, { showEnd: true });
expect(parsed).toStrictEqual(expected);
});
it('it handles next correctly', () => {
const selectedId = '8ee5';
const nextId = 'notHere';
const expected = [
{
id: '5946',
time: '08:00 - 08:30',
title: 'Welcome to Ontime',
isNow: false,
isNext: false,
colour: '',
},
{
id: '8ee5',
time: '09:40 - 09:50',
title: 'Unless recalled by the OSC address',
isNow: true,
isNext: false,
colour: '',
},
];
const parsed = formatEventList(testEvent, selectedId, nextId, { showEnd: true });
expect(parsed).toStrictEqual(expected);
});
});
@@ -0,0 +1,55 @@
import { EndAction, OntimeEvent, SupportedEvent, TimerType } from 'ontime-types';
import { cloneEvent } from '../eventsManager';
describe('cloneEvent()', () => {
it('creates a stem from a given event', () => {
const original = {
id: 'unique',
type: SupportedEvent.Event,
title: 'title',
cue: 'cue',
subtitle: 'subtitle',
presenter: 'presenter',
note: 'note',
timeStart: 0,
duration: 10,
timeEnd: 10,
timerType: TimerType.CountDown,
endAction: EndAction.None,
isPublic: false,
skip: false,
colour: 'F00',
revision: 10,
user0: 'user0',
user1: 'user1',
user2: 'user2',
user3: 'user3',
user4: 'user4',
user5: 'user5',
user6: 'user6',
user7: 'user7',
user8: 'user8',
user9: 'user9',
} as OntimeEvent;
const cloned = cloneEvent(original);
expect(cloned).not.toBe(original);
// @ts-expect-error -- safeguarding this
expect(cloned?.id).toBe(undefined);
expect(cloned.title).toBe(original.title);
expect(cloned.subtitle).toBe(original.subtitle);
expect(cloned.presenter).toBe(original.presenter);
expect(cloned.note).toBe(original.note);
expect(cloned.endAction).toBe(original.endAction);
expect(cloned.timerType).toBe(original.timerType);
expect(cloned.timeStart).toBe(original.timeStart);
expect(cloned.timeEnd).toBe(original.timeEnd);
expect(cloned.duration).toBe(original.duration);
expect(cloned.isPublic).toBe(original.isPublic);
expect(cloned.skip).toBe(original.skip);
expect(cloned.colour).toBe(original.colour);
expect(cloned.type).toBe(SupportedEvent.Event);
expect(cloned.revision).toBe(0);
});
});
@@ -1,56 +0,0 @@
import getDelayTo from '../getDelayTo';
describe('getDelayTo function', () => {
it('handles list with delays', () => {
const delayDuration = 100;
const events = [
{ type: 'event' },
{ type: 'delay', duration: delayDuration },
{ type: 'event' },
];
const notDelayed = getDelayTo(events, 0);
expect(notDelayed).toBe(0);
const delayedEvent = getDelayTo(events, 2);
expect(delayedEvent).toBe(delayDuration);
});
it('handles list without delays', () => {
const events = [{ type: 'event' }, { type: 'event' }];
const notDelayed = getDelayTo(events, 1);
expect(notDelayed).toBe(0);
});
it('handles list with multiple delays', () => {
const delayDuration = 100;
const events = [
{ type: 'event' },
{ type: 'delay', duration: delayDuration },
{ type: 'event' },
{ type: 'delay', duration: delayDuration },
{ type: 'event' },
];
const doubleDelay = getDelayTo(events, 4);
expect(doubleDelay).toBe(delayDuration * 2);
});
it('handles list with blocks', () => {
const events = [
{ type: 'event' },
{ type: 'delay', duration: 100 },
{ type: 'event' },
{ type: 'block' },
{ type: 'event' },
];
const notDelayed = getDelayTo(events, 4);
expect(notDelayed).toBe(0);
});
it('handles index greater than list', () => {
const events = [{ type: 'event' }, { type: 'delay', duration: 100 }, { type: 'event' }];
const notDelayed = getDelayTo(events, 3);
expect(notDelayed).toBe(0);
});
it('handles negative index (not found)', () => {
const events = [{ type: 'event' }, { type: 'delay', duration: 100 }, { type: 'event' }];
const notDelayed = getDelayTo(events, -1);
expect(notDelayed).toBe(0);
});
});
+2 -2
View File
@@ -1,6 +1,6 @@
import { Alias } from 'ontime-types';
import isEqual from 'react-fast-compare';
import { Location, resolvePath } from 'react-router-dom';
import { Alias } from 'ontime-types';
/**
* Validates an alias against defined parameters
@@ -47,7 +47,7 @@ export const getAliasRoute = (location: Location, data: Alias[], searchParams: U
const aliasOnPage = searchParams.get('alias');
for (const d of data) {
if (aliasOnPage) {
// if the alias fits the alias on this page, but the URL is diferent, we redirect user to the new URL
// if the alias fits the alias on this page, but the URL is different, we redirect user to the new URL
// if we have the same alias and its enabled and its not empty
if (d.alias !== '' && d.enabled && d.alias === aliasOnPage) {
const newAliasPath = resolvePath(d.pathAndParams);
+12
View File
@@ -0,0 +1,12 @@
export function debounce(callback: () => void, wait: number) {
let timeout: NodeJS.Timeout | null;
return () => {
if (timeout) {
return;
}
timeout = setTimeout(() => {
timeout = null;
callback();
}, wait);
};
}
@@ -0,0 +1,6 @@
export function isMacOS() {
const userAgent = navigator.userAgent.toLowerCase();
return userAgent.includes('macintosh') || userAgent.includes('mac os');
}
export const deviceAlt = isMacOS() ? '⌥' : 'Alt';
+13 -155
View File
@@ -1,174 +1,32 @@
import { OntimeEvent, OntimeRundownEntry, SupportedEvent } from 'ontime-types';
import { formatTime } from './time';
/**
* @description From a list of events, returns only events of type event with calculated delays
* @param {Object[]} rundown - given rundown
* @returns {Object[]} Filtered events with calculated delays
*/
export const getEventsWithDelay = (rundown: OntimeRundownEntry[]): OntimeEvent[] => {
if (rundown == null) return [];
const delayedEvents: OntimeEvent[] = [];
// Add running delay
let delay = 0;
for (const event of rundown) {
if (event.type === SupportedEvent.Block) delay = 0;
else if (event.type === SupportedEvent.Delay) {
if (typeof event.duration === 'number') {
delay += event.duration;
}
} else if (event.type === SupportedEvent.Event) {
const delayedEvent = { ...event };
if (delay !== 0) {
delayedEvent.timeStart = Math.max(delayedEvent.timeStart + delay, 0);
delayedEvent.timeEnd = Math.max(delayedEvent.timeEnd + delay, 0);
}
delayedEvents.push(delayedEvent);
}
}
return delayedEvents;
};
/**
* @description Returns trimmed event list array
* @param {Object[]} rundown - given rundown
* @param {string} selectedId - id of currently selected event
* @param {number} limit - max number of events to return
* @returns {Object[]} Event list with maximum <limit> objects
*/
export const trimRundown = (rundown: OntimeEvent[], selectedId: string, limit: number): OntimeEvent[] => {
if (rundown == null) return [];
const BEFORE = 2;
const trimmedRundown = [...rundown];
// limit events length if necessary
if (limit != null) {
while (trimmedRundown.length > limit) {
const idx = trimmedRundown.findIndex((e) => e.id === selectedId);
if (idx <= BEFORE) {
trimmedRundown.pop();
} else {
trimmedRundown.shift();
}
}
}
return trimmedRundown;
};
type FormatEventListOptionsProp = {
showEnd?: boolean;
};
/**
* @description Returns list of events formatted to be displayed
* @param {Object[]} rundown - given rundown
* @param {string} selectedId - id of currently selected event
* @param {string} nextId - id of next event
* @param {object} [options]
* @param {boolean} [options.showEnd] - whether to show the end time
* @returns {Object[]} Formatted list of events [{time: -, title: -, isNow, isNext}]
*/
export const formatEventList = (
rundown: OntimeEvent[],
selectedId: string,
nextId: string,
options: FormatEventListOptionsProp,
): ScheduleEvent[] => {
if (rundown == null) return [];
const { showEnd = false } = options;
const givenEvents = [...rundown];
// format list
const formattedEvents = [];
for (const event of givenEvents) {
const start = formatTime(event.timeStart);
const end = formatTime(event.timeEnd);
formattedEvents.push({
id: event.id,
time: showEnd ? `${start} - ${end}` : start,
title: event.title,
isNow: event.id === selectedId,
isNext: event.id === nextId,
colour: event.colour,
});
}
return formattedEvents;
};
export type ScheduleEvent = {
id: string;
time: string;
title: string;
isNow: boolean;
isNext: boolean;
colour: string;
};
import { OntimeEvent, SupportedEvent } from 'ontime-types';
/**
* @description Creates a safe duplicate of an event
* @param {object} event
* @return {object} clean event
* @param {OntimeEvent} event
* @param {string} [after]
* @return {OntimeEvent} clean event
*/
type ClonedEvent = OntimeEvent | { after?: string };
type ClonedEvent = Omit<
OntimeEvent,
'id' | 'user0' | 'user1' | 'user2' | 'user3' | 'user4' | 'user5' | 'user6' | 'user7' | 'user8' | 'user9'
>;
export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => {
return {
type: SupportedEvent.Event,
title: event.title,
cue: event.cue,
subtitle: event.subtitle,
presenter: event.presenter,
note: event.note,
timeStart: event.timeStart,
duration: event.duration,
timeEnd: event.timeEnd,
timerType: event.timerType,
endAction: event.endAction,
isPublic: event.isPublic,
skip: event.skip,
colour: event.colour,
after: after,
revision: 0,
};
};
/**
* Gets first event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @return {OntimeEvent | null}
*/
export function getFirstEvent(rundown: OntimeRundownEntry[]) {
return rundown.length ? rundown[0] : null;
}
/**
* Gets next event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @param {string} currentId
* @return {OntimeEvent | null}
*/
export function getNextEvent(rundown: OntimeRundownEntry[], currentId: string) {
const index = rundown.findIndex((event) => event.id === currentId);
if (index !== -1 && index + 1 < rundown.length) {
return rundown[index + 1];
} else {
return null;
}
}
/**
* Gets previous event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @param {string} currentId
* @return {OntimeEvent | null}
*/
export function getPreviousEvent(rundown: OntimeRundownEntry[], currentId: string) {
const index = rundown.findIndex((event) => event.id === currentId);
if (index !== -1 && index - 1 >= 0) {
return rundown[index - 1];
} else {
return null;
}
}
@@ -0,0 +1,56 @@
import axios from 'axios';
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
type FileOptions = {
name: string;
type: string;
};
type BlobOptions = {
type: string;
};
export default async function fileDownload(url: string, fileOptions: FileOptions, blobOptions: BlobOptions) {
const response = await axios({
url: `${url}/db`,
method: 'GET',
});
const headerLine = response.headers['Content-Disposition'];
let { name: fileName } = fileOptions;
const { type: fileType } = fileOptions;
const { project, rundown, userFields } = response.data;
// try and get the filename from the response
if (headerLine != null) {
const startFileNameIndex = headerLine.indexOf('"') + 1;
const endFileNameIndex = headerLine.lastIndexOf('"');
fileName = headerLine.substring(startFileNameIndex, endFileNameIndex);
}
let fileContent = '';
if (fileType === 'json') {
fileContent = JSON.stringify(response.data);
fileName += '.json';
}
if (fileType === 'csv') {
const sheetData = makeTable(project, rundown, userFields);
fileContent = makeCSV(sheetData);
fileName += '.csv';
}
const blob = new Blob([fileContent], { type: blobOptions.type });
const downloadUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.setAttribute('href', downloadUrl);
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
// Clean up the URL.createObjectURL to release resources
URL.revokeObjectURL(downloadUrl);
return;
}
@@ -1,25 +0,0 @@
/**
* @description calculates delay to a given event
* @param {array} events
* @param {number} eventIndex
* @return {number} - delay value of given event
*/
export default function getDelayTo(events, eventIndex) {
let delay = 0;
let index = 0;
if (eventIndex >= 0) {
for (const event of events) {
if (eventIndex === index) {
return delay;
}
if (event.type === 'delay') {
delay += event.duration;
} else if (event.type === 'block') {
delay = 0;
}
index++;
}
}
return 0;
}
+2 -14
View File
@@ -1,6 +1,6 @@
import { Log, RuntimeStore } from 'ontime-types';
import { RUNTIME, websocketUrl } from '../api/apiConstants';
import { isProduction, RUNTIME, websocketUrl } from '../api/apiConstants';
import { ontimeQueryClient } from '../queryClient';
import { socketClientName } from '../stores/connectionName';
import { addLog } from '../stores/logger';
@@ -64,7 +64,7 @@ export const connectSocket = (preferredClientName?: string) => {
}
case 'ontime': {
runtime.setState(payload as RuntimeStore);
if (import.meta.env.DEV) {
if (!isProduction) {
ontimeQueryClient.setQueryData(RUNTIME, data.payload);
}
break;
@@ -87,18 +87,6 @@ export const connectSocket = (preferredClientName?: string) => {
runtime.setState(state);
break;
}
case 'ontime-titles': {
const state = runtime.getState();
state.titles = payload;
runtime.setState(state);
break;
}
case 'ontime-titlesPublic': {
const state = runtime.getState();
state.titlesPublic = payload;
runtime.setState(state);
break;
}
case 'ontime-timerMessage': {
const state = runtime.getState();
state.timerMessage = payload;
+6 -6
View File
@@ -3,27 +3,27 @@ import Color from 'color';
type ColourCombination = {
backgroundColor: string;
color: string;
}
};
/**
* @description Selects text colour to maintain accessible contrast
* @param bgColour
* @return {{backgroundColor, color: string}}
*/
export const getAccessibleColour = (bgColour: string): ColourCombination => {
export const getAccessibleColour = (bgColour?: string): ColourCombination => {
if (bgColour) {
try {
const textColor = Color(bgColour).isLight() ? 'black' : '#fffffa';
return { backgroundColor: bgColour, color: textColor };
} catch (error) {
console.log(`Unable to parse colour: ${bgColour}`);
} catch (_error) {
/* we do not handle errors here */
}
}
return { backgroundColor: '#000', color: "#fffffa" };
return { backgroundColor: '#000', color: '#fffffa' };
};
/**
* @description Creates a list of classnames from array of css module conditions
* @param classNames - css modules objects
*/
export const cx = (classNames: any[]) => classNames.filter(Boolean).join(" ");
export const cx = (classNames: any[]) => classNames.filter(Boolean).join(' ');
+2 -1
View File
@@ -51,6 +51,7 @@ export const formatTime = (milliseconds: number | null, options?: FormatOptions,
return '...';
}
const timeFormat = resolver();
const { showSeconds = false, format: formatString = 'hh:mm a' } = options || {};
const fallback = options?.showSeconds ? 'hh:mm:ss a' : 'hh:mm a';
const { showSeconds = false, format: formatString = fallback } = options || {};
return timeFormat === '12' ? formatFromMillis(milliseconds, formatString) : millisToString(milliseconds, showSeconds);
};
+3 -2
View File
@@ -1,8 +1,9 @@
/* eslint-disable react/display-name */
import { ComponentType, useEffect } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import useAliases from '../common/hooks-query/useAliases';
import { getAliasRoute } from '../common/utils/aliases';
import { ComponentType, useEffect } from 'react';
import { useSearchParams, useNavigate, useLocation } from 'react-router-dom';
const withAlias = <P extends object>(Component: ComponentType<P>) => {
return (props: Partial<P>) => {
@@ -42,7 +42,7 @@ export default function MessageControl() {
actionHandler={() => setMessage.presenterVisible(!data.timerMessage.visible)}
/>
<div className={style.buttonSection}>
<label className={style.label}>Timer messsage blink</label>
<label className={style.label}>Timer message blink</label>
<label className={style.label}>Blackout timer screens</label>
<Button
className={`${data.timerMessage.timerBlink ? style.blink : ''}`}
@@ -44,6 +44,11 @@ $table-header-font-size: calc(1rem - 3px);
min-width: 2rem;
text-align: right;
font-weight: 400;
position: sticky;
left: 0;
z-index: 1;
background-color: $gray-1300;
}
}
@@ -61,6 +66,11 @@ $table-header-font-size: calc(1rem - 3px);
.eventRow {
vertical-align: top;
&:hover {
outline: 1px solid $blue-700;
outline-offset: -1px;
}
td {
background-color: $gray-1250;
border-radius: 2px;
@@ -113,14 +123,6 @@ $table-header-font-size: calc(1rem - 3px);
}
}
.delaySymbol {
svg {
font-size: 1.5rem;
color: $ontime-delay;
margin: 0 auto;
}
}
.delayedTime {
color: $ontime-delay-text;
font-size: calc(1rem - 2px);
+37 -156
View File
@@ -1,33 +1,21 @@
import { MutableRefObject, useEffect, useRef } from 'react';
import { Tooltip } from '@chakra-ui/react';
import {
closestCenter,
DndContext,
DragEndEvent,
KeyboardSensor,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { horizontalListSortingStrategy, SortableContext, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import { useRef } from 'react';
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { OntimeBlock, OntimeDelay, OntimeEvent, OntimeRundown, OntimeRundownEntry, SupportedEvent } from 'ontime-types';
import Color from 'color';
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useLocalStorage } from '../../common/hooks/useLocalStorage';
import { millisToDelayString } from '../../common/utils/dateConfig';
import { getAccessibleColour } from '../../common/utils/styleUtils';
import { tooltipDelayFast } from '../../ontimeConfig';
import BlockRow from './cuesheet-table-elements/BlockRow';
import CuesheetHeader from './cuesheet-table-elements/CuesheetHeader';
import DelayRow from './cuesheet-table-elements/DelayRow';
import EventRow from './cuesheet-table-elements/EventRow';
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
import { useCuesheetSettings } from './store/CuesheetSettings';
import { SortableCell } from './tableElements/SortableCell';
import { initialColumnOrder } from './cuesheetCols';
import style from './Cuesheet.module.scss';
const pastOpacity = '0.2';
interface CuesheetProps {
data: OntimeRundown;
columns: ColumnDef<OntimeRundownEntry>[];
@@ -46,6 +34,8 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
const [columnSizing, setColumnSizing] = useLocalStorage('table-sizes', {});
const selectedRef = useRef<HTMLTableRowElement | null>(null);
const tableContainerRef = useRef<HTMLDivElement | null>(null);
useFollowComponent({ followRef: selectedRef, scrollRef: tableContainerRef, doFollow: followSelected });
const table = useReactTable({
data,
@@ -63,78 +53,6 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
onColumnSizingChange: setColumnSizing,
getCoreRowModel: getCoreRowModel(),
});
const tableContainerRef = useRef<HTMLDivElement>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
delay: 100,
tolerance: 50,
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 100,
tolerance: 50,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
// when selection moves, view should follow
useEffect(() => {
function scrollToComponent(
componentRef: MutableRefObject<HTMLTableRowElement>,
scrollRef: MutableRefObject<HTMLDivElement>,
) {
const componentRect = componentRef.current.getBoundingClientRect();
const scrollRect = scrollRef.current.getBoundingClientRect();
const top = componentRect.top - scrollRect.top + scrollRef.current.scrollTop - 100;
scrollRef.current.scrollTo({ top, behavior: 'smooth' });
}
if (!followSelected) {
return;
}
if (selectedRef.current && tableContainerRef.current) {
// Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => {
scrollToComponent(
selectedRef as MutableRefObject<HTMLTableRowElement>,
tableContainerRef as MutableRefObject<HTMLDivElement>,
);
});
}
// eslint-disable-next-line -- the prompt seems incorrect, we need the refs
}, [selectedRef.current, tableContainerRef.current, followSelected]);
const handleOnDragEnd = (event: DragEndEvent) => {
const { delta, active, over } = event;
// cancel if delta y is greater than 200
if (delta.y > 200) return;
// cancel if we do not have an over id
if (over?.id == null) return;
// get index of from
const fromIndex = columnOrder.indexOf(active.id as string);
// get index of to
const toIndex = columnOrder.indexOf(over.id as string);
if (toIndex === -1) {
return;
}
const reorderedCols = [...columnOrder];
const reorderedItem = reorderedCols.splice(fromIndex, 1);
reorderedCols.splice(toIndex, 0, reorderedItem[0]);
saveColumnOrder(reorderedCols);
};
const resetColumnOrder = () => {
saveColumnOrder(initialColumnOrder);
@@ -148,6 +66,8 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
setColumnSizing({});
};
const headerGroups = table.getHeaderGroups;
let eventIndex = 0;
let isPast = Boolean(selectedId);
@@ -163,70 +83,28 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
)}
<div ref={tableContainerRef} className={style.cuesheetContainer}>
<table className={style.cuesheet}>
<thead className={style.tableHeader}>
{table.getHeaderGroups().map((headerGroup) => {
const key = headerGroup.id;
return (
<DndContext key={key} sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleOnDragEnd}>
<tr key={headerGroup.id}>
<th className={style.indexColumn}>
<Tooltip label='Event Order' openDelay={tooltipDelayFast}>
#
</Tooltip>
</th>
<SortableContext key={key} items={headerGroup.headers} strategy={horizontalListSortingStrategy}>
{headerGroup.headers.map((header) => {
const width = header.getSize();
return (
<SortableCell key={header.column.columnDef.id} header={header} style={{ width }}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</SortableCell>
);
})}
</SortableContext>
</tr>
</DndContext>
);
})}
</thead>
<CuesheetHeader headerGroups={headerGroups} />
<tbody>
{table.getRowModel().rows.map((row) => {
const entryType = row.original.type as SupportedEvent;
const key = row.original.id;
const isSelected = selectedId === key;
if (isSelected) {
isPast = false;
}
if (entryType === SupportedEvent.Block) {
const title = (row.original as OntimeBlock).title;
return (
<tr key={key} className={style.blockRow}>
<td>{title}</td>
</tr>
);
if (isOntimeBlock(row.original)) {
return <BlockRow key={key} title={row.original.title} />;
}
if (entryType === SupportedEvent.Delay) {
const delayVal = (row.original as OntimeDelay).duration;
if (isOntimeDelay(row.original)) {
const delayVal = row.original.duration;
if (!showDelayBlock || delayVal === 0) {
return null;
}
const delayTime = millisToDelayString(delayVal);
return (
<tr key={key} className={style.delayRow}>
<td>{delayTime}</td>
</tr>
);
return <DelayRow key={key} duration={delayVal} />;
}
if (entryType === SupportedEvent.Event) {
if (isOntimeEvent(row.original)) {
eventIndex++;
const isSelected = key === selectedId;
if (isSelected) {
@@ -237,25 +115,28 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
return null;
}
const bgFallback = 'transparent';
const bgColour = (row.original as OntimeEvent).colour || bgFallback;
const textColour = bgColour === bgFallback ? undefined : getAccessibleColour(bgColour);
const isSkipped = (row.original as OntimeEvent).skip;
let rowBgColour: string | undefined;
if (row.original.id === selectedId) {
rowBgColour = '#D20300'; // $red-700
if (isSelected) {
rowBgColour = 'var(--cuesheet-running-bg-override, #D20300)'; // $red-700
} else if (row.original.colour) {
try {
// the colour is user defined and might be invalid
const colour = new Color(row.original.colour).alpha(0.25);
rowBgColour = colour.hsl().string();
} catch (_error) {
/* we do not handle errors here */
}
}
return (
<tr
<EventRow
key={key}
className={`${style.eventRow} ${isSkipped ? style.skip : ''}`}
style={{ opacity: `${isPast ? pastOpacity : '1'}` }}
ref={isSelected ? selectedRef : undefined}
eventIndex={eventIndex}
isPast={isPast}
selectedRef={isSelected ? selectedRef : undefined}
skip={row.original.skip}
colour={row.original.colour}
>
<td className={style.indexColumn} style={{ backgroundColor: bgColour, color: textColour?.color }}>
{eventIndex}
</td>
{row.getVisibleCells().map((cell) => {
return (
<td key={cell.id} style={{ width: cell.column.getSize(), backgroundColor: rowBgColour }}>
@@ -263,7 +144,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
</td>
);
})}
</tr>
</EventRow>
);
}
@@ -21,4 +21,4 @@
border: 1px solid $white-10;
border-radius: 4px;
}
}
}
@@ -1,11 +1,12 @@
import { useCallback, useEffect, useMemo } from 'react';
import { EventData, OntimeRundownEntry } from 'ontime-types';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { OntimeRundownEntry, ProjectData } from 'ontime-types';
import Empty from '../../common/components/state/Empty';
import { useEventAction } from '../../common/hooks/useEventAction';
import { useCuesheet } from '../../common/hooks/useSocket';
import useRundown from '../../common/hooks-query/useRundown';
import useUserFields from '../../common/hooks-query/useUserFields';
import ExportModal, { ExportType } from '../modals/export-modal/ExportModal';
import CuesheetTableHeader from './cuesheet-table-header/CuesheetTableHeader';
import Cuesheet from './Cuesheet';
@@ -20,6 +21,8 @@ export default function CuesheetWrapper() {
const { updateEvent } = useEventAction();
const featureData = useCuesheet();
const columns = useMemo(() => makeCuesheetColumns(userFields), [userFields]);
const [isModalOpen, setIsModalOpen] = useState(false);
const [headerData, setheaderData] = useState<ProjectData | null>(null);
// Set window title
useEffect(() => {
@@ -69,37 +72,80 @@ export default function CuesheetWrapper() {
);
const exportHandler = useCallback(
(headerData: EventData) => {
(headerData: ProjectData, exportType: ExportType) => {
if (!headerData || !rundown || !userFields) {
return;
}
const sheetData = makeTable(headerData, rundown, userFields);
const csvContent = makeCSV(sheetData);
let fileName = '';
let url = '';
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
if (exportType === 'json') {
const jsonContent = JSON.stringify({
headerData,
rundown,
userFields,
});
fileName = 'ontime export.json';
const blob = new Blob([jsonContent], { type: 'application/json;charset=utf-8;' });
url = URL.createObjectURL(blob);
} else if (exportType === 'csv') {
const sheetData = makeTable(headerData, rundown, userFields);
const csvContent = makeCSV(sheetData);
fileName = 'ontime export.csv';
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
url = URL.createObjectURL(blob);
} else {
console.error('Invalid export type: ', exportType);
return;
}
const link = document.createElement('a');
link.setAttribute('href', url);
link.setAttribute('download', 'ontime export.csv');
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
// Clean up the URL.createObjectURL to release resources
URL.revokeObjectURL(url);
return;
},
[rundown, userFields],
);
const onModalClose = (exportType?: ExportType) => {
setIsModalOpen(false);
if (!exportType) {
return;
}
if (headerData) {
exportHandler(headerData, exportType);
}
};
const handleOpenModal = (projectData: ProjectData) => {
setheaderData(projectData);
setIsModalOpen(true);
};
if (!rundown || !userFields) {
return <Empty text='Loading...' />;
}
return (
<div className={styles.tableWrapper} data-testid='cuesheet'>
<CuesheetTableHeader handleCSVExport={exportHandler} featureData={featureData} />
<CuesheetTableHeader handleExport={handleOpenModal} featureData={featureData} />
<Cuesheet data={rundown} columns={columns} handleUpdate={handleUpdate} selectedId={featureData.selectedEventId} />
<ExportModal
isOpen={isModalOpen}
onClose={onModalClose}
buttonVariants={{ csv: 'ontime-filled', json: 'ontime-ghosted' }}
/>
</div>
);
}
@@ -6,7 +6,11 @@ exports[`makeTable() > returns array of arrays with given fields 1`] = `
"Ontime · Schedule Template",
],
[
"Event Name",
"Project Title",
"",
],
[
"Project Description",
"",
],
[
@@ -26,7 +26,7 @@ describe('parseField()', () => {
});
it('returns an empty string on undefined fields', () => {
expect(parseField('presenter', undefined)).toBe('');
expect(parseField('presenter')).toBe('');
});
describe('simply returns any other value in any other field', () => {
@@ -0,0 +1,18 @@
import { memo } from 'react';
import style from '../Cuesheet.module.scss';
interface BlockRowProps {
title: string;
}
function BlockRow(props: BlockRowProps) {
const { title } = props;
return (
<tr className={style.blockRow}>
<td>{title}</td>
</tr>
);
}
export default memo(BlockRow);
@@ -0,0 +1,108 @@
import { memo } from 'react';
import { Tooltip } from '@chakra-ui/react';
import {
closestCenter,
DndContext,
DragEndEvent,
KeyboardSensor,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { horizontalListSortingStrategy, SortableContext, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import { flexRender, HeaderGroup } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types';
import { useLocalStorage } from '../../../common/hooks/useLocalStorage';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { initialColumnOrder } from '../cuesheetCols';
import { SortableCell } from './SortableCell';
import style from '../Cuesheet.module.scss';
interface CuesheetHeaderProps {
headerGroups: () => HeaderGroup<OntimeRundownEntry>[];
}
function CuesheetHeader(props: CuesheetHeaderProps) {
const { headerGroups } = props;
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>('table-order', initialColumnOrder);
const handleOnDragEnd = (event: DragEndEvent) => {
const { delta, active, over } = event;
// cancel if delta y is greater than 200
if (delta.y > 200) return;
// cancel if we do not have an over id
if (over?.id == null) return;
// get index of from
const fromIndex = columnOrder.indexOf(active.id as string);
// get index of to
const toIndex = columnOrder.indexOf(over.id as string);
if (toIndex === -1) {
return;
}
const reorderedCols = [...columnOrder];
const reorderedItem = reorderedCols.splice(fromIndex, 1);
reorderedCols.splice(toIndex, 0, reorderedItem[0]);
saveColumnOrder(reorderedCols);
};
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
delay: 100,
tolerance: 50,
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 100,
tolerance: 50,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
return (
<thead className={style.tableHeader}>
{headerGroups().map((headerGroup) => {
const key = headerGroup.id;
return (
<DndContext key={key} sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleOnDragEnd}>
<tr key={headerGroup.id}>
<th className={style.indexColumn}>
<Tooltip label='Event Order' openDelay={tooltipDelayFast}>
#
</Tooltip>
</th>
<SortableContext key={key} items={headerGroup.headers} strategy={horizontalListSortingStrategy}>
{headerGroup.headers.map((header) => {
const width = header.getSize();
return (
<SortableCell key={header.column.columnDef.id} header={header} style={{ width }}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</SortableCell>
);
})}
</SortableContext>
</tr>
</DndContext>
);
})}
</thead>
);
}
export default memo(CuesheetHeader);
@@ -0,0 +1,6 @@
interface CuesheetRowProps {
row: OntimeRundownEntry;
isSelected: boolean;
}
function CuesheetRow() {}
@@ -0,0 +1,22 @@
import { memo } from 'react';
import { millisToDelayString } from '../../../common/utils/dateConfig';
import style from '../Cuesheet.module.scss';
interface DelayRowProps {
duration: number;
}
function DelayRow(props: DelayRowProps) {
const { duration } = props;
const delayTime = millisToDelayString(duration);
return (
<tr className={style.delayRow}>
<td>{delayTime}</td>
</tr>
);
}
export default memo(DelayRow);
@@ -0,0 +1,66 @@
import { memo, MutableRefObject, PropsWithChildren, useLayoutEffect, useRef, useState } from 'react';
import { getAccessibleColour } from '../../../common/utils/styleUtils';
import style from '../Cuesheet.module.scss';
const pastOpacity = '0.2';
interface EventRowProps {
eventIndex: number;
isPast?: boolean;
selectedRef?: MutableRefObject<HTMLTableRowElement | null>;
skip?: boolean;
colour?: string;
}
function EventRow(props: PropsWithChildren<EventRowProps>) {
const { children, eventIndex, isPast, selectedRef, skip, colour } = props;
const ownRef = useRef<HTMLTableRowElement>(null);
const [isVisible, setIsVisible] = useState(false);
const bgColour = colour;
const textColour = getAccessibleColour(bgColour);
useLayoutEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
}
},
{
root: null,
threshold: 0.01,
},
);
const handleRefCurrent = ownRef.current;
if (selectedRef) {
setIsVisible(true);
} else if (handleRefCurrent) {
observer.observe(handleRefCurrent);
}
return () => {
if (handleRefCurrent) {
observer.unobserve(handleRefCurrent);
}
};
}, [ownRef, selectedRef]);
return (
<tr
className={`${style.eventRow} ${skip ? style.skip : ''}`}
style={{ opacity: `${isPast ? pastOpacity : '1'}` }}
ref={selectedRef ?? ownRef}
>
<td className={style.indexColumn} style={{ backgroundColor: bgColour, color: textColour?.color }}>
{eventIndex}
</td>
{isVisible ? children : null}
</tr>
);
}
export default memo(EventRow);
@@ -101,13 +101,13 @@ $active-colour: $gray-500;
.actionIcon {
cursor: pointer;
&:hover {
color: $active-colour;
}
&.enabled {
color: $active-indicator;
}
&:hover {
color: $active-colour;
}
}
}
@@ -3,21 +3,20 @@ import { IoContract } from '@react-icons/all-files/io5/IoContract';
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
import { IoLocate } from '@react-icons/all-files/io5/IoLocate';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { EventData, Playback } from 'ontime-types';
import { formatDisplay } from 'ontime-utils';
import { Playback, ProjectData } from 'ontime-types';
import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
import useFullscreen from '../../../common/hooks/useFullscreen';
import { useTimer } from '../../../common/hooks/useSocket';
import useEventData from '../../../common/hooks-query/useEventData';
import { formatTime } from '../../../common/utils/time';
import useProjectData from '../../../common/hooks-query/useProjectData';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { useCuesheetSettings } from '../store/CuesheetSettings';
import PlaybackIcon from '../tableElements/PlaybackIcon';
import CuesheetTableHeaderTimers from './CuesheetTableHeaderTimers';
import style from './CuesheetTableHeader.module.scss';
interface CuesheetTableHeaderProps {
handleCSVExport: (headerData: EventData) => void;
handleExport: (headerData: ProjectData) => void;
featureData: {
playback: Playback;
selectedEventIndex: number | null;
@@ -26,18 +25,17 @@ interface CuesheetTableHeaderProps {
};
}
export default function CuesheetTableHeader({ handleCSVExport, featureData }: CuesheetTableHeaderProps) {
export default function CuesheetTableHeader({ handleExport, featureData }: CuesheetTableHeaderProps) {
const followSelected = useCuesheetSettings((state) => state.followSelected);
const showSettings = useCuesheetSettings((state) => state.showSettings);
const toggleSettings = useCuesheetSettings((state) => state.toggleSettings);
const toggleFollow = useCuesheetSettings((state) => state.toggleFollow);
const timer = useTimer();
const { isFullScreen, toggleFullScreen } = useFullscreen();
const { data: event } = useEventData();
const { data: project } = useProjectData();
const exportCsv = () => {
if (event) {
handleCSVExport(event);
const exportProject = () => {
if (project) {
handleExport(project);
}
};
@@ -47,32 +45,17 @@ export default function CuesheetTableHeader({ handleCSVExport, featureData }: Cu
featureData.numEvents ? featureData.numEvents : '-'
}`;
// prepare presentation variables
const isOvertime = (timer.current ?? 0) < 0;
const timerNow = timer.current == null ? '-' : `${isOvertime ? '-' : ''}${formatDisplay(timer.current)}`;
const timeNow = formatTime(timer.clock, {
showSeconds: true,
format: 'hh:mm:ss a',
});
return (
<div className={style.header}>
<div className={style.event}>
<div className={style.title}>{event?.title || '-'}</div>
<div className={style.title}>{project?.title || '-'}</div>
<div className={style.eventNow}>{featureData?.titleNow || '-'}</div>
</div>
<div className={style.playback}>
<div className={style.playbackLabel}>{selected}</div>
<PlaybackIcon state={featureData.playback} />
</div>
<div className={style.timer}>
<div className={style.timerLabel}>Running Timer</div>
<div className={style.value}>{timerNow}</div>
</div>
<div className={style.clock}>
<div className={style.clockLabel}>Time Now</div>
<div className={style.value}>{timeNow}</div>
</div>
<CuesheetTableHeaderTimers />
<div className={style.headerActions}>
<Tooltip openDelay={tooltipDelayFast} label='Toggle follow'>
<span onClick={() => toggleFollow()} className={`${style.actionIcon} ${followSelected ? style.enabled : ''}`}>
@@ -89,9 +72,9 @@ export default function CuesheetTableHeader({ handleCSVExport, featureData }: Cu
{isFullScreen ? <IoContract /> : <IoExpand />}
</span>
</Tooltip>
<Tooltip openDelay={tooltipDelayFast} label='Export rundown to CSV'>
<span className={style.actionIcon} onClick={exportCsv}>
CSV
<Tooltip openDelay={tooltipDelayFast} label='Export rundown'>
<span className={style.actionIcon} onClick={exportProject}>
Export
</span>
</Tooltip>
</div>
@@ -0,0 +1,31 @@
import { formatDisplay } from 'ontime-utils';
import { useTimer } from '../../../common/hooks/useSocket';
import { formatTime } from '../../../common/utils/time';
import style from './CuesheetTableHeader.module.scss';
export default function CuesheetTableHeaderTimers() {
const timer = useTimer();
// prepare presentation variables
const isOvertime = (timer.current ?? 0) < 0;
const timerNow = timer.current == null ? '-' : `${isOvertime ? '-' : ''}${formatDisplay(timer.current)}`;
const timeNow = formatTime(timer.clock, {
showSeconds: true,
format: 'hh:mm:ss a',
});
return (
<>
<div className={style.timer}>
<div className={style.timerLabel}>Running Timer</div>
<div className={style.value}>{timerNow}</div>
</div>
<div className={style.clock}>
<div className={style.clockLabel}>Time Now</div>
<div className={style.value}>{timeNow}</div>
</div>
</>
);
}
@@ -1,3 +1,4 @@
import { memo } from 'react';
import { Button, Checkbox, Switch } from '@chakra-ui/react';
import { Column } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types';
@@ -19,7 +20,7 @@ interface CuesheetTableSettingsProps {
handleClearToggles: () => void;
}
export default function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
const { columns, handleResetResizing, handleResetReordering, handleClearToggles } = props;
const showPrevious = useCuesheetSettings((state) => state.showPrevious);
const togglePreviousVisibility = useCuesheetSettings((state) => state.togglePreviousVisibility);
@@ -81,3 +82,5 @@ export default function CuesheetTableSettings(props: CuesheetTableSettingsProps)
</div>
);
}
export default memo(CuesheetTableSettings);
@@ -1,17 +1,13 @@
import { useCallback } from 'react';
import { Tooltip } from '@chakra-ui/react';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
import { IoChevronUp } from '@react-icons/all-files/io5/IoChevronUp';
import { CellContext, ColumnDef } from '@tanstack/react-table';
import { OntimeEvent, OntimeRundownEntry, UserFields } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { millisToDelayString } from '../../common/utils/dateConfig';
import { tooltipDelayFast } from '../../ontimeConfig';
import DelayIndicator from '../../common/components/delay-indicator/DelayIndicator';
import EditableCell from './cuesheet-table-elements/EditableCell';
import { useCuesheetSettings } from './store/CuesheetSettings';
import EditableCell from './tableElements/EditableCell';
import style from './Cuesheet.module.scss';
@@ -20,30 +16,6 @@ function makePublic(row: CellContext<OntimeRundownEntry, unknown>) {
return cellValue ? <IoCheckmark className={style.check} /> : '';
}
function DelayIndicator(props: { delayValue: number }) {
const { delayValue } = props;
if (delayValue < 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<span className={style.delaySymbol}>
<IoChevronDown />
</span>
</Tooltip>
);
}
if (delayValue > 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<span className={style.delaySymbol}>
<IoChevronUp />
</span>
</Tooltip>
);
}
return null;
}
function MakeTimer({ getValue, row: { original } }: CellContext<OntimeRundownEntry, unknown>) {
const showDelayedTimes = useCuesheetSettings((state) => state.showDelayedTimes);
const cellValue = (getValue() as number | null) ?? 0;
@@ -77,6 +49,13 @@ function MakeUserField({ getValue, row: { index }, column: { id }, table }: Cell
export function makeCuesheetColumns(userFields?: UserFields): ColumnDef<OntimeRundownEntry>[] {
return [
{
accessorKey: 'cue',
id: 'cue',
header: 'Cue',
cell: (row) => row.getValue(),
size: 75,
},
{
accessorKey: 'isPublic',
id: 'isPublic',
@@ -1,5 +1,5 @@
import { stringify } from 'csv-stringify/browser/esm/sync';
import { EventData, OntimeEntryCommonKeys, OntimeRundown, UserFields } from 'ontime-types';
import { OntimeEntryCommonKeys, OntimeRundown, ProjectData, UserFields } from 'ontime-types';
import { millisToString } from 'ontime-utils';
/**
@@ -38,10 +38,11 @@ export const parseField = (field: keyof OntimeRundown, data: unknown): string =>
* @param {object} userFields
* @return {(string[])[]}
*/
export const makeTable = (headerData: EventData, rundown: OntimeRundown, userFields: UserFields): string[][] => {
export const makeTable = (headerData: ProjectData, rundown: OntimeRundown, userFields: UserFields): string[][] => {
const data = [
['Ontime · Schedule Template'],
['Event Name', headerData?.title || ''],
['Project Title', headerData?.title || ''],
['Project Description', headerData?.description || ''],
['Public URL', headerData?.publicUrl || ''],
['Backstage URL', headerData?.backstageUrl || ''],
[],
@@ -5,6 +5,7 @@ import { OntimeEntryCommonKeys, OntimeEvent } from 'ontime-types';
*/
export const defaultColumnOrder: OntimeEntryCommonKeys[] = [
'isPublic',
'cue',
'timeStart',
'timeEnd',
'duration',
@@ -144,7 +144,7 @@ $playback-width: 26rem;
.eventEditor {
border-radius: 8px 8px 0 0;
background-color: $bg-container-l2;
box-shadow: rgba(0, 0, 0, 0.35) 0 3px 6px 6px;
box-shadow: $large-bottom-drawer-shadow;
border-top: 1px solid $white-20;
position: absolute;
bottom: 0;
@@ -164,7 +164,7 @@ $playback-width: 26rem;
}
.header {
background-color: $gray-1200;
background-color: $gray-1250;
padding: 0.5rem;
border-left: 1px solid $white-10;
border-radius: 0 8px 0 0;
@@ -215,6 +215,11 @@ $playback-width: 26rem;
flex-direction: column;
}
.mainContainer > .rundown {
padding: 1rem 0;
}
.content {
padding-top: 1.5rem;
}
@@ -6,10 +6,14 @@
gap: max(1rem, 2vh);
display: grid;
grid-template-areas:
'eventInfo eventActions'
'timeOptions titles';
grid-template-columns: auto 1fr;
grid-template-areas: 'time left right';
grid-template-columns: auto 1fr 1fr;
}
.timeOptions {
grid-area: time;
display: flex;
gap: 1.5rem;
.timers,
.timeSettings {
@@ -19,51 +23,23 @@
}
}
.eventInfo {
grid-area: eventInfo;
.left,
.right {
display: flex;
align-items: center;
.eventId {
margin-left:$element-spacing;
}
flex-direction: column;
gap: 0.5rem;
}
.eventActions {
grid-area: eventActions;
margin-left: auto;
.left {
grid-area: left;
padding: 0 1rem;
border-left: 1px solid $border-color-ondark;
}
.timeOptions {
grid-area: timeOptions;
display: flex;
gap: 1.5rem;
}
.titles {
grid-area: titles;
display: grid;
grid-template-areas: 'left right';
grid-template-columns: 1fr 1fr;
.left,
.right {
display: flex;
flex-direction: column;
gap: 8px;
}
.left {
grid-area: left;
padding: 0 1rem;
border-left: 1px solid $border-color-ondark;
}
.right {
padding-left: 1rem;
grid-area: right;
border-left: 1px solid $border-color-ondark;
}
.right {
padding-left: 1rem;
grid-area: right;
border-left: 1px solid $border-color-ondark;
}
@mixin input-label() {
@@ -94,6 +70,12 @@
}
}
.eventActions {
margin-left: auto;
display: flex;
gap: 0.5rem;
}
.spacer {
height: 1.25rem;
}
@@ -104,6 +86,12 @@
gap: 1rem;
}
.splitTwo {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
.column {
display: flex;
flex-direction: column;
@@ -116,4 +104,4 @@
.fullHeight {
height: 100%
}
}
@@ -1,38 +1,45 @@
import { useEffect, useState } from 'react';
import { OntimeEvent } from 'ontime-types';
import { useCallback, useEffect, useState } from 'react';
import { isOntimeEvent, OntimeEvent } from 'ontime-types';
import CopyTag from '../../common/components/copy-tag/CopyTag';
import { useEventAction } from '../../common/hooks/useEventAction';
import useRundown from '../../common/hooks-query/useRundown';
import { useAppMode } from '../../common/stores/appModeStore';
import getDelayTo from '../../common/utils/getDelayTo';
import EventEditorDataLeft from './composite/EventEditorDataLeft';
import EventEditorDataRight from './composite/EventEditorDataRight';
import EventEditorTimes from './composite/EventEditorTimes';
import EventEditorTitles from './composite/EventEditorTitles';
import style from './EventEditor.module.scss';
export type EventEditorSubmitActions = keyof OntimeEvent;
export type EditorUpdateFields = 'cue' | 'title' | 'presenter' | 'subtitle' | 'note' | 'colour';
export default function EventEditor() {
const openId = useAppMode((state) => state.editId);
const { data } = useRundown();
const { updateEvent } = useEventAction();
const [event, setEvent] = useState<OntimeEvent | null>(null);
const [delay, setDelay] = useState(0);
useEffect(() => {
if (!data || !openId) {
setEvent(null);
return;
}
const eventIndex = data.findIndex((event) => event.id === openId);
if (eventIndex > -1) {
const event = data[eventIndex];
if (event.type === 'event') {
setDelay(getDelayTo(data, eventIndex));
setEvent(data[eventIndex] as OntimeEvent);
}
const event = data.find((event) => event.id === openId);
if (event && isOntimeEvent(event)) {
setEvent(event);
}
}, [data, event, openId]);
}, [data, openId]);
const handleSubmit = useCallback(
(field: EditorUpdateFields, value: string) => {
updateEvent({ id: event?.id, [field]: value });
},
[event?.id, updateEvent],
);
if (!event) {
return <span>Loading...</span>;
@@ -40,34 +47,35 @@ export default function EventEditor() {
return (
<div className={style.eventEditor}>
<div className={style.eventInfo}>
Event ID
<span className={style.eventId}>
<CopyTag label={event.id}>{event.id}</CopyTag>
</span>
</div>
<div className={style.eventActions}>
<CopyTag label='OSC trigger'>{`/ontime/gotoid/${event.id}`}</CopyTag>
</div>
<EventEditorTimes
eventId={event.id}
timeStart={event.timeStart}
timeEnd={event.timeEnd}
duration={event.duration}
delay={delay}
delay={event.delay ?? 0}
isPublic={event.isPublic}
endAction={event.endAction}
timerType={event.timerType}
/>
<EventEditorTitles
key={event.id}
<EventEditorDataLeft
key={`${event.id}-left`}
eventId={event.id}
cue={event.cue}
title={event.title}
presenter={event.presenter}
subtitle={event.subtitle}
handleSubmit={handleSubmit}
/>
<EventEditorDataRight
key={`${event.id}-right`}
note={event.note}
colour={event.colour}
/>
handleSubmit={handleSubmit}
>
<CopyTag label='Event ID'>{event.id}</CopyTag>
<CopyTag label='OSC trigger by id'>{`/ontime/gotoid/${event.id}`}</CopyTag>
<CopyTag label='OSC trigger by cue'>{`/ontime/gotocue/${event.cue}`}</CopyTag>
</EventEditorDataRight>
</div>
);
}
@@ -3,7 +3,7 @@ import { Textarea } from '@chakra-ui/react';
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
import { TitleActions } from './EventEditorTitles';
import { TitleActions } from './EventEditorDataLeft';
import style from '../EventEditor.module.scss';
@@ -24,7 +24,9 @@ export default function CountedTextArea(props: CountedTextAreaProps) {
return (
<div className={`${style.column} ${style.fullHeight}`}>
<div className={style.countedInput}>
<label className={style.inputLabel} htmlFor={field}>{label}</label>
<label className={style.inputLabel} htmlFor={field}>
{label}
</label>
<span className={style.charCount}>{`${value.length} characters`}</span>
</div>
<Textarea
@@ -1,21 +1,20 @@
import { useCallback } from 'react';
import { Input } from '@chakra-ui/react';
import { Input, InputProps } from '@chakra-ui/react';
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
import { TitleActions } from './EventEditorTitles';
import { EditorUpdateFields } from '../EventEditor';
import style from '../EventEditor.module.scss';
interface CountedTextInputProps {
field: TitleActions;
interface CountedTextInputProps extends InputProps {
field: EditorUpdateFields;
label: string;
initialValue: string;
submitHandler: (field: TitleActions, value: string) => void;
submitHandler: (field: EditorUpdateFields, value: string) => void;
}
export default function CountedTextInput(props: CountedTextInputProps) {
const { field, label, initialValue, submitHandler } = props;
const { field, label, initialValue, submitHandler, maxLength } = props;
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
@@ -26,7 +25,9 @@ export default function CountedTextInput(props: CountedTextInputProps) {
return (
<div className={style.column}>
<div className={style.countedInput}>
<label className={style.inputLabel} htmlFor={field}>{label}</label>
<label className={style.inputLabel} htmlFor={field}>
{label}
</label>
<span className={style.charCount}>{`${value.length} characters`}</span>
</div>
<Input
@@ -35,6 +36,7 @@ export default function CountedTextInput(props: CountedTextInputProps) {
variant='ontime-filled'
data-testid='input-textfield'
value={value}
maxLength={maxLength || 50}
onChange={onChange}
onBlur={onBlur}
onKeyDown={onKeyDown}
@@ -0,0 +1,54 @@
import { memo } from 'react';
import { Input } from '@chakra-ui/react';
import { sanitiseCue } from 'ontime-utils';
import { type EditorUpdateFields } from '../EventEditor';
import CountedTextInput from './CountedTextInput';
import style from '../EventEditor.module.scss';
interface EventEditorLeftProps {
eventId: string;
cue: string;
title: string;
presenter: string;
subtitle: string;
handleSubmit: (field: EditorUpdateFields, value: string) => void;
}
const EventEditorDataLeft = (props: EventEditorLeftProps) => {
const { eventId, cue, title, presenter, subtitle, handleSubmit } = props;
const cueSubmitHandler = (_field: string, newValue: string) => {
handleSubmit('cue', sanitiseCue(newValue));
};
return (
<div className={style.left}>
<div className={style.splitTwo}>
<div className={style.column}>
<div className={style.countedInput}>
<label className={style.inputLabel} htmlFor='eventId'>
Event ID (read only)
</label>
</div>
<Input
id='eventId'
size='sm'
variant='ontime-filled'
data-testid='input-textfield'
value={eventId}
readOnly
/>
</div>
<CountedTextInput field='cue' label='Cue' initialValue={cue} submitHandler={cueSubmitHandler} maxLength={10} />
</div>
<CountedTextInput field='title' label='Title' initialValue={title} submitHandler={handleSubmit} />
<CountedTextInput field='presenter' label='Presenter' initialValue={presenter} submitHandler={handleSubmit} />
<CountedTextInput field='subtitle' label='Subtitle' initialValue={subtitle} submitHandler={handleSubmit} />
</div>
);
};
export default memo(EventEditorDataLeft);
@@ -0,0 +1,33 @@
import { memo, PropsWithChildren } from 'react';
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
import { EditorUpdateFields } from '../EventEditor';
import CountedTextArea from './CountedTextArea';
import style from '../EventEditor.module.scss';
interface EventEditorRightProps {
note: string;
colour: string;
handleSubmit: (field: EditorUpdateFields, value: string) => void;
}
const EventEditorDataRight = (props: PropsWithChildren<EventEditorRightProps>) => {
const { children, note, colour, handleSubmit } = props;
return (
<div className={style.right}>
<div className={style.column}>
<label className={style.inputLabel}>Colour</label>
<div className={style.inline}>
<SwatchSelect name='colour' value={colour} handleChange={handleSubmit} />
</div>
</div>
<CountedTextArea field='note' label='Note' initialValue={note} submitHandler={handleSubmit} />
<div className={style.eventActions}>{children}</div>
</div>
);
};
export default memo(EventEditorDataRight);
@@ -130,6 +130,7 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
>
<option value={TimerType.CountDown}>Count down</option>
<option value={TimerType.CountUp}>Count up</option>
<option value={TimerType.TimeToEnd}>Time to end</option>
<option value={TimerType.Clock}>Clock</option>
</Select>
<label className={style.inputLabel}>End Action</label>
@@ -1,50 +0,0 @@
import { memo } from 'react';
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
import { useEventAction } from '../../../common/hooks/useEventAction';
import CountedTextArea from './CountedTextArea';
import CountedTextInput from './CountedTextInput';
import style from '../EventEditor.module.scss';
interface EventEditorTitlesProps {
eventId: string;
title: string;
presenter: string;
subtitle: string;
note: string;
colour: string;
}
export type TitleActions = 'title' | 'presenter' | 'subtitle' | 'note' | 'colour';
const EventEditorTitles = (props: EventEditorTitlesProps) => {
const { eventId, title, presenter, subtitle, note, colour } = props;
const { updateEvent } = useEventAction();
const handleSubmit = (field: TitleActions, value: string) => {
updateEvent({ id: eventId, [field]: value });
};
return (
<div className={style.titles}>
<div className={style.left}>
<CountedTextInput field='title' label='Title' initialValue={title} submitHandler={handleSubmit} />
<CountedTextInput field='presenter' label='Presenter' initialValue={presenter} submitHandler={handleSubmit} />
<CountedTextInput field='subtitle' label='Subtitle' initialValue={subtitle} submitHandler={handleSubmit} />
</div>
<div className={style.right}>
<div className={style.column}>
<label className={style.inputLabel}>Colour</label>
<div className={style.inline}>
<SwatchSelect name='colour' value={colour} handleChange={handleSubmit} />
</div>
</div>
<CountedTextArea field='note' label='Note' initialValue={note} submitHandler={handleSubmit} />
</div>
</div>
);
};
export default memo(EventEditorTitles);
+8 -8
View File
@@ -12,17 +12,17 @@ export default function Info() {
const showNif = useEditorSettings((state) => state.eventSettings.showNif);
const titlesNow = {
title: data.titles.titleNow || '',
subtitle: data.titles.subtitleNow || '',
presenter: data.titles.presenterNow || '',
note: data.titles.noteNow || '',
title: data.eventNow?.title || '',
subtitle: data.eventNow?.subtitle || '',
presenter: data.eventNow?.presenter || '',
note: data.eventNow?.note || '',
};
const titlesNext = {
title: data.titles.titleNext || '',
subtitle: data.titles.subtitleNext || '',
presenter: data.titles.presenterNext || '',
note: data.titles.noteNext || '',
title: data.eventNext?.title || '',
subtitle: data.eventNext?.subtitle || '',
presenter: data.eventNext?.presenter || '',
note: data.eventNext?.note || '',
};
const selected = !data.numEvents
+2 -1
View File
@@ -1,5 +1,6 @@
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { serverPort } from '../../common/api/apiConstants';
import useInfo from '../../common/hooks-query/useInfo';
import { openLink } from '../../common/utils/linkUtils';
@@ -9,7 +10,7 @@ export default function InfoNif() {
const { data } = useInfo();
const handleClick = (address: string) => {
const baseURL = 'http://__IP__:4001';
const baseURL = `http://__IP__:${serverPort}`;
openLink(baseURL.replace('__IP__', address));
};
@@ -1,9 +1,9 @@
import useEventData from '../../../common/hooks-query/useEventData';
import useProjectData from '../../../common/hooks-query/useProjectData';
import style from '../Info.module.scss';
export default function InfoHeader({ selected }: { selected: string }) {
const { data } = useEventData();
const { data } = useProjectData();
return (
<>
+26 -3
View File
@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect } from 'react';
import { memo, useCallback, useEffect, useState } from 'react';
import { VStack } from '@chakra-ui/react';
import { IoColorWand } from '@react-icons/all-files/io5/IoColorWand';
import { IoExtensionPuzzle } from '@react-icons/all-files/io5/IoExtensionPuzzle';
@@ -10,11 +10,12 @@ import { IoPushOutline } from '@react-icons/all-files/io5/IoPushOutline';
import { IoSaveOutline } from '@react-icons/all-files/io5/IoSaveOutline';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { downloadRundown } from '../../common/api/ontimeApi';
import { downloadCSV, downloadRundown } from '../../common/api/ontimeApi';
import QuitIconBtn from '../../common/components/buttons/QuitIconBtn';
import TooltipActionBtn from '../../common/components/buttons/TooltipActionBtn';
import useElectronEvent from '../../common/hooks/useElectronEvent';
import { AppMode, useAppMode } from '../../common/stores/appModeStore';
import ExportModal, { ExportType } from '../modals/export-modal/ExportModal';
import style from './MenuBar.module.scss';
@@ -100,6 +101,22 @@ const MenuBar = (props: MenuBarProps) => {
};
}, [handleKeyPress, isElectron]);
const [isModalOpen, setIsModalOpen] = useState(false);
const onModalClose = (exportType?: ExportType) => {
setIsModalOpen(false);
if (!exportType) {
return;
}
if (exportType === 'json') {
downloadRundown();
} else if (exportType === 'csv') {
downloadCSV();
}
};
return (
<VStack>
<QuitIconBtn disabled={!isElectron} clickHandler={sendShutdown} size='md' />
@@ -128,12 +145,18 @@ const MenuBar = (props: MenuBarProps) => {
{...buttonStyle}
icon={<IoSaveOutline />}
isDisabled={appMode === AppMode.Run}
clickHandler={downloadRundown}
clickHandler={() => setIsModalOpen(true)}
tooltip='Export project file'
aria-label='Export project file'
size='sm'
/>
<ExportModal
onClose={onModalClose}
isOpen={isModalOpen}
buttonVariants={{ csv: 'ontime-subtle-on-light', json: 'ontime-filled' }}
/>
<div className={style.gap} />
<TooltipActionBtn
{...buttonStyle}
@@ -1,4 +1,4 @@
.headerButtons {
text-align: right;
padding-top: 24px;
padding: 1.5rem 1rem 0 1rem;
}
@@ -0,0 +1,10 @@
.modalHeader {
font-weight: 400;
}
.modalBody {
display: flex;
justify-content: space-between;
margin-top: 1rem;
margin-bottom: 1rem;
}
@@ -0,0 +1,35 @@
import { Button, Modal, ModalBody, ModalCloseButton, ModalContent, ModalHeader, ModalOverlay } from '@chakra-ui/react';
import styles from './ExportModal.module.scss';
export type ExportType = 'csv' | 'json';
interface ExportModalProps {
isOpen: boolean;
onClose: (type?: ExportType) => void;
buttonVariants: {
csv: string;
json: string;
};
}
export default function ExportModal(props: ExportModalProps) {
const { isOpen, onClose, buttonVariants } = props;
return (
<Modal isOpen={isOpen} onClose={onClose} motionPreset='scale' size='xl' colorScheme='blackAlpha'>
<ModalOverlay />
<ModalContent>
<ModalHeader className={styles.modalHeader}>Download options</ModalHeader>
<ModalCloseButton />
<ModalBody className={styles.modalBody}>
<Button onClick={() => onClose('csv')} variant={buttonVariants.csv} width='48%'>
rundown as CSV
</Button>
<Button onClick={() => onClose('json')} variant={buttonVariants.json} width='48%'>
Project file
</Button>
</ModalBody>
</ModalContent>
</Modal>
);
}
@@ -16,12 +16,12 @@ import {
ModalOverlay,
Textarea,
} from '@chakra-ui/react';
import type { EventData } from 'ontime-types';
import type { ProjectData } from 'ontime-types';
import { EVENT_DATA, RUNDOWN_TABLE } from '../../../common/api/apiConstants';
import { PROJECT_DATA, RUNDOWN_TABLE } from '../../../common/api/apiConstants';
import { postNew } from '../../../common/api/ontimeApi';
import useEventData from '../../../common/hooks-query/useEventData';
import { eventDataPlaceholder } from '../../../common/models/EventData';
import useProjectData from '../../../common/hooks-query/useProjectData';
import { projectDataPlaceholder } from '../../../common/models/ProjectData';
import { ontimeQueryClient } from '../../../common/queryClient';
import styles from '../Modal.module.scss';
@@ -32,7 +32,7 @@ interface QuickStartProps {
}
export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
const { data, status } = useEventData();
const { data, status } = useProjectData();
const {
handleSubmit,
register,
@@ -49,10 +49,10 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
if (data) reset(data);
}, [data, reset]);
const onSubmit = async (data: Partial<EventData>) => {
const onSubmit = async (data: Partial<ProjectData>) => {
try {
await postNew(data);
await ontimeQueryClient.invalidateQueries(EVENT_DATA);
await ontimeQueryClient.invalidateQueries(PROJECT_DATA);
await ontimeQueryClient.invalidateQueries(RUNDOWN_TABLE);
onClose();
@@ -61,7 +61,7 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
}
};
const onReset = () => reset(eventDataPlaceholder);
const onReset = () => reset(projectDataPlaceholder);
const disableButtons = status !== 'success' || isSubmitting;
return (
@@ -86,13 +86,13 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
<div className={styles.column}>
<AlertTitle>Note</AlertTitle>
<AlertDescription>
On submit, application options will be kept but rundown and event data will be reset
On submit, application options will be kept but rundown and project data will be reset
</AlertDescription>
</div>
</Alert>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Event title
Project title
<Input
variant='ontime-filled-on-light'
size='sm'
@@ -104,7 +104,7 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
</div>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Event description
Project description
<Input
variant='ontime-filled-on-light'
size='sm'
@@ -116,7 +116,7 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
</div>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Public Info
Public info
<Textarea
variant='ontime-filled-on-light'
size='sm'
@@ -128,7 +128,7 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
</div>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Public QR Code Url
Public QR code Url
<Input
variant='ontime-filled-on-light'
size='sm'
@@ -139,7 +139,7 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
</div>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Backstage Info
Backstage info
<Textarea
variant='ontime-filled-on-light'
size='sm'
@@ -151,7 +151,7 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
</div>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Backstage QR Code Url
Backstage QR code Url
<Input
variant='ontime-filled-on-light'
size='sm'
@@ -61,7 +61,7 @@ export default function AppSettingsModal() {
<ModalSplitInput
field='serverPort'
title='Ontime is available on port'
description='Default 4001'
description='Default 4001 (needs app restart to change)'
error={errors.serverPort?.message}
>
<Input
@@ -69,7 +69,6 @@ export default function AppSettingsModal() {
size='sm'
textAlign='right'
maxLength={5}
disabled
variant='ontime-filled-on-light'
{...register('serverPort', {
required: { value: true, message: 'Required field' },
@@ -1,11 +1,11 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { Input, Textarea } from '@chakra-ui/react';
import { EventData } from 'ontime-types';
import { ProjectData } from 'ontime-types';
import { logAxiosError } from '../../../common/api/apiUtils';
import { postEventData } from '../../../common/api/eventDataApi';
import useEventData from '../../../common/hooks-query/useEventData';
import { postProjectData } from '../../../common/api/projectDataApi';
import useProjectData from '../../../common/hooks-query/useProjectData';
import ModalLoader from '../modal-loader/ModalLoader';
import { inputProps } from '../modalHelper';
import ModalInput from '../ModalInput';
@@ -13,14 +13,14 @@ import OntimeModalFooter from '../OntimeModalFooter';
import style from './SettingsModal.module.scss';
export default function EventDataForm() {
const { data, status, isFetching, refetch } = useEventData();
export default function ProjectDataForm() {
const { data, status, isFetching, refetch } = useProjectData();
const {
handleSubmit,
register,
reset,
formState: { errors, isSubmitting, isDirty, isValid },
} = useForm<EventData>({
} = useForm<ProjectData>({
defaultValues: data,
values: data,
resetOptions: {
@@ -34,11 +34,11 @@ export default function EventDataForm() {
}
}, [data, reset]);
const onSubmit = async (formData: EventData) => {
const onSubmit = async (formData: ProjectData) => {
try {
await postEventData(formData);
await postProjectData(formData);
} catch (error) {
logAxiosError('Error saving event settings', error);
logAxiosError('Error saving project data', error);
} finally {
await refetch();
}
@@ -55,10 +55,10 @@ export default function EventDataForm() {
}
return (
<form onSubmit={handleSubmit(onSubmit)} id='event-data' className={style.sectionContainer}>
<form onSubmit={handleSubmit(onSubmit)} id='project-data' className={style.sectionContainer}>
<ModalInput
field='title'
title='Event title'
title='Project title'
description='Shown in overview screens'
error={errors.title?.message}
>
@@ -73,7 +73,7 @@ export default function EventDataForm() {
</ModalInput>
<ModalInput
field='description'
title='Event description'
title='Project description'
description='Free field, shown in editor'
error={errors.description?.message}
>
@@ -87,7 +87,7 @@ export default function EventDataForm() {
/>
</ModalInput>
<div style={{ height: '16px' }} />
<ModalInput field='publicInfo' title='Public Info' description='Information shown in public screens'>
<ModalInput field='publicInfo' title='Public info' description='Information shown in public screens'>
<Textarea
{...inputProps}
variant='ontime-filled-on-light'
@@ -107,7 +107,7 @@ export default function EventDataForm() {
/>
</ModalInput>
<div style={{ height: '16px' }} />
<ModalInput field='backstageInfo' title='Backstage Info' description='Information shown in public screens'>
<ModalInput field='backstageInfo' title='Backstage info' description='Information shown in public screens'>
<Textarea
{...inputProps}
variant='ontime-filled-on-light'
@@ -128,7 +128,7 @@ export default function EventDataForm() {
/>
</ModalInput>
<OntimeModalFooter
formId='event-data'
formId='project-data'
handleRevert={onReset}
isDirty={isDirty}
isValid={isValid}
@@ -6,7 +6,7 @@ import AliasesForm from './AliasesForm';
import AppSettingsModal from './AppSettings';
import CuesheetSettingsForm from './CuesheetSettingsForm';
import EditorSettings from './EditorSettings';
import EventDataForm from './EventDataForm';
import ProjectDataForm from './ProjectDataForm';
import ViewSettingsForm from './ViewSettingsForm';
interface ModalManagerProps {
@@ -22,7 +22,7 @@ export default function SettingsModal(props: ModalManagerProps) {
<Tabs variant='ontime' size='sm' isLazy>
<TabList>
<Tab>App</Tab>
<Tab>Event Data</Tab>
<Tab>Project Data</Tab>
<Tab>Editor</Tab>
<Tab>Cuesheet</Tab>
<Tab>Views</Tab>
@@ -33,7 +33,7 @@ export default function SettingsModal(props: ModalManagerProps) {
<AppSettingsModal />
</TabPanel>
<TabPanel>
<EventDataForm />
<ProjectDataForm />
</TabPanel>
<TabPanel>
<EditorSettings />
@@ -0,0 +1,24 @@
@use '../../../src/theme/v2Styles' as *;
@use '../../../src/theme/ontimeColours' as *;
.operatorContainer {
width: 100vw;
height: 100vh;
color: $ui-white;
}
.operatorEvents {
width: 100vw;
height: 100vh;
padding-top: 0.25rem;
overflow: auto;
display: flex;
flex-direction: column;
gap: 2px;
}
.spacer {
min-height: 95vh;
}
@@ -0,0 +1,170 @@
import { useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { isOntimeEvent, OntimeEvent, SupportedEvent, UserFields } from 'ontime-types';
import { getFirstEvent, getLastEvent } from 'ontime-utils';
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
import Empty from '../../common/components/state/Empty';
import { getOperatorOptions } from '../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useOperator } from '../../common/hooks/useSocket';
import useProjectData from '../../common/hooks-query/useProjectData';
import useRundown from '../../common/hooks-query/useRundown';
import useUserFields from '../../common/hooks-query/useUserFields';
import { isStringBoolean } from '../../common/utils/viewUtils';
import FollowButton from './follow-button/FollowButton';
import OperatorBlock from './operator-block/OperatorBlock';
import OperatorEvent from './operator-event/OperatorEvent';
import StatusBar from './status-bar/StatusBar';
import style from './Operator.module.scss';
const selectedOffset = 50;
type TitleFields = Pick<OntimeEvent, 'title' | 'subtitle' | 'presenter'>;
export default function Operator() {
const { data, status } = useRundown();
const { data: userFields, status: userFieldsStatus } = useUserFields();
const { data: projectData, status: projectDataStatus } = useProjectData();
const featureData = useOperator();
const [searchParams] = useSearchParams();
const isAutomatedScroll = useRef(false);
const [lockAutoScroll, setLockAutoScroll] = useState(false);
const selectedRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const scrollToComponent = useFollowComponent({
followRef: selectedRef,
scrollRef: scrollRef,
doFollow: !lockAutoScroll,
topOffset: selectedOffset,
setScrollFlag: () => (isAutomatedScroll.current = true),
});
// Set window title
useEffect(() => {
document.title = 'ontime - Operator';
}, []);
// reset scroll if nothing is selected
useEffect(() => {
if (!featureData?.selectedEventId) {
if (!lockAutoScroll) {
scrollRef.current?.scrollTo(0, 0);
}
}
}, [featureData?.selectedEventId, lockAutoScroll, scrollRef]);
const handleOffset = () => {
if (featureData.selectedEventId) {
scrollToComponent();
}
setLockAutoScroll(false);
};
const handleScroll = () => {
// prevent considering automated scrolls as user scrolls
if (isAutomatedScroll.current) {
isAutomatedScroll.current = false;
return;
}
if (selectedRef?.current && scrollRef?.current) {
const selectedRect = selectedRef.current.getBoundingClientRect();
const scrollerRect = scrollRef.current.getBoundingClientRect();
if (selectedRect && scrollerRect) {
const distanceFromTop = selectedRect.top - scrollerRect.top;
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > selectedOffset;
setLockAutoScroll(hasScrolledOutOfThreshold);
}
}
};
const missingData = !data || !userFields || !projectData;
const isLoading = status === 'loading' || userFieldsStatus === 'loading' || projectDataStatus === 'loading';
if (missingData || isLoading) {
return <Empty text='Loading...' />;
}
// get fields which the user subscribed to
const subscribe = searchParams.get('subscribe') as keyof UserFields | null;
const main = searchParams.get('main') as keyof TitleFields | null;
const secondary = searchParams.get('secondary') as keyof TitleFields | null;
const subscribedAlias = subscribe ? userFields[subscribe] : '';
const showSeconds = isStringBoolean(searchParams.get('showseconds'));
const operatorOptions = getOperatorOptions(userFields);
let isPast = Boolean(featureData.selectedEventId);
const hidePast = isStringBoolean(searchParams.get('hidepast'));
const firstEvent = getFirstEvent(data);
const lastEvent = getLastEvent(data);
return (
<div className={style.operatorContainer}>
<NavigationMenu />
<ViewParamsEditor paramFields={operatorOptions} />
<StatusBar
projectTitle={projectData.title}
playback={featureData.playback}
selectedEventId={featureData.selectedEventId}
firstStart={firstEvent?.timeStart}
firstId={firstEvent?.id}
lastEnd={lastEvent?.timeEnd}
lastId={lastEvent?.id}
/>
<div className={style.operatorEvents} onScroll={handleScroll} ref={scrollRef}>
{data.map((entry) => {
if (isOntimeEvent(entry)) {
const isSelected = featureData.selectedEventId === entry.id;
if (isSelected) {
isPast = false;
}
// hide past events (if setting) and skipped events
if ((hidePast && isPast) || entry.skip) {
return null;
}
const mainField = main ? entry?.[main] || entry.title : entry.title;
const secondaryField = secondary ? entry?.[secondary] || entry.subtitle : entry.subtitle;
const subscribedData = (subscribe ? entry?.[subscribe] : undefined) || '';
return (
<OperatorEvent
key={entry.id}
colour={entry.colour}
cue={entry.cue}
main={mainField}
secondary={secondaryField}
timeStart={entry.timeStart}
timeEnd={entry.timeEnd}
duration={entry.duration}
delay={entry.delay}
isSelected={isSelected}
subscribed={subscribedData}
subscribedAlias={subscribedAlias}
showSeconds={showSeconds}
isPast={isPast}
selectedRef={isSelected ? selectedRef : undefined}
/>
);
}
if (entry.type === SupportedEvent.Block) {
return <OperatorBlock key={entry.id} title={entry.title} />;
}
return null;
})}
<div className={style.spacer} />
</div>
<FollowButton isVisible={lockAutoScroll} onClickHandler={handleOffset} />
</div>
);
}
@@ -0,0 +1,38 @@
@use '../../../../src/theme/ontimeColours' as *;
@use '../../../../src/theme/v2Styles' as *;
.followButton {
position: relative;
bottom: 12rem;
margin: 0 auto;
z-index: 1;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 1rem;
background-color: $blue-700;
color: #fff;
font-size: 1rem;
border-radius: 99px;
transition: bottom 1s;
&:active {
transition: background-color $transition-time-action;
background-color: $blue-900;
}
}
.hidden {
transition: bottom 1s;
bottom: -50px;
}
// tablet
@media (min-width: $min-tablet) {
.followButton {
font-size: 1.25rem;
gap: 1rem;
padding: 0.25rem 1rem;
}
}
@@ -0,0 +1,23 @@
import { IoLocate } from '@react-icons/all-files/io5/IoLocate';
import { cx } from '../../../common/utils/styleUtils';
import style from './FollowButton.module.scss';
interface FollowButtonProps {
isVisible: boolean;
onClickHandler: () => void;
}
export default function FollowButton(props: FollowButtonProps) {
const { isVisible, onClickHandler } = props;
const classes = cx([style.followButton, !isVisible && style.hidden]);
return (
<button className={classes} onClick={onClickHandler} type='button'>
<IoLocate />
Follow
</button>
);
}
@@ -0,0 +1,16 @@
@use '../../../../src/theme/ontimeColours' as *;
@use '../../../../src/theme/v2Styles' as *;
.block {
width: 100%;
padding: 0.25rem 0.5rem;
background-color: $gray-1350;
font-size: 1.25rem;
}
// tablet
@media (min-width: $min-tablet) {
.block {
padding: 0.25rem 1rem;
}
}
@@ -0,0 +1,13 @@
import { memo } from 'react';
import style from './OperatorBlock.module.scss';
interface OperatorBlockProps {
title: string;
}
function OperatorBlock({ title }: OperatorBlockProps) {
return <div className={style.block}>{title}</div>;
}
export default memo(OperatorBlock);
@@ -0,0 +1,111 @@
@use '../../../../src/theme/v2Styles' as *;
@use '../../../../src/theme/ontimeColours' as *;
@import '../Operator.module.scss';
@mixin clock-size {
font-size: calc(1rem - 2px);
@media (min-width: $min-tablet) {
font-size: 1rem;
}
}
.event {
opacity: 1;
border-top: 1px solid $white-1;
padding-right: 0.5rem;
color: $white-90;
background-color: $gray-1300;
display: grid;
align-items: center;
grid-template-columns: 1.25rem 1fr auto;
grid-template-rows: auto auto auto;
column-gap: 0.5rem;
grid-template-areas:
"binder main schedule"
"binder secondary running"
"binder fields fields";
&.subscribed {
background-color: $gray-1250;
}
&.running {
border-top: 1px solid $gray-1300;
background-color: var(--operator-running-bg-override, $red-700);
}
&.past {
border-top: 1px solid transparent;
opacity: 0.2;
}
}
.binder {
grid-area: binder;
color: $section-white;
height: 100%;
display: grid;
place-content: center;
position: relative;
background-color: $gray-1050; // to override inline
.cue {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
width: 6em;
rotate: -90deg;
letter-spacing: 0.5px;
}
}
.mainField {
grid-area: main;
font-size: 1.5rem;
letter-spacing: 0.5px;
color: $ui-white;
}
.secondaryField {
grid-area: secondary;
font-size: 1.25rem;
letter-spacing: 0.5px;
}
.schedule {
@include clock-size;
grid-area: schedule;
justify-self: end;
}
.running {
@include clock-size;
grid-area: running;
justify-self: end;
}
.fields {
grid-area: fields;
font-size: 1.25rem;
font-weight: 400;
color: $ui-black;
margin: 0.25rem 0;
.field {
font-weight: 600;
padding: 0 0.25rem;
background-color: var(--operator-highlight-override, $orange-600);
margin-right: 0.5rem;
}
.value {
color: $orange-500
}
}
.fields::after {
content: '\200b';
}

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