Compare commits

..

41 Commits

Author SHA1 Message Date
Charalambos Makridakis 8b0e03e980 #914 Refactor: Luxon-Depenency, formatFromMillis() (#924) 2024-04-29 23:20:12 +02:00
Carlos Valente 0966659596 chore: version bump 2024-04-29 22:44:24 +02:00
Carlos Valente 1123e29499 fix: explicit rundown order in tests 2024-04-29 22:41:02 +02:00
Carlos Valente 3d2248cceb style: remove unnecessary padding 2024-04-29 22:41:02 +02:00
Carlos Valente 043ba38916 chore: update typeface 2024-04-29 22:41:02 +02:00
Carlos Valente 5b6d4c5b28 style: toggle settings button 2024-04-29 22:41:02 +02:00
Carlos Valente 07e8bedff4 style: text formatting in menu 2024-04-29 22:41:02 +02:00
Carlos Valente 6e61a7ecf9 refactor: runtime side effects 2024-04-29 22:40:39 +02:00
Alex Christoffer Rasmussen accdf2c396 Fix: custom fields with undefined colour is not accepted by parseCustomFields (#925) 2024-04-29 22:33:58 +02:00
Alex Christoffer Rasmussen 5e39c9b51f Feat: export custom fileds to sheets (#926)
* export custom keys to sheets

* use `:` in place of `-`
2024-04-29 15:15:18 +02:00
Carlos Valente b52fb0f9ee chore: update automation scripts 2024-04-28 22:11:42 +02:00
Carlos Valente 54f30e69a1 chore: version bump 2024-04-27 22:39:04 +02:00
Carlos Valente 189cee0104 fix: state stale in integrations 2024-04-27 22:34:08 +02:00
Alex Christoffer Rasmussen 9e04a1dcfa shortcuts using useHotkeys from mantine/hooks (#907)
---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2024-04-27 18:00:41 +02:00
Carlos Valente 0c3c08ac98 refactor: custom fields casing 2024-04-27 15:36:29 +02:00
Carlos Valente 95b437e18c refactor(build): remove legal 2024-04-27 10:18:10 +02:00
Carlos Valente 75b5118b0f refactor: user alerts on destructive actions 2024-04-27 10:18:10 +02:00
Carlos Valente dcbb92c470 style: prevent overflow on defaults 2024-04-26 19:40:21 +02:00
Carlos Valente 8da8a08464 refactor: invert offset 2024-04-26 19:39:05 +02:00
Carlos Valente 8b5034efbf refactor: added time is time ahead 2024-04-26 19:39:05 +02:00
Alex Christoffer Rasmussen 74418ce17e Link start times from excel import (#903)
* add test for linking start time in excel import

* link ids

* add link to UI

* ensure times with cachedRundown

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2024-04-26 18:09:59 +02:00
Carlos Valente d8a8f0ad92 style: use user given capitalisation 2024-04-26 15:05:17 +02:00
Carlos Valente 817ebcdd26 Event defaults (#910)
* chore: cleanup outdated comment

* style: align blocks

* style: use ui-black background

* chore: clarify docs

* feat: event defaults

* chore: cleanup
2024-04-24 20:13:51 +02:00
Carlos Valente c4f51192de chore: test custom field renaming 2024-04-23 22:05:56 +02:00
Carlos Valente 5cf5b2fae3 chore: cleanup 2024-04-23 22:05:56 +02:00
Carlos Valente 8f249b9d51 refactor: rundown must be in order 2024-04-19 20:49:37 +02:00
Carlos Valente bef69e7d0f fix: obfuscation logic for operator pin 2024-04-19 20:38:01 +02:00
Carlos Valente dd81b92584 refactor: simplify l3 2024-04-19 20:38:01 +02:00
Alex Christoffer Rasmussen b14ac07568 excel import leading and trailing whitespace (#902)
* add test for leading and trailing whitespace

* trim whitespace
2024-04-19 20:36:06 +02:00
Carlos Valente c4136a0ec7 refactor: simplify custom field shape 2024-04-19 12:06:19 +02:00
Carlos Valente cf14b0b427 refactor: consolidate language of AuxTimer (#896) 2024-04-17 21:31:10 +02:00
Carlos Valente 16a169b22c Beta 2 review (#897)
* style: subdue secondary buttons

* refactor: remove public message
2024-04-17 21:25:03 +02:00
Carlos Valente 8af5feb9af Delete block (#895) 2024-04-17 21:24:27 +02:00
Carlos Valente 88b45fd952 fix: shutdown from menu (#893) 2024-04-16 21:21:01 +02:00
jwetzell a8e9962eed make application menu aware of what port should be used in link items (#890) 2024-04-16 21:20:41 +02:00
Carlos Valente 70c93a69fe Custom add (#889) 2024-04-14 21:52:12 +02:00
Carlos Valente 0ef1b44d9e chore: update README 2024-04-14 12:55:03 +02:00
cv 84839824fb fix: link to managing custom fields 2024-04-14 12:55:03 +02:00
Carlos Valente b63e3ed8f3 chore: update readme (#888)
* chore: update readme

* docs: funding
2024-04-13 11:30:51 +02:00
Carlos Valente 7eab0bb300 chore: version bump (#885) 2024-04-13 10:16:00 +02:00
Carlos Valente 55d1aca9b6 V3 (#657)
* refactor: cleanup routes

* style: smaller base font

* chore: upgrade dependencies

* chore: lock node version to electron

* refactor: pass HTTP to integration controller (#652)

* refactor: deprecate onair control

* refactor: remove playback router

* Several project files user folder (#617)

* chore: automated screenshots (#667)

* feat: app settings (#658)

* refactor: remove deprecated event data (#674)

* Studio clock (#663)

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* Feat: reorder events with alt+ctrl + arrow up/down (#645)

* Warning and danger per event (#677)

---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* refactor: stabilise actionHandler (#683)

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* improvement: hide seconds (#675)

* wip: overview (#688)

* fix: focus cursor (#695)

* refactor: update lower third (#665)

* Refactor/time formatting (#696)

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* feat: multiple selection (#703)

---------

Co-authored-by: asharonbaltazar <asharonbaltazar@outlook.com>
Co-authored-by: Alex <ac@omnivox.dk>

* fix: test - go to `Edit mode` befor tying to click `Event options` button (#708)

* refactor: runtime service (#715)

* fix: issue with loosing cursor position on message (#719)

* remove info panel (#721)

* Event editor continue (#722)

* update API - part  (#709)

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* refactor: update timers (#729)

* feat: many timers (#706)

---------

Co-authored-by: arc-alex <ac@omnivox.dk>

* refactor: excel cleanup (#734)

* refactor: allow import of blocks and skip import (#735)

* Project manager (#697)

* refactor: UI for linking events (#763)

* upgraded pipeline actions (#777)

* Over under (#771)

* custom fields (#744)


---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* Sheets settings (#774)

---------

Co-authored-by: arc-alex <ac@omnivox.dk>

* style: tweaks to lower thirds (#785)

* refactor: delays account for gaps (#784)

* refactor: partial state updates (#780)

* feat: generate crash report (#787)

* Sheet use limited input device auth flow (#782)

---------

Co-authored-by: cv <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* Custom fields views (#789)

* refactor: deprecate presenter and subtitle (#795)

* refactor: organise API around resources (#798)

---------

Co-authored-by: Bianca Procopio <biancahprocopio@gmail.com>

* Time to end (#804)

* Skip fixes (#805)

* fix: onair derives from playback

* Param nav (#822)

---------

Co-authored-by: Alex Christoffer Rasmussen <ac@omnivox.dk>

* refactor: download files from interface (#831)

* Quick options (#814)

* End pause (#832)

* chore: bump node version in docker (#834)

* refactor: follow in run mode (#840)

* fix: uncaught error in http integration (#837)

* Apply project (#843)

Co-authored-by: Matteo Gheza <matteo.gheza07@gmail.com>
Co-authored-by: Ary <arylmoraesn@gmail.com>
Co-authored-by: Alex Christoffer Rasmussen <ac@omnivox.dk>
Co-authored-by: Fabian Posenau <19673098+kellhogs@users.noreply.github.com>
Co-authored-by: Fabian Posenau <fabian@fphome.de>
Co-authored-by: Alex Rohleder <alexrohleder96@gmail.com>
Co-authored-by: asharonbaltazar <asharonbaltazar@outlook.com>
Co-authored-by: Bianca Procopio <biancahprocopio@gmail.com>
Co-authored-by: Fabian Posenau <fabianpos99+github@gmail.com>
2024-04-13 10:06:46 +02:00
280 changed files with 4491 additions and 3858 deletions
+1
View File
@@ -19,6 +19,7 @@
"rules": {
"no-useless-concat": "warn",
"prefer-template": "warn",
"no-throw-literal": "error",
"no-console": [
"warn",
{
+2
View File
@@ -0,0 +1,2 @@
github: cpvalente
buy_me_a_coffee: cpvalente
Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 660 KiB

@@ -1,9 +1,8 @@
name: Ontime build v3
name: Ontime build
on:
# Only trigger manually for now for testing
# push:
# tags: [ "*" ]
push:
tags: [ "*" ]
workflow_dispatch:
jobs:
@@ -1,4 +1,4 @@
name: Docker Image CI Ontime V2
name: Docker Image CI Ontime
on:
push:
@@ -13,14 +13,14 @@ jobs:
CI: ''
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Setup env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
- name: Setup Node.js environment
uses: actions/setup-node@v3.6.0
uses: actions/setup-node@v4
with:
version: 16.16.0
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v2
-108
View File
@@ -1,108 +0,0 @@
name: Ontime build v2
on:
push:
tags: [ "*" ]
workflow_dispatch:
jobs:
build_macos:
runs-on: macOS-latest
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm build
- name: Electron - Build app
run: pnpm dist-mac
- name: Release
uses: softprops/action-gh-release@v1
with:
files: |
./apps/electron/dist/ontime-macOS-x64.dmg
./apps/electron/dist/ontime-macOS-arm64.dmg
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build_windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm build
- name: Electron - Build app
run: pnpm dist-win
- name: Release
uses: softprops/action-gh-release@v1
with:
files: './apps/electron/dist/ontime-win64.exe'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build_ubuntu:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm build
- name: Electron - Build app
run: pnpm dist-linux
- name: Release
uses: softprops/action-gh-release@v1
with:
files: './apps/electron/dist/ontime-linux.AppImage'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -1,9 +1,8 @@
name: Ontime test v3
name: Ontime test
on:
# Only trigger manually for now for testing
# pull_request:
# branches: '*'
pull_request:
branches: '*'
workflow_dispatch:
jobs:
-106
View File
@@ -1,106 +0,0 @@
name: Ontime test v2
on:
pull_request:
branches: '*'
workflow_dispatch:
jobs:
unit-test:
runs-on: ubuntu-latest
timeout-minutes: 20
env:
CI: ''
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Run code quality per package
- name: React - Run linter + TypeScript checks
if: always()
run: pnpm lint && tsc --noEmit
working-directory: ./apps/client
- name: Server - Run linter + TypeScript checks
if: always()
run: pnpm lint && tsc --noEmit
working-directory: ./apps/server
- name: Utils - Run linter + TypeScript checks
if: always()
run: pnpm lint && tsc --noEmit
working-directory: ./packages/utils
- name: Types - Run linter
if: always()
run: pnpm lint
working-directory: ./packages/types
# We choose to run tests separately
- name: React - Run unit tests
if: always()
run: pnpm test:pipeline
working-directory: ./apps/client
- name: Server - Run unit tests
if: always()
run: pnpm test:pipeline
working-directory: ./apps/server
- name: Utils - Run unit tests
if: always()
run: pnpm test:pipeline
working-directory: ./packages/utils
e2e-test:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build client
run: pnpm build:local
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: pnpm e2e
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
- uses: actions/upload-artifact@v3
with:
name: automated-screenshots
path: automated-screenshots/
retention-days: 14
+94 -110
View File
@@ -1,5 +1,8 @@
[![Ontime build v2](https://github.com/cpvalente/ontime/actions/workflows/build_v2.yml/badge.svg)](https://github.com/cpvalente/ontime/actions/workflows/build_v2.yml)
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-green.svg)](https://www.gnu.org/licenses/gpl-3.0)
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-green.svg)](https://www.gnu.org/licenses/gpl-3.0)
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/cpvalente/ontime/total)
![Docker Pulls](https://img.shields.io/docker/pulls/getontime/ontime)
[![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/cpvalente)
[![](https://img.shields.io/static/v1?label=Buy%20me%20a%20coffee&message=%E2%9D%A4&logo=buymeacoffee&color=%23fe8e86)](https://www.buymeacoffee.com/cpvalente)
## Download the latest releases here
@@ -10,146 +13,115 @@
<a href="https://hub.docker.com/r/getontime/ontime"><img alt="Get from Dockerhub" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/dockerhub.png"/></a>
</div>
<br />
<a href="https://www.buymeacoffee.com/cpvalente" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="32"></a>
# Ontime
Ontime is an application for creating and managing event running order and timers.
Ontime is a browser-based application that manages event rundowns, scheduling, and cueing.
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.
With Ontime, you can plan, track your schedule, manage automation and cross-department show information all in one place.
This makes for a simple and cheap way to distribute over a venue using a network infrastructure instead of video outputs.
Ontime is made by entertainment and broadcast engineers and used by
- Conference organisers
- Touring shows and receiving venues
- Broadcasters and streamers
- Theatres and opera houses
- Houses of worship
![App Window](https://github.com/cpvalente/ontime/blob/master/.github/aux-images/app.png)
### For live environments
![Views](https://github.com/cpvalente/ontime/blob/master/.github/aux-images/overview.png)
Ontime is designed for use in live environments. \
This guides the application into being flexible and efficiently integrating into different workflows.
### For teams
All information added in Ontime is shared with the production team and other software / hardware in your workflow. \
Ontime also improves team collaboration with dedicated views for cuesheets and operators, and for public and production
signage.
### Simple infrastructure
All the data is distributed over the network, making its distribution and infrastructure flexible and cheap. \
With the availability of the docker image, you can also leverage IT infrastructure to make Ontime available online for
your team and clients.
Ontime is made by video engineers and entertainment technicians.
![App Window](https://github.com/cpvalente/ontime/blob/master/.github/aux-images/editor.png)
![Views](https://github.com/cpvalente/ontime/blob/master/.github/aux-images/ontime-overview.webp)
[Read the docs to learn more](https://docs.getontime.no)
## Using Ontime
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.
Ontime can be started by downloading the latest release for your platform. \
Alternatively you can also use the docker image, available at [Docker Hub](https://hub.docker.com/r/getontime/ontime)
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 />
You can then use the menu in the top left corner to select the desired view.
The menu will be initially hidden until there is mouse interaction.
Once installed and running, any device in the network has access to Ontime.
In the case of unattended machines or automation, it is possible to use different URL to recall
individual views and extend view settings using the URL presets feature
Ontime provides different screens which allow for different types of interactions with the data. These are called
views. \
Each view in Ontime focuses on empowering a specific role or achieving a particular task.
You can access the different views by reaching the ontime server, in your browser, at (_default port
4001_) `http://localhost:4001` or `http://192.168.1.3:4001`
```
For the presentation views
For the backstage views
-------------------------------------------------------------
IP.ADDRESS:4001 > Web server default to presenter timer view
IP.ADDRESS:4001/timer > Presenter / Stage timer view
IP.ADDRESS:4001/minimal > Simple timer view
IP.ADDRESS:4001/clock > Simple clock view
IP.ADDRESS:4001/sm > Stage Manager / Backstage view
IP.ADDRESS:4001/public > Public / Foyer view
IP.ADDRESS:4001/lower > Lower Thirds
IP.ADDRESS:4001/backstage > Stage Manager / Backstage view
IP.ADDRESS:4001/countdown > Countdown to anything
IP.ADDRESS:4001/studio > Studio Clock
```
```
For management views
For the public views
-------------------------------------------------------------
IP.ADDRESS:4001/public > Public / Foyer view
IP.ADDRESS:4001/lower > Lower Thirds
```
```
For production views
-------------------------------------------------------------
IP.ADDRESS:4001/editor > the control interface, same as the app
IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
IP.ADDRESS:4001/operator > automated views for operators
```
More documentation is available [in our docs](https://docs.getontime.no)
## Feature List (in no specific order)
## Main features
- [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)
- [x] Configurable Lower Thirds
- [x] Collaborative editing with the cuesheet view
- [x] Send live messages to different screen types
- [x] Differentiate between backstage and public data
- [x] Workflow for managing delays
- [x] Rich protocol integrations for Control and Feedback
- OSC (Open Sound Control)
- HTTP
- WebSockets
- [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://docs.getontime.no/features/count-to-anything/): have
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 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.
## Rich APIs for workflow integrations
The app is currently being developed for a broad user base, from broadcast to entertainment and
conference halls.
Taking advantage of the integrations, we currently use Ontime with:
- `disguise`: trigger Ontime from d3's timeline using the **OSC API**, and **render views** using d3's
webmodule
- `OBS`: **render views** using the Browser Module
- `QLab`: trigger Ontime using **OSC API**
- `Companion`: Ontime has a **companion module**. Issue report and feature requests should be done
in the [repository getontime/ontime](https://github.com/bitfocus/companion-module-getontime-ontime)
### Make your own viewer
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).
<br />
We have prepared a few resources to help here:
- Shipped with Ontime there is a small clock to get you started, it is available at `http://localhost:4001/external/demo` and the [code can be found here](https://github.com/cpvalente/ontime/tree/master/apps/server/src/external/demo)
- See [this repository](https://github.com/cpvalente/ontime-viewer-template-v2) with a template on
how to get you started
- See information about the [Websocket API](https://docs.getontime.no/api/osc-and-ws/)
<br />
More information [in the docs](https://docs.getontime.no/features/custom-views/)
### Headless run
You can self-host and run Ontime in a docker image.
The docker image along with documentation is [available Docker Hub at getontime/ontime](https://hub.docker.com/r/getontime/ontime)
If you want to run this image in a Raspberry Pi, please see [the docs](https://docs.getontime.no/additional-notes/use-with-rpi/)
* [x] Distribute data over network and render it in the browser
* [x] Collaborative
* [x] Extendable
* [x] Send messages to different screen types
* [x] Differentiate between backstage and public data
* [x] Workflow for managing delays
* [x] Rich protocol integrations for Control and Feedback
* [x] For servers: use docker to run Ontime in in a server, configure from a browser anywhere
* [x] Multi-platform (available on Windows, MacOS and Linux)
* [x] Companion integration [follow link](https://bitfocus.io/connections/getontime-ontime)
## Roadmap
### Continued development
Several features are planned in the roadmap, and we continuously adjust this to match how users interact with the app.
<br />
Ontime is under active development. We continue adding and tweaking features in collaboration with users.
Have an idea? Reach out via [email](mail@getontime.no)
or [open an issue](https://github.com/cpvalente/ontime/issues/new)
### Issues
We use Github's issue tracking for bug reporting and feature requests. <br />
We use Github's issue tracking for bug reporting and feature requests. \
Found a bug? [Open an issue](https://github.com/cpvalente/ontime/issues/new).
#### Unsigned App
@@ -169,13 +141,15 @@ or in Linux
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: the solution in macOS is different across versions, please refer to
the [apple documentation](https://support.apple.com/en-gb/guide/mac-help/mh40616/mac)
- In Linux: right-click the AppImage file -> Properties -> Permissions -> select Allow Executing
File as a Program
Long story short: Ontime app is unsigned. </br>Purchasing the certificates for both Mac and Windows
would mean a recurrent expense and is not a priority. This is unlikely to change in future. If you
have tips on how to improve this or would like to sponsor the code signing,
Long story short: Ontime app is unsigned. \
Purchasing the certificates for both Mac and Windows would mean a recurrent expense and is not a priority. \
This is unlikely to change in future. \
If you have tips on how to improve this or would like to sponsor the code signing,
please [open an issue](https://github.com/cpvalente/ontime/issues/new)
#### Safari
@@ -187,19 +161,29 @@ There are known issues with Safari versions lower than 13:
There is no plan for any further work on this.
# Contributing
## Contributing
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.
<br />
Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md)
# Help
## Help
Help is underway! ... and can be found [here](https://docs.getontime.no)
# License
## License
This project is licensed under the terms of the GNU GPL v3
## SPONSOR
You can help the development of this project or say thank you with a one time donation. \
See [the terms of donations](https://github.com/cpvalente/ontime/blob/master/.github/FUNDING.md).
<p align="center">
<br>
<a href="https://www.buymeacoffee.com/cpvalente" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" width="200"></a>
</p>
+19
View File
@@ -0,0 +1,19 @@
# Terms of sponsorship
Ontime does not exist as a fiscal entity. The sponsorship recipient is the project owner. In this light, contributors are funding the project owner's capacity to work on the project, not the project itself. As a contribution recipient, the code owner reserves the right to redistribute.
## Why sponsor
Ontime is and will remain free, regardless of your decision to contribute.
Regardless, you may choose to provide a monetary contribution:
### Improve user experience
Certain parts of the application user experience are tied to our capacity to pay for services. App signing and Google Sheets integration are good examples of this.
### Cool stuff
We may choose not to implement some features. This could be related to the complexity required, to the skill set, or to the fact that we just feel it doesn't improve the app for a significant percentage of users. In those cases, a feature may be considered "not worth it," regardless of its pertinence.
As a user, you can choose to sponsor a particular feature or donate to the app so that we can create bounties for features.
### Priorities
Monetary contributions are most immediately needed to offset the costs of presenting and distributing Ontime, i.e., hosting, domains, and certificates.
Additional funding will be considered according to the needs at the time.
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "3.0.0-alpha",
"version": "3.0.0-beta.3",
"private": true,
"type": "module",
"dependencies": {
@@ -10,6 +10,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
"@fontsource/open-sans": "^5.0.28",
"@mantine/hooks": "^7.6.2",
"@react-icons/all-files": "^4.1.0",
"@sentry/react": "^7.92.0",
@@ -28,7 +29,6 @@
"react-hook-form": "^7.49.2",
"react-qr-code": "^2.0.12",
"react-router-dom": "^6.3.0",
"typeface-open-sans": "^1.1.13",
"web-vitals": "^3.1.1",
"zustand": "^4.5.0"
},
@@ -86,7 +86,7 @@
"ontime-utils": "workspace:*",
"prettier": "^3.0.3",
"sass": "^1.57.1",
"typescript": "^5.2.2",
"typescript": "^5.4.3",
"vite": "^5.1.0",
"vite-plugin-compression2": "^0.12.0",
"vite-plugin-svgr": "^4.2.0",
-30
View File
@@ -1,4 +1,3 @@
import { useEffect } from 'react';
import { BrowserRouter } from 'react-router-dom';
import { ChakraProvider } from '@chakra-ui/react';
import { QueryClientProvider } from '@tanstack/react-query';
@@ -7,7 +6,6 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { ContextMenu } from './common/components/context-menu/ContextMenu';
import ErrorBoundary from './common/components/error-boundary/ErrorBoundary';
import { AppContextProvider } from './common/context/AppContext';
import useElectronEvent from './common/hooks/useElectronEvent';
import { ontimeQueryClient } from './common/queryClient';
import { socketClientName } from './common/stores/connectionName';
import { connectSocket } from './common/utils/socket';
@@ -15,38 +13,10 @@ import theme from './theme/theme';
import { TranslationProvider } from './translation/TranslationProvider';
import AppRouter from './AppRouter';
// Load Open Sans typeface
// @ts-expect-error no types from font import
import('typeface-open-sans');
const preferredClientName = socketClientName.getState().name;
connectSocket(preferredClientName);
function App() {
const { isElectron, sendToElectron } = useElectronEvent();
useEffect(() => {
const handleKeyPress = (event: KeyboardEvent) => {
// handle held key
if (event.repeat) return;
// check if the alt key is pressed
if (event.altKey) {
if (event.code === 'KeyT') {
// ask to see debug
sendToElectron('set-window', 'show-dev');
}
}
};
if (isElectron) {
document.addEventListener('keydown', handleKeyPress);
}
return () => {
if (isElectron) {
document.removeEventListener('keydown', handleKeyPress);
}
};
}, [isElectron, sendToElectron]);
return (
<ChakraProvider disableGlobalStyle resetCSS theme={theme}>
<QueryClientProvider client={ontimeQueryClient}>
+3 -3
View File
@@ -7,7 +7,7 @@ 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 Operator = lazy(() => import('./features/operator/OperatorExport'));
const TimerView = lazy(() => import('./features/viewers/timer/Timer'));
const MinimalTimerView = lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
@@ -49,12 +49,12 @@ export default function AppRouter() {
<Route path='/backstage' element={<SBackstage />} />
<Route path='/public' element={<SPublic />} />
<Route path='/studio' element={<SStudio />} />
{/*/!* 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 />} />
+1 -7
View File
@@ -1,11 +1,5 @@
import axios, { AxiosResponse } from 'axios';
import {
DatabaseModel,
GetInfo,
MessageResponse,
ProjectData,
ProjectFileListResponse,
} from 'ontime-types';
import { DatabaseModel, GetInfo, MessageResponse, ProjectData, ProjectFileListResponse } from 'ontime-types';
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
@@ -1,96 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
AlertDialog,
AlertDialogBody,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
Button,
IconButton,
Tooltip,
} from '@chakra-ui/react';
import { IoPowerOutline } from '@react-icons/all-files/io5/IoPowerOutline';
import { useEmitLog } from '../../stores/logger';
interface QuitIconBtnProps {
clickHandler: () => void;
disabled?: boolean;
}
const quitBtnStyle = {
color: '#D20300', // $red-700
borderColor: '#D20300', // $red-700
_focus: { boxShadow: 'none' },
_hover: {
background: '#D20300', // $red-700
color: 'white',
_disabled: {
color: '#D20300', // $red-700
background: 'none',
},
},
_active: {
background: '#9A0000', // $red-1000
color: 'white',
},
variant: 'outline',
isRound: true,
};
export default function QuitIconBtn(props: QuitIconBtnProps) {
const { clickHandler, disabled, ...rest } = props;
const [isOpen, setIsOpen] = useState(false);
const { emitInfo } = useEmitLog();
const onClose = () => setIsOpen(false);
const cancelRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
if (window.process?.type === 'renderer') {
window.ipcRenderer.on('user-request-shutdown', () => {
emitInfo('Shutdown request');
setIsOpen(true);
});
}
}, [emitInfo]);
const handleShutdown = useCallback(() => {
onClose();
clickHandler();
}, [clickHandler]);
return (
<>
<Tooltip label='Quit Application'>
<IconButton
aria-label='Quit Application'
size='lg'
icon={<IoPowerOutline />}
onClick={() => setIsOpen(true)}
isDisabled={disabled}
{...quitBtnStyle}
{...rest}
/>
</Tooltip>
<AlertDialog isOpen={isOpen} leastDestructiveRef={cancelRef} onClose={onClose}>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize='lg' fontWeight='bold'>
Ontime Shutdown
</AlertDialogHeader>
<AlertDialogBody>This will shutdown the Ontime server. Are you sure?</AlertDialogBody>
<AlertDialogFooter>
<Button ref={cancelRef} onClick={onClose} variant='ghost'>
Cancel
</Button>
<Button colorScheme='red' onClick={handleShutdown} ml={3}>
Shutdown
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
</>
);
}
@@ -5,7 +5,7 @@ import { OptionWithoutGroup } from './ContextMenu';
export const ContextMenuOption = ({ label, onClick, isDisabled, icon: Icon, withDivider }: OptionWithoutGroup) => (
<>
{withDivider && <MenuDivider />}
<MenuItem icon={<Icon />} onClick={onClick} isDisabled={isDisabled}>
<MenuItem icon={<Icon style={{ fontSize: '1rem' }} />} onClick={onClick} isDisabled={isDisabled}>
{label}
</MenuItem>
</>
@@ -1,27 +1,25 @@
import { useEffect, useRef } from 'react';
import { RefObject, useEffect } from 'react';
import { Textarea, TextareaProps } from '@chakra-ui/react';
// @ts-expect-error no types from library
import autosize from 'autosize/dist/autosize';
export const AutoTextArea = (props: TextareaProps) => {
const { value } = props;
const ref = useRef<HTMLTextAreaElement>(null);
export const AutoTextArea = (props: TextareaProps & { inputref: RefObject<unknown> }) => {
const { value, inputref } = props;
useEffect(() => {
const node = ref.current;
autosize(ref.current);
const node = inputref.current;
autosize(inputref.current);
return () => {
autosize.destroy(node);
};
}, [value]);
}, [inputref, value]);
return (
<Textarea
overflow='hidden'
w='100%'
ref={inputref}
resize='none'
ref={ref}
transition='height none'
variant='ontime-transparent'
{...props}
@@ -1,58 +0,0 @@
import { useCallback, useRef } from 'react';
import { Input, Textarea } from '@chakra-ui/react';
import { EventEditorSubmitActions } from '../../../../features/rundown/event-editor/EventEditor';
import { Size } from '../../../models/Util.type';
import useReactiveTextInput from './useReactiveTextInput';
interface BaseProps {
isTextArea?: boolean;
isFullHeight?: boolean;
size?: Size;
field: EventEditorSubmitActions;
initialText?: string;
submitHandler: (field: EventEditorSubmitActions, newValue: string) => void;
}
interface TextInputProps extends BaseProps {
isTextArea?: false;
}
type ResizeOptions = 'horizontal' | 'vertical' | 'none';
interface TextAreaProps extends BaseProps {
isTextArea: true;
resize?: ResizeOptions;
}
type InputProps = TextInputProps | TextAreaProps;
export default function TextInput(props: InputProps) {
const { isTextArea, isFullHeight, size = 'sm', field, initialText = '', submitHandler } = props;
const inputRef = useRef(null);
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
const textInputProps = useReactiveTextInput(initialText, submitCallback, { submitOnEnter: true });
const textAreaProps = useReactiveTextInput(initialText, submitCallback);
let resize: ResizeOptions = 'none';
if (isTextArea) {
resize = (props as TextAreaProps)?.resize ?? 'none';
}
return isTextArea ? (
<Textarea
ref={inputRef}
size={size}
resize={resize}
variant='ontime-filled'
{...textAreaProps}
style={{ height: isFullHeight ? '100%' : undefined }}
data-testid='input-textarea'
/>
) : (
<Input ref={inputRef} size={size} variant='ontime-filled' {...textInputProps} data-testid='input-textfield' />
);
}
@@ -1,17 +1,20 @@
import { ChangeEvent, KeyboardEvent, useCallback, useEffect, useState } from 'react';
import { ChangeEvent, KeyboardEvent, RefObject, useCallback, useEffect, useMemo, useState } from 'react';
import { getHotkeyHandler, HotkeyItem } from '@mantine/hooks';
interface UseReactiveTextInputReturn {
value: string;
onChange: (event: ChangeEvent) => void;
onBlur: (event: ChangeEvent) => void;
onKeyDown: (event: KeyboardEvent) => void;
onKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
}
export default function useReactiveTextInput(
initialText: string,
submitCallback: (newValue: string) => void,
ref: RefObject<HTMLInputElement>,
options?: {
submitOnEnter?: boolean;
submitOnCtrlEnter?: boolean;
},
): UseReactiveTextInputReturn {
const [text, setText] = useState<string>(initialText);
@@ -44,43 +47,45 @@ export default function useReactiveTextInput(
const handleSubmit = useCallback(
(valueToSubmit: string) => {
// No need to update if it hasn't changed
if (valueToSubmit === initialText) {
return;
}
const cleanVal = valueToSubmit.trim();
submitCallback(cleanVal);
if (cleanVal !== valueToSubmit) {
setText(cleanVal);
if (valueToSubmit !== initialText) {
const cleanVal = valueToSubmit.trim();
submitCallback(cleanVal);
if (cleanVal !== valueToSubmit) {
setText(cleanVal);
}
}
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
},
[initialText, submitCallback],
[initialText, ref, submitCallback],
);
/**
* @description Handles common keys for submit and cancel
* @param {string} key
* @description Handles escape events
* @param {string} valueToSubmit
*/
const keyHandler = useCallback(
(key: string) => {
switch (key) {
case 'Escape':
setText(initialText);
break;
case 'Enter':
if (options?.submitOnEnter) {
handleSubmit(text);
}
break;
}
},
[initialText, options?.submitOnEnter, handleSubmit, text],
);
const handleEscape = useCallback(() => {
// No need to update if it hasn't changed
setText(initialText);
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
}, [initialText, ref]);
const keyHandler = useMemo(() => {
const hotKeys: HotkeyItem[] = [['Escape', handleEscape, { preventDefault: true }]];
if (options?.submitOnEnter) {
hotKeys.push(['Enter', () => handleSubmit(text)]);
}
if (options?.submitOnCtrlEnter) {
hotKeys.push(['mod + Enter', () => handleSubmit(text)]);
}
return getHotkeyHandler(hotKeys);
}, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, text]);
return {
value: text,
onChange: (event: ChangeEvent) => handleChange((event.target as HTMLInputElement).value),
onBlur: (event: ChangeEvent) => handleSubmit((event.target as HTMLInputElement).value),
onKeyDown: (event: KeyboardEvent) => keyHandler(event.key),
onKeyDown: keyHandler,
};
}
@@ -19,7 +19,7 @@ interface MultiPartProgressBar {
export default function MultiPartProgressBar(props: MultiPartProgressBar) {
const { now, complete, normalColor, warning, warningColor, danger, dangerColor, hidden, className = '' } = props;
const percentComplete = 100 - clamp(100 - (Math.max(now ?? 0, 0) * 100) / complete, 0, 100);
const percentRemaining = complete === 0 ? 0 : 100 - clamp(100 - (Math.max(now ?? 0, 0) * 100) / complete, 0, 100);
const dangerWidth = danger ? clamp((danger / complete) * 100, 0, 100) : 0;
const warningWidth = warning ? clamp((warning / complete) * 100, 0, 100) : 0;
@@ -37,7 +37,7 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
className='multiprogress-bar__bg-danger'
style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }}
/>
<div className='multiprogress-bar__indicator' style={{ width: `${percentComplete}%` }} />
<div className='multiprogress-bar__indicator' style={{ width: `${percentRemaining}%` }} />
</>
)}
</div>
@@ -0,0 +1,60 @@
import { useEffect, useState } from 'react';
import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { debounce } from '../../utils/debounce';
import style from './NavigationMenu.module.scss';
interface FloatingNavigationProps {
toggleMenu: () => void;
toggleSettings: () => void;
}
export default function FloatingNavigation(props: FloatingNavigationProps) {
const { toggleMenu, toggleSettings } = props;
const [showButton, setShowButton] = useState(false);
// show on mouse move
useEffect(() => {
let fadeOut: NodeJS.Timeout | null = null;
const setShowMenuTrue = () => {
setShowButton(true);
if (fadeOut) {
clearTimeout(fadeOut);
}
fadeOut = setTimeout(() => setShowButton(false), 3000);
};
const debouncedShowMenu = debounce(setShowMenuTrue, 1000);
document.addEventListener('mousemove', debouncedShowMenu);
return () => {
document.removeEventListener('mousemove', debouncedShowMenu);
if (fadeOut) {
clearTimeout(fadeOut);
}
};
}, []);
return (
<div className={`${style.buttonContainer} ${!showButton ? style.hidden : ''}`}>
<button
onClick={toggleMenu}
aria-label='toggle menu'
className={style.navButton}
data-testid='navigation__toggle-menu'
>
<IoApps />
</button>
<button
className={style.button}
onClick={toggleSettings}
aria-label='toggle settings'
data-testid='navigation__toggle-settings'
>
<IoSettingsOutline />
</button>
</div>
);
}
@@ -1,12 +1,11 @@
@use '../../../theme/mixins' as *;
$menu-bg: $gray-1200;
$menu-hover-bg: $gray-1350;
$menu-focus-bg: $gray-1300;
$icon-color: $ui-white;
$button-bg: $gray-1050;
$button-size: 48px;
$button-size: 3rem;
.buttonContainer {
display: flex;
@@ -21,7 +20,7 @@ $button-size: 48px;
position: fixed;
left: 0;
top: 0;
z-index: 2;
z-index: 12;
&.hidden {
opacity: 0;
@@ -29,7 +28,7 @@ $button-size: 48px;
}
.button {
font-size: 24px;
font-size: 1.5rem;
color: $icon-color;
background-color: $button-bg;
width: $button-size;
@@ -44,31 +43,10 @@ $button-size: 48px;
z-index: 3;
}
.menuContainer {
top: 0;
left: 0;
height: fit-content;
position: absolute;
background-color: $menu-bg;
min-width: 200px;
border-radius: 0 0 24px 0;
border-right: 1px solid $border-color-ondark;
box-shadow: $box-shadow-l2;
padding-bottom: 1rem;
max-height: 100vh;
overflow-y: auto;
}
.buttonsContainer {
margin-top: calc(56px + 1rem);
}
.link {
@include action-link;
justify-content: space-between;
padding: 0.5rem 1rem;
padding: 0.75rem 1.5rem;
gap: 0.5rem;
&:hover {
background-color: $menu-hover-bg;
@@ -91,7 +69,7 @@ $button-size: 48px;
}
.linkIcon {
display: inline-block;
margin-left: auto;
transform: rotate(45deg);
}
@@ -1,13 +1,20 @@
import { memo, useEffect, useRef, useState } from 'react';
import { memo, useRef } from 'react';
import { createPortal } from 'react-dom';
import { Link, useLocation, useSearchParams } from 'react-router-dom';
import { useDisclosure } from '@chakra-ui/react';
import { Link } from 'react-router-dom';
import {
Drawer,
DrawerBody,
DrawerCloseButton,
DrawerContent,
DrawerHeader,
DrawerOverlay,
useDisclosure,
} from '@chakra-ui/react';
import { useFullscreen } from '@mantine/hooks';
import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { IoContract } from '@react-icons/all-files/io5/IoContract';
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
import { IoPencilSharp } from '@react-icons/all-files/io5/IoPencilSharp';
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { navigatorConstants } from '../../../viewerConfig';
@@ -19,69 +26,42 @@ import RenameClientModal from './rename-client-modal/RenameClientModal';
import style from './NavigationMenu.module.scss';
function NavigationMenu() {
const location = useLocation();
interface NavigationMenuProps {
isOpen: boolean;
onClose: () => void;
}
function NavigationMenu(props: NavigationMenuProps) {
const { isOpen, onClose } = props;
const { isOpen: isRenameOpen, onOpen: onRenameOpen, onClose: onRenameClose } = useDisclosure();
const { fullscreen, toggle } = useFullscreen();
const { toggleMirror } = useViewOptionsStore();
const [showButton, setShowButton] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
const [showMenu, setShowMenu] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
useClickOutside(menuRef, () => setShowMenu(false));
const { isOpen, onOpen, onClose } = useDisclosure();
const toggleMenu = () => setShowMenu((prev) => !prev);
// show on mouse move
useEffect(() => {
let fadeOut: NodeJS.Timeout | null = null;
const setShowMenuTrue = () => {
setShowButton(true);
if (fadeOut) {
clearTimeout(fadeOut);
}
fadeOut = setTimeout(() => setShowButton(false), 3000);
};
document.addEventListener('mousemove', setShowMenuTrue);
return () => {
document.removeEventListener('mousemove', setShowMenuTrue);
if (fadeOut) {
clearTimeout(fadeOut);
}
};
}, []);
const handleFullscreen = () => toggle();
const handleMirror = () => toggleMirror();
const showEditFormDrawer = () => {
searchParams.set('edit', 'true');
setSearchParams(searchParams);
};
useClickOutside(menuRef, () => onClose);
return createPortal(
<div id='navigation-menu-portal' ref={menuRef}>
<RenameClientModal isOpen={isOpen} onClose={onClose} />
<div className={`${style.buttonContainer} ${!showButton && !showMenu ? style.hidden : ''}`}>
<button onClick={toggleMenu} aria-label='toggle menu' className={style.navButton}>
<IoApps />
</button>
<button className={style.button} onClick={showEditFormDrawer}>
<IoPencilSharp />
</button>
{showMenu && (
<div className={style.menuContainer} data-testid='navigation-menu'>
<RenameClientModal isOpen={isRenameOpen} onClose={onRenameClose} />
<Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
<DrawerOverlay />
<DrawerContent>
<DrawerHeader>
<DrawerCloseButton size='lg' />
Ontime
</DrawerHeader>
<DrawerBody padding={0}>
<div className={style.buttonsContainer}>
<div
className={style.link}
tabIndex={0}
role='button'
onClick={handleFullscreen}
onClick={toggle}
onKeyDown={(event) => {
isKeyEnter(event) && handleFullscreen();
isKeyEnter(event) && toggle();
}}
>
Toggle Fullscreen
@@ -91,9 +71,9 @@ function NavigationMenu() {
className={style.link}
tabIndex={0}
role='button'
onClick={handleMirror}
onClick={() => toggleMirror()}
onKeyDown={(event) => {
isKeyEnter(event) && handleMirror();
isKeyEnter(event) && toggleMirror();
}}
>
Flip Screen
@@ -103,20 +83,35 @@ function NavigationMenu() {
className={style.link}
tabIndex={0}
role='button'
onClick={onOpen}
onClick={onRenameOpen}
onKeyDown={(event) => {
isKeyEnter(event) && onOpen();
isKeyEnter(event) && onRenameOpen();
}}
>
Rename Client
</div>
</div>
<hr className={style.separator} />
<Link to='/cuesheet' className={style.link} tabIndex={0}>
<Link
to='/editor'
className={`${style.link} ${location.pathname === '/editor' ? style.current : ''}`}
tabIndex={0}
>
<IoLockClosedOutline />
Editor
<IoArrowUp className={style.linkIcon} />
</Link>
<Link
to='/cuesheet'
className={`${style.link} ${location.pathname === '/cuesheet' ? style.current : ''}`}
tabIndex={0}
>
<IoLockClosedOutline />
Cuesheet
<IoArrowUp className={style.linkIcon} />
</Link>
<Link to='/op' className={style.link} tabIndex={0}>
<Link to='/op' className={`${style.link} ${location.pathname === '/op' ? style.current : ''}`} tabIndex={0}>
<IoLockClosedOutline />
Operator
<IoArrowUp className={style.linkIcon} />
</Link>
@@ -132,11 +127,10 @@ function NavigationMenu() {
<IoArrowUp className={style.linkIcon} />
</Link>
))}
</div>
)}
</div>
</DrawerBody>
</DrawerContent>
</Drawer>
</div>,
document.body,
);
}
@@ -0,0 +1,16 @@
import { memo } from 'react';
import NavigationMenu from './NavigationMenu';
interface ProductionNavigationMenuProps {
isMenuOpen: boolean;
onMenuClose: () => void;
}
function ProductionNavigationMenu(props: ProductionNavigationMenuProps) {
const { isMenuOpen, onMenuClose } = props;
return <NavigationMenu isOpen={isMenuOpen} onClose={onMenuClose} />;
}
export default memo(ProductionNavigationMenu);
@@ -0,0 +1,27 @@
import { memo, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useDisclosure } from '@chakra-ui/react';
import FloatingNavigation from './FloatingNavigation';
import NavigationMenu from './NavigationMenu';
function ViewNavigationMenu() {
const [searchParams, setSearchParams] = useSearchParams();
const { isOpen: isMenuOpen, onOpen: onMenuOpen, onClose: onMenuClose } = useDisclosure();
const showEditFormDrawer = useCallback(() => {
searchParams.set('edit', 'true');
setSearchParams(searchParams);
}, [searchParams, setSearchParams]);
const toggleMenu = () => (isMenuOpen ? onMenuClose() : onMenuOpen());
return (
<>
<FloatingNavigation toggleMenu={toggleMenu} toggleSettings={showEditFormDrawer} />
<NavigationMenu isOpen={isMenuOpen} onClose={onMenuClose} />
</>
);
}
export default memo(ViewNavigationMenu);
@@ -1,5 +0,0 @@
.modalBody {
display: flex;
flex-direction: column;
gap: 1rem;
}
@@ -13,8 +13,6 @@ import {
import { setClientName } from '../../../hooks/useSocket';
import { useSocketClientName } from '../../../stores/connectionName';
import style from './RenameClientModal.module.scss';
interface RenameClientModalProps {
isOpen: boolean;
onClose: () => void;
@@ -45,18 +43,18 @@ export default function RenameClientModal({ isOpen, onClose }: RenameClientModal
motionPreset='slideInBottom'
scrollBehavior='inside'
preserveScrollBarGap
variant='ontime-small'
variant='ontime'
>
<ModalOverlay />
<ModalContent>
<ModalHeader>Rename client</ModalHeader>
<ModalCloseButton />
<ModalBody className={style.modalBody}>
<ModalBody>
<Input
placeholder='Connection must have a name'
defaultValue={newName}
onChange={(e) => setNewName(e.target.value)}
variant='ontime-filled-on-light'
variant='ontime-filled'
/>
<Button
isDisabled={newName === clientName || !newName}
@@ -1,13 +1,14 @@
@use '../../../theme/viewerDefs' as *;
$progress-bar-size: 12px;
$progress-bar-br: 6px;
$progress-bar-br: 3px;
.progress-bar__bg {
width: 100%;
height: $progress-bar-size;
border-radius: $progress-bar-br;
background-color: var(--timer-progress-bg-override, $viewer-card-bg-color);
overflow: clip;
&--hidden {
display: none;
@@ -18,6 +19,5 @@ $progress-bar-br: 6px;
height: $progress-bar-size;
background-color: var(--timer-progress-override, $accent-color);
transition: 1s linear;
border-radius: $progress-bar-br 0 0 $progress-bar-br;
transition-property: width;
}
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { HStack, IconButton, PinInput, PinInputField } from '@chakra-ui/react';
import { IconButton, PinInput, PinInputField } from '@chakra-ui/react';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import style from './ProtectRoute.module.scss';
@@ -39,8 +39,8 @@ export default function PinPage(props: PinPageProps) {
return (
<div className={style.container}>
{`Ontime ${permission || ''}`}
<HStack spacing='10px' className={failed ? style.pin__failed : style.pin}>
{`Ontime ${permission}`}
<div className={failed ? style.pin__failed : style.pin}>
<PinInput
type='alphanumeric'
size='lg'
@@ -57,8 +57,15 @@ export default function PinPage(props: PinPageProps) {
<PinInputField />
<PinInputField />
</PinInput>
<IconButton aria-label='Enter' size='lg' isRound icon={<IoCheckmark />} onClick={validate} />
</HStack>
<IconButton
variant='ontime-filled'
aria-label='Enter'
size='lg'
isRound
icon={<IoCheckmark />}
onClick={validate}
/>
</div>
</div>
);
}
@@ -1,27 +1,31 @@
.container {
display: grid;
place-content: center;
height: 100vh;
padding-bottom: 30vh;
display: flex;
flex-direction: column;
align-items: center;
padding-top: 25vh;
background: $bg-container-l1;
color: $ontime-color;
font-family: $ontime-font-family;
font-weight: 200;
text-align: center;
font-size: 3vw;
font-size: 3rem;
}
.pin,
.pin__failed {
padding: 20px;
display: flex;
gap: 0.125em;
padding-block: 0.5em;
input {
border-radius: 50%;
border-radius: 99px;
border-color: $gray-500;
&:hover {
border-color: $blue-500;
}
}
button {
margin-left: 20px;
margin-left: 1em;
}
}
@@ -33,9 +37,9 @@
@keyframes colourFade {
from {
background: $action-blue;
background: $red-500;
}
to {
background: rgba($action-blue, 0);
background: rgba($red-500, 0);
}
}
}
@@ -24,6 +24,8 @@ interface ScheduleProviderProps {
time?: number;
}
const numEventsPerPage = 8;
export const ScheduleProvider = ({
children,
events,
@@ -37,7 +39,7 @@ export const ScheduleProvider = ({
// look for overrides from views
const hidePast = isStringBoolean(searchParams.get('hidePast'));
const stopCycle = isStringBoolean(searchParams.get('stopCycle'));
const eventsPerPage = Number(searchParams.get('eventsPerPage') ?? 7);
const eventsPerPage = Number(searchParams.get('eventsPerPage') ?? numEventsPerPage);
let selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
@@ -1,24 +1,9 @@
.drawerContent {
background-color: $gray-1250;
}
.drawerHeader {
@extend .drawerContent;
color: $section-white;
}
.drawerFooter {
@extend .drawerContent;
display: flex;
justify-content: start;
justify-content: end;
gap: $section-spacing;
button[type='reset'] {
padding: 0 2em;
margin-right: auto;
}
button[type='submit'] {
button {
padding: 0 2em;
}
}
@@ -26,6 +11,9 @@
.label {
font-size: $inner-section-text-size;
color: $label-gray;
display: flex;
flex-direction: column;
gap: 0.25rem
}
.columnSection {
@@ -52,15 +52,16 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
}
}, [searchParams, onOpen]);
const onCloseWithoutSaving = () => {
onClose();
const handleClose = () => {
searchParams.delete('edit');
setSearchParams(searchParams);
onClose();
};
const resetParams = () => {
setSearchParams();
onClose();
};
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
@@ -69,18 +70,20 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, paramFields);
setSearchParams(newSearchParams);
onClose();
};
return (
<Drawer isOpen={isOpen} placement='right' onClose={onCloseWithoutSaving} size='lg'>
<Drawer isOpen={isOpen} placement='right' onClose={handleClose} variant='ontime' size='lg'>
<DrawerOverlay />
<DrawerContent>
<DrawerHeader className={style.drawerHeader}>
<DrawerCloseButton _hover={{ bg: '#ebedf0', color: '#333' }} size='lg' />
<DrawerHeader>
<DrawerCloseButton size='lg' />
Customise
</DrawerHeader>
<DrawerBody className={style.drawerContent}>
<DrawerBody>
<form id='edit-params-form' onSubmit={onParamsFormSubmit}>
{paramFields.map((field) => (
<div key={field.title} className={style.columnSection}>
@@ -96,10 +99,7 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
<DrawerFooter className={style.drawerFooter}>
<Button variant='ontime-ghosted' onClick={resetParams} type='reset'>
Reset
</Button>
<Button variant='ontime-subtle' onClick={onCloseWithoutSaving}>
Cancel
Reset to default
</Button>
<Button variant='ontime-filled' form='edit-params-form' type='submit'>
Save
@@ -249,12 +249,11 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
export const getLowerThirdOptions = (customFields: CustomFields): ParamField[] => {
const topSourceOptions = makeOptionsFromCustomFields(customFields, {
title: 'Title',
lowerMsg: 'Lower Third Message',
});
const bottomSourceOptions = makeOptionsFromCustomFields(customFields, {
title: 'Title',
lowerMsg: 'Lower Third Message',
none: 'None',
});
return [
@@ -267,7 +266,7 @@ export const getLowerThirdOptions = (customFields: CustomFields): ParamField[] =
event: 'Event Load',
manual: 'Manual',
},
defaultValue: 'event',
defaultValue: 'manual',
},
{
id: 'top-src',
@@ -283,7 +282,7 @@ export const getLowerThirdOptions = (customFields: CustomFields): ParamField[] =
description: 'Select the data source for the bottom element',
type: 'option',
values: bottomSourceOptions,
defaultValue: 'lowerMsg',
defaultValue: 'none',
},
{
id: 'top-colour',
@@ -394,9 +393,9 @@ export const getBackstageOptions = (timeFormat: string, customFields: CustomFiel
{
id: 'eventsPerPage',
title: 'Events per page',
description: 'Sets the number of events on the page, can cause overlow',
description: 'Sets the number of events on the page, can cause overflow',
type: 'number',
placeholder: '7 (default)',
placeholder: '8 (default)',
},
{
id: 'secondary-src',
@@ -431,9 +430,9 @@ export const getPublicOptions = (timeFormat: string, customFields: CustomFields)
{
id: 'eventsPerPage',
title: 'Events per page',
description: 'Sets the number of events on the page, can cause overlow',
description: 'Sets the number of events on the page, can cause overflow',
type: 'number',
placeholder: '7 (default)',
placeholder: '8 (default)',
},
{
id: 'secondary-src',
@@ -11,7 +11,7 @@ export default function useCustomFields() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: CUSTOM_FIELDS,
queryFn: getCustomFields,
placeholderData: placeholder,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchInterval,
@@ -11,14 +11,13 @@ export function useHttpSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: HTTP_SETTINGS,
queryFn: getHTTP,
placeholderData: httpPlaceholder,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
// we need to jump through some hoops because of the type op port
return { data: data ?? httpPlaceholder, status, isFetching, isError, refetch };
}
@@ -10,7 +10,7 @@ export default function useInfo() {
const { data, status, isError, refetch, isFetching } = useQuery<GetInfo>({
queryKey: APP_INFO,
queryFn: getInfo,
placeholderData: ontimePlaceholderInfo,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
@@ -11,7 +11,7 @@ export default function useOscSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: OSC_SETTINGS,
queryFn: getOSC,
placeholderData: oscPlaceholderSettings,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
@@ -9,7 +9,7 @@ export default function useProjectData() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: PROJECT_DATA,
queryFn: getProjectData,
placeholderData: projectDataPlaceholder,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
@@ -14,7 +14,7 @@ export function useProjectList() {
const { data, status, refetch } = useQuery({
queryKey: PROJECT_LIST,
queryFn: getProjects,
placeholderData: placeholderProjectList,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
@@ -13,7 +13,7 @@ export default function useRundown() {
const { data, status, isError, refetch, isFetching } = useQuery<RundownCached>({
queryKey: RUNDOWN,
queryFn: fetchNormalisedRundown,
placeholderData: cachedRundownPlaceholder,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchInterval,
@@ -10,7 +10,7 @@ export default function useSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: APP_SETTINGS,
queryFn: getSettings,
placeholderData: ontimePlaceholderSettings,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
@@ -8,7 +8,7 @@ export default function useUrlPresets() {
const { data, status, isError, refetch } = useQuery({
queryKey: URL_PRESETS,
queryFn: getUrlPresets,
placeholderData: [],
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
@@ -9,12 +9,12 @@ export default function useViewSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: VIEW_SETTINGS,
queryFn: getView,
placeholderData: viewsSettingsPlaceholder,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data, status, isError, refetch, isFetching };
return { data: data ?? viewsSettingsPlaceholder, status, isError, refetch, isFetching };
}
+16 -4
View File
@@ -21,11 +21,11 @@ import { useEditorSettings } from '../stores/editorSettings';
import { forgivingStringToMillis } from '../utils/dateConfig';
/**
* @description Set of utilities for events
* @description Set of utilities for events //TODO: should this be called useEntryAction and so on
*/
export const useEventAction = () => {
const queryClient = useQueryClient();
const { defaultPublic, linkPrevious, defaultDuration } = useEditorSettings((state) => state.eventSettings);
const { defaultPublic, linkPrevious, defaultDuration, defaultWarnTime, defaultDangerTime } = useEditorSettings();
/**
* Calls mutation to add new event
@@ -48,6 +48,8 @@ export const useEventAction = () => {
defaultPublic: boolean;
linkPrevious: boolean;
lastEventId: string;
defaultWarnTime: number;
defaultDangerTime: number;
}>;
/**
@@ -64,6 +66,8 @@ export const useEventAction = () => {
defaultPublic: options?.defaultPublic ?? defaultPublic,
lastEventId: options?.lastEventId,
linkPrevious: options?.linkPrevious ?? linkPrevious,
defaultWarnTime,
defaultDangerTime,
};
if (applicationOptions.linkPrevious && applicationOptions?.lastEventId) {
@@ -85,6 +89,14 @@ export const useEventAction = () => {
if (newEvent.duration === undefined && newEvent.timeEnd === undefined) {
newEvent.duration = forgivingStringToMillis(defaultDuration);
}
if (newEvent.timeDanger === undefined) {
newEvent.timeDanger = forgivingStringToMillis(defaultDangerTime);
}
if (newEvent.timeWarning === undefined) {
newEvent.timeWarning = forgivingStringToMillis(defaultWarnTime);
}
}
// handle adding options that concern all event type
@@ -98,7 +110,7 @@ export const useEventAction = () => {
logAxiosError('Failed adding event', error);
}
},
[_addEventMutation, defaultDuration, defaultPublic, linkPrevious],
[_addEventMutation, defaultDangerTime, defaultDuration, defaultPublic, defaultWarnTime, linkPrevious, queryClient],
);
/**
@@ -155,7 +167,7 @@ export const useEventAction = () => {
const updateCustomField = useCallback(
async (eventId: string, field: string, value: string) => {
updateEvent({ id: eventId, custom: { [field]: { value } } });
updateEvent({ id: eventId, custom: { [field]: value } });
},
[updateEvent],
);
+14 -20
View File
@@ -25,8 +25,6 @@ export const useOperator = () => {
export const useMessageControl = () => {
const featureSelector = (state: RuntimeStore) => ({
timer: state.message.timer,
public: state.message.public,
lower: state.message.lower,
external: state.message.external,
onAir: state.onAir,
});
@@ -37,10 +35,6 @@ export const useMessageControl = () => {
export const setMessage = {
timerText: (payload: string) => socketSendJson('message', { timer: { text: payload } }),
timerVisible: (payload: boolean) => socketSendJson('message', { timer: { visible: payload } }),
publicText: (payload: string) => socketSendJson('message', { public: { text: payload } }),
publicVisible: (payload: boolean) => socketSendJson('message', { public: { visible: payload } }),
lowerText: (payload: string) => socketSendJson('message', { lower: { text: payload } }),
lowerVisible: (payload: boolean) => socketSendJson('message', { lower: { visible: payload } }),
timerBlink: (payload: boolean) => socketSendJson('message', { timer: { blink: payload } }),
timerBlackout: (payload: boolean) => socketSendJson('message', { timer: { blackout: payload } }),
};
@@ -59,12 +53,12 @@ export const setPlayback = {
start: () => socketSendJson('start'),
pause: () => socketSendJson('pause'),
roll: () => socketSendJson('roll'),
startNext: () => socketSendJson('start', { next: true }),
startNext: () => socketSendJson('start', 'next'),
previous: () => {
socketSendJson('load', { previous: true });
socketSendJson('load', 'previous');
},
next: () => {
socketSendJson('load', { next: true });
socketSendJson('load', 'next');
},
stop: () => {
socketSendJson('stop');
@@ -89,27 +83,27 @@ export const useInfoPanel = () => {
return useRuntimeStore(featureSelector);
};
export const useExtraTimerTime = () => {
const featureSelector = (state: RuntimeStore) => state.timer1.current;
export const useAuxTimerTime = () => {
const featureSelector = (state: RuntimeStore) => state.auxtimer1.current;
return useRuntimeStore(featureSelector);
};
export const useExtraTimerControl = () => {
export const useAuxTimerControl = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.timer1.playback,
direction: state.timer1.direction,
playback: state.auxtimer1.playback,
direction: state.auxtimer1.direction,
});
return useRuntimeStore(featureSelector);
};
export const setExtraTimer = {
start: () => socketSendJson('extratimer', SimplePlayback.Start),
pause: () => socketSendJson('extratimer', SimplePlayback.Pause),
stop: () => socketSendJson('extratimer', SimplePlayback.Stop),
setDirection: (direction: SimpleDirection) => socketSendJson('extratimer', { direction }),
setTime: (time: number) => socketSendJson('extratimer', { settime: time }),
export const setAuxTimer = {
start: () => socketSendJson('auxtimer', { '1': SimplePlayback.Start }),
pause: () => socketSendJson('auxtimer', { '1': SimplePlayback.Pause }),
stop: () => socketSendJson('auxtimer', { '1': SimplePlayback.Stop }),
setDirection: (direction: SimpleDirection) => socketSendJson('auxtimer', { '1': { direction } }),
setDuration: (time: number) => socketSendJson('auxtimer', { '1': { duration: time } }),
};
export const useCuesheet = () => {
@@ -0,0 +1,11 @@
import { useEffect } from 'react';
/**
* Sets tab title
* @param title
*/
export function useWindowTitle(title: string) {
useEffect(() => {
document.title = `ontime - ${title}`;
}, []);
}
@@ -33,5 +33,5 @@ export const useAppMode = create<AppModeStore>()((set) => ({
return { mode };
});
},
setCursor: (cursor: string | null) => set(() => ({ cursor })),
setCursor: (cursor: string | null) => set({ cursor }),
}));
+54 -52
View File
@@ -2,66 +2,68 @@ import { create } from 'zustand';
import { booleanFromLocalStorage } from '../utils/localStorage';
type EditorSettings = {
showQuickEntry: boolean;
linkPrevious: boolean;
defaultPublic: boolean;
type EditorSettingsStore = {
defaultDuration: string;
linkPrevious: boolean;
defaultWarnTime: string;
defaultDangerTime: string;
defaultPublic: boolean;
setDefaultDuration: (defaultDuration: string) => void;
setLinkPrevious: (linkPrevious: boolean) => void;
setWarnTime: (warnTime: string) => void;
setDangerTime: (dangerTime: string) => void;
setDefaultPublic: (defaultPublic: boolean) => void;
};
type EditorSettingsStore = {
eventSettings: EditorSettings;
setLocalEventSettings: (newState: EditorSettings) => void;
setShowQuickEntry: (showQuickEntry: boolean) => void;
setLinkPrevious: (linkPrevious: boolean) => void;
setDefaultPublic: (defaultPublic: boolean) => void;
setDefaultDuration: (defaultDuration: string) => void;
export const editorSettingsDefaults = {
duration: '00:10:00',
linkPrevious: true,
warnTime: '00:02:00', // 120000 same as backend
dangerTime: '00:01:00', // 60000 same as backend
isPublic: true,
};
enum EditorSettingsKeys {
ShowQuickEntry = 'ontime-show-quick-entry',
LinkPrevious = 'ontime-link-previous',
DefaultPublic = 'ontime-default-public',
DefaultDuration = 'ontime-default-duration',
LinkPrevious = 'ontime-link-previous',
DefaultWarnTime = 'ontime-default-warn-time',
DefaultDangerTime = 'ontime-default-danger-time',
DefaultPublic = 'ontime-default-public',
}
export const useEditorSettings = create<EditorSettingsStore>((set) => ({
eventSettings: {
showQuickEntry: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, false),
linkPrevious: booleanFromLocalStorage(EditorSettingsKeys.LinkPrevious, true),
defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.DefaultPublic, true),
defaultDuration: localStorage.getItem(EditorSettingsKeys.DefaultDuration) ?? '00:10:00',
},
export const useEditorSettings = create<EditorSettingsStore>((set) => {
return {
defaultDuration: localStorage.getItem(EditorSettingsKeys.DefaultDuration) ?? editorSettingsDefaults.duration,
linkPrevious: booleanFromLocalStorage(EditorSettingsKeys.LinkPrevious, editorSettingsDefaults.linkPrevious),
defaultWarnTime: localStorage.getItem(EditorSettingsKeys.DefaultWarnTime) ?? editorSettingsDefaults.warnTime,
defaultDangerTime: localStorage.getItem(EditorSettingsKeys.DefaultDangerTime) ?? editorSettingsDefaults.dangerTime,
defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.DefaultPublic, editorSettingsDefaults.isPublic),
setLocalEventSettings: (value) =>
set(() => {
localStorage.setItem(EditorSettingsKeys.ShowQuickEntry, String(value.showQuickEntry));
localStorage.setItem(EditorSettingsKeys.LinkPrevious, String(value.linkPrevious));
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(value.defaultPublic));
return { eventSettings: value };
}),
setDefaultDuration: (defaultDuration) =>
set(() => {
localStorage.setItem(EditorSettingsKeys.DefaultDuration, String(defaultDuration));
return { defaultDuration };
}),
setShowQuickEntry: (showQuickEntry) =>
set((state) => {
localStorage.setItem(EditorSettingsKeys.ShowQuickEntry, String(showQuickEntry));
return { eventSettings: { ...state.eventSettings, showQuickEntry } };
}),
setLinkPrevious: (linkPrevious) =>
set((state) => {
localStorage.setItem(EditorSettingsKeys.LinkPrevious, String(linkPrevious));
return { eventSettings: { ...state.eventSettings, linkPrevious } };
}),
setDefaultPublic: (defaultPublic) =>
set((state) => {
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(defaultPublic));
return { eventSettings: { ...state.eventSettings, defaultPublic } };
}),
setDefaultDuration: (defaultDuration) =>
set((state) => {
localStorage.setItem(EditorSettingsKeys.DefaultDuration, String(defaultDuration));
return { eventSettings: { ...state.eventSettings, defaultDuration } };
}),
}));
setLinkPrevious: (linkPrevious) =>
set(() => {
localStorage.setItem(EditorSettingsKeys.LinkPrevious, String(linkPrevious));
return { linkPrevious };
}),
setWarnTime: (defaultWarnTime) =>
set(() => {
localStorage.setItem(EditorSettingsKeys.DefaultWarnTime, String(defaultWarnTime));
return { defaultWarnTime };
}),
setDangerTime: (defaultDangerTime) =>
set(() => {
localStorage.setItem(EditorSettingsKeys.DefaultDangerTime, String(defaultDangerTime));
return { defaultDangerTime };
}),
setDefaultPublic: (defaultPublic) =>
set(() => {
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(defaultPublic));
return { defaultPublic };
}),
};
});
@@ -0,0 +1,11 @@
import { create } from 'zustand';
type EntryCopyStore = {
entryCopyId: string | null;
setEntryCopyId: (eventId: string | null) => void;
};
export const useEntryCopy = create<EntryCopyStore>()((set) => ({
entryCopyId: null,
setEntryCopyId: (entryCopyId: string | null) => set({ entryCopyId }),
}));
+1 -9
View File
@@ -23,14 +23,6 @@ export const runtimeStorePlaceholder: RuntimeStore = {
blink: false,
blackout: false,
},
public: {
text: '',
visible: false,
},
lower: {
text: '',
visible: false,
},
external: {
text: '',
visible: false,
@@ -49,7 +41,7 @@ export const runtimeStorePlaceholder: RuntimeStore = {
eventNext: null,
publicEventNow: null,
publicEventNext: null,
timer1: {
auxtimer1: {
current: 0,
direction: SimpleDirection.CountUp,
duration: 0,
@@ -22,7 +22,7 @@ describe('cloneEvent()', () => {
timeWarning: 120000,
timeDanger: 60000,
custom: {
lighting: { value: '3' },
lighting: '3',
} as EventCustomFields,
} as OntimeEvent;
@@ -4,3 +4,5 @@ export function isMacOS() {
}
export const deviceAlt = isMacOS() ? '⌥' : 'Alt';
export const deviceMod = isMacOS() ? '⌘' : 'Ctrl';
+1
View File
@@ -8,4 +8,5 @@ export const isIPAddress = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/;
export const startsWithHttp = /^http:\/\//;
export const startsWithSlash = /^\//;
export const isAlphanumeric = /^[a-z0-9]+$/i;
export const isASCII = /^[ -~]+$/; //https://catonmat.net/my-favorite-regex
export const isNotEmpty = /\S/;
+3 -3
View File
@@ -114,9 +114,9 @@ export const connectSocket = (preferredClientName?: string) => {
updateDevTools({ publicEventNext: payload });
break;
}
case 'ontime-timer1': {
patchRuntime('timer1', payload);
updateDevTools({ timer1: payload });
case 'ontime-auxtimer1': {
patchRuntime('auxtimer1', payload);
updateDevTools({ auxtimer1: payload });
break;
}
}
@@ -33,3 +33,4 @@ export const cx = (classNames: any[]) => classNames.filter(Boolean).join(' ');
export const enDash = '';
export const timerPlaceholder = '––:––:––';
export const timerPlaceholderMin = '––:––';
@@ -7,5 +7,5 @@
gap: 0.25rem;
overflow: hidden;
background-color: black;
background-color: $ui-black;
}
@@ -3,12 +3,12 @@ import { ErrorBoundary } from '@sentry/react';
import { useKeyDown } from '../../common/hooks/useKeyDown';
import AboutPanel from './panel/about-panel/AboutPanel';
import FeatureSettingsPanel from './panel/feature-settings-panel/FeatureSettingsPanel';
import GeneralPanel from './panel/general-panel/GeneralPanel';
import IntegrationsPanel from './panel/integrations-panel/IntegrationsPanel';
import InterfacePanel from './panel/interface-panel/InterfacePanel';
import LogPanel from './panel/log-panel/LogPanel';
import ProjectPanel from './panel/project-panel/ProjectPanel';
import ProjectSettingsPanel from './panel/project-settings-panel/ProjectSettingsPanel';
import ShutdownPanel from './panel/shutdown-panel/ShutdownPanel';
import SourcesPanel from './panel/sources-panel/SourcesPanel';
import PanelContent from './panel-content/PanelContent';
import PanelList from './panel-list/PanelList';
@@ -27,12 +27,12 @@ export default function AppSettings() {
<PanelContent onClose={close}>
{panel === 'project' && <ProjectPanel location={location} />}
{panel === 'general' && <GeneralPanel location={location} />}
{panel === 'project_settings' && <ProjectSettingsPanel />}
{panel === 'feature_settings' && <FeatureSettingsPanel location={location} />}
{panel === 'sources' && <SourcesPanel />}
{panel === 'interface' && <InterfacePanel />}
{panel === 'integrations' && <IntegrationsPanel location={location} />}
{panel === 'about' && <AboutPanel />}
{panel === 'log' && <LogPanel />}
{panel === 'shutdown' && <ShutdownPanel />}
</PanelContent>
</ErrorBoundary>
</div>
@@ -4,7 +4,12 @@
.actions {
display: flex;
gap: 0.5rem;
gap: 0.5px;
}
.actionButtons {
display: flex;
gap: 1em;
}
.fieldForm {
@@ -20,3 +25,15 @@
justify-content: flex-end;
gap: 1rem;
}
.fit {
width: fit-content;
}
.aliasConstrain {
min-width: 12em;
}
.flex {
display: flex;
}
@@ -0,0 +1,24 @@
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import { PanelBaseProps } from '../../settingsStore';
import * as Panel from '../PanelUtils';
import CustomFields from './custom-fields/CustomFields';
import UrlPresetsForm from './UrlPresetsForm';
export default function FeatureSettingsPanel({ location }: PanelBaseProps) {
const customFieldsRef = useScrollIntoView<HTMLDivElement>('custom', location);
const urlPresetsRef = useScrollIntoView<HTMLDivElement>('urlpresets', location);
return (
<>
<Panel.Header>Feature Settings</Panel.Header>
<div ref={customFieldsRef}>
<CustomFields />
</div>
<div ref={urlPresetsRef}>
<UrlPresetsForm />
</div>
</>
);
}
@@ -15,7 +15,7 @@ import { handleLinks } from '../../../../common/utils/linkUtils';
import { validateUrlPresetPath } from '../../../../common/utils/urlPresets';
import * as Panel from '../PanelUtils';
import style from './GeneralPanel.module.scss';
import style from './FeatureSettings.module.scss';
const urlPresetsDocs = 'https://docs.getontime.no/features/url-presets/';
@@ -91,7 +91,7 @@ export default function UrlPresetsForm() {
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} data-testid='url-preset-form'>
<Panel.Card>
<Panel.SubHeader>
URL Presets
URL presets
<div className={style.actionButtons}>
<Button variant='ontime-ghosted' size='md' onClick={onReset} isDisabled={!canSubmit}>
Revert to saved
@@ -4,13 +4,14 @@ import { IoPencil } from '@react-icons/all-files/io5/IoPencil';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { CustomField, CustomFieldLabel } from 'ontime-types';
import Swatch from '../../../../common/components/input/colour-input/Swatch';
import Swatch from '../../../../../common/components/input/colour-input/Swatch';
import CustomFieldForm from './CustomFieldForm';
import style from './ProjectSettingsPanel.module.scss';
import style from '../FeatureSettings.module.scss';
interface CustomFieldEntryProps {
field: string;
colour: string;
label: string;
onEdit: (label: CustomFieldLabel, patch: CustomField) => Promise<void>;
@@ -18,13 +19,11 @@ interface CustomFieldEntryProps {
}
export default function CustomFieldEntry(props: CustomFieldEntryProps) {
const { colour, label, onEdit, onDelete } = props;
const { colour, label, onEdit, onDelete, field } = props;
const [isEditing, setIsEditing] = useState(false);
const handleEdit = async (patch: CustomField) => {
const oldLabel = label;
await onEdit(oldLabel, patch);
await onEdit(field, patch);
setIsEditing(false);
};
@@ -64,7 +63,7 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
color='#FA5656' // $red-500
icon={<IoTrash />}
aria-label='Delete entry'
onClick={() => onDelete(label)}
onClick={() => onDelete(field)}
/>
</td>
</tr>
@@ -4,11 +4,11 @@ import { Button, Input } from '@chakra-ui/react';
import { CustomField } from 'ontime-types';
import { isAlphanumeric } from 'ontime-utils';
import { maybeAxiosError } from '../../../../common/api/utils';
import SwatchSelect from '../../../../common/components/input/colour-input/SwatchSelect';
import * as Panel from '../PanelUtils';
import { maybeAxiosError } from '../../../../../common/api/utils';
import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect';
import * as Panel from '../../PanelUtils';
import style from './ProjectSettingsPanel.module.scss';
import style from '../FeatureSettings.module.scss';
interface CustomFieldsFormProps {
onSubmit: (field: CustomField) => Promise<void>;
@@ -0,0 +1,98 @@
import { useState } from 'react';
import { Alert, AlertDescription, AlertIcon, Button } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { CustomField, CustomFieldLabel } from 'ontime-types';
import { deleteCustomField, editCustomField, postCustomField } from '../../../../../common/api/customFields';
import ExternalLink from '../../../../../common/components/external-link/ExternalLink';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import * as Panel from '../../PanelUtils';
import CustomFieldEntry from './CustomFieldEntry';
import CustomFieldForm from './CustomFieldForm';
const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
export default function CustomFields() {
const { data, refetch } = useCustomFields();
const [isAdding, setIsAdding] = useState(false);
const handleInitiateCreate = () => {
setIsAdding(true);
};
const handleCancel = () => {
setIsAdding(false);
};
const handleCreate = async (customField: CustomField) => {
await postCustomField(customField);
refetch();
setIsAdding(false);
};
const handleEditField = async (label: CustomFieldLabel, customField: CustomField) => {
await editCustomField(label, customField);
refetch();
};
const handleDelete = async (label: string) => {
try {
await deleteCustomField(label);
refetch();
} catch (_error) {
/** we do not handle errors here */
}
};
return (
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>
Custom fields
<Button variant='ontime-subtle' rightIcon={<IoAdd />} size='sm' onClick={handleInitiateCreate}>
New
</Button>
</Panel.SubHeader>
<Panel.Divider />
<Panel.Section>
<Alert status='info' variant='ontime-on-dark-info'>
<AlertIcon />
<AlertDescription>
Custom fields allow for additional information to be added to an event (eg. light, sound, camera). <br />
<br />
This data is not used by Ontime.
<ExternalLink href={customFieldsDocsUrl}>See the docs</ExternalLink>
</AlertDescription>
</Alert>
</Panel.Section>
<Panel.Section>
{isAdding && <CustomFieldForm onSubmit={handleCreate} onCancel={handleCancel} />}
<Panel.Table>
<thead>
<tr>
<th>Colour</th>
<th>Name</th>
<th />
</tr>
</thead>
<tbody>
{Object.entries(data).map(([key, { colour, label }]) => {
return (
<CustomFieldEntry
key={key}
field={key}
colour={colour}
label={label}
onEdit={handleEditField}
onDelete={handleDelete}
/>
);
})}
</tbody>
</Panel.Table>
</Panel.Section>
</Panel.Card>
</Panel.Section>
);
}
@@ -2,23 +2,3 @@
display: flex;
gap: 1em;
}
.pad {
padding: 0 2rem;
}
.fit {
width: fit-content;
}
.aliasConstrain {
min-width: 12em;
}
.fullWidth {
width: 100%;
}
.flex {
display: flex;
}
@@ -1,28 +1,28 @@
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import { PanelBaseProps } from '../../settingsStore';
import EditorSettingsForm from '../interface-panel/EditorSettingsForm';
import * as Panel from '../PanelUtils';
import GeneralPanelForm from './GeneralPanelForm';
import UrlPresetsForm from './UrlPresetsForm';
import ViewSettingsForm from './ViewSettingsForm';
export default function GeneralPanel({ location }: PanelBaseProps) {
const manageRef = useScrollIntoView<HTMLDivElement>('manage', location);
const generalRef = useScrollIntoView<HTMLDivElement>('settings', location);
const editorRef = useScrollIntoView<HTMLDivElement>('editor', location);
const viewRef = useScrollIntoView<HTMLDivElement>('view', location);
const urlPresetsRef = useScrollIntoView<HTMLDivElement>('urlpresets', location);
return (
<>
<Panel.Header>Settings</Panel.Header>
<div ref={manageRef}>
<Panel.Header>App Settings</Panel.Header>
<div ref={generalRef}>
<GeneralPanelForm />
</div>
<div ref={editorRef}>
<EditorSettingsForm />
</div>
<div ref={viewRef}>
<ViewSettingsForm />
</div>
<div ref={urlPresetsRef}>
<UrlPresetsForm />
</div>
</>
);
}
@@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { Controller, useForm } from 'react-hook-form';
import { Alert, AlertDescription, AlertIcon, Button, Input, Switch } from '@chakra-ui/react';
import { ViewSettings } from 'ontime-types';
@@ -99,7 +99,13 @@ export default function ViewSettingsForm() {
title='Override CSS styles'
description='Enables overriding view styles with custom stylesheet'
/>
<Switch {...register('overrideStyles')} variant='ontime' size='lg' />
<Controller
control={control}
name='overrideStyles'
render={({ field: { onChange, value, ref } }) => (
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
)}
/>
</Panel.ListItem>
</Panel.ListGroup>
<Panel.ListGroup>
@@ -120,9 +126,15 @@ export default function ViewSettingsForm() {
<Panel.ListItem>
<Panel.Field
title='Freeze timer on end'
description='Timer in views will stop from going negative after reaching'
description='Timer in views will stop from going negative and instead stop at 00:00:00'
/>
<Controller
control={control}
name='freezeEnd'
render={({ field: { onChange, value, ref } }) => (
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
)}
/>
<Switch {...register('freezeEnd')} variant='ontime' size='lg' />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
@@ -1,4 +1,4 @@
import { useFieldArray, useForm } from 'react-hook-form';
import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { Button, IconButton, Input, Select, Switch } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
@@ -75,7 +75,7 @@ export default function HttpIntegrations() {
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>
HTTP
HTTP settings
<div className={style.flex}>
<Button variant='ontime-ghosted' size='sm' onClick={() => reset()} isDisabled={!canSubmit}>
Revert to saved
@@ -99,7 +99,13 @@ export default function HttpIntegrations() {
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='HTTP Output' description='Provide feedback from Ontime through HTTP' />
<Switch variant='ontime' size='lg' {...register('enabledOut')} />
<Controller
control={control}
name='enabledOut'
render={({ field: { onChange, value, ref } }) => (
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
)}
/>
</Panel.ListItem>
</Panel.ListGroup>
@@ -2,6 +2,10 @@
width: 100%;
}
.halfWidth {
width: 50%;
}
.fitContents.fitContents {
width: max-content; /* override chakra */
}
@@ -1,4 +1,4 @@
import { useFieldArray, useForm } from 'react-hook-form';
import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { Button, IconButton, Input, Select, Switch } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
@@ -8,7 +8,7 @@ import { generateId } from 'ontime-utils';
import { maybeAxiosError } from '../../../../common/api/utils';
import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings';
import { isKeyEscape } from '../../../../common/utils/keyEvent';
import { isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex';
import { isASCII, isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex';
import * as Panel from '../PanelUtils';
import { cycles } from './integrationUtils';
@@ -65,7 +65,8 @@ export default function OscIntegrations() {
prepend({
id: generateId(),
cycle: 'onLoad',
message: '',
address: '',
payload: '',
enabled: false,
});
};
@@ -80,7 +81,7 @@ export default function OscIntegrations() {
return (
<Panel.Card>
<Panel.SubHeader>
Open Sound Control
OSC settings
<div className={style.flex}>
<Button variant='ontime-ghosted' size='sm' onClick={() => reset()} isDisabled={!canSubmit}>
Revert to saved
@@ -102,12 +103,18 @@ export default function OscIntegrations() {
<Panel.Section as='form' id='osc-form' onSubmit={handleSubmit(onSubmit)} onKeyDown={preventEscape}>
<Panel.Loader isLoading={isLoading} />
<Panel.Title>OSC Settings</Panel.Title>
<Panel.Title>General OSC settings</Panel.Title>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='OSC input' description='Allow control of Ontime through OSC' />
<Switch variant='ontime' size='lg' {...register('enabledIn')} />
<Controller
control={control}
name='enabledIn'
render={({ field: { onChange, value, ref } }) => (
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
)}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
@@ -140,7 +147,13 @@ export default function OscIntegrations() {
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='OSC output' description='Provide feedback from Ontime with OSC' />
<Switch variant='ontime' size='lg' {...register('enabledOut')} />
<Controller
control={control}
name='enabledOut'
render={({ field: { onChange, value, ref } }) => (
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
)}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
@@ -195,7 +208,7 @@ export default function OscIntegrations() {
<Panel.Divider />
<Panel.Title>
OSC Integrations
OSC integrations
<Button variant='ontime-subtle' size='sm' rightIcon={<IoAdd />} onClick={handleAddNewSubscription}>
Add
</Button>
@@ -207,14 +220,15 @@ export default function OscIntegrations() {
<tr>
<th>Enabled</th>
<th>Cycle</th>
<th className={style.fullWidth}>Message</th>
<th className={style.halfWidth}>Address</th>
<th className={style.halfWidth}>Payload</th>
<th />
</tr>
</thead>
<tbody>
{fields.map((field, index) => {
// @ts-expect-error -- not sure why it is not finding the type, it is ok
const maybeError = errors.subscriptions?.[index]?.message?.message;
const maybeAddressError = errors.subscriptions?.[index]?.address?.message;
const maybePayloadError = errors.subscriptions?.[index]?.payload?.message;
return (
<tr key={field.id}>
<td>
@@ -234,22 +248,40 @@ export default function OscIntegrations() {
))}
</Select>
</td>
<td className={style.fullWidth}>
<td className={style.halfWidth}>
<Input
key={field.id}
size='sm'
variant='ontime-filled'
autoComplete='off'
placeholder='/from-ontime/{{timer.current}}'
{...register(`subscriptions.${index}.message`, {
placeholder='/from-ontime/'
{...register(`subscriptions.${index}.address`, {
required: { value: true, message: 'Required field' },
pattern: {
value: startsWithSlash,
message: 'OSC messages should start with a forward slash',
validate: {
oscStartsWithSlash: (value) =>
startsWithSlash.test(value) || 'OSC address should start with a forward slash',
oscStringIsAscii: (value) =>
isASCII.test(value) || 'OSC address only allow ASCII characters',
},
})}
/>
{maybeError && <Panel.Error>{maybeError}</Panel.Error>}
{maybeAddressError && <Panel.Error>{maybeAddressError}</Panel.Error>}
</td>
<td className={style.halfWidth}>
<Input
key={field.id}
size='sm'
variant='ontime-filled'
autoComplete='off'
placeholder='{{timer.current}}'
{...register(`subscriptions.${index}.payload`, {
validate: {
oscStringIsAscii: (value) =>
isASCII.test(value) || 'OSC payloads only allow ASCII characters',
},
})}
/>
{maybePayloadError && <Panel.Error>{maybePayloadError}</Panel.Error>}
</td>
<td>
<IconButton
@@ -1,71 +1,142 @@
import { Switch } from '@chakra-ui/react';
import { Select, Switch } from '@chakra-ui/react';
import { EndAction, TimerType } from 'ontime-types';
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import { useEditorSettings } from '../../../../common/stores/editorSettings';
import { editorSettingsDefaults, useEditorSettings } from '../../../../common/stores/editorSettings';
import { forgivingStringToMillis } from '../../../../common/utils/dateConfig';
import * as Panel from '../PanelUtils';
export default function EditorSettingsForm() {
const eventSettings = useEditorSettings((state) => state.eventSettings);
const setShowQuickEntry = useEditorSettings((state) => state.setShowQuickEntry);
const setLinkPrevious = useEditorSettings((state) => state.setLinkPrevious);
const setDefaultPublic = useEditorSettings((state) => state.setDefaultPublic);
const setDefaultDuration = useEditorSettings((state) => state.setDefaultDuration);
const eventSettings = useEditorSettings((state) => state);
const setDefaultDuration = eventSettings.setDefaultDuration;
const setLinkPrevious = eventSettings.setLinkPrevious;
const setWarnTime = eventSettings.setWarnTime;
const setDangerTime = eventSettings.setDangerTime;
const setDefaultPublic = eventSettings.setDefaultPublic;
const durationInMs = forgivingStringToMillis(eventSettings.defaultDuration);
const warnTimeInMs = forgivingStringToMillis(eventSettings.defaultWarnTime);
const dangerTimeInMs = forgivingStringToMillis(eventSettings.defaultDangerTime);
return (
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Editor settings</Panel.SubHeader>
<Panel.Divider />
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Show quick entry'
description='Whether the quick entry buttons show under selected event'
/>
<Switch
variant='ontime'
size='lg'
defaultChecked={eventSettings.showQuickEntry}
onChange={(event) => setShowQuickEntry(event.target.checked)}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Link previous'
description='New events start time will be linked to the previous event'
/>
<Switch
variant='ontime'
size='lg'
defaultChecked={eventSettings.linkPrevious}
onChange={(event) => setLinkPrevious(event.target.checked)}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Default duration'
description='When creating a new event, what is the default duration'
/>
<TimeInput<'defaultDuration'>
name='defaultDuration'
submitHandler={(_field, value) => setDefaultDuration(value)}
time={durationInMs}
placeholder='00:10:00'
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='Default public' description='New events will be public' />
<Switch
variant='ontime'
size='lg'
defaultChecked={eventSettings.defaultPublic}
onChange={(event) => setDefaultPublic(event.target.checked)}
/>
</Panel.ListItem>
</Panel.ListGroup>
<Panel.Section>
<Panel.Title>Rundown defaults for new events</Panel.Title>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='Default duration' description='Default duration for new events' />
<TimeInput<'defaultDuration'>
name='defaultDuration'
submitHandler={(_field, value) => setDefaultDuration(value)}
time={durationInMs}
placeholder={editorSettingsDefaults.duration}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Link previous'
description='Whether the start time of new events should be linked to the previous event end time'
/>
<Switch
variant='ontime'
size='lg'
defaultChecked={eventSettings.linkPrevious}
onChange={(event) => setLinkPrevious(event.target.checked)}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='Timer type' description='Default type of timer for new events' />
<Select variant='ontime' size='sm' width='auto' isDisabled>
<option value={TimerType.CountDown}>Count down</option>
<option value={TimerType.CountUp}>Count up</option>
<option value={TimerType.TimeToEnd}>Time to end</option>
<option value={TimerType.Clock}>Clock</option>
</Select>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='End Action' description='Default end action for new events' />
<Select variant='ontime' size='sm' width='auto' isDisabled>
<option value={EndAction.None}>None</option>
<option value={EndAction.Stop}>Stop</option>
<option value={EndAction.LoadNext}>Load next</option>
<option value={EndAction.PlayNext}>Play next</option>
</Select>
</Panel.ListItem>
</Panel.ListGroup>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='Warning time' description='Default threshold for warning time in an event' />
<TimeInput<'warnTime'>
name='warnTime'
submitHandler={(_field, value) => setWarnTime(value)}
time={warnTimeInMs}
placeholder={editorSettingsDefaults.warnTime}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='Danger time' description='Default threshold for danger time in an event' />
<TimeInput<'dangerTime'>
name='dangerTime'
submitHandler={(_field, value) => setDangerTime(value)}
time={dangerTimeInMs}
placeholder={editorSettingsDefaults.dangerTime}
/>
</Panel.ListItem>
</Panel.ListGroup>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='Default public' description='New events will be public' />
<Switch
variant='ontime'
size='lg'
defaultChecked={eventSettings.defaultPublic}
onChange={(event) => setDefaultPublic(event.target.checked)}
/>
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
<Panel.Section>
<Panel.Title>Play mode</Panel.Title>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Show quick entry'
description='Whether the quick entry buttons show above / under selected event'
/>
<Switch variant='ontime' size='lg' defaultChecked={false} isDisabled />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Follow playback'
description='Whether view automatically follows the event being played'
/>
<Switch variant='ontime' size='lg' defaultChecked isDisabled />
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
<Panel.Section>
<Panel.Title>Edit mode</Panel.Title>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Show quick entry'
description='Whether the quick entry buttons show above / under selected event'
/>
<Switch variant='ontime' size='lg' defaultChecked isDisabled />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Follow playback'
description='Whether view automatically follows the event being played'
/>
<Switch variant='ontime' size='lg' defaultChecked={false} isDisabled />
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
</Panel.Card>
</Panel.Section>
);
@@ -6,7 +6,7 @@ import InfoNif from './NetworkInterfaces';
export default function LogPanel() {
return (
<>
<Panel.Header>Application log</Panel.Header>
<Panel.Header>Log</Panel.Header>
<Panel.Section>
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
</Panel.Section>
@@ -56,7 +56,7 @@ export default function ProjectData() {
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)}>
<Panel.Card>
<Panel.SubHeader>
Project Data
Project data
<div className={style.headerButtons}>
<Button variant='ontime-ghosted' size='sm' onClick={onReset} isDisabled={isSubmitting || !isDirty}>
Revert to saved
@@ -29,22 +29,21 @@ export default function ProjectList() {
};
const reorderedProjectFiles = useMemo(() => {
if (!data?.files?.length) return [];
if (!data.files?.length) return [];
const currentlyLoadedIndex = files.findIndex((project) => project.filename === lastLoadedProject);
const projectFiles = [...files];
const current = projectFiles.splice(currentlyLoadedIndex, 1)?.[0];
return [current, ...projectFiles];
}, [data.files.length, files, lastLoadedProject]);
}, [data.files?.length, files, lastLoadedProject]);
return (
<Panel.Table>
<thead>
<tr>
<th className={style.containCell}>Project Name</th>
<th>Date Created</th>
<th>Date Modified</th>
<th>Last Used</th>
<th />
</tr>
</thead>
@@ -53,7 +52,6 @@ export default function ProjectList() {
<ProjectListItem
key={project.filename}
filename={project.filename}
createdAt={project.createdAt}
updatedAt={project.updatedAt}
onToggleEditMode={handleToggleEditMode}
onSubmit={handleClear}
@@ -21,7 +21,6 @@ export type EditMode = 'rename' | 'duplicate' | null;
interface ProjectListItemProps {
current?: boolean;
filename: string;
createdAt: string;
updatedAt: string;
onToggleEditMode: (editMode: EditMode, filename: string | null) => void;
onSubmit: () => void;
@@ -32,14 +31,13 @@ interface ProjectListItemProps {
export default function ProjectListItem({
current,
createdAt,
updatedAt,
editingFilename,
editingMode,
filename,
onRefetch,
onSubmit,
onToggleEditMode,
updatedAt,
}: ProjectListItemProps) {
const [submitError, setSubmitError] = useState<string | null>(null);
@@ -102,7 +100,6 @@ export default function ProjectListItem({
) : (
<>
<td className={style.containCell}>{filename}</td>
<td>{new Date(createdAt).toLocaleString()}</td>
<td>{new Date(updatedAt).toLocaleString()}</td>
<td className={style.actionButton}>
<ActionMenu
@@ -6,7 +6,7 @@ import ManageProjects from './ManageProjects';
import ProjectData from './ProjectData';
export default function ProjectPanel({ location }: PanelBaseProps) {
const projectRef = useScrollIntoView<HTMLDivElement>('project', location);
const projectRef = useScrollIntoView<HTMLDivElement>('data', location);
const manageRef = useScrollIntoView<HTMLDivElement>('manage', location);
return (
@@ -1,101 +0,0 @@
import { useState } from 'react';
import { Alert, AlertDescription, AlertIcon, Button } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { CustomField, CustomFieldLabel } from 'ontime-types';
import { deleteCustomField, editCustomField, postCustomField } from '../../../../common/api/customFields';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import useCustomFields from '../../../../common/hooks-query/useCustomFields';
import * as Panel from '../PanelUtils';
import CustomFieldEntry from './CustomFieldEntry';
import CustomFieldForm from './CustomFieldForm';
const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
export default function ProjectSettingsPanel() {
const { data, refetch } = useCustomFields();
const [isAdding, setIsAdding] = useState(false);
const handleInitiateCreate = () => {
setIsAdding(true);
};
const handleCancel = () => {
setIsAdding(false);
};
const handleCreate = async (customField: CustomField) => {
await postCustomField(customField);
refetch();
setIsAdding(false);
};
const handleEditField = async (label: CustomFieldLabel, customField: CustomField) => {
await editCustomField(label, customField);
refetch();
};
const handleDelete = async (label: string) => {
try {
await deleteCustomField(label);
refetch();
} catch (_error) {
/** we do not handle errors here */
}
};
return (
<>
<Panel.Header>Project Settings</Panel.Header>
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>
Custom fields
<Button variant='ontime-subtle' rightIcon={<IoAdd />} size='sm' onClick={handleInitiateCreate}>
New
</Button>
</Panel.SubHeader>
<Panel.Divider />
<Panel.Section>
<Alert status='info' variant='ontime-on-dark-info'>
<AlertIcon />
<AlertDescription>
Custom fields allow for additional information to be added to an event (eg. light, sound, camera).{' '}
<br />
<br />
This data is not used by Ontime.
<ExternalLink href={customFieldsDocsUrl}>See the docs</ExternalLink>
</AlertDescription>
</Alert>
</Panel.Section>
<Panel.Section>
{isAdding && <CustomFieldForm onSubmit={handleCreate} onCancel={handleCancel} />}
<Panel.Table>
<thead>
<tr>
<th>Colour</th>
<th>Name</th>
<th />
</tr>
</thead>
<tbody>
{Object.entries(data).map(([key, { colour, label }]) => {
return (
<CustomFieldEntry
key={key}
colour={colour}
label={label}
onEdit={handleEditField}
onDelete={handleDelete}
/>
);
})}
</tbody>
</Panel.Table>
</Panel.Section>
</Panel.Card>
</Panel.Section>
</>
);
}
@@ -0,0 +1,60 @@
import { useRef } from 'react';
import {
AlertDialog,
AlertDialogBody,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
Button,
useDisclosure,
} from '@chakra-ui/react';
import useElectronEvent from '../../../../common/hooks/useElectronEvent';
import * as Panel from '../PanelUtils';
export default function ShutdownPanel() {
const { isElectron, sendToElectron } = useElectronEvent();
const { isOpen, onOpen, onClose } = useDisclosure();
const cancelRef = useRef<HTMLButtonElement | null>(null);
const sendShutdown = () => {
sendToElectron('shutdown', 'now');
onClose();
};
return (
<>
<Panel.Header>Shutdown Ontime</Panel.Header>
<Panel.Section>
<Panel.Paragraph>
This will shutdown the Ontime server. <br />
The runtime state will be lost, but your project is kept for next time.
</Panel.Paragraph>
<Button colorScheme='red' onClick={onOpen} maxWidth='350px' isDisabled={!isElectron}>
Shutdown ontime
</Button>
<AlertDialog variant='ontime' isOpen={isOpen} leastDestructiveRef={cancelRef} onClose={onClose}>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize='lg' fontWeight='bold'>
Ontime Shutdown
</AlertDialogHeader>
<AlertDialogBody>
This will shutdown the Ontime server. <br /> Are you sure?
</AlertDialogBody>
<AlertDialogFooter>
<Button ref={cancelRef} onClick={onClose} variant='ontime-ghosted-white'>
Cancel
</Button>
<Button colorScheme='red' onClick={sendShutdown} ml={4}>
Shutdown
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
</Panel.Section>
</>
);
}
@@ -1,9 +1,10 @@
import { ChangeEvent, useEffect, useState } from 'react';
import { Button, Input } from '@chakra-ui/react';
import { Button, Input, Spinner } from '@chakra-ui/react';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import { IoShieldCheckmarkOutline } from '@react-icons/all-files/io5/IoShieldCheckmarkOutline';
import { getWorksheetNames } from '../../../../common/api/sheets';
import { maybeAxiosError } from '../../../../common/api/utils';
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
import { openLink } from '../../../../common/utils/linkUtils';
import * as Panel from '../PanelUtils';
@@ -29,7 +30,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
const sheetId = useSheetStore((state) => state.sheetId);
const setSheetId = useSheetStore((state) => state.setSheetId);
const setWorksheets = useSheetStore((state) => state.setWorksheets);
const patchStepData = useSheetStore((state) => state.patchStepData);
const authenticationStatus = useSheetStore((state) => state.authenticationStatus);
const setAuthenticationStatus = useSheetStore((state) => state.setAuthenticationStatus);
@@ -91,8 +92,13 @@ export default function GSheetSetup(props: GSheetSetupProps) {
setAuthenticationStatus(result.authenticated);
if (result.authenticated !== 'pending') {
if (result.authenticated == 'authenticated') {
const names = await getWorksheetNames(result.sheetId);
setWorksheets(names);
try {
const names = await getWorksheetNames(result.sheetId);
setWorksheets(names);
} catch (error) {
const message = maybeAxiosError(error);
patchStepData({ worksheet: { available: false, error: message } });
}
}
setLoading('');
return;
@@ -184,6 +190,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
) : (
<Panel.ListGroup>
<div className={style.buttonRow}>
{isAuthenticating && <Spinner />}
<CopyTag label='Google Auth Key' disabled={!canAuthenticate} size='sm'>
{authKey ? authKey : 'Upload files to generate Auth Key'}
</CopyTag>
@@ -192,8 +199,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
size='sm'
leftIcon={<IoShieldCheckmarkOutline />}
onClick={handleAuthenticate}
isDisabled={!canAuthenticate || isLoading}
isLoading={loading === 'authenticate' || isAuthenticating}
isDisabled={!canAuthenticate}
>
Authenticate
</Button>
@@ -26,6 +26,7 @@
.buttonRow {
display: flex;
gap: 1rem;
align-items: center;
justify-content: end;
}
@@ -2,7 +2,7 @@ import { ChangeEvent, useRef, useState } from 'react';
import { Button, Input } from '@chakra-ui/react';
import { IoCloudOutline } from '@react-icons/all-files/io5/IoCloudOutline';
import { IoDownloadOutline } from '@react-icons/all-files/io5/IoDownloadOutline';
import { ImportMap, unpackError } from 'ontime-utils';
import { getErrorMessage, ImportMap } from 'ontime-utils';
import {
getWorksheetNames as getWorksheetNamesExcel,
@@ -59,7 +59,7 @@ export default function SourcesPanel() {
setImportFlow('excel');
setHasFile('done');
} catch (error) {
const errorMessage = unpackError(error);
const errorMessage = getErrorMessage(error);
setError(`Error uploading file: ${errorMessage}`);
setWorksheets(null);
setHasFile('none');
@@ -141,14 +141,13 @@ export default function ImportMapForm(props: ImportMapFormProps) {
size='sm'
{...register(label as keyof NamedImportMap)}
>
{worksheetNames &&
worksheetNames.map((name) => {
return (
<option key={name} value={name}>
{name}
</option>
);
})}
{worksheetNames?.map((name) => {
return (
<option key={name} value={name}>
{name}
</option>
);
})}
</Select>
</td>
<td className={style.singleActionCell} />
@@ -1,12 +1,13 @@
import { ImportCustom } from 'ontime-utils';
import { convertToImportMap } from '../importMapUtils';
import { convertToImportMap, NamedImportMap } from '../importMapUtils';
describe('convertToImportMap', () => {
it('converts a namedImportMap to a importMap', () => {
const defaultNamedImporMap = {
Worksheet: 'event schedule',
Start: 'time start',
'Link start': 'link start',
End: 'time end',
Duration: 'duration',
Cue: 'cue',
@@ -26,7 +27,7 @@ describe('convertToImportMap', () => {
{ ontimeName: 'EmptyImportName', importName: '' },
{ ontimeName: '', importName: 'EmptyOntimeName' },
] as ImportCustom[],
};
} as NamedImportMap;
const importMap = convertToImportMap(defaultNamedImporMap);
expect(importMap.custom).toStrictEqual({
@@ -6,6 +6,7 @@ export type NamedImportMap = typeof namedImportMap;
export const namedImportMap = {
Worksheet: 'event schedule',
Start: 'time start',
'Link start': 'link start',
End: 'time end',
Duration: 'duration',
Cue: 'cue',
@@ -21,6 +22,15 @@ export const namedImportMap = {
custom: [] as ImportCustom[],
};
function isNamedImportMap(obj: unknown): obj is NamedImportMap {
if (typeof obj !== 'object' || obj === null) {
return false;
}
const keys = Object.keys(namedImportMap);
return keys.every((key) => Object.hasOwn(obj, key));
}
export function convertToImportMap(namedImportMap: NamedImportMap): ImportMap {
const custom = namedImportMap.custom.reduce((accumulator, { ontimeName, importName }) => {
if (ontimeName && importName) {
@@ -32,6 +42,7 @@ export function convertToImportMap(namedImportMap: NamedImportMap): ImportMap {
return {
worksheet: namedImportMap.Worksheet,
timeStart: namedImportMap.Start,
linkStart: namedImportMap['Link start'],
timeEnd: namedImportMap.End,
duration: namedImportMap.Duration,
cue: namedImportMap.Cue,
@@ -52,10 +63,22 @@ export function persistImportMap(options: NamedImportMap) {
localStorage.setItem('ontime-import-options', JSON.stringify(options));
}
export function getPersistedOptions(): NamedImportMap {
function getPersistImportMap(): unknown {
const options = localStorage.getItem('ontime-import-options');
if (!options) {
return namedImportMap;
throw new Error('no import options found');
}
return JSON.parse(options);
}
export function getPersistedOptions(): NamedImportMap {
try {
const options = getPersistImportMap();
if (!isNamedImportMap(options)) {
return namedImportMap;
}
return options;
} catch {
return namedImportMap;
}
}
@@ -1,12 +1,27 @@
.center {
text-align: center;
}
.center {
text-align: center;
}
.nowrap {
white-space: nowrap;
}
.nowrap {
white-space: nowrap;
}
tr .secondaryRow {
background-color: $white-7;
padding-left: 2em;
}
tr .secondaryRow {
background-color: $white-7;
padding-left: 2em;
}
.linkStartActive {
color: $active-indicator;
transform: rotate(-45deg);
}
.flex {
display: flex;
align-items: center;
gap: 0.5rem;
}
.subdued {
opacity: $opacity-disabled;
}
@@ -1,4 +1,5 @@
import { Fragment } from 'react';
import { IoLink } from '@react-icons/all-files/io5/IoLink';
import { CustomFields, isOntimeBlock, isOntimeEvent, OntimeRundown } from 'ontime-types';
import { millisToString } from 'ontime-utils';
@@ -83,7 +84,10 @@ export default function PreviewRundown(props: PreviewRundownProps) {
</td>
<td className={style.nowrap}>{event.cue}</td>
<td>{event.title}</td>
<td>{millisToString(event.timeStart)}</td>
<td className={style.flex}>
<span className={event.linkStart ? style.subdued : undefined}>{millisToString(event.timeStart)}</span>
{event.linkStart && <IoLink className={style.linkStartActive} />}
</td>
<td>{millisToString(event.timeEnd)}</td>
<td>{millisToString(event.duration)}</td>
<td>{millisToString(event.timeWarning)}</td>
@@ -101,7 +105,7 @@ export default function PreviewRundown(props: PreviewRundownProps) {
fieldHeaders.map((field) => {
let value = '';
if (field in event.custom) {
value = event.custom[field].value;
value = event.custom[field];
}
return <td key={field}>{value}</td>;
})}
@@ -11,23 +11,28 @@ export const settingPanels: Readonly<SettingsOption[]> = [
{
id: 'project',
label: 'Project',
secondary: [{ id: 'project__manage', label: 'Manage project files' }],
},
{
id: 'general',
label: 'General',
secondary: [
{ id: 'general__manage', label: 'Manage Ontime settings' },
{ id: 'general__view', label: 'View settings' },
{ id: 'general__urlpresets', label: 'URL presets' },
{ id: 'project__data', label: 'Project data' },
{ id: 'project__manage', label: 'Manage projects' },
],
},
{
id: 'project_settings',
label: 'Project Settings',
secondary: [{ id: 'project_settings__custom', label: 'Custom fields' }],
id: 'general',
label: 'App Settings',
secondary: [
{ id: 'general__settings', label: 'General settings' },
{ id: 'general__editor', label: 'Editor settings' },
{ id: 'general__view', label: 'View settings' },
],
},
{
id: 'feature_settings',
label: 'Feature Settings',
secondary: [
{ id: 'feature_settings__custom', label: 'Custom fields' },
{ id: 'feature_settings__urlpresets', label: 'URL Presets' },
],
},
{ id: 'interface', label: 'Interface', secondary: [{ id: 'general__editor', label: 'Editor settings' }] },
{
id: 'sources',
label: 'Data Sources',
@@ -51,9 +56,15 @@ export const settingPanels: Readonly<SettingsOption[]> = [
label: 'About',
split: true,
},
{
id: 'shutdown',
label: 'Shutdown',
split: true,
},
] as const;
export type SettingsOptionId = (typeof settingPanels)[number]['id'];
export interface PanelBaseProps {
location?: string;
}
@@ -1,3 +1,5 @@
@import "./MessageControl.module.scss";
.inputItems {
display: grid;
grid-template-columns: 1fr auto;
@@ -6,10 +8,5 @@
}
.label {
font-size: $inner-section-text-size;
color: $label-gray;
&.active {
color: $action-text-color;
}
@extend .label;
}
@@ -10,3 +10,19 @@
gap: $element-spacing;
margin-top: -0.5rem;
}
.singleAction {
display: flex;
flex-direction: column;
gap: $element-spacing;
margin-top: $element-inner-spacing;
}
.label {
font-size: $inner-section-text-size;
color: $label-gray;
&.active {
color: $action-text-color;
}
}
@@ -17,29 +17,14 @@ export default function MessageControl() {
const message = useMessageControl();
const blink = message.timer.blink;
const blackout = message.timer.blackout;
return (
<div className={style.messageContainer}>
<InputRow
label='Public / Backstage screen message'
placeholder='Shown in public and backstage screens'
text={message.public.text || ''}
visible={message.public.visible || false}
changeHandler={(newValue) => setMessage.publicText(newValue)}
actionHandler={() => setMessage.publicVisible(!message.public.visible)}
/>
<InputRow
label='Lower third message'
placeholder='Shown in lower third'
text={message.lower.text || ''}
visible={message.lower.visible || false}
changeHandler={(newValue) => setMessage.lowerText(newValue)}
actionHandler={() => setMessage.lowerVisible(!message.lower.visible)}
/>
<InputRow
label='Timer'
placeholder='Message shown in stage timer'
text={message.timer.text || ''}
visible={message.timer.visible || false}
text={message.timer.text}
visible={message.timer.visible}
changeHandler={(newValue) => setMessage.timerText(newValue)}
actionHandler={() => setMessage.timerVisible(!message.timer.visible)}
/>
@@ -52,7 +37,7 @@ export default function MessageControl() {
onClick={() => setMessage.timerBlink(!blink)}
data-testid='toggle timer blink'
>
Blink message
Blink
</Button>
<Button
size='sm'
@@ -66,11 +51,11 @@ export default function MessageControl() {
</Button>
</div>
<InputRow
label='External Message (readonly)'
label='External Message (read only)'
placeholder={enDash}
readonly
text={message.external.text || ''}
visible={message.external.visible || false}
text={message.external.text}
visible={message.external.visible}
changeHandler={noop}
actionHandler={noop}
/>
@@ -2,7 +2,8 @@ import { Playback } from 'ontime-types';
import { usePlaybackControl } from '../../../common/hooks/useSocket';
import { ExtraTimer } from './extra-timer/ExtraTimer';
import AddTime from './add-time/AddTime';
import { AuxTimer } from './aux-timer/AuxTimer';
import PlaybackButtons from './playback-buttons/PlaybackButtons';
import PlaybackTimer from './playback-timer/PlaybackTimer';
@@ -13,13 +14,15 @@ export default function PlaybackControl() {
return (
<div className={style.mainContainer}>
<PlaybackTimer playback={data.playback as Playback} />
<PlaybackTimer playback={data.playback as Playback}>
<AddTime playback={data.playback} />
</PlaybackTimer>
<PlaybackButtons
playback={data.playback}
numEvents={data.numEvents}
selectedEventIndex={data.selectedEventIndex}
/>
<ExtraTimer />
<AuxTimer />
</div>
);
}
@@ -0,0 +1,16 @@
.addTime {
grid-area: addtime;
display: flex;
flex-direction: column;
gap: $element-inner-spacing;
}
.addButtons {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $element-inner-spacing;
}
.tallButtons {
height: 28px;
}
@@ -0,0 +1,58 @@
import { Tooltip } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoRemove } from '@react-icons/all-files/io5/IoRemove';
import { Playback } from 'ontime-types';
import { MILLIS_PER_SECOND } from 'ontime-utils';
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import { useLocalStorage } from '../../../../common/hooks/useLocalStorage';
import { setPlayback } from '../../../../common/hooks/useSocket';
import { forgivingStringToMillis } from '../../../../common/utils/dateConfig';
import { tooltipDelayMid } from '../../../../ontimeConfig';
import TapButton from '../tap-button/TapButton';
import style from './AddTime.module.scss';
interface AddTimeProps {
playback: Playback;
}
export default function AddTime(props: AddTimeProps) {
const { playback } = props;
const [time, setTime] = useLocalStorage('add-time', 300_000); // 5 minutes
const handleTimeChange = (_field: string, value: string) => {
const newTime = forgivingStringToMillis(value);
setTime(newTime);
};
const handleAddTime = (direction: 'add' | 'remove') => {
// API expects input in seconds
if (direction === 'add') {
setPlayback.addTime(time / MILLIS_PER_SECOND);
} else {
setPlayback.addTime((-1 * time) / MILLIS_PER_SECOND);
}
};
const canAddTime = playback === Playback.Play || playback === Playback.Pause;
const doDisableButtons = !canAddTime || time === 0;
return (
<div className={style.addTime}>
<TimeInput name='addtime' submitHandler={handleTimeChange} time={time} placeholder='Add time' />
<div className={style.addButtons}>
<Tooltip label='Remove time' openDelay={tooltipDelayMid} shouldWrapChildren>
<TapButton onClick={() => handleAddTime('remove')} disabled={doDisableButtons} className={style.tallButtons}>
<IoRemove />
</TapButton>
</Tooltip>
<Tooltip label='Add time' openDelay={tooltipDelayMid} shouldWrapChildren>
<TapButton onClick={() => handleAddTime('add')} disabled={doDisableButtons} className={style.tallButtons}>
<IoAdd />
</TapButton>
</Tooltip>
</div>
</div>
);
}
@@ -6,16 +6,16 @@ import { IoStop } from '@react-icons/all-files/io5/IoStop';
import { Playback, SimpleDirection, SimplePlayback } from 'ontime-types';
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import { setExtraTimer, useExtraTimerControl, useExtraTimerTime } from '../../../../common/hooks/useSocket';
import { setAuxTimer, useAuxTimerControl, useAuxTimerTime } from '../../../../common/hooks/useSocket';
import { forgivingStringToMillis } from '../../../../common/utils/dateConfig';
import TapButton from '../tap-button/TapButton';
import style from './ExtraTimer.module.scss';
import style from './AuxTimer.module.scss';
export function ExtraTimer() {
const { playback, direction } = useExtraTimerControl();
export function AuxTimer() {
const { playback, direction } = useAuxTimerControl();
const { start, pause, stop, setDirection } = setExtraTimer;
const { start, pause, stop, setDirection } = setAuxTimer;
const toggleDirection = () => {
const newDirection = direction === SimpleDirection.CountDown ? SimpleDirection.CountUp : SimpleDirection.CountDown;
@@ -30,10 +30,10 @@ export function ExtraTimer() {
return (
<div className={style.extraRow}>
<ExtraTimeInput />
<AuxTimerInput />
<TapButton onClick={toggleDirection} aspect='tight'>
{direction === SimpleDirection.CountDown && <IoArrowDown />}
{direction === SimpleDirection.CountUp && <IoArrowUp />}
{direction === SimpleDirection.CountDown && <IoArrowDown data-testid='aux-timer-direction' />}
{direction === SimpleDirection.CountUp && <IoArrowUp data-testid='aux-timer-direction' />}
</TapButton>
<TapButton
@@ -42,7 +42,7 @@ export function ExtraTimer() {
active={playback === SimplePlayback.Start}
disabled={!userCan.start}
>
<IoPlay />
<IoPlay data-testid='aux-timer-start' />
</TapButton>
<TapButton
onClick={pause}
@@ -50,25 +50,25 @@ export function ExtraTimer() {
active={playback === SimplePlayback.Pause}
disabled={!userCan.pause}
>
<IoPause />
<IoPause data-testid='aux-timer-pause' />
</TapButton>
<TapButton onClick={stop} theme={Playback.Stop} disabled={!userCan.stop}>
<IoStop />
<IoStop data-testid='aux-timer-stop' />
</TapButton>
</div>
);
}
function ExtraTimeInput() {
const time = useExtraTimerTime();
const { setTime } = setExtraTimer;
function AuxTimerInput() {
const time = useAuxTimerTime();
const { setDuration } = setAuxTimer;
const handleTimeUpdate = (_field: string, value: string) => {
const newTime = forgivingStringToMillis(value);
setTime(newTime);
setDuration(newTime / 1000); //frontend api is seconds based;
};
return (
<TimeInput<'extraTimer'> submitHandler={handleTimeUpdate} name='extraTimer' time={time} placeholder='Timer 1' />
<TimeInput<'auxTimer'> submitHandler={handleTimeUpdate} name='auxTimer' time={time} placeholder='Aux Timer 1' />
);
}
@@ -34,6 +34,7 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) {
const noEvents = numEvents === 0;
const disableGo = isRolling || noEvents || (isLast && !isArmed);
const disableNext = isRolling || noEvents || isLast;
const disablePrev = isRolling || noEvents || isFirst;
const playbackCan = validatePlayback(playback);
@@ -73,7 +74,7 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) {
</TapButton>
</Tooltip>
<Tooltip label='Next event' openDelay={tooltipDelayMid}>
<TapButton onClick={setPlayback.next} disabled={disableGo}>
<TapButton onClick={setPlayback.next} disabled={disableNext}>
<IoPlaySkipForward />
</TapButton>
</Tooltip>
@@ -1,16 +1,17 @@
.timeContainer {
display: grid;
grid-template-areas:
'ind clk clk btn'
'... sta fin btn';
'indicators timer addtime'
'status status addtime';
grid-template-rows: 1fr auto;
grid-template-columns: 1.25rem 1fr 1fr 5rem;
grid-template-columns: 1.25rem 1fr 6.5rem;
gap: $element-inner-spacing;
justify-items: start;
}
// ---------> INDICATORS
.indicators {
grid-area: ind;
grid-area: indicators;
width: 100%;
height: 100%;
display: flex;
@@ -18,72 +19,46 @@
justify-content: space-evenly;
}
.indRoll,
.indDelay,
.indNegative {
.indicatorRoll,
.indicatorDelay,
.indicatorNegative {
background-color: $black-10;
}
.indRoll,
.indRollActive,
.indDelay,
.indDelayActive {
.indicatorRoll,
.indicatorDelay {
margin: 0 auto;
border-radius: 6px;
width: 12px;
height: 12px;
border-radius: 5px;
width: 10px;
height: 10px;
}
.indRollActive {
.indicatorRoll[data-active='true'] {
background-color: $ontime-roll;
}
.indNegative,
.indNegativeActive {
margin: 0 auto;
width: 90%;
height: 0.25rem;
}
.indNegativeActive {
background-color: $playback-negative;
}
.indDelayActive {
.indicatorDelay[data-active='true'] {
background-color: $ontime-delay;
}
.btn {
grid-area: btn;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
width: 100%;
gap: $element-inner-spacing;
.indicatorNegative {
margin: 0 auto;
width: 90%;
height: 0.25rem;
&[data-active='true'] {
background-color: $playback-negative;
}
}
.minus {
grid-area: min;
display: flex;
flex-direction: column;
}
// ---------> LABELS
.start,
.finish,
.roll {
.status {
grid-area: status;
height: 1.5rem;
}
.start {
grid-area: sta;
}
.finish {
grid-area: fin;
}
.roll {
grid-area: 2 / 2 / 2 / 4 ;
display: flex;
gap: $section-spacing;
margin-left: 1.5rem;
}
.tag {

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