mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-03 22:47:59 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e16913cb42 | |||
| 03cbc8544d | |||
| 49c44a831c | |||
| b51f3c966d | |||
| a344ad2f72 | |||
| 8c6ef9bfc3 | |||
| 55ce38f4cd |
@@ -21,6 +21,6 @@
|
||||
}
|
||||
],
|
||||
"rules": {
|
||||
"no-console": ["warn", { "allow": ["warn", "error"]}]
|
||||
"no-console": "warn"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
build
|
||||
coverage
|
||||
dist
|
||||
node_modules
|
||||
|
||||
playwright-report
|
||||
|
||||
**/*.toml
|
||||
**/*.yml
|
||||
**/*.json
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"endOfLine": "lf",
|
||||
"trailingComma": "all",
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
|
||||
@@ -56,8 +56,6 @@ E2E tests are in a separate package. On running, [playwright](https://playwright
|
||||
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
|
||||
|
||||
@@ -14,11 +14,10 @@
|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
@@ -29,7 +28,7 @@ outputs.
|
||||
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 />
|
||||
@@ -51,9 +50,8 @@ 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
|
||||
@@ -65,14 +63,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
|
||||
@@ -85,19 +83,17 @@ 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
|
||||
|
||||
@@ -117,8 +113,7 @@ 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
|
||||
@@ -126,11 +121,26 @@ 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.
|
||||
You can self-host and run Ontime in a docker image. The run command will:
|
||||
|
||||
The docker image along with documentation is [available Docker Hub at getontime/ontime](https://hub.docker.com/r/getontime/ontime)
|
||||
- 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__
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
## Roadmap
|
||||
|
||||
@@ -138,32 +148,32 @@ If you want to run this image in a Raspberry Pi, please see [the docs](https://o
|
||||
|
||||
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: 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 macOS: after attempting to run the installer, navigate to System Preferences -> Security &
|
||||
Privacy and allow the execution of the app
|
||||
- In Linux: right-click the AppImage file -> Properties -> Permissions -> select Allow Executing
|
||||
File as a Program
|
||||
|
||||
@@ -175,7 +185,6 @@ 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
|
||||
|
||||
@@ -185,8 +194,7 @@ 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,5 +1,4 @@
|
||||
{
|
||||
"endOfLine": "lf",
|
||||
"trailingComma": "all",
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "2.13.1",
|
||||
"version": "2.8.1-rc-table",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@chakra-ui/react": "^2.7.0",
|
||||
|
||||
@@ -6,7 +6,6 @@ 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'));
|
||||
@@ -59,9 +58,6 @@ 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 />} />
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import axios from 'axios';
|
||||
import { Alias, OSCSettings, OscSubscription, ProjectData, Settings, UserFields, ViewSettings } from 'ontime-types';
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import {
|
||||
Alias,
|
||||
OntimeRundown,
|
||||
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';
|
||||
|
||||
@@ -110,17 +118,32 @@ export async function postOscSubscriptions(data: OscSubscription) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @description HTTP request to download db in CSV format
|
||||
* @description HTTP request to download db
|
||||
* @return {Promise}
|
||||
*/
|
||||
export const downloadCSV = () => {
|
||||
return fileDownload(ontimeURL, { name: 'rundown', type: 'csv' }, { type: 'text/csv;charset=utf-8;' });
|
||||
};
|
||||
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';
|
||||
|
||||
/**
|
||||
* @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;' });
|
||||
// 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();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -147,6 +170,31 @@ export const uploadData = async (file: File, setProgress: (value: number) => voi
|
||||
.then((response) => response.data.id);
|
||||
};
|
||||
|
||||
type Backend = {
|
||||
rundown: OntimeRundown;
|
||||
project: ProjectData;
|
||||
userFields: UserFields;
|
||||
};
|
||||
|
||||
export async function postPreviewExcel(file: File, setProgress: (value: number) => void, options?: UploadDataOptions) {
|
||||
const formData = new FormData();
|
||||
formData.append('userFile', file);
|
||||
formData.append('options', JSON.stringify(options));
|
||||
console.log('appending options', options);
|
||||
|
||||
const response: AxiosResponse<Backend> = await axios.post(`${ontimeURL}/previewExcel`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
const complete = progressEvent?.total ? Math.round((progressEvent.loaded * 100) / progressEvent.total) : 0;
|
||||
setProgress(complete);
|
||||
},
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export type HasUpdate = {
|
||||
url: string;
|
||||
version: string;
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
@use '../../../theme/v2Styles' as *;
|
||||
|
||||
.delaySymbol {
|
||||
svg {
|
||||
font-size: 1.5rem;
|
||||
color: $ontime-delay;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
@use "../../../theme/_ontimeColours" as *;
|
||||
|
||||
@mixin pad-item {
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.previewTable {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-rows: repeat(6, auto);
|
||||
font-size: calc(1rem - 2px);
|
||||
}
|
||||
|
||||
.field {
|
||||
font-weight: 200;
|
||||
@include pad-item;
|
||||
}
|
||||
|
||||
.value {
|
||||
@include pad-item;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ProjectData } from 'ontime-types';
|
||||
|
||||
import style from './PreviewColumn.module.scss';
|
||||
|
||||
interface PreviewProjectDataProps {
|
||||
project: ProjectData;
|
||||
}
|
||||
|
||||
export default function PreviewProjectData({ project }: PreviewProjectDataProps) {
|
||||
return (
|
||||
<div className={style.previewTable}>
|
||||
<span className={style.field}>Title</span>
|
||||
<span className={style.value}>{project.title}</span>
|
||||
<span className={style.field}>Description</span>
|
||||
<span className={style.value}>{project.description}</span>
|
||||
<span className={style.field}>Public URL</span>
|
||||
<span className={style.value}>{project.publicUrl}</span>
|
||||
<span className={style.field}>Public info</span>
|
||||
<span className={style.value}>{project.publicInfo}</span>
|
||||
<span className={style.field}>Backstage URL</span>
|
||||
<span className={style.value}>{project.backstageUrl}</span>
|
||||
<span className={style.field}>Backstage info</span>
|
||||
<span className={style.value}>{project.backstageInfo}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { isOntimeEvent, OntimeRundown, UserFields } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
|
||||
import { getAccessibleColour } from '../../utils/styleUtils';
|
||||
|
||||
import style from './PreviewTable.module.scss';
|
||||
|
||||
interface PreviewRundownProps {
|
||||
rundown: OntimeRundown;
|
||||
userFields: UserFields;
|
||||
}
|
||||
|
||||
function booleanToText(value?: boolean) {
|
||||
return value ? 'Yes' : undefined;
|
||||
}
|
||||
|
||||
export default function PreviewRundown({ rundown, userFields }: PreviewRundownProps) {
|
||||
return (
|
||||
<div className={style.container}>
|
||||
<div className={style.scrollContainer}>
|
||||
<table className={style.rundownPreview}>
|
||||
<thead className={style.header}>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Type</th>
|
||||
<th>Cue</th>
|
||||
<th>Title</th>
|
||||
<th>Subtitle</th>
|
||||
<th>Presenter</th>
|
||||
<th>Note</th>
|
||||
<th>Time Start</th>
|
||||
<th>Time End</th>
|
||||
<th>Duration</th>
|
||||
<th>Is Public</th>
|
||||
<th>Skip</th>
|
||||
<th>Colour</th>
|
||||
<th>Timer Type</th>
|
||||
<th>End Action</th>
|
||||
<th>
|
||||
user0 <Tag>{userFields.user0}</Tag>
|
||||
</th>
|
||||
<th>
|
||||
user1 <Tag>{userFields.user1}</Tag>
|
||||
</th>
|
||||
<th>
|
||||
user2 <Tag>{userFields.user2}</Tag>
|
||||
</th>
|
||||
<th>
|
||||
user3 <Tag>{userFields.user3}</Tag>
|
||||
</th>
|
||||
<th>
|
||||
user4 <Tag>{userFields.user4}</Tag>
|
||||
</th>
|
||||
<th>
|
||||
user5 <Tag>{userFields.user5}</Tag>
|
||||
</th>
|
||||
<th>
|
||||
user6 <Tag>{userFields.user6}</Tag>
|
||||
</th>
|
||||
<th>
|
||||
user7 <Tag>{userFields.user7}</Tag>
|
||||
</th>
|
||||
<th>
|
||||
user8 <Tag>{userFields.user8}</Tag>
|
||||
</th>
|
||||
<th>
|
||||
user9 <Tag>{userFields.user9}</Tag>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className={style.body}>
|
||||
{rundown.map((event, index) => {
|
||||
const key = event.id;
|
||||
if (isOntimeEvent(event)) {
|
||||
const colour = event.colour ? getAccessibleColour(event.colour) : {};
|
||||
const isPublic = booleanToText(event.isPublic);
|
||||
const skip = booleanToText(event.skip);
|
||||
return (
|
||||
<tr key={key}>
|
||||
<th>
|
||||
<Tag>{index + 1}</Tag>
|
||||
</th>
|
||||
<th>Event</th>
|
||||
<th>{event.cue}</th>
|
||||
<th>{event.title}</th>
|
||||
<th>{event.subtitle}</th>
|
||||
<th>{event.presenter}</th>
|
||||
<th>{event.note}</th>
|
||||
<th>{millisToString(event.timeStart)}</th>
|
||||
<th>{millisToString(event.timeEnd)}</th>
|
||||
<th>{millisToString(event.duration)}</th>
|
||||
<th>{isPublic && <Tag>{isPublic}</Tag>}</th>
|
||||
<th>{skip && <Tag>{skip}</Tag>}</th>
|
||||
<th style={{ ...colour }}>{event.colour}</th>
|
||||
<th>
|
||||
<Tag>{event.timerType}</Tag>
|
||||
</th>
|
||||
<th>
|
||||
<Tag>{event.endAction}</Tag>
|
||||
</th>
|
||||
<th>{event.user0}</th>
|
||||
<th>{event.user1}</th>
|
||||
<th>{event.user2}</th>
|
||||
<th>{event.user3}</th>
|
||||
<th>{event.user4}</th>
|
||||
<th>{event.user5}</th>
|
||||
<th>{event.user6}</th>
|
||||
<th>{event.user7}</th>
|
||||
<th>{event.user8}</th>
|
||||
<th>{event.user9}</th>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Tag({ children }: { children: ReactNode }) {
|
||||
return <span className={style.tag}>{children}</span>;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
@use "../../../theme/_ontimeColours" as *;
|
||||
|
||||
.container {
|
||||
max-width: 100%;
|
||||
max-height: max(300px, 30vh);
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
.scrollContainer {
|
||||
overflow-x: scroll;
|
||||
}
|
||||
|
||||
.rundownPreview {
|
||||
font-size: calc(1rem - 2px);
|
||||
border-collapse: separate;
|
||||
}
|
||||
|
||||
.header,
|
||||
.body {
|
||||
th {
|
||||
font-weight: 400;
|
||||
height: unset;
|
||||
line-height: calc(1rem - 2px);
|
||||
white-space: nowrap;
|
||||
padding-left: 0.25rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
th {
|
||||
font-weight: 200;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
tr {
|
||||
word-wrap: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
|
||||
tr:nth-child(odd) {
|
||||
background-color: $gray-50;
|
||||
}
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 10px;
|
||||
background-color: $blue-500;
|
||||
color: $pure-white;
|
||||
border-radius: 2px;
|
||||
padding: 0 0.25rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { UserFields } from 'ontime-types';
|
||||
|
||||
import style from './PreviewColumn.module.scss';
|
||||
|
||||
interface PreviewUserFieldProps {
|
||||
userFields: UserFields;
|
||||
}
|
||||
|
||||
export default function PreviewUserField({ userFields }: PreviewUserFieldProps) {
|
||||
return (
|
||||
<div className={style.previewTable}>
|
||||
<span className={style.field}>user0</span>
|
||||
<span className={style.value}>{userFields.user0}</span>
|
||||
<span className={style.field}>user1</span>
|
||||
<span className={style.value}>{userFields.user1}</span>
|
||||
<span className={style.field}>user2</span>
|
||||
<span className={style.value}>{userFields.user2}</span>
|
||||
<span className={style.field}>user3</span>
|
||||
<span className={style.value}>{userFields.user3}</span>
|
||||
<span className={style.field}>user4</span>
|
||||
<span className={style.value}>{userFields.user4}</span>
|
||||
<span className={style.field}>user5</span>
|
||||
<span className={style.value}>{userFields.user5}</span>
|
||||
<span className={style.field}>user6</span>
|
||||
<span className={style.value}>{userFields.user6}</span>
|
||||
<span className={style.field}>user7</span>
|
||||
<span className={style.value}>{userFields.user7}</span>
|
||||
<span className={style.field}>user8</span>
|
||||
<span className={style.value}>{userFields.user8}</span>
|
||||
<span className={style.field}>user9</span>
|
||||
<span className={style.value}>{userFields.user9}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -58,7 +58,7 @@ function NavigationMenu() {
|
||||
const handleMirror = () => toggleMirror();
|
||||
|
||||
const showEditFormDrawer = () => {
|
||||
searchParams.set('edit', 'true');
|
||||
searchParams.append('edit', 'true');
|
||||
setSearchParams(searchParams);
|
||||
};
|
||||
|
||||
@@ -116,10 +116,6 @@ 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
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import Empty from '../state/Empty';
|
||||
|
||||
import { useSchedule } from './ScheduleContext';
|
||||
import ScheduleItem from './ScheduleItem';
|
||||
|
||||
@@ -11,9 +13,8 @@ interface 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 null;
|
||||
return <Empty text='No events to show' />;
|
||||
}
|
||||
|
||||
let selectedState: 'past' | 'now' | 'future' = 'past';
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
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[];
|
||||
@@ -34,25 +32,12 @@ export const ScheduleProvider = ({
|
||||
time = 10,
|
||||
}: PropsWithChildren<ScheduleProviderProps>) => {
|
||||
const [visiblePage, setVisiblePage] = useState(0);
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// 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 numPages = Math.ceil(events.length / eventsPerPage);
|
||||
const eventStart = eventsPerPage * visiblePage;
|
||||
const eventEnd = eventsPerPage * (visiblePage + 1);
|
||||
const paginatedEvents = viewEvents.slice(eventStart, eventEnd);
|
||||
const paginatedEvents = events.slice(eventStart, eventEnd);
|
||||
const selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
|
||||
|
||||
const resolveScheduleType = () => {
|
||||
if (selectedEventIndex >= eventStart && selectedEventIndex < eventEnd) {
|
||||
@@ -67,9 +52,7 @@ export const ScheduleProvider = ({
|
||||
|
||||
// every SCROLL_TIME go to the next array
|
||||
useInterval(() => {
|
||||
if (stopCycle) {
|
||||
setVisiblePage(0);
|
||||
} else if (events.length > eventsPerPage) {
|
||||
if (events.length > eventsPerPage) {
|
||||
const next = (visiblePage + 1) % numPages;
|
||||
setVisiblePage(next);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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-1250;
|
||||
background-color: $gray-1200;
|
||||
}
|
||||
|
||||
.drawerHeader {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { UserFields } from 'ontime-types';
|
||||
|
||||
import { ParamField } from './types';
|
||||
|
||||
export const TIME_FORMAT_OPTION: ParamField = {
|
||||
@@ -132,7 +130,7 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
|
||||
{
|
||||
id: 'hideovertime',
|
||||
title: 'Hide Overtime',
|
||||
description: 'Whether to suppress overtime styles (red borders and red text)',
|
||||
description: 'Whether to supress overtime styles (red borders and red text)',
|
||||
type: 'boolean',
|
||||
},
|
||||
{
|
||||
@@ -194,37 +192,6 @@ 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,
|
||||
{
|
||||
@@ -234,61 +201,3 @@ 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,32 +0,0 @@
|
||||
// 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,63 +0,0 @@
|
||||
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 */
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,15 +13,6 @@ 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,
|
||||
@@ -79,8 +70,7 @@ export const setPlayback = {
|
||||
|
||||
export const useInfoPanel = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
eventNow: state.eventNow,
|
||||
eventNext: state.eventNext,
|
||||
titles: state.titles,
|
||||
playback: state.playback,
|
||||
selectedEventIndex: state.loaded.selectedEventIndex,
|
||||
numEvents: state.loaded.numEvents,
|
||||
@@ -95,7 +85,7 @@ export const useCuesheet = () => {
|
||||
selectedEventId: state.loaded.selectedEventId,
|
||||
selectedEventIndex: state.loaded.selectedEventIndex,
|
||||
numEvents: state.loaded.numEvents,
|
||||
titleNow: state.eventNow?.title || '',
|
||||
titleNow: state.titles.titleNow,
|
||||
});
|
||||
|
||||
return useRuntimeStore(featureSelector, deepCompare);
|
||||
|
||||
@@ -42,10 +42,26 @@ export const runtimeStorePlaceholder = {
|
||||
nextEventId: null,
|
||||
nextPublicEventId: null,
|
||||
},
|
||||
eventNow: null,
|
||||
eventNext: null,
|
||||
publicEventNow: null,
|
||||
publicEventNext: 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,
|
||||
},
|
||||
};
|
||||
|
||||
export const runtime = createStore<RuntimeStore>(() => ({
|
||||
|
||||
@@ -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 different, we redirect user to the new URL
|
||||
// if the alias fits the alias on this page, but the URL is diferent, 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);
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
export function debounce(callback: () => void, wait: number) {
|
||||
let timeout: NodeJS.Timeout | null;
|
||||
return () => {
|
||||
if (timeout) {
|
||||
return;
|
||||
}
|
||||
timeout = setTimeout(() => {
|
||||
timeout = null;
|
||||
callback();
|
||||
}, wait);
|
||||
};
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export function isMacOS() {
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
return userAgent.includes('macintosh') || userAgent.includes('mac os');
|
||||
}
|
||||
|
||||
export const deviceAlt = isMacOS() ? '⌥' : 'Alt';
|
||||
@@ -1,56 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -87,6 +87,18 @@ 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;
|
||||
|
||||
@@ -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) {
|
||||
/* we do not handle errors here */
|
||||
} catch (error) {
|
||||
console.log(`Unable to parse colour: ${bgColour}`);
|
||||
}
|
||||
}
|
||||
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(" ");
|
||||
|
||||
@@ -51,7 +51,6 @@ export const formatTime = (milliseconds: number | null, options?: FormatOptions,
|
||||
return '...';
|
||||
}
|
||||
const timeFormat = resolver();
|
||||
const fallback = options?.showSeconds ? 'hh:mm:ss a' : 'hh:mm a';
|
||||
const { showSeconds = false, format: formatString = fallback } = options || {};
|
||||
const { showSeconds = false, format: formatString = 'hh:mm a' } = options || {};
|
||||
return timeFormat === '12' ? formatFromMillis(milliseconds, formatString) : millisToString(milliseconds, showSeconds);
|
||||
};
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function MessageControl() {
|
||||
actionHandler={() => setMessage.presenterVisible(!data.timerMessage.visible)}
|
||||
/>
|
||||
<div className={style.buttonSection}>
|
||||
<label className={style.label}>Timer message blink</label>
|
||||
<label className={style.label}>Timer messsage blink</label>
|
||||
<label className={style.label}>Blackout timer screens</label>
|
||||
<Button
|
||||
className={`${data.timerMessage.timerBlink ? style.blink : ''}`}
|
||||
|
||||
@@ -44,11 +44,6 @@ $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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,11 +61,6 @@ $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;
|
||||
@@ -123,6 +113,14 @@ $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);
|
||||
|
||||
@@ -1,21 +1,33 @@
|
||||
import { useRef } from 'react';
|
||||
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 { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||
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>[];
|
||||
@@ -34,8 +46,6 @@ 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,
|
||||
@@ -53,6 +63,78 @@ 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);
|
||||
@@ -66,8 +148,6 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
|
||||
setColumnSizing({});
|
||||
};
|
||||
|
||||
const headerGroups = table.getHeaderGroups;
|
||||
|
||||
let eventIndex = 0;
|
||||
let isPast = Boolean(selectedId);
|
||||
|
||||
@@ -83,7 +163,37 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
|
||||
)}
|
||||
<div ref={tableContainerRef} className={style.cuesheetContainer}>
|
||||
<table className={style.cuesheet}>
|
||||
<CuesheetHeader headerGroups={headerGroups} />
|
||||
<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>
|
||||
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map((row) => {
|
||||
const key = row.original.id;
|
||||
@@ -93,7 +203,13 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
|
||||
}
|
||||
|
||||
if (isOntimeBlock(row.original)) {
|
||||
return <BlockRow key={key} title={row.original.title} />;
|
||||
const title = row.original.title;
|
||||
|
||||
return (
|
||||
<tr key={key} className={style.blockRow}>
|
||||
<td>{title}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
if (isOntimeDelay(row.original)) {
|
||||
const delayVal = row.original.duration;
|
||||
@@ -102,7 +218,12 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
|
||||
return null;
|
||||
}
|
||||
|
||||
return <DelayRow key={key} duration={delayVal} />;
|
||||
const delayTime = millisToDelayString(delayVal);
|
||||
return (
|
||||
<tr key={key} className={style.delayRow}>
|
||||
<td>{delayTime}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
if (isOntimeEvent(row.original)) {
|
||||
eventIndex++;
|
||||
@@ -115,28 +236,25 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
|
||||
return null;
|
||||
}
|
||||
|
||||
let rowBgColour: string | undefined;
|
||||
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 */
|
||||
}
|
||||
}
|
||||
const bgFallback = 'transparent';
|
||||
const bgColour = row.original.colour || bgFallback;
|
||||
const textColour = bgColour === bgFallback ? undefined : getAccessibleColour(bgColour);
|
||||
const isSkipped = row.original.skip;
|
||||
|
||||
let rowBgColour: string | undefined;
|
||||
if (row.original.id === selectedId) {
|
||||
rowBgColour = '#D20300'; // $red-700
|
||||
}
|
||||
return (
|
||||
<EventRow
|
||||
<tr
|
||||
key={key}
|
||||
eventIndex={eventIndex}
|
||||
isPast={isPast}
|
||||
selectedRef={isSelected ? selectedRef : undefined}
|
||||
skip={row.original.skip}
|
||||
colour={row.original.colour}
|
||||
className={`${style.eventRow} ${isSkipped ? style.skip : ''}`}
|
||||
style={{ opacity: `${isPast ? pastOpacity : '1'}` }}
|
||||
ref={isSelected ? selectedRef : undefined}
|
||||
>
|
||||
<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 }}>
|
||||
@@ -144,7 +262,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</EventRow>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,4 +21,4 @@
|
||||
border: 1px solid $white-10;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { OntimeRundownEntry, ProjectData } from 'ontime-types';
|
||||
|
||||
import Empty from '../../common/components/state/Empty';
|
||||
@@ -6,7 +6,6 @@ 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';
|
||||
@@ -21,8 +20,6 @@ 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(() => {
|
||||
@@ -72,76 +69,37 @@ export default function CuesheetWrapper() {
|
||||
);
|
||||
|
||||
const exportHandler = useCallback(
|
||||
(headerData: ProjectData, exportType: ExportType) => {
|
||||
(headerData: ProjectData) => {
|
||||
if (!headerData || !rundown || !userFields) {
|
||||
return;
|
||||
}
|
||||
|
||||
let fileName = '';
|
||||
let url = '';
|
||||
const sheetData = makeTable(headerData, rundown, userFields);
|
||||
const csvContent = makeCSV(sheetData);
|
||||
|
||||
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 blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', fileName);
|
||||
link.setAttribute('download', 'ontime export.csv');
|
||||
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 handleExport={handleOpenModal} featureData={featureData} />
|
||||
<CuesheetTableHeader handleCSVExport={exportHandler} featureData={featureData} />
|
||||
<Cuesheet data={rundown} columns={columns} handleUpdate={handleUpdate} selectedId={featureData.selectedEventId} />
|
||||
<ExportModal isOpen={isModalOpen} onClose={onModalClose} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,11 +6,7 @@ exports[`makeTable() > returns array of arrays with given fields 1`] = `
|
||||
"Ontime · Schedule Template",
|
||||
],
|
||||
[
|
||||
"Project Title",
|
||||
"",
|
||||
],
|
||||
[
|
||||
"Project Description",
|
||||
"Event Name",
|
||||
"",
|
||||
],
|
||||
[
|
||||
|
||||
@@ -26,7 +26,7 @@ describe('parseField()', () => {
|
||||
});
|
||||
|
||||
it('returns an empty string on undefined fields', () => {
|
||||
expect(parseField('presenter')).toBe('');
|
||||
expect(parseField('presenter', undefined)).toBe('');
|
||||
});
|
||||
|
||||
describe('simply returns any other value in any other field', () => {
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
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);
|
||||
@@ -1,108 +0,0 @@
|
||||
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);
|
||||
@@ -1,6 +0,0 @@
|
||||
interface CuesheetRowProps {
|
||||
row: OntimeRundownEntry;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
function CuesheetRow() {}
|
||||
@@ -1,22 +0,0 @@
|
||||
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);
|
||||
@@ -1,66 +0,0 @@
|
||||
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);
|
||||
+4
-4
@@ -101,13 +101,13 @@ $active-colour: $gray-500;
|
||||
.actionIcon {
|
||||
cursor: pointer;
|
||||
|
||||
&.enabled {
|
||||
color: $active-indicator;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: $active-colour;
|
||||
}
|
||||
|
||||
&.enabled {
|
||||
color: $active-indicator;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,19 +4,20 @@ 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 { Playback, ProjectData } from 'ontime-types';
|
||||
import { formatDisplay } from 'ontime-utils';
|
||||
|
||||
import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
|
||||
import useFullscreen from '../../../common/hooks/useFullscreen';
|
||||
import { useTimer } from '../../../common/hooks/useSocket';
|
||||
import useProjectData from '../../../common/hooks-query/useProjectData';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||
import { useCuesheetSettings } from '../store/CuesheetSettings';
|
||||
|
||||
import CuesheetTableHeaderTimers from './CuesheetTableHeaderTimers';
|
||||
import PlaybackIcon from '../tableElements/PlaybackIcon';
|
||||
|
||||
import style from './CuesheetTableHeader.module.scss';
|
||||
|
||||
interface CuesheetTableHeaderProps {
|
||||
handleExport: (headerData: ProjectData) => void;
|
||||
handleCSVExport: (headerData: ProjectData) => void;
|
||||
featureData: {
|
||||
playback: Playback;
|
||||
selectedEventIndex: number | null;
|
||||
@@ -25,17 +26,18 @@ interface CuesheetTableHeaderProps {
|
||||
};
|
||||
}
|
||||
|
||||
export default function CuesheetTableHeader({ handleExport, featureData }: CuesheetTableHeaderProps) {
|
||||
export default function CuesheetTableHeader({ handleCSVExport, 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: project } = useProjectData();
|
||||
|
||||
const exportProject = () => {
|
||||
const exportCsv = () => {
|
||||
if (project) {
|
||||
handleExport(project);
|
||||
handleCSVExport(project);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -45,6 +47,14 @@ export default function CuesheetTableHeader({ handleExport, featureData }: Cuesh
|
||||
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}>
|
||||
@@ -55,7 +65,14 @@ export default function CuesheetTableHeader({ handleExport, featureData }: Cuesh
|
||||
<div className={style.playbackLabel}>{selected}</div>
|
||||
<PlaybackIcon state={featureData.playback} />
|
||||
</div>
|
||||
<CuesheetTableHeaderTimers />
|
||||
<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>
|
||||
<div className={style.headerActions}>
|
||||
<Tooltip openDelay={tooltipDelayFast} label='Toggle follow'>
|
||||
<span onClick={() => toggleFollow()} className={`${style.actionIcon} ${followSelected ? style.enabled : ''}`}>
|
||||
@@ -72,9 +89,9 @@ export default function CuesheetTableHeader({ handleExport, featureData }: Cuesh
|
||||
{isFullScreen ? <IoContract /> : <IoExpand />}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip openDelay={tooltipDelayFast} label='Export rundown'>
|
||||
<span className={style.actionIcon} onClick={exportProject}>
|
||||
Export
|
||||
<Tooltip openDelay={tooltipDelayFast} label='Export rundown to CSV'>
|
||||
<span className={style.actionIcon} onClick={exportCsv}>
|
||||
CSV
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
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,4 +1,3 @@
|
||||
import { memo } from 'react';
|
||||
import { Button, Checkbox, Switch } from '@chakra-ui/react';
|
||||
import { Column } from '@tanstack/react-table';
|
||||
import { OntimeRundownEntry } from 'ontime-types';
|
||||
@@ -20,7 +19,7 @@ interface CuesheetTableSettingsProps {
|
||||
handleClearToggles: () => void;
|
||||
}
|
||||
|
||||
function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
|
||||
export default function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
|
||||
const { columns, handleResetResizing, handleResetReordering, handleClearToggles } = props;
|
||||
const showPrevious = useCuesheetSettings((state) => state.showPrevious);
|
||||
const togglePreviousVisibility = useCuesheetSettings((state) => state.togglePreviousVisibility);
|
||||
@@ -82,5 +81,3 @@ function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(CuesheetTableSettings);
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
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 DelayIndicator from '../../common/components/delay-indicator/DelayIndicator';
|
||||
import { millisToDelayString } from '../../common/utils/dateConfig';
|
||||
import { tooltipDelayFast } from '../../ontimeConfig';
|
||||
|
||||
import EditableCell from './cuesheet-table-elements/EditableCell';
|
||||
import { useCuesheetSettings } from './store/CuesheetSettings';
|
||||
import EditableCell from './tableElements/EditableCell';
|
||||
|
||||
import style from './Cuesheet.module.scss';
|
||||
|
||||
@@ -16,6 +20,30 @@ 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;
|
||||
|
||||
+2
-8
@@ -8,12 +8,10 @@ import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||
|
||||
interface PlaybackIconProps {
|
||||
state: Playback;
|
||||
skipTooltip?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function PlaybackIcon(props: PlaybackIconProps) {
|
||||
const { state, skipTooltip, className } = props;
|
||||
const { state } = props;
|
||||
|
||||
// if timer is Pause or Armed
|
||||
let label = 'Timer Paused';
|
||||
@@ -30,13 +28,9 @@ export default function PlaybackIcon(props: PlaybackIconProps) {
|
||||
Icon = IoStop;
|
||||
}
|
||||
|
||||
if (skipTooltip) {
|
||||
return <Icon className={className} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip openDelay={tooltipDelayFast} label={label} shouldWrapChildren>
|
||||
<Icon className={className} />
|
||||
<Icon />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -144,7 +144,7 @@ $playback-width: 26rem;
|
||||
.eventEditor {
|
||||
border-radius: 8px 8px 0 0;
|
||||
background-color: $bg-container-l2;
|
||||
box-shadow: $large-bottom-drawer-shadow;
|
||||
box-shadow: rgba(0, 0, 0, 0.35) 0 3px 6px 6px;
|
||||
border-top: 1px solid $white-20;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
@@ -164,7 +164,7 @@ $playback-width: 26rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
background-color: $gray-1250;
|
||||
background-color: $gray-1200;
|
||||
padding: 0.5rem;
|
||||
border-left: 1px solid $white-10;
|
||||
border-radius: 0 8px 0 0;
|
||||
|
||||
@@ -12,17 +12,17 @@ export default function Info() {
|
||||
const showNif = useEditorSettings((state) => state.eventSettings.showNif);
|
||||
|
||||
const titlesNow = {
|
||||
title: data.eventNow?.title || '',
|
||||
subtitle: data.eventNow?.subtitle || '',
|
||||
presenter: data.eventNow?.presenter || '',
|
||||
note: data.eventNow?.note || '',
|
||||
title: data.titles.titleNow || '',
|
||||
subtitle: data.titles.subtitleNow || '',
|
||||
presenter: data.titles.presenterNow || '',
|
||||
note: data.titles.noteNow || '',
|
||||
};
|
||||
|
||||
const titlesNext = {
|
||||
title: data.eventNext?.title || '',
|
||||
subtitle: data.eventNext?.subtitle || '',
|
||||
presenter: data.eventNext?.presenter || '',
|
||||
note: data.eventNext?.note || '',
|
||||
title: data.titles.titleNext || '',
|
||||
subtitle: data.titles.subtitleNext || '',
|
||||
presenter: data.titles.presenterNext || '',
|
||||
note: data.titles.noteNext || '',
|
||||
};
|
||||
|
||||
const selected = !data.numEvents
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
import { memo, useCallback, useEffect } 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,12 +10,11 @@ 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 { downloadCSV, downloadRundown } from '../../common/api/ontimeApi';
|
||||
import { 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';
|
||||
|
||||
@@ -101,22 +100,6 @@ 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' />
|
||||
@@ -145,14 +128,12 @@ const MenuBar = (props: MenuBarProps) => {
|
||||
{...buttonStyle}
|
||||
icon={<IoSaveOutline />}
|
||||
isDisabled={appMode === AppMode.Run}
|
||||
clickHandler={() => setIsModalOpen(true)}
|
||||
clickHandler={downloadRundown}
|
||||
tooltip='Export project file'
|
||||
aria-label='Export project file'
|
||||
size='sm'
|
||||
/>
|
||||
|
||||
<ExportModal onClose={onModalClose} isOpen={isModalOpen} />
|
||||
|
||||
<div className={style.gap} />
|
||||
<TooltipActionBtn
|
||||
{...buttonStyle}
|
||||
|
||||
@@ -41,8 +41,8 @@ $el-padding-with-compensation: 24px; // 16 + 8
|
||||
.title {
|
||||
font-size: $inner-section-text-size;
|
||||
color: $gray-500;
|
||||
padding-left: 8px;
|
||||
margin: 8px 0;
|
||||
padding-left: 0.5rem;
|
||||
margin: 0.5rem 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
.modalHeader {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.modalBody {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export default function ExportModal(props: ExportModalProps) {
|
||||
const { isOpen, onClose } = 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='ontime-subtle-on-light' width='48%'>
|
||||
Rundown as CSV
|
||||
</Button>
|
||||
<Button onClick={() => onClose('json')} variant='ontime-filled' width='48%'>
|
||||
Project file
|
||||
</Button>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
@use '../../../theme/v2Styles' as *;
|
||||
@use '../../../theme/ontimeColours' as *;
|
||||
|
||||
.header {
|
||||
font-size: $inner-section-text-size;
|
||||
color: $gray-500;
|
||||
padding-left: 0.5rem;
|
||||
margin: 0.5rem 0;
|
||||
text-transform: uppercase;
|
||||
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.moreExpanded {
|
||||
transform: scaleY(-1);
|
||||
transition: transform $transition-time-feedback;
|
||||
}
|
||||
|
||||
.moreCollapsed {
|
||||
transform: scaleY(1);
|
||||
transition: transform $transition-time-feedback;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { PropsWithChildren, useState } from 'react';
|
||||
import { IoChevronUp } from '@react-icons/all-files/io5/IoChevronUp';
|
||||
|
||||
import style from './CollapsableSection.module.scss';
|
||||
|
||||
interface CollapsableSectionProps {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export default function CollapsableSection(props: PropsWithChildren<CollapsableSectionProps>) {
|
||||
const { title, children } = props;
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={style.title} onClick={() => setCollapsed((prev) => !prev)}>
|
||||
{title}
|
||||
<IoChevronUp className={collapsed ? style.moreCollapsed : style.moreExpanded} />
|
||||
</div>
|
||||
{!collapsed && children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { OntimeRundown, ProjectData, UserFields } from 'ontime-types';
|
||||
|
||||
import PreviewProjectData from '../../../common/components/import-preview/PreviewProjectData';
|
||||
import PreviewRundown from '../../../common/components/import-preview/PreviewRundown';
|
||||
|
||||
import style from '../Modal.module.scss';
|
||||
|
||||
interface ReviewFileProps {
|
||||
rundown: OntimeRundown;
|
||||
project: ProjectData;
|
||||
userFields: UserFields;
|
||||
}
|
||||
|
||||
export default function ReviewFile(props: ReviewFileProps) {
|
||||
const { rundown, project, userFields } = props;
|
||||
|
||||
return (
|
||||
<div className={style.columnSection}>
|
||||
<div className={style.title}>Review Project Data</div>
|
||||
<PreviewProjectData project={project} />
|
||||
<div className={style.title}>Review Rundown</div>
|
||||
<PreviewRundown rundown={rundown} userFields={userFields} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { ChangeEvent, useRef, useState } from 'react';
|
||||
import { Input } from '@chakra-ui/react';
|
||||
|
||||
import UploadEntry from './upload-entry/UploadEntry';
|
||||
import { useUploadModalContextStore } from './uploadModalContext';
|
||||
import { validateFile } from './uploadUtils';
|
||||
|
||||
import style from './UploadModal.module.scss';
|
||||
|
||||
export default function UploadFile() {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { file, setFile, progress } = useUploadModalContextStore();
|
||||
|
||||
const [errors, setErrors] = useState<string | undefined>();
|
||||
const success = false;
|
||||
|
||||
const clearFile = () => {
|
||||
setFile(null);
|
||||
};
|
||||
|
||||
const handleFile = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const fileSelected = event?.target?.files?.[0];
|
||||
if (!fileSelected) return;
|
||||
|
||||
const validate = validateFile(fileSelected);
|
||||
setErrors(validate.errors?.[0]);
|
||||
|
||||
if (validate.isValid) {
|
||||
setFile(fileSelected);
|
||||
} else {
|
||||
setFile(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
type='file'
|
||||
onChange={handleFile}
|
||||
accept='.json, .xlsx'
|
||||
data-testid='file-input'
|
||||
/>
|
||||
<div className={style.uploadArea} onClick={handleClick}>
|
||||
Click to upload Ontime project or xlsx file
|
||||
</div>
|
||||
{(file || errors) && (
|
||||
<UploadEntry file={file} errors={errors} progress={progress} success={success} handleClear={clearFile} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5,79 +5,31 @@
|
||||
.uploadBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.uploadArea {
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
border: 2px dashed $gray-50;
|
||||
max-width: 550px;
|
||||
|
||||
min-height: 150px;
|
||||
border: 2px dashed $gray-200;
|
||||
border-radius: 3px;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
transition-property: background-color;
|
||||
transition-duration: $transition-time-action;
|
||||
font-size: calc(1rem - 1px);
|
||||
|
||||
&:hover {
|
||||
border: 2px solid $blue-500;
|
||||
background-color: $blue-50;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.comment {
|
||||
color: gray;
|
||||
}
|
||||
}
|
||||
|
||||
.uploadedItem {
|
||||
background-color: $gray-50;
|
||||
padding: 8px;
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"icon title close"
|
||||
"icon info ."
|
||||
"progress progress progress";
|
||||
grid-template-columns: auto 1fr auto;
|
||||
column-gap: 16px;
|
||||
border-radius: 3px;
|
||||
|
||||
.icon {
|
||||
align-self: center;
|
||||
grid-area: icon;
|
||||
font-size: 32px;
|
||||
color: $gray-700;
|
||||
}
|
||||
|
||||
.fileTitle {
|
||||
grid-area: title;
|
||||
font-size: 14px;
|
||||
color: $gray-1350;
|
||||
}
|
||||
|
||||
.fileInfo {
|
||||
grid-area: info;
|
||||
font-size: 12px;
|
||||
color: $gray-1100;
|
||||
}
|
||||
|
||||
.fileProgress {
|
||||
grid-area: progress;
|
||||
}
|
||||
|
||||
.cancelUpload {
|
||||
grid-area: close;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.error {
|
||||
.icon {
|
||||
color: $error-red;
|
||||
}
|
||||
}
|
||||
|
||||
&.success {
|
||||
.icon {
|
||||
color: $green-500;
|
||||
}
|
||||
color: $modal-note-color;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,3 +41,9 @@
|
||||
.pad {
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
.twoColumn {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ChangeEvent, useCallback, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
@@ -9,23 +8,36 @@ import {
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Progress,
|
||||
Switch,
|
||||
} from '@chakra-ui/react';
|
||||
import { IoClose } from '@react-icons/all-files/io5/IoClose';
|
||||
import { IoDocumentTextOutline } from '@react-icons/all-files/io5/IoDocumentTextOutline';
|
||||
import { IoWarningOutline } from '@react-icons/all-files/io5/IoWarningOutline';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { OntimeRundown } from 'ontime-types';
|
||||
|
||||
import { RUNDOWN_TABLE } from '../../../common/api/apiConstants';
|
||||
import { uploadData } from '../../../common/api/ontimeApi';
|
||||
import { useEmitLog } from '../../../common/stores/logger';
|
||||
import ModalSplitInput from '../ModalSplitInput';
|
||||
import { postPreviewExcel, uploadData } from '../../../common/api/ontimeApi';
|
||||
import { projectDataPlaceholder } from '../../../common/models/ProjectData';
|
||||
import { userFieldsPlaceholder } from '../../../common/models/UserFields';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
|
||||
import { validateFile } from './utils';
|
||||
import ExcelFileOptions from './upload-options/ExcelFileOptions';
|
||||
import OntimeFileOptions from './upload-options/OntimeFileOptions';
|
||||
import UploadStepTracker from './upload-step/UploadStep';
|
||||
import ReviewFile from './ReviewExcel';
|
||||
import UploadFile from './UploadFile';
|
||||
import { useUploadModalContextStore } from './uploadModalContext';
|
||||
import { defaultExcelImportMap, ExcelImportMapKeys, isExcelFile, isOntimeFile } from './uploadUtils';
|
||||
|
||||
import style from './UploadModal.module.scss';
|
||||
|
||||
export type UploadStep = 'upload' | 'review';
|
||||
|
||||
export interface OntimeInputOptions {
|
||||
onlyImportRundown?: boolean;
|
||||
}
|
||||
|
||||
export type ExcelInputOptions = {
|
||||
[K in ExcelImportMapKeys]: string;
|
||||
};
|
||||
|
||||
interface UploadModalProps {
|
||||
onClose: () => void;
|
||||
isOpen: boolean;
|
||||
@@ -33,65 +45,106 @@ interface UploadModalProps {
|
||||
|
||||
export default function UploadModal({ onClose, isOpen }: UploadModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { emitError } = useEmitLog();
|
||||
const [errors, setErrors] = useState<string | undefined>();
|
||||
const [isSubmitting, setSubmitting] = useState(false);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const overrideOptionRef = useRef<HTMLInputElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const handleFile = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
||||
const fileUploaded = event?.target?.files?.[0];
|
||||
if (!fileUploaded) return;
|
||||
const { file, setProgress, clear } = useUploadModalContextStore();
|
||||
|
||||
const validate = validateFile(fileUploaded);
|
||||
setErrors(validate.errors?.[0]);
|
||||
const [uploadStep, setUploadStep] = useState<UploadStep>('upload');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [rundown, setRundown] = useState<OntimeRundown>([]);
|
||||
const [userFields, setUserFields] = useState(userFieldsPlaceholder);
|
||||
const [project, setProject] = useState(projectDataPlaceholder);
|
||||
|
||||
if (validate.isValid) {
|
||||
setFile(fileUploaded);
|
||||
} else {
|
||||
setFile(null);
|
||||
}
|
||||
}, []);
|
||||
const [errors, setErrors] = useState('');
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
setSubmitting(true);
|
||||
const ontimeFileOptions = useRef<Partial<OntimeInputOptions>>({});
|
||||
const excelFileOptions = useRef<Partial<ExcelInputOptions>>(defaultExcelImportMap);
|
||||
|
||||
useEffect(() => {
|
||||
clear();
|
||||
setUploadStep('upload');
|
||||
setSubmitting(false);
|
||||
setRundown([]);
|
||||
setUserFields(userFieldsPlaceholder);
|
||||
setProject(projectDataPlaceholder);
|
||||
setErrors('');
|
||||
}, [clear, isOpen]);
|
||||
|
||||
const handleParse = async () => {
|
||||
if (file) {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const options = {
|
||||
onlyRundown: overrideOptionRef.current?.checked || false,
|
||||
};
|
||||
await uploadData(file, setProgress, options);
|
||||
if (isOntimeFile(file)) {
|
||||
await handleOntimeFile(file);
|
||||
await queryClient.invalidateQueries(RUNDOWN_TABLE);
|
||||
} else if (isExcelFile(file)) {
|
||||
await handleExcelFile(file);
|
||||
}
|
||||
} catch (error) {
|
||||
emitError(`Failed uploading file: ${error}`);
|
||||
setErrors(`Failed uploading file: ${error}`);
|
||||
} finally {
|
||||
await queryClient.invalidateQueries(RUNDOWN_TABLE);
|
||||
setSuccess(true);
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
setSubmitting(false);
|
||||
}, [emitError, file, queryClient]);
|
||||
|
||||
const handleClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
async function handleExcelFile(file: File) {
|
||||
const options = excelFileOptions.current;
|
||||
// TODO: option type should be central, to also be used by backend
|
||||
const response = await postPreviewExcel(file, setProgress, options);
|
||||
if (response.status === 200) {
|
||||
setRundown(response.data.rundown);
|
||||
setUserFields(response.data.userFields);
|
||||
setProject(response.data.project);
|
||||
setUploadStep('review');
|
||||
}
|
||||
}
|
||||
|
||||
const clearFile = () => {
|
||||
setFile(null);
|
||||
async function handleOntimeFile(file: File) {
|
||||
const options = {
|
||||
onlyRundown: Boolean(ontimeFileOptions.current.onlyImportRundown),
|
||||
};
|
||||
await uploadData(file, setProgress, options);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
clearFile();
|
||||
setSuccess(false);
|
||||
setErrors(undefined);
|
||||
setProgress(0);
|
||||
clear();
|
||||
setRundown([]);
|
||||
setUserFields(userFieldsPlaceholder);
|
||||
setProject(projectDataPlaceholder);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const disableSubmit = !file || isSubmitting;
|
||||
const handleFinalise = async () => {
|
||||
if (file) {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const options = {
|
||||
//onlyRundown: overrideOptionRef.current?.checked || false,
|
||||
};
|
||||
await uploadData(file, setProgress, options);
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
await queryClient.invalidateQueries(RUNDOWN_TABLE);
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isUpload = uploadStep === 'upload';
|
||||
const isExcel = isExcelFile(file);
|
||||
const isOntime = isOntimeFile(file);
|
||||
|
||||
const handleGoBack = isUpload ? undefined : () => setUploadStep('upload');
|
||||
const handleSubmit = isUpload ? handleParse : handleFinalise;
|
||||
const disableSubmit = isUpload && !file;
|
||||
const disableGoBack = isUpload;
|
||||
const submitText = isUpload ? 'Upload' : 'Finish';
|
||||
|
||||
const modalClasses = cx([style.modalWidthOverride, isExcel ? style.doExtend : null]);
|
||||
|
||||
console.log('debug', isExcel, modalClasses);
|
||||
return (
|
||||
<Modal
|
||||
onClose={handleClose}
|
||||
@@ -101,65 +154,43 @@ export default function UploadModal({ onClose, isOpen }: UploadModalProps) {
|
||||
size='xl'
|
||||
scrollBehavior='inside'
|
||||
preserveScrollBarGap
|
||||
variant='ontime-small'
|
||||
variant='ontime-upload'
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>File import</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody className={style.uploadBody}>
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
type='file'
|
||||
onChange={handleFile}
|
||||
accept='.json, .xlsx'
|
||||
data-testid='file-input'
|
||||
/>
|
||||
<div className={style.uploadArea} onClick={handleClick}>
|
||||
Click to upload Ontime project file
|
||||
</div>
|
||||
{file && (
|
||||
<div className={`${style.uploadedItem} ${success ? style.success : ''}`}>
|
||||
<IoClose className={style.cancelUpload} onClick={clearFile} />
|
||||
<IoDocumentTextOutline className={style.icon} />
|
||||
<span className={style.fileTitle}>{file.name}</span>
|
||||
<span className={style.fileInfo}>{`${(file.size / 1024).toFixed(2)}kb - ${file.type}`}</span>
|
||||
<Progress variant='ontime-on-light' className={style.fileProgress} value={progress} />
|
||||
</div>
|
||||
{isExcel && <UploadStepTracker uploadStep={uploadStep} />}
|
||||
{uploadStep === 'upload' ? (
|
||||
<>
|
||||
<UploadFile />
|
||||
{errors && <div className={style.error}>{errors}</div>}
|
||||
{isOntime && <OntimeFileOptions optionsRef={ontimeFileOptions} />}
|
||||
{isExcel && <ExcelFileOptions optionsRef={excelFileOptions} />}
|
||||
</>
|
||||
) : (
|
||||
<ReviewFile rundown={rundown} project={project} userFields={userFields} />
|
||||
)}
|
||||
{errors && (
|
||||
<div className={`${style.uploadedItem} ${style.error}`}>
|
||||
<IoWarningOutline className={style.icon} />
|
||||
<span className={style.fileTitle}>{errors}</span>
|
||||
<span className={style.fileInfo}>Please try again</span>
|
||||
<Progress className={style.fileProgress} value={progress} />
|
||||
</div>
|
||||
)}
|
||||
<div className={style.uploadOptions}>
|
||||
<span className={style.title}>Import options</span>
|
||||
<ModalSplitInput
|
||||
field=''
|
||||
title='Only import rundown'
|
||||
description='All other options, including application settings will be discarded'
|
||||
>
|
||||
<Switch variant='ontime-on-light' ref={overrideOptionRef} />
|
||||
</ModalSplitInput>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter className={`${style.buttonSection} ${style.pad}`}>
|
||||
<Button onClick={handleClose} isDisabled={isSubmitting} variant='ontime-ghost-on-light' size='sm'>
|
||||
Cancel
|
||||
<Button
|
||||
onClick={handleGoBack}
|
||||
isDisabled={disableGoBack || submitting}
|
||||
variant='ontime-ghost-on-light'
|
||||
size='sm'
|
||||
>
|
||||
Go Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
isLoading={isSubmitting}
|
||||
isLoading={submitting}
|
||||
isDisabled={disableSubmit}
|
||||
variant='ontime-filled'
|
||||
padding='0 2em'
|
||||
size='sm'
|
||||
>
|
||||
Import
|
||||
{submitText}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
@use '../../../../theme/ontimeColours' as *;
|
||||
@use '../../../../theme/v2Styles' as *;
|
||||
|
||||
.uploadedItem {
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
max-width: 550px;
|
||||
|
||||
border: 1px solid $gray-200;
|
||||
padding: 0.5rem;
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"icon title close"
|
||||
"icon info ."
|
||||
"progress progress progress";
|
||||
grid-template-columns: auto 1fr auto;
|
||||
column-gap: 1rem;
|
||||
border-radius: 3px;
|
||||
|
||||
.icon {
|
||||
align-self: center;
|
||||
grid-area: icon;
|
||||
font-size: 2rem;
|
||||
color: $gray-700;
|
||||
}
|
||||
|
||||
.fileTitle {
|
||||
grid-area: title;
|
||||
font-size: calc(1rem - 2px);
|
||||
color: $ui-black;
|
||||
}
|
||||
|
||||
.fileInfo {
|
||||
grid-area: info;
|
||||
font-size: calc(1rem - 4px);
|
||||
color: $gray-1100;
|
||||
}
|
||||
|
||||
.fileProgress {
|
||||
grid-area: progress;
|
||||
}
|
||||
|
||||
.cancelUpload {
|
||||
grid-area: close;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.error {
|
||||
.icon {
|
||||
color: $error-red;
|
||||
}
|
||||
}
|
||||
|
||||
&.success {
|
||||
.icon {
|
||||
color: $green-500;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Progress } from '@chakra-ui/react';
|
||||
import { IoClose } from '@react-icons/all-files/io5/IoClose';
|
||||
import { IoDocumentTextOutline } from '@react-icons/all-files/io5/IoDocumentTextOutline';
|
||||
import { IoWarningOutline } from '@react-icons/all-files/io5/IoWarningOutline';
|
||||
|
||||
import { isExcelFile, isOntimeFile } from '../uploadUtils';
|
||||
|
||||
import style from './UploadEntry.module.scss';
|
||||
|
||||
interface UploadEntryProps {
|
||||
file: File | null;
|
||||
errors?: string;
|
||||
progress: number;
|
||||
success: boolean;
|
||||
handleClear: () => void;
|
||||
}
|
||||
|
||||
export default function UploadEntry(props: UploadEntryProps) {
|
||||
const { file, errors, progress, success, handleClear } = props;
|
||||
|
||||
if (errors) {
|
||||
return (
|
||||
<div className={`${style.uploadedItem} ${style.error}`}>
|
||||
<IoWarningOutline className={style.icon} />
|
||||
<span className={style.fileTitle}>{errors}</span>
|
||||
<span className={style.fileInfo}>Please try again</span>
|
||||
<Progress className={style.fileProgress} value={progress} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (file) {
|
||||
const fileSize = `${(file.size / 1024).toFixed(2)}kb`;
|
||||
let fileType = '';
|
||||
if (isOntimeFile(file)) {
|
||||
fileType = 'Ontime Project File';
|
||||
} else if (isExcelFile(file)) {
|
||||
fileType = 'Excel Rundown';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${style.uploadedItem} ${success ? style.success : ''}`}>
|
||||
<IoClose className={style.cancelUpload} onClick={handleClear} />
|
||||
<IoDocumentTextOutline className={style.icon} />
|
||||
<span className={style.fileTitle}>{file.name}</span>
|
||||
<span className={style.fileInfo}>{`${fileSize} - ${fileType}`}</span>
|
||||
<Progress variant='ontime-on-light' className={style.fileProgress} value={progress} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { MutableRefObject } from 'react';
|
||||
import { Input } from '@chakra-ui/react';
|
||||
|
||||
import ModalSplitInput from '../../ModalSplitInput';
|
||||
|
||||
import ImportMapTable, { type TableEntry } from './ImportMapTable';
|
||||
import { ExcelInputOptions } from '../UploadModal';
|
||||
|
||||
import style from '../UploadModal.module.scss';
|
||||
|
||||
interface ExcelFileOptionsProps {
|
||||
optionsRef: MutableRefObject<ExcelInputOptions>;
|
||||
}
|
||||
|
||||
export default function ExcelFileOptions(props: ExcelFileOptionsProps) {
|
||||
const { optionsRef } = props;
|
||||
|
||||
const updateRef = <T extends keyof ExcelInputOptions>(field: T, value: ExcelInputOptions[T]) => {
|
||||
// avoid unnecessary changes
|
||||
if (optionsRef.current[field] !== value) {
|
||||
optionsRef.current = { ...optionsRef.current, [field]: value };
|
||||
}
|
||||
};
|
||||
|
||||
const worksheet: TableEntry[] = [{ label: 'Worksheet', title: 'worksheet', value: optionsRef.current.worksheet }];
|
||||
|
||||
const timings: TableEntry[] = [
|
||||
{ label: 'Start time', title: 'timeStart', value: optionsRef.current.timeStart },
|
||||
{ label: 'End Time', title: 'timeEnd', value: optionsRef.current.timeEnd },
|
||||
{ label: 'Duration', title: 'duration', value: optionsRef.current.duration },
|
||||
];
|
||||
|
||||
const titles: TableEntry[] = [
|
||||
{ label: 'Cue', title: 'cue', value: optionsRef.current.cue },
|
||||
{ label: 'Colour', title: 'colour', value: optionsRef.current.colour },
|
||||
{ label: 'Title', title: 'title', value: optionsRef.current.title },
|
||||
{ label: 'Presenter', title: 'presenter', value: optionsRef.current.presenter },
|
||||
{ label: 'Subtitle', title: 'subtitle', value: optionsRef.current.subtitle },
|
||||
{ label: 'Note', title: 'note', value: optionsRef.current.note },
|
||||
];
|
||||
|
||||
const options: TableEntry[] = [
|
||||
{ label: 'Is Public', title: 'isPublic', value: optionsRef.current.isPublic },
|
||||
{ label: 'Timer Type', title: 'timerType', value: optionsRef.current.timerType },
|
||||
{ label: 'End Action', title: 'endAction', value: optionsRef.current.endAction },
|
||||
];
|
||||
|
||||
const userFields: TableEntry[] = [
|
||||
{ label: 'User 0', title: 'user0', value: optionsRef.current.user0 },
|
||||
{ label: 'User 1', title: 'user1', value: optionsRef.current.user1 },
|
||||
{ label: 'User 2', title: 'user2', value: optionsRef.current.user2 },
|
||||
{ label: 'User 3', title: 'user3', value: optionsRef.current.user3 },
|
||||
{ label: 'User 4', title: 'user4', value: optionsRef.current.user4 },
|
||||
{ label: 'User 5', title: 'user5', value: optionsRef.current.user5 },
|
||||
{ label: 'User 6', title: 'user6', value: optionsRef.current.user6 },
|
||||
{ label: 'User 7', title: 'user7', value: optionsRef.current.user7 },
|
||||
{ label: 'User 8', title: 'user8', value: optionsRef.current.user8 },
|
||||
{ label: 'User 9', title: 'user9', value: optionsRef.current.user9 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={style.uploadOptions}>
|
||||
<div className={style.twoColumn}>
|
||||
<ImportMapTable title='Import options' fields={worksheet} handleOnChange={updateRef} />
|
||||
</div>
|
||||
|
||||
<div className={style.twoColumn}>
|
||||
<ImportMapTable title='Timings' fields={timings} handleOnChange={updateRef} />
|
||||
<ImportMapTable title='Options' fields={options} handleOnChange={updateRef} />
|
||||
</div>
|
||||
|
||||
<div className={style.twoColumn}>
|
||||
<ImportMapTable title='Titles' fields={titles} handleOnChange={updateRef} />
|
||||
<ImportMapTable title='User Fields' fields={userFields} handleOnChange={updateRef} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
@use '../../../../theme/v2Styles' as *;
|
||||
@use '../../../../theme/ontimeColours' as *;
|
||||
|
||||
.importTable {
|
||||
margin: 0.5rem;
|
||||
font-size: $inner-section-text-size;
|
||||
height: fit-content;
|
||||
|
||||
thead {
|
||||
color: $gray-500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background-color: $gray-50;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Input } from '@chakra-ui/react';
|
||||
|
||||
import { ExcelInputOptions } from '../UploadModal';
|
||||
|
||||
import style from './ImportMapTable.module.scss';
|
||||
|
||||
// TODO: make this generic
|
||||
export type TableEntry = { label: string; title: keyof ExcelInputOptions; value: string };
|
||||
|
||||
interface ImportMapTableProps {
|
||||
title: string;
|
||||
fields: TableEntry[];
|
||||
handleOnChange: (field: keyof ExcelInputOptions, value: string) => void;
|
||||
}
|
||||
|
||||
export default function ImportMapTable(props: ImportMapTableProps) {
|
||||
const { title, fields, handleOnChange } = props;
|
||||
|
||||
return (
|
||||
<table className={style.importTable}>
|
||||
<thead>{title}</thead>
|
||||
<tbody>
|
||||
{fields.map((field) => {
|
||||
return (
|
||||
<tr key={field.title}>
|
||||
<td>
|
||||
<label className={style.label} htmlFor={field.title}>
|
||||
{field.title}
|
||||
</label>
|
||||
</td>
|
||||
<td>
|
||||
<Input
|
||||
id={field.title}
|
||||
size='xs'
|
||||
variant='ontime-filled-on-light'
|
||||
maxLength={25}
|
||||
defaultValue={field.value}
|
||||
placeholder='Use default column name'
|
||||
onBlur={(event) => {
|
||||
handleOnChange(field.title, event.target.value);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { MutableRefObject } from 'react';
|
||||
import { Switch } from '@chakra-ui/react';
|
||||
|
||||
import ModalSplitInput from '../../ModalSplitInput';
|
||||
import { OntimeInputOptions } from '../UploadModal';
|
||||
|
||||
import style from '../UploadModal.module.scss';
|
||||
|
||||
interface OntimeFileOptionsProps {
|
||||
optionsRef: MutableRefObject<OntimeInputOptions>;
|
||||
}
|
||||
|
||||
export default function OntimeFileOptions(props: OntimeFileOptionsProps) {
|
||||
const { optionsRef } = props;
|
||||
|
||||
const updateRef = <T extends keyof OntimeInputOptions>(field: T, value: OntimeInputOptions[T]) => {
|
||||
optionsRef.current = { ...optionsRef.current, [field]: value };
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={style.uploadOptions}>
|
||||
<span className={style.title}>Import options</span>
|
||||
<ModalSplitInput
|
||||
field=''
|
||||
title='Only import rundown'
|
||||
description='All other options, including application settings will be discarded'
|
||||
>
|
||||
<Switch
|
||||
variant='ontime-on-light'
|
||||
onChange={(e) => {
|
||||
updateRef('onlyImportRundown', e.target.checked);
|
||||
}}
|
||||
/>
|
||||
</ModalSplitInput>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
@use '../../../../theme/ontimeColours' as *;
|
||||
|
||||
@mixin row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.stepRow {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
align-items: center;
|
||||
margin: 0 auto;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.idle {
|
||||
@include row;
|
||||
color: $blue-500;
|
||||
}
|
||||
|
||||
.inactive {
|
||||
@include row;
|
||||
color: $gray-700;
|
||||
}
|
||||
|
||||
.active {
|
||||
@include row;
|
||||
color: $blue-700;
|
||||
}
|
||||
|
||||
|
||||
.inactiveIcon {
|
||||
color: $gray-700;
|
||||
}
|
||||
|
||||
.activeIcon {
|
||||
color: $blue-500;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { IoCheckmarkCircle } from '@react-icons/all-files/io5/IoCheckmarkCircle';
|
||||
import { IoChevronForward } from '@react-icons/all-files/io5/IoChevronForward';
|
||||
import { IoEllipseOutline } from '@react-icons/all-files/io5/IoEllipseOutline';
|
||||
|
||||
import type { UploadStep } from '../UploadModal';
|
||||
|
||||
import style from './UploadStep.module.scss';
|
||||
|
||||
export default function UploadStepTracker({ uploadStep }: { uploadStep: UploadStep }) {
|
||||
const isUpload = uploadStep === 'upload';
|
||||
const isReview = uploadStep === 'review';
|
||||
|
||||
return (
|
||||
<div className={style.stepRow}>
|
||||
<div className={isUpload ? style.active : style.idle}>
|
||||
<IoCheckmarkCircle />
|
||||
Upload
|
||||
</div>
|
||||
<IoChevronForward className={isReview ? style.activeIcon : style.inactiveIcon} />
|
||||
<div className={isReview ? style.active : style.inactive}>
|
||||
{isReview ? <IoCheckmarkCircle /> : <IoEllipseOutline />}
|
||||
Review
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
type UploadModalContext = {
|
||||
file: File | null;
|
||||
setFile: (file: File | null) => void;
|
||||
|
||||
progress: number;
|
||||
setProgress: (progress: number) => void;
|
||||
|
||||
clear: () => void;
|
||||
};
|
||||
|
||||
export const useUploadModalContextStore = create<UploadModalContext>((set) => ({
|
||||
file: null,
|
||||
setFile: (file: File | null) => set({ file }),
|
||||
|
||||
progress: 0,
|
||||
setProgress: (progress: number) => set({ progress }),
|
||||
|
||||
clear: () => set({ file: null, progress: 0 }),
|
||||
}));
|
||||
@@ -0,0 +1,70 @@
|
||||
type ValidationStatus = {
|
||||
errors: string[];
|
||||
isValid: boolean;
|
||||
};
|
||||
|
||||
export function validateFile(file: File): ValidationStatus {
|
||||
const status: ValidationStatus = { errors: [], isValid: true };
|
||||
if (!file) {
|
||||
status.errors.push('No file to upload');
|
||||
status.isValid = false;
|
||||
}
|
||||
|
||||
// Limit file size to 1MB
|
||||
if (file.size > 1000000) {
|
||||
status.errors.push('File size limit (1MB) exceeded');
|
||||
status.isValid = false;
|
||||
}
|
||||
|
||||
// Check file extension
|
||||
if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.json')) {
|
||||
status.errors.push('Unhandled file type');
|
||||
status.isValid = false;
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
export type MaybeFile = null | 'ontime' | 'excel';
|
||||
|
||||
export function isExcelFile(file: File | null) {
|
||||
return file?.name.endsWith('.xlsx');
|
||||
}
|
||||
|
||||
export function isOntimeFile(file: File | null) {
|
||||
return file?.name.endsWith('.json');
|
||||
}
|
||||
|
||||
export type ExcelImportMapKeys = keyof typeof defaultExcelImportMap;
|
||||
|
||||
export const defaultExcelImportMap = {
|
||||
worksheet: 'ontime',
|
||||
projectName: 'project name',
|
||||
projectDescription: 'project description',
|
||||
publicUrl: 'public url',
|
||||
publicInfo: 'public info',
|
||||
backstageUrl: 'backstage url',
|
||||
backstageInfo: 'backstage info',
|
||||
timeStart: 'start',
|
||||
timeEnd: 'end',
|
||||
duration: 'duration',
|
||||
cue: 'cue',
|
||||
title: 'title',
|
||||
presenter: 'presenter',
|
||||
subtitle: 'subtitle',
|
||||
isPublic: 'public',
|
||||
skip: 'skip',
|
||||
note: 'note',
|
||||
colour: 'colour',
|
||||
endAction: 'end action',
|
||||
timerType: 'timer type',
|
||||
user0: 'user0',
|
||||
user1: 'user1',
|
||||
user2: 'user2',
|
||||
user3: 'user3',
|
||||
user4: 'user4',
|
||||
user5: 'user5',
|
||||
user6: 'user6',
|
||||
user7: 'user7',
|
||||
user8: 'user8',
|
||||
user9: 'user9',
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
type ValidationStatus = {
|
||||
errors: string[];
|
||||
isValid: boolean;
|
||||
};
|
||||
|
||||
export function validateFile(file: File): ValidationStatus {
|
||||
const status: ValidationStatus = { errors: [], isValid: true };
|
||||
if (!file) {
|
||||
status.errors.push('No file to upload');
|
||||
status.isValid = false;
|
||||
}
|
||||
|
||||
// Limit file size to 1MB
|
||||
if (file.size > 1000000) {
|
||||
status.errors.push('File size limit (1MB) exceeded');
|
||||
status.isValid = false;
|
||||
}
|
||||
|
||||
// Check file extension
|
||||
if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.json')) {
|
||||
status.errors.push('Unhandled file type');
|
||||
status.isValid = false;
|
||||
}
|
||||
return status;
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
@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;
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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);
|
||||
@@ -1,111 +0,0 @@
|
||||
@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';
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { memo, RefObject } from 'react';
|
||||
|
||||
import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator';
|
||||
import { useTimer } from '../../../common/hooks/useSocket';
|
||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
|
||||
import style from './OperatorEvent.module.scss';
|
||||
|
||||
interface OperatorEventProps {
|
||||
colour: string;
|
||||
cue: string;
|
||||
main: string;
|
||||
secondary: string;
|
||||
timeStart: number;
|
||||
timeEnd: number;
|
||||
duration: number;
|
||||
delay?: number;
|
||||
isSelected: boolean;
|
||||
subscribed?: string;
|
||||
subscribedAlias: string;
|
||||
showSeconds: boolean;
|
||||
isPast: boolean;
|
||||
selectedRef?: RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
// extract this to contain re-renders
|
||||
function RollingTime() {
|
||||
const timer = useTimer();
|
||||
return <>{formatTime(timer.current, { showSeconds: true, format: 'hh:mm:ss' })}</>;
|
||||
}
|
||||
|
||||
function OperatorEvent(props: OperatorEventProps) {
|
||||
const {
|
||||
colour,
|
||||
cue,
|
||||
main,
|
||||
secondary,
|
||||
timeStart,
|
||||
timeEnd,
|
||||
duration,
|
||||
delay,
|
||||
isSelected,
|
||||
subscribed,
|
||||
subscribedAlias,
|
||||
showSeconds,
|
||||
isPast,
|
||||
selectedRef,
|
||||
} = props;
|
||||
|
||||
const start = formatTime(timeStart, { showSeconds });
|
||||
const end = formatTime(timeEnd, { showSeconds });
|
||||
|
||||
const cueColours = colour && getAccessibleColour(colour);
|
||||
|
||||
const operatorClasses = cx([
|
||||
style.event,
|
||||
isSelected ? style.running : null,
|
||||
subscribed ? style.subscribed : null,
|
||||
isPast ? style.past : null,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className={operatorClasses} ref={selectedRef}>
|
||||
<div className={style.binder} style={{ ...cueColours }}>
|
||||
<span className={style.cue}>{cue}</span>
|
||||
</div>
|
||||
|
||||
<span className={style.mainField}>{main}</span>
|
||||
<span className={style.schedule}>
|
||||
{start} - {end}
|
||||
</span>
|
||||
|
||||
<span className={style.secondaryField}>{secondary}</span>
|
||||
<span className={style.running}>
|
||||
<DelayIndicator delayValue={delay} />
|
||||
{isSelected ? <RollingTime /> : formatTime(duration, { showSeconds: true, format: 'hh:mm:ss' })}
|
||||
</span>
|
||||
|
||||
<div className={style.fields}>
|
||||
{subscribed && (
|
||||
<>
|
||||
<span className={style.field}>{subscribedAlias}</span>
|
||||
<span className={style.value}>{subscribed}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(OperatorEvent);
|
||||
@@ -1,105 +0,0 @@
|
||||
@use '../../../../src/theme/ontimeColours' as *;
|
||||
@use '../../../../src/theme/v2Styles' as *;
|
||||
|
||||
@mixin column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.statusBar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
background-color: $gray-1350;
|
||||
z-index: 2;
|
||||
|
||||
padding: 0.5rem 1rem;
|
||||
border-bottom: 1px solid $white-10;
|
||||
box-shadow: $large-top-drawer-shadow;
|
||||
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"playback timer1B timer2B timer3B";
|
||||
grid-template-columns: 1fr auto auto auto;
|
||||
column-gap: 1.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.playbackIcon {
|
||||
grid-area: playback;
|
||||
font-size: 2rem;
|
||||
color: $gray-700;
|
||||
|
||||
&.active {
|
||||
color: $ui-white;
|
||||
}
|
||||
}
|
||||
|
||||
.timeNow {
|
||||
grid-area: timer1B;
|
||||
@include column;
|
||||
}
|
||||
|
||||
.elapsedTime {
|
||||
grid-area: timer2B;
|
||||
@include column;
|
||||
}
|
||||
|
||||
.runningTime {
|
||||
grid-area: timer3B;
|
||||
@include column;
|
||||
}
|
||||
|
||||
.title {
|
||||
grid-area: title;
|
||||
font-size: 1.25rem;
|
||||
padding-left: 0.25rem;
|
||||
display: none;
|
||||
line-height: 1.25em;
|
||||
}
|
||||
|
||||
.startTime {
|
||||
grid-area: timer2A;
|
||||
@include column;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.endTime {
|
||||
grid-area: timer3A;
|
||||
@include column;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.75rem;
|
||||
color: $gray-700;
|
||||
}
|
||||
|
||||
.timer {
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
// tablet
|
||||
@media (min-width: $min-tablet) {
|
||||
.statusBar {
|
||||
grid-template-areas:
|
||||
"playback timer1B timer2A timer3A"
|
||||
"title title timer2B timer3B";
|
||||
row-gap: 0.25rem;
|
||||
column-gap: 2rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.startTime {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.endTime {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Playback } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
|
||||
import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
|
||||
import { useTimer } from '../../../common/hooks/useSocket';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
|
||||
import styles from './StatusBar.module.scss';
|
||||
|
||||
interface StatusBarProps {
|
||||
projectTitle: string;
|
||||
playback: Playback;
|
||||
selectedEventId: string | null;
|
||||
firstStart?: number;
|
||||
firstId?: string;
|
||||
lastEnd?: number;
|
||||
lastId?: string;
|
||||
}
|
||||
|
||||
export default function StatusBar(props: StatusBarProps) {
|
||||
const { projectTitle, playback, selectedEventId, firstStart, firstId, lastEnd, lastId } = props;
|
||||
|
||||
const timer = useTimer();
|
||||
|
||||
const getTimeStart = () => {
|
||||
if (firstStart === undefined) {
|
||||
return '...';
|
||||
}
|
||||
|
||||
if (selectedEventId) {
|
||||
if (firstId === selectedEventId) {
|
||||
return millisToString(timer.expectedFinish);
|
||||
}
|
||||
}
|
||||
return millisToString(firstStart);
|
||||
};
|
||||
|
||||
const getTimeEnd = () => {
|
||||
if (lastEnd === undefined) {
|
||||
return '...';
|
||||
}
|
||||
|
||||
if (selectedEventId) {
|
||||
if (lastId === selectedEventId) {
|
||||
return millisToString(timer.expectedFinish);
|
||||
}
|
||||
}
|
||||
return millisToString(lastEnd);
|
||||
};
|
||||
|
||||
// use user defined format
|
||||
const timeNow = formatTime(timer.clock, {
|
||||
showSeconds: true,
|
||||
});
|
||||
|
||||
const runningTime = millisToString(timer.current);
|
||||
const elapsedTime = millisToString(timer.elapsed);
|
||||
|
||||
const PlaybackIconComponent = useMemo(() => {
|
||||
const isPlaying = playback === Playback.Play || playback === Playback.Roll;
|
||||
const classes = cx([styles.playbackIcon, isPlaying ? styles.active : null]);
|
||||
return <PlaybackIcon state={playback} skipTooltip className={classes} />;
|
||||
}, [playback]);
|
||||
|
||||
return (
|
||||
<div className={styles.statusBar}>
|
||||
{PlaybackIconComponent}
|
||||
<div className={styles.timeNow}>
|
||||
<span className={styles.label}>Time now</span>
|
||||
<span className={styles.timer}>{timeNow}</span>
|
||||
</div>
|
||||
<div className={styles.elapsedTime}>
|
||||
<span className={styles.label}>Elapsed time</span>
|
||||
<span className={styles.timer}>{elapsedTime}</span>
|
||||
</div>
|
||||
<div className={styles.runningTime}>
|
||||
<span className={styles.label}>Running timer</span>
|
||||
<span className={styles.timer}>{runningTime}</span>
|
||||
</div>
|
||||
|
||||
<span className={styles.title}>{projectTitle}</span>
|
||||
<div className={styles.startTime}>
|
||||
<span className={styles.label}>Scheduled start</span>
|
||||
<span className={styles.timer}>{getTimeStart()}</span>
|
||||
</div>
|
||||
<div className={styles.endTime}>
|
||||
<span className={styles.label}>Scheduled end</span>
|
||||
<span className={styles.timer}>{getTimeEnd()}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Fragment, lazy, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Fragment, lazy, MutableRefObject, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { closestCenter, DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
|
||||
import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { OntimeRundown, Playback, SupportedEvent } from 'ontime-types';
|
||||
import { getFirst, getNext, getPrevious } from 'ontime-utils';
|
||||
|
||||
import { useEventAction } from '../../common/hooks/useEventAction';
|
||||
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
||||
import { useRundownEditor } from '../../common/hooks/useSocket';
|
||||
import { AppMode, useAppMode } from '../../common/stores/appModeStore';
|
||||
import { useEditorSettings } from '../../common/stores/editorSettings';
|
||||
@@ -42,7 +41,6 @@ export default function Rundown(props: RundownProps) {
|
||||
const moveCursorTo = useAppMode((state) => state.setCursor);
|
||||
const cursorRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
useFollowComponent({ followRef: cursorRef, scrollRef: scrollRef, doFollow: true });
|
||||
|
||||
// DND KIT
|
||||
const sensors = useSensors(useSensor(PointerSensor));
|
||||
@@ -155,6 +153,28 @@ export default function Rundown(props: RundownProps) {
|
||||
};
|
||||
}, [handleKeyPress]);
|
||||
|
||||
// when cursor moves, view should follow
|
||||
useEffect(() => {
|
||||
function scrollToComponent(
|
||||
componentRef: MutableRefObject<HTMLDivElement>,
|
||||
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 (cursorRef.current && scrollRef.current) {
|
||||
// Use requestAnimationFrame to ensure the component is fully loaded
|
||||
window.requestAnimationFrame(() => {
|
||||
scrollToComponent(cursorRef as MutableRefObject<HTMLDivElement>, scrollRef as MutableRefObject<HTMLDivElement>);
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line -- the prompt seems incorrect
|
||||
}, [cursorRef?.current, scrollRef]);
|
||||
|
||||
useEffect(() => {
|
||||
// in run mode, we follow selection
|
||||
if (!viewFollowsCursor || !featureData?.selectedEventId) {
|
||||
|
||||
@@ -7,7 +7,7 @@ $block-binder-width: 2rem;
|
||||
$block-clearance: 0.5rem;
|
||||
$block-border-radius: 0.5rem;
|
||||
$block-text-color: $gray-50;
|
||||
$block-bg: $gray-1250;
|
||||
$block-bg: $gray-1200;
|
||||
$block-bg2: $gray-1050; // for delay and blocks
|
||||
$block-box-shadow: rgba(0, 0, 0, 0.5) 0 0 3px 2px;
|
||||
$secondary-block-height: 2.5rem;
|
||||
|
||||
@@ -128,9 +128,6 @@ $skip-opacity: 0.1;
|
||||
|
||||
.eventTitle {
|
||||
grid-area: title;
|
||||
overflow: hidden;
|
||||
max-height: calc(2.5em + 2px);
|
||||
line-height: 1.25em;
|
||||
}
|
||||
|
||||
.eventActions {
|
||||
@@ -175,9 +172,6 @@ $skip-opacity: 0.1;
|
||||
font-size: calc(1rem - 3px);
|
||||
color: $block-text-color;
|
||||
line-height: calc(1rem - 3px);
|
||||
|
||||
max-height: 2rem;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
|
||||
<span>
|
||||
<IoPeople
|
||||
className={`${style.statusIcon} ${isPublic ? style.active : style.disabled}`}
|
||||
data-ispublic={isPublic}
|
||||
data-isPublic={isPublic}
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { SupportedEvent } from 'ontime-types';
|
||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||
import { useEditorSettings } from '../../../common/stores/editorSettings';
|
||||
import { useEmitLog } from '../../../common/stores/logger';
|
||||
import { deviceAlt } from '../../../common/utils/deviceUtils';
|
||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
||||
|
||||
import style from './QuickAddBlock.module.scss';
|
||||
@@ -83,7 +82,7 @@ const QuickAddBlock = (props: QuickAddBlockProps) => {
|
||||
className={style.quickBtn}
|
||||
data-testid='quick-add-event'
|
||||
>
|
||||
Event {showKbd && <span className={style.keyboard}>{`${deviceAlt} + E`}</span>}
|
||||
Event {showKbd && <span className={style.keyboard}>Alt + E</span>}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip label='Add Delay' openDelay={tooltipDelayMid}>
|
||||
@@ -95,7 +94,7 @@ const QuickAddBlock = (props: QuickAddBlockProps) => {
|
||||
className={style.quickBtn}
|
||||
data-testid='quick-add-delay'
|
||||
>
|
||||
Delay {showKbd && <span className={style.keyboard}>{`${deviceAlt} + D`}</span>}
|
||||
Delay {showKbd && <span className={style.keyboard}>Alt + D</span>}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip label='Add Block' openDelay={tooltipDelayMid}>
|
||||
@@ -107,7 +106,7 @@ const QuickAddBlock = (props: QuickAddBlockProps) => {
|
||||
className={style.quickBtn}
|
||||
data-testid='quick-add-block'
|
||||
>
|
||||
Block {showKbd && <span className={style.keyboard}>{`${deviceAlt} + B`}</span>}
|
||||
Block {showKbd && <span className={style.keyboard}>Alt + B</span>}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable react/display-name */
|
||||
import { ComponentType, useMemo } from 'react';
|
||||
import { SupportedEvent } from 'ontime-types';
|
||||
import { TitleBlock } from 'ontime-types';
|
||||
import { useStore } from 'zustand';
|
||||
|
||||
import useProjectData from '../../common/hooks-query/useProjectData';
|
||||
@@ -9,6 +9,8 @@ import useViewSettings from '../../common/hooks-query/useViewSettings';
|
||||
import { runtime } from '../../common/stores/runtime';
|
||||
import { useViewOptionsStore } from '../../common/stores/viewOptions';
|
||||
|
||||
export type TitleManager = TitleBlock & { showNow: boolean; showNext: boolean };
|
||||
|
||||
const withData = <P extends object>(Component: ComponentType<P>) => {
|
||||
return (props: Partial<P>) => {
|
||||
// persisted app state
|
||||
@@ -21,29 +23,51 @@ const withData = <P extends object>(Component: ComponentType<P>) => {
|
||||
|
||||
const publicEvents = useMemo(() => {
|
||||
if (Array.isArray(rundownData)) {
|
||||
return rundownData.filter((e) => e.type === SupportedEvent.Event && e.title && e.isPublic);
|
||||
return rundownData.filter((e) => e.type === 'event' && e.title && e.isPublic);
|
||||
}
|
||||
return [];
|
||||
}, [rundownData]);
|
||||
|
||||
// websocket data
|
||||
const data = useStore(runtime);
|
||||
const {
|
||||
timer,
|
||||
publicMessage,
|
||||
timerMessage,
|
||||
lowerMessage,
|
||||
playback,
|
||||
onAir,
|
||||
eventNext,
|
||||
publicEventNext,
|
||||
publicEventNow,
|
||||
eventNow,
|
||||
loaded,
|
||||
} = data;
|
||||
const publicSelectedId = loaded.selectedPublicEventId;
|
||||
const selectedId = loaded.selectedEventId;
|
||||
const nextId = loaded.nextEventId;
|
||||
const { timer, titles, titlesPublic, publicMessage, timerMessage, lowerMessage, playback, onAir } = data;
|
||||
const publicSelectedId = data.loaded.selectedPublicEventId;
|
||||
const selectedId = data.loaded.selectedEventId;
|
||||
const nextId = data.loaded.nextEventId;
|
||||
|
||||
/********************************************/
|
||||
/*** + titleManager ***/
|
||||
/*** WRAP INFORMATION RELATED TO TITLES ***/
|
||||
/*** ---------------------------------- ***/
|
||||
/********************************************/
|
||||
// is there a now field?
|
||||
let showNow = true;
|
||||
if (!titles.titleNow && !titles.subtitleNow && !titles.presenterNow) showNow = false;
|
||||
|
||||
// is there a next field?
|
||||
let showNext = true;
|
||||
if (!titles.titleNext && !titles.subtitleNext && !titles.presenterNext) showNext = false;
|
||||
|
||||
const titleManager: TitleManager = { ...titles, showNow: showNow, showNext: showNext };
|
||||
|
||||
/********************************************/
|
||||
/*** + publicTitleManager ***/
|
||||
/*** WRAP INFORMATION RELATED TO TITLES ***/
|
||||
/*** ---------------------------------- ***/
|
||||
/********************************************/
|
||||
// is there a now field?
|
||||
let showPublicNow = true;
|
||||
if (!titlesPublic.titleNow && !titlesPublic.subtitleNow && !titlesPublic.presenterNow) showPublicNow = false;
|
||||
|
||||
// is there a next field?
|
||||
let showPublicNext = true;
|
||||
if (!titlesPublic.titleNext && !titlesPublic.subtitleNext && !titlesPublic.presenterNext) showPublicNext = false;
|
||||
|
||||
const publicTitleManager: TitleManager = {
|
||||
...titlesPublic,
|
||||
showNow: showPublicNow,
|
||||
showNext: showPublicNext,
|
||||
};
|
||||
|
||||
/******************************************/
|
||||
/*** + TimeManagerType ***/
|
||||
@@ -51,6 +75,9 @@ const withData = <P extends object>(Component: ComponentType<P>) => {
|
||||
/*** -------------------------------- ***/
|
||||
/******************************************/
|
||||
|
||||
// inject info:
|
||||
// is timer finished
|
||||
// get clock string
|
||||
const TimeManagerType = {
|
||||
...timer,
|
||||
playback,
|
||||
@@ -68,10 +95,8 @@ const withData = <P extends object>(Component: ComponentType<P>) => {
|
||||
pres={timerMessage}
|
||||
publ={publicMessage}
|
||||
lower={lowerMessage}
|
||||
eventNow={eventNow}
|
||||
publicEventNow={publicEventNow}
|
||||
eventNext={eventNext}
|
||||
publicEventNext={publicEventNext}
|
||||
title={titleManager}
|
||||
publicTitle={publicTitleManager}
|
||||
time={TimeManagerType}
|
||||
events={publicEvents}
|
||||
backstageEvents={rundownData}
|
||||
|
||||
@@ -89,10 +89,7 @@
|
||||
border-radius: 8px;
|
||||
|
||||
&.blink {
|
||||
animation-name: blink;
|
||||
animation-timing-function: ease-in-out;
|
||||
animation-iteration-count: 3;
|
||||
animation-duration: 1s;
|
||||
animation: blink 0.5s ease-in-out 3;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,11 +99,7 @@
|
||||
margin-top: 2em;
|
||||
padding-top: 1em;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.timer-gap {
|
||||
flex: 1;
|
||||
max-width: 7.5em;
|
||||
gap: 7.5em;
|
||||
}
|
||||
|
||||
.aux-timers {
|
||||
@@ -166,12 +159,9 @@
|
||||
|
||||
@keyframes blink {
|
||||
0% {
|
||||
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||
}
|
||||
50% {
|
||||
background-color: var(--card-background-color-blink-override, $playback-start);
|
||||
}
|
||||
100% {
|
||||
20% {
|
||||
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,13 +11,14 @@ import Schedule from '../../../common/components/schedule/Schedule';
|
||||
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
|
||||
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
|
||||
import TitleCard from '../../../common/components/title-card/TitleCard';
|
||||
import { BACKSTAGE_OPTIONS } from '../../../common/components/view-params-editor/constants';
|
||||
import { TIME_FORMAT_OPTION } from '../../../common/components/view-params-editor/constants';
|
||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { TimeManagerType } from '../../../common/models/TimeManager.type';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||
import { titleVariants } from '../common/animation';
|
||||
import { TitleManager } from '../ViewWrapper';
|
||||
|
||||
import './Backstage.scss';
|
||||
|
||||
@@ -29,8 +30,7 @@ const formatOptions = {
|
||||
interface BackstageProps {
|
||||
isMirrored: boolean;
|
||||
publ: Message;
|
||||
eventNow: OntimeEvent | null;
|
||||
eventNext: OntimeEvent | null;
|
||||
title: TitleManager;
|
||||
time: TimeManagerType;
|
||||
backstageEvents: OntimeEvent[];
|
||||
selectedId: string | null;
|
||||
@@ -39,7 +39,7 @@ interface BackstageProps {
|
||||
}
|
||||
|
||||
export default function Backstage(props: BackstageProps) {
|
||||
const { isMirrored, publ, eventNow, eventNext, time, backstageEvents, selectedId, general, viewSettings } = props;
|
||||
const { isMirrored, publ, title, time, backstageEvents, selectedId, general, viewSettings } = props;
|
||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const [blinkClass, setBlinkClass] = useState(false);
|
||||
@@ -92,7 +92,7 @@ export default function Backstage(props: BackstageProps) {
|
||||
return (
|
||||
<div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'>
|
||||
<NavigationMenu />
|
||||
<ViewParamsEditor paramFields={BACKSTAGE_OPTIONS} />
|
||||
<ViewParamsEditor paramFields={[TIME_FORMAT_OPTION]} />
|
||||
<div className='project-header'>
|
||||
{general.title}
|
||||
<div className='clock-container'>
|
||||
@@ -110,7 +110,7 @@ export default function Backstage(props: BackstageProps) {
|
||||
|
||||
<div className='now-container'>
|
||||
<AnimatePresence>
|
||||
{eventNow && (
|
||||
{title.showNow && (
|
||||
<motion.div
|
||||
className={`event now ${blinkClass ? 'blink' : ''}`}
|
||||
key='now'
|
||||
@@ -121,21 +121,19 @@ export default function Backstage(props: BackstageProps) {
|
||||
>
|
||||
<TitleCard
|
||||
label='now'
|
||||
title={eventNow.title}
|
||||
subtitle={eventNow.subtitle}
|
||||
presenter={eventNow.presenter}
|
||||
title={title.titleNow}
|
||||
subtitle={title.subtitleNow}
|
||||
presenter={title.presenterNow}
|
||||
/>
|
||||
<div className='timer-group'>
|
||||
<div className='aux-timers'>
|
||||
<div className='aux-timers__label'>{getLocalizedString('common.started_at')}</div>
|
||||
<div className='aux-timers__value'>{startedAt}</div>
|
||||
</div>
|
||||
<div className='timer-gap' />
|
||||
<div className='aux-timers'>
|
||||
<div className='aux-timers__label'>{getLocalizedString('common.expected_finish')}</div>
|
||||
<div className='aux-timers__value'>{expectedFinish}</div>
|
||||
</div>
|
||||
<div className='timer-gap' />
|
||||
<div className='aux-timers'>
|
||||
<div className='aux-timers__label'>{getLocalizedString('common.stage_timer')}</div>
|
||||
<div className='aux-timers__value'>{stageTimer}</div>
|
||||
@@ -146,7 +144,7 @@ export default function Backstage(props: BackstageProps) {
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{eventNext && (
|
||||
{title.showNext && (
|
||||
<motion.div
|
||||
className='event next'
|
||||
key='next'
|
||||
@@ -157,9 +155,9 @@ export default function Backstage(props: BackstageProps) {
|
||||
>
|
||||
<TitleCard
|
||||
label='next'
|
||||
title={eventNext.title}
|
||||
subtitle={eventNext.subtitle}
|
||||
presenter={eventNext.presenter}
|
||||
title={title.titleNext}
|
||||
subtitle={title.subtitleNext}
|
||||
presenter={title.presenterNext}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Message } from 'ontime-types';
|
||||
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
|
||||
import { LOWER_THIRDS_OPTIONS } from '../../../common/components/view-params-editor/constants';
|
||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { TitleManager } from '../ViewWrapper';
|
||||
|
||||
import { LowerOptions } from './LowerWrapper';
|
||||
|
||||
@@ -12,14 +13,12 @@ import './LowerLines.scss';
|
||||
|
||||
interface LowerLinesProps {
|
||||
lower: Message;
|
||||
heading: string;
|
||||
subheading: string;
|
||||
title: TitleManager;
|
||||
options: LowerOptions;
|
||||
doShow: boolean;
|
||||
}
|
||||
|
||||
export default function LowerLines(props: LowerLinesProps) {
|
||||
const { lower, heading, subheading, options, doShow } = props;
|
||||
const { lower, title, options } = props;
|
||||
const [showLower, setShowLower] = useState(true);
|
||||
|
||||
// Unmount if fadeOut
|
||||
@@ -37,8 +36,8 @@ export default function LowerLines(props: LowerLinesProps) {
|
||||
}, [options.fadeOut, options.transitionIn]);
|
||||
|
||||
useEffect(() => {
|
||||
setShowLower(doShow);
|
||||
}, [doShow]);
|
||||
setShowLower(title.showNow);
|
||||
}, [title.showNow]);
|
||||
|
||||
// Format messages
|
||||
const showLowerMessage = lower.text !== '' && lower.visible;
|
||||
@@ -147,14 +146,14 @@ export default function LowerLines(props: LowerLinesProps) {
|
||||
>
|
||||
<motion.div className='title-container' variants={titleContainerVariants}>
|
||||
<motion.div className='title' variants={titleVariants}>
|
||||
{heading}
|
||||
{title.titleNow}
|
||||
</motion.div>
|
||||
<div className='title-decor' />
|
||||
</motion.div>
|
||||
<motion.div className='subtitle-container' variants={subtitleContainerVariants}>
|
||||
<div className='sub-decor' />
|
||||
<motion.div className='subtitle' variants={subtitleVariants}>
|
||||
{subheading}
|
||||
{title.presenterNow}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import isEqual from 'react-fast-compare';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Message, OntimeEvent, ViewSettings } from 'ontime-types';
|
||||
import { Message, ViewSettings } from 'ontime-types';
|
||||
|
||||
import { overrideStylesURL } from '../../../common/api/apiConstants';
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { TitleManager } from '../ViewWrapper';
|
||||
|
||||
import LowerLines from './LowerLines';
|
||||
|
||||
@@ -16,26 +17,33 @@ export type LowerOptions = {
|
||||
keyColour?: string;
|
||||
fadeOut: number;
|
||||
};
|
||||
|
||||
interface LowerProps {
|
||||
eventNow: OntimeEvent | null;
|
||||
title: TitleManager;
|
||||
lower: Message;
|
||||
viewSettings: ViewSettings;
|
||||
}
|
||||
|
||||
// prevent triggering animation without a content change
|
||||
const areEqual = (prevProps: LowerProps, nextProps: LowerProps) => {
|
||||
return isEqual(prevProps.eventNow?.title, nextProps.eventNow?.title) && isEqual(prevProps.lower, nextProps.lower);
|
||||
return isEqual(prevProps.title, nextProps.title) && isEqual(prevProps.lower, nextProps.lower);
|
||||
};
|
||||
|
||||
const Lower = (props: LowerProps) => {
|
||||
const { eventNow, lower, viewSettings } = props;
|
||||
const { title, lower, viewSettings } = props;
|
||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const [heading, setHeading] = useState('');
|
||||
const [subheading, setSubheading] = useState('');
|
||||
const [showLower, setShowLower] = useState(false);
|
||||
const [titles, setTitles] = useState<TitleManager>({
|
||||
titleNow: '',
|
||||
titleNext: '',
|
||||
subtitleNow: '',
|
||||
subtitleNext: '',
|
||||
presenterNow: '',
|
||||
presenterNext: '',
|
||||
noteNow: '',
|
||||
noteNext: '',
|
||||
showNow: false,
|
||||
showNext: false,
|
||||
});
|
||||
|
||||
// Set window title
|
||||
useEffect(() => {
|
||||
@@ -46,32 +54,28 @@ const Lower = (props: LowerProps) => {
|
||||
useEffect(() => {
|
||||
// clear titles if necessary
|
||||
// will trigger an animation out in the component
|
||||
let timeout: NodeJS.Timeout;
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
if (
|
||||
title?.titleNow !== titles?.titleNow ||
|
||||
title?.subtitleNow !== titles?.subtitleNow ||
|
||||
title?.presenterNow !== titles?.presenterNow
|
||||
) {
|
||||
setTitles((t) => ({ ...t, showNow: false }));
|
||||
|
||||
const haveTitlesChanged = eventNow?.title !== heading || eventNow?.presenter !== subheading;
|
||||
const areTitlesEmpty = !eventNow?.title && !eventNow?.presenter;
|
||||
const transitionTime = 2000;
|
||||
|
||||
// we have new titles
|
||||
if (haveTitlesChanged && !areTitlesEmpty) {
|
||||
// show lower
|
||||
setHeading(eventNow?.title ?? '');
|
||||
setSubheading(eventNow?.presenter ?? '');
|
||||
setShowLower(true);
|
||||
|
||||
// schedule transition out
|
||||
const transitionTime = 5000;
|
||||
timeout = setTimeout(() => {
|
||||
setShowLower(false);
|
||||
setTitles(title);
|
||||
}, transitionTime);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timeout) {
|
||||
if (timeout != null) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line -- we do this to keep animations
|
||||
}, [eventNow?.title, eventNow?.presenter]);
|
||||
}, [title.titleNow, title.subtitleNow, title.presenterNow]);
|
||||
|
||||
// defer rendering until we load stylesheets
|
||||
if (!shouldRender) {
|
||||
@@ -131,7 +135,7 @@ const Lower = (props: LowerProps) => {
|
||||
}
|
||||
}
|
||||
|
||||
return <LowerLines lower={lower} heading={heading} subheading={subheading} options={options} doShow={showLower} />;
|
||||
return <LowerLines lower={lower} title={titles} options={options} />;
|
||||
};
|
||||
|
||||
export default memo(Lower, areEqual);
|
||||
|
||||
@@ -9,13 +9,14 @@ import Schedule from '../../../common/components/schedule/Schedule';
|
||||
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
|
||||
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
|
||||
import TitleCard from '../../../common/components/title-card/TitleCard';
|
||||
import { PUBLIC_OPTIONS } from '../../../common/components/view-params-editor/constants';
|
||||
import { TIME_FORMAT_OPTION } from '../../../common/components/view-params-editor/constants';
|
||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { TimeManagerType } from '../../../common/models/TimeManager.type';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||
import { titleVariants } from '../common/animation';
|
||||
import { TitleManager } from '../ViewWrapper';
|
||||
|
||||
import './Public.scss';
|
||||
|
||||
@@ -27,8 +28,7 @@ const formatOptions = {
|
||||
interface BackstageProps {
|
||||
isMirrored: boolean;
|
||||
publ: Message;
|
||||
publicEventNow: OntimeEvent | null;
|
||||
publicEventNext: OntimeEvent | null;
|
||||
publicTitle: TitleManager;
|
||||
time: TimeManagerType;
|
||||
events: OntimeEvent[];
|
||||
publicSelectedId: string | null;
|
||||
@@ -37,8 +37,7 @@ interface BackstageProps {
|
||||
}
|
||||
|
||||
export default function Public(props: BackstageProps) {
|
||||
const { isMirrored, publ, publicEventNow, publicEventNext, time, events, publicSelectedId, general, viewSettings } =
|
||||
props;
|
||||
const { isMirrored, publ, publicTitle, time, events, publicSelectedId, general, viewSettings } = props;
|
||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
const { getLocalizedString } = useTranslation();
|
||||
|
||||
@@ -58,7 +57,7 @@ export default function Public(props: BackstageProps) {
|
||||
return (
|
||||
<div className={`public-screen ${isMirrored ? 'mirror' : ''}`} data-testid='public-view'>
|
||||
<NavigationMenu />
|
||||
<ViewParamsEditor paramFields={PUBLIC_OPTIONS} />
|
||||
<ViewParamsEditor paramFields={[TIME_FORMAT_OPTION]} />
|
||||
<div className='project-header'>
|
||||
{general.title}
|
||||
<div className='clock-container'>
|
||||
@@ -69,7 +68,7 @@ export default function Public(props: BackstageProps) {
|
||||
|
||||
<div className='now-container'>
|
||||
<AnimatePresence>
|
||||
{publicEventNow && (
|
||||
{publicTitle.showNow && (
|
||||
<motion.div
|
||||
className='event now'
|
||||
key='now'
|
||||
@@ -80,16 +79,16 @@ export default function Public(props: BackstageProps) {
|
||||
>
|
||||
<TitleCard
|
||||
label='now'
|
||||
title={publicEventNow.title}
|
||||
subtitle={publicEventNow.subtitle}
|
||||
presenter={publicEventNow.presenter}
|
||||
title={publicTitle.titleNow}
|
||||
subtitle={publicTitle.subtitleNow}
|
||||
presenter={publicTitle.presenterNow}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{publicEventNext && (
|
||||
{publicTitle.showNext && (
|
||||
<motion.div
|
||||
className='event next'
|
||||
key='next'
|
||||
@@ -100,9 +99,9 @@ export default function Public(props: BackstageProps) {
|
||||
>
|
||||
<TitleCard
|
||||
label='next'
|
||||
title={publicEventNext.title}
|
||||
subtitle={publicEventNext.subtitle}
|
||||
presenter={publicEventNext.presenter}
|
||||
title={publicTitle.titleNext}
|
||||
subtitle={publicTitle.subtitleNext}
|
||||
presenter={publicTitle.presenterNext}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet
|
||||
import { TimeManagerType } from '../../../common/models/TimeManager.type';
|
||||
import { secondsInMillis } from '../../../common/utils/dateConfig';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
import { TitleManager } from '../ViewWrapper';
|
||||
|
||||
import { type ScheduleEvent, formatEventList, trimRundown } from './studioClock.utils';
|
||||
|
||||
@@ -25,7 +26,7 @@ const formatOptions = {
|
||||
|
||||
interface StudioClockProps {
|
||||
isMirrored: boolean;
|
||||
eventNext: OntimeEvent | null;
|
||||
title: TitleManager;
|
||||
time: TimeManagerType;
|
||||
backstageEvents: OntimeRundown;
|
||||
selectedId: string | null;
|
||||
@@ -35,7 +36,7 @@ interface StudioClockProps {
|
||||
}
|
||||
|
||||
export default function StudioClock(props: StudioClockProps) {
|
||||
const { isMirrored, eventNext, time, backstageEvents, selectedId, nextId, onAir, viewSettings } = props;
|
||||
const { isMirrored, title, time, backstageEvents, selectedId, nextId, onAir, viewSettings } = props;
|
||||
|
||||
// deferring rendering seems to affect styling (font and useFitText)
|
||||
useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
@@ -86,7 +87,7 @@ export default function StudioClock(props: StudioClockProps) {
|
||||
className='next-title'
|
||||
style={{ fontSize: titleFontSize, height: '10vh', width: '100%', maxWidth: '75%' }}
|
||||
>
|
||||
{eventNext?.title ?? ''}
|
||||
{title.titleNext}
|
||||
</div>
|
||||
<div className={isNegative ? 'next-countdown' : 'next-countdown next-countdown--overtime'}>
|
||||
{selectedId !== null && formatDisplay(time.current)}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user