mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-03 06:28:01 +00:00
Compare commits
117 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12d9e5922c | |||
| 63983fc39a | |||
| 41af3b7466 | |||
| 78b386742e | |||
| ad71793cf3 | |||
| 10d7273ec0 | |||
| 6dc2bebeee | |||
| d59885fc20 | |||
| d22cc48565 | |||
| 8196ea584d | |||
| 0c31438e44 | |||
| 10d3986832 | |||
| 088927bbbb | |||
| d87ba12b2e | |||
| 5b01329c37 | |||
| 8df419d835 | |||
| d7392b93d2 | |||
| 429df21557 | |||
| da7ef59216 | |||
| 474f1e2177 | |||
| fc5338903b | |||
| 11d06de133 | |||
| cbc2ae5116 | |||
| a6ff874a2a | |||
| 7ff75835ff | |||
| c890251dad | |||
| c1e2700c38 | |||
| 5a397c6da7 | |||
| c1377544a0 | |||
| 1a420a1ddd | |||
| 436a34aaee | |||
| 4848fdbee3 | |||
| 71dace669b | |||
| 67c448572c | |||
| 53963a9ad7 | |||
| 5355e45b80 | |||
| 12f81c63ce | |||
| 7f78fac162 | |||
| e8b5cb6750 | |||
| 212e5f9e68 | |||
| 1890fc49d7 | |||
| 47a519bac1 | |||
| 083b376113 | |||
| 5b20a47ff6 | |||
| c3880cd5a7 | |||
| 91fd275564 | |||
| cf08bfc796 | |||
| 7eb9ee8d03 | |||
| ec8952be56 | |||
| 6a5020424b | |||
| 39711c1edd | |||
| 3c83f4d15b | |||
| 4c77d26a71 | |||
| 5de40e8679 | |||
| 32d70b3cd5 | |||
| 86cec56a3d | |||
| f6557a9814 | |||
| c146954a2f | |||
| 2df3d376ad | |||
| 74e1ae609c | |||
| e748bff0dd | |||
| b4ad533e6a | |||
| 0cd2ca4191 | |||
| cb8ba776e2 | |||
| c7328a33f6 | |||
| fc5800bc21 | |||
| 3c1be1fc10 | |||
| a2950442d8 | |||
| ee121bcfcb | |||
| 4234334fbb | |||
| 7f63cde7a5 | |||
| f965db148c | |||
| e6bb52ad9c | |||
| 6d067f3d3a | |||
| 506f9a868b | |||
| adb8981191 | |||
| a0b3e56896 | |||
| 229fff05a4 | |||
| 5b7045e6a5 | |||
| 7cc4364a63 | |||
| b4e73ff6b5 | |||
| 9031051465 | |||
| a15a9671d2 | |||
| ecd13fdc5a | |||
| 3fb862628c | |||
| 1f3133d4f3 | |||
| 64573aff8a | |||
| 655e9e8fb5 | |||
| bac2dc6b87 | |||
| 65a5aaa665 | |||
| c889d00e6b | |||
| 3753d51d85 | |||
| cb23b83b9d | |||
| a567fdc446 | |||
| 3d9a9a2226 | |||
| 9a4d659b04 | |||
| a5d8b91f34 | |||
| 5409c0ac6e | |||
| 3998b8927d | |||
| 7b9f64a630 | |||
| c5c0401ac4 | |||
| 96280e5932 | |||
| b2c01a141a | |||
| dc4ba4012c | |||
| d1e201ebb5 | |||
| a463cb491b | |||
| 6ffc314513 | |||
| f2632412aa | |||
| 062dc0c914 | |||
| fe9f12a14b | |||
| 3144888eb1 | |||
| 820eacde91 | |||
| 6886a3236f | |||
| bb541c551f | |||
| 3196bd7802 | |||
| ce0657cc6d | |||
| 44c2b1b5e3 |
@@ -19,7 +19,6 @@
|
||||
"rules": {
|
||||
"no-useless-concat": "warn",
|
||||
"prefer-template": "warn",
|
||||
"no-throw-literal": "error",
|
||||
"no-console": [
|
||||
"warn",
|
||||
{
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
github: cpvalente
|
||||
buy_me_a_coffee: cpvalente
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 188 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 183 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 336 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 660 KiB |
@@ -1,4 +1,4 @@
|
||||
name: Docker Image CI Ontime
|
||||
name: Docker Image CI Ontime V2
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -13,14 +13,14 @@ jobs:
|
||||
CI: ''
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup env
|
||||
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v3.6.0
|
||||
with:
|
||||
node-version: 18.18.2
|
||||
version: 16.16.0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
@@ -0,0 +1,108 @@
|
||||
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,8 +1,9 @@
|
||||
name: Ontime build
|
||||
name: Ontime build v3
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: [ "*" ]
|
||||
# Only trigger manually for now for testing
|
||||
# push:
|
||||
# tags: [ "*" ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -30,14 +31,7 @@ jobs:
|
||||
run: pnpm build
|
||||
|
||||
- name: Electron - Build app
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLEID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLEIDPASS }}
|
||||
APPLE_TEAM_ID: ${{ secrets.TEAMID }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
run: pnpm dist-mac
|
||||
timeout-minutes: 60
|
||||
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
@@ -0,0 +1,106 @@
|
||||
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
|
||||
@@ -1,8 +1,9 @@
|
||||
name: Ontime test
|
||||
name: Ontime test v3
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: '*'
|
||||
# Only trigger manually for now for testing
|
||||
# pull_request:
|
||||
# branches: '*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -45,6 +45,3 @@ apps/server/src/preloaded-db/db.json
|
||||
|
||||
# versioning file
|
||||
**/ONTIME_VERSION.js
|
||||
|
||||
# temporary write files
|
||||
**.tmp
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM node:18.18-alpine
|
||||
FROM node:16-alpine
|
||||
|
||||
# Set environment variables
|
||||
# Environment Variable to signal that we are running production
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||

|
||||

|
||||
[](https://github.com/sponsors/cpvalente)
|
||||
[](https://www.buymeacoffee.com/cpvalente)
|
||||
[](https://github.com/cpvalente/ontime/actions/workflows/build_v2.yml)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||
|
||||
## Download the latest releases here
|
||||
|
||||
@@ -13,115 +10,146 @@
|
||||
<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 a browser-based application that manages event rundowns, scheduling, and cueing.
|
||||
Ontime is an application for creating and managing event running order and timers.
|
||||
|
||||
With Ontime, you can plan, track your schedule, manage automation and cross-department show information all in one place.
|
||||
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.
|
||||
|
||||
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
|
||||
This makes for a simple and cheap way to distribute over a venue using a network infrastructure instead of video outputs.
|
||||
|
||||
### For live environments
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
[Read the docs to learn more](https://docs.getontime.no)
|
||||
|
||||
## Using Ontime
|
||||
|
||||
Ontime can be started by downloading the latest release for your platform. \
|
||||
Alternatively you can also use the docker image, available at [Docker Hub](https://hub.docker.com/r/getontime/ontime)
|
||||
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.
|
||||
|
||||
Once installed and running, any device in the network has access to 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.
|
||||
|
||||
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`
|
||||
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
|
||||
|
||||
```
|
||||
For the backstage views
|
||||
For the presentation 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/backstage > Stage Manager / Backstage view
|
||||
IP.ADDRESS:4001/countdown > Countdown to anything
|
||||
IP.ADDRESS:4001/sm > Stage Manager / Backstage view
|
||||
IP.ADDRESS:4001/public > Public / Foyer view
|
||||
IP.ADDRESS:4001/lower > Lower Thirds
|
||||
IP.ADDRESS:4001/studio > Studio Clock
|
||||
```
|
||||
|
||||
```
|
||||
For the public views
|
||||
-------------------------------------------------------------
|
||||
IP.ADDRESS:4001/public > Public / Foyer view
|
||||
IP.ADDRESS:4001/lower > Lower Thirds
|
||||
```
|
||||
|
||||
```
|
||||
For production views
|
||||
For management 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)
|
||||
|
||||
## Main features
|
||||
## Feature List (in no specific order)
|
||||
|
||||
* [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)
|
||||
- [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/)
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Continued development
|
||||
|
||||
Ontime is under active development. We continue adding and tweaking features in collaboration with users.
|
||||
|
||||
Several features are planned in the roadmap, and we continuously adjust this to match how users interact with the app.
|
||||
<br />
|
||||
Have an idea? Reach out via [email](mail@getontime.no)
|
||||
or [open an issue](https://github.com/cpvalente/ontime/issues/new)
|
||||
|
||||
### Issues
|
||||
|
||||
We use Github's issue tracking for bug reporting and feature requests. \
|
||||
We use Github's issue tracking for bug reporting and feature requests. <br />
|
||||
Found a bug? [Open an issue](https://github.com/cpvalente/ontime/issues/new).
|
||||
|
||||
#### Unsigned App
|
||||
@@ -141,15 +169,13 @@ 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. \
|
||||
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. </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,
|
||||
please [open an issue](https://github.com/cpvalente/ontime/issues/new)
|
||||
|
||||
#### Safari
|
||||
@@ -161,29 +187,19 @@ 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
@@ -1,19 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.0-alpha",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
@@ -10,8 +10,6 @@
|
||||
"@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",
|
||||
"@tanstack/react-query": "^5.17.9",
|
||||
@@ -29,6 +27,7 @@
|
||||
"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 +85,7 @@
|
||||
"ontime-utils": "workspace:*",
|
||||
"prettier": "^3.0.3",
|
||||
"sass": "^1.57.1",
|
||||
"typescript": "^5.4.3",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.1.0",
|
||||
"vite-plugin-compression2": "^0.12.0",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
|
||||
+32
-1
@@ -1,3 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { ChakraProvider } from '@chakra-ui/react';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
@@ -6,6 +7,7 @@ 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';
|
||||
@@ -13,12 +15,41 @@ 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}>
|
||||
<ChakraProvider resetCSS theme={theme}>
|
||||
<QueryClientProvider client={ontimeQueryClient}>
|
||||
<AppContextProvider>
|
||||
<BrowserRouter>
|
||||
|
||||
@@ -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/OperatorExport'));
|
||||
const Operator = lazy(() => import('./features/operator/Operator'));
|
||||
|
||||
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,54 +1,36 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { DatabaseModel, GetInfo, MessageResponse, ProjectData, ProjectFileListResponse } from 'ontime-types';
|
||||
|
||||
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
|
||||
import {
|
||||
CustomFields,
|
||||
DatabaseModel,
|
||||
GetInfo,
|
||||
MessageResponse,
|
||||
OntimeRundown,
|
||||
ProjectData,
|
||||
ProjectFileListResponse,
|
||||
} from 'ontime-types';
|
||||
import { ImportMap } from 'ontime-utils';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
import { createBlob, downloadBlob } from './utils';
|
||||
import fileDownload from './utils';
|
||||
|
||||
const dbPath = `${apiEntryUrl}/db`;
|
||||
|
||||
/**
|
||||
* HTTP request to the current DB
|
||||
* HTTP request to download db in JSON format
|
||||
*/
|
||||
async function getDb(): Promise<AxiosResponse<DatabaseModel>> {
|
||||
return axios.get(`${dbPath}/download`);
|
||||
export async function downloadRundown(fileName?: string) {
|
||||
return fileDownload(
|
||||
dbPath,
|
||||
{ name: fileName ?? 'rundown', type: 'json' },
|
||||
{ type: 'application/json;charset=utf-8;' },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request download of the current project file
|
||||
* @param fileName
|
||||
* HTTP request to download db in CSV format
|
||||
*/
|
||||
export async function downloadProject(fileName: string = 'ontime-project') {
|
||||
try {
|
||||
const { data, name } = await fileDownload(fileName);
|
||||
|
||||
const fileContent = JSON.stringify(data, null, 2);
|
||||
|
||||
const blob = createBlob(fileContent, 'application/json;charset=utf-8;');
|
||||
downloadBlob(blob, `${name}.json`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request download of the current rundown as a CSV file
|
||||
* @param fileName
|
||||
*/
|
||||
export async function downloadCSV(fileName: string = 'rundown') {
|
||||
try {
|
||||
const { data, name } = await fileDownload(fileName);
|
||||
const { project, rundown, customFields } = data;
|
||||
|
||||
const sheetData = makeTable(project, rundown, customFields);
|
||||
const fileContent = makeCSV(sheetData);
|
||||
|
||||
const blob = createBlob(fileContent, 'text/csv;charset=utf-8;');
|
||||
downloadBlob(blob, `${name}.csv`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
export async function downloadCSV(fileName?: string) {
|
||||
return fileDownload(dbPath, { name: fileName ?? 'rundown', type: 'csv' }, { type: 'text/csv;charset=utf-8;' });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,23 +128,28 @@ export async function getInfo(): Promise<GetInfo> {
|
||||
return res.data;
|
||||
}
|
||||
|
||||
type PreviewSpreadsheetResponse = {
|
||||
rundown: OntimeRundown;
|
||||
customFields: CustomFields;
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility function gets project from db
|
||||
* @param fileName
|
||||
* @returns
|
||||
* Make patch changes to the objects in the db
|
||||
*/
|
||||
async function fileDownload(fileName: string): Promise<{ data: DatabaseModel; name: string }> {
|
||||
const response = await getDb();
|
||||
export async function importSpreadsheetPreview(file: File, options: ImportMap): Promise<PreviewSpreadsheetResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append('spreadsheet', file);
|
||||
formData.append('options', JSON.stringify(options));
|
||||
|
||||
const headerLine = response.headers['Content-Disposition'];
|
||||
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(
|
||||
`${dbPath}/spreadsheet/preview`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// try and get the filename from the response
|
||||
let name = fileName;
|
||||
if (headerLine != null) {
|
||||
const startFileNameIndex = headerLine.indexOf('"') + 1;
|
||||
const endFileNameIndex = headerLine.lastIndexOf('"');
|
||||
name = headerLine.substring(startFileNameIndex, endFileNameIndex);
|
||||
}
|
||||
|
||||
return { data: response.data, name };
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { CustomFields, OntimeRundown } from 'ontime-types';
|
||||
import { ImportMap } from 'ontime-utils';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
const excelPath = `${apiEntryUrl}/excel`;
|
||||
|
||||
type PreviewSpreadsheetResponse = {
|
||||
rundown: OntimeRundown;
|
||||
customFields: CustomFields;
|
||||
};
|
||||
|
||||
/**
|
||||
* upload Excel file to server
|
||||
* @return string - file ID op the uploaded file
|
||||
*/
|
||||
export async function upload(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append('excel', file);
|
||||
await axios.post(`${excelPath}/upload`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Worksheet names
|
||||
* @return string[] - array of available worksheets
|
||||
*/
|
||||
export async function getWorksheetNames(): Promise<string[]> {
|
||||
const response: AxiosResponse<string[]> = await axios.get(`${excelPath}/worksheets`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> {
|
||||
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, {
|
||||
options,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import axios from 'axios';
|
||||
import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types';
|
||||
import { ImportMap } from 'ontime-utils';
|
||||
|
||||
@@ -9,10 +9,7 @@ const sheetsPath = `${apiEntryUrl}/sheets`;
|
||||
/**
|
||||
* HTTP request to verify whether we are authenticated with Google Sheet service
|
||||
*/
|
||||
export const verifyAuthenticationStatus = async (): Promise<{
|
||||
authenticated: AuthenticationStatus;
|
||||
sheetId: string;
|
||||
}> => {
|
||||
export const verifyAuthenticationStatus = async (): Promise<{ authenticated: AuthenticationStatus }> => {
|
||||
const response = await axios.get(`${sheetsPath}/connect`);
|
||||
return response.data;
|
||||
};
|
||||
@@ -61,11 +58,6 @@ export const previewRundown = async (
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getWorksheetNames = async (sheetId: string): Promise<string[]> => {
|
||||
const response: AxiosResponse<string[]> = await axios.post(`${sheetsPath}/${sheetId}/worksheets`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* HTTP request to upload the rundown to a google sheet
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,7 @@ import axios, { AxiosError } from 'axios';
|
||||
import { LogLevel } from 'ontime-types';
|
||||
import { generateId, millisToString } from 'ontime-utils';
|
||||
|
||||
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
|
||||
import { ontimeQueryClient } from '../queryClient';
|
||||
import { addLog } from '../stores/logger';
|
||||
import { nowInMillis } from '../utils/time';
|
||||
@@ -55,29 +56,63 @@ export async function invalidateAllCaches() {
|
||||
await ontimeQueryClient.invalidateQueries();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates blob from content
|
||||
* @param fileContent
|
||||
* @param type
|
||||
* @returns
|
||||
*/
|
||||
export function createBlob(fileContent: string, type: string): Blob {
|
||||
return new Blob([fileContent], { type });
|
||||
}
|
||||
type FileOptions = {
|
||||
name: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
type BlobOptions = {
|
||||
type: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* downloads a blob
|
||||
* @param downloadUrl
|
||||
* @param fileName
|
||||
* Gets DB from backend and prepares a file to be downloaded
|
||||
* @param url
|
||||
* @param fileOptions
|
||||
* @param blobOptions
|
||||
* @returns
|
||||
*/
|
||||
export function downloadBlob(blob: Blob, fileName: string) {
|
||||
export default async function fileDownload(url: string, fileOptions: FileOptions, blobOptions: BlobOptions) {
|
||||
const response = await axios({
|
||||
url: `${url}/db`,
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
const headerLine = response.headers['Content-Disposition'];
|
||||
let { name: fileName } = fileOptions;
|
||||
const { type: fileType } = fileOptions;
|
||||
const { project, rundown, customFields } = response.data;
|
||||
|
||||
// try and get the filename from the response
|
||||
if (headerLine != null) {
|
||||
const startFileNameIndex = headerLine.indexOf('"') + 1;
|
||||
const endFileNameIndex = headerLine.lastIndexOf('"');
|
||||
fileName = headerLine.substring(startFileNameIndex, endFileNameIndex);
|
||||
}
|
||||
|
||||
let fileContent = '';
|
||||
|
||||
if (fileType === 'json') {
|
||||
fileContent = JSON.stringify(response.data);
|
||||
fileName += '.json';
|
||||
}
|
||||
|
||||
if (fileType === 'csv') {
|
||||
const sheetData = makeTable(project, rundown, customFields);
|
||||
fileContent = makeCSV(sheetData);
|
||||
fileName += '.csv';
|
||||
}
|
||||
|
||||
const blob = new Blob([fileContent], { type: blobOptions.type });
|
||||
const downloadUrl = URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('href', downloadUrl);
|
||||
link.setAttribute('download', fileName);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
// Clean up the URL.createObjectURL to release resources
|
||||
URL.revokeObjectURL(downloadUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
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 style={{ fontSize: '1rem' }} />} onClick={onClick} isDisabled={isDisabled}>
|
||||
<MenuItem icon={<Icon />} onClick={onClick} isDisabled={isDisabled}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
</>
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
import { RefObject, useEffect } from 'react';
|
||||
import { useEffect, useRef } 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 & { inputref: RefObject<unknown> }) => {
|
||||
const { value, inputref } = props;
|
||||
export const AutoTextArea = (props: TextareaProps) => {
|
||||
const { value } = props;
|
||||
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const node = inputref.current;
|
||||
autosize(inputref.current);
|
||||
const node = ref.current;
|
||||
autosize(ref.current);
|
||||
return () => {
|
||||
autosize.destroy(node);
|
||||
};
|
||||
}, [inputref, value]);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
overflow='hidden'
|
||||
w='100%'
|
||||
ref={inputref}
|
||||
resize='none'
|
||||
ref={ref}
|
||||
transition='height none'
|
||||
variant='ontime-transparent'
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
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,20 +1,17 @@
|
||||
import { ChangeEvent, KeyboardEvent, RefObject, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { getHotkeyHandler, HotkeyItem } from '@mantine/hooks';
|
||||
import { ChangeEvent, KeyboardEvent, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
interface UseReactiveTextInputReturn {
|
||||
value: string;
|
||||
onChange: (event: ChangeEvent) => void;
|
||||
onBlur: (event: ChangeEvent) => void;
|
||||
onKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
|
||||
onKeyDown: (event: KeyboardEvent) => 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);
|
||||
@@ -47,45 +44,43 @@ export default function useReactiveTextInput(
|
||||
const handleSubmit = useCallback(
|
||||
(valueToSubmit: string) => {
|
||||
// No need to update if it hasn't changed
|
||||
if (valueToSubmit !== initialText) {
|
||||
const cleanVal = valueToSubmit.trim();
|
||||
submitCallback(cleanVal);
|
||||
if (cleanVal !== valueToSubmit) {
|
||||
setText(cleanVal);
|
||||
}
|
||||
if (valueToSubmit === initialText) {
|
||||
return;
|
||||
}
|
||||
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, ref, submitCallback],
|
||||
[initialText, submitCallback],
|
||||
);
|
||||
|
||||
/**
|
||||
* @description Handles escape events
|
||||
* @param {string} valueToSubmit
|
||||
* @description Handles common keys for submit and cancel
|
||||
* @param {string} key
|
||||
*/
|
||||
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]);
|
||||
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],
|
||||
);
|
||||
|
||||
return {
|
||||
value: text,
|
||||
onChange: (event: ChangeEvent) => handleChange((event.target as HTMLInputElement).value),
|
||||
onBlur: (event: ChangeEvent) => handleSubmit((event.target as HTMLInputElement).value),
|
||||
onKeyDown: keyHandler,
|
||||
onKeyDown: (event: KeyboardEvent) => keyHandler(event.key),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ interface MultiPartProgressBar {
|
||||
export default function MultiPartProgressBar(props: MultiPartProgressBar) {
|
||||
const { now, complete, normalColor, warning, warningColor, danger, dangerColor, hidden, className = '' } = props;
|
||||
|
||||
const percentRemaining = complete === 0 ? 0 : 100 - clamp(100 - (Math.max(now ?? 0, 0) * 100) / complete, 0, 100);
|
||||
const percentComplete = 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: `${percentRemaining}%` }} />
|
||||
<div className='multiprogress-bar__indicator' style={{ width: `${percentComplete}%` }} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
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,11 +1,12 @@
|
||||
@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: 3rem;
|
||||
$button-size: 48px;
|
||||
|
||||
.buttonContainer {
|
||||
display: flex;
|
||||
@@ -20,7 +21,7 @@ $button-size: 3rem;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 12;
|
||||
z-index: 2;
|
||||
|
||||
&.hidden {
|
||||
opacity: 0;
|
||||
@@ -28,7 +29,7 @@ $button-size: 3rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
font-size: 1.5rem;
|
||||
font-size: 24px;
|
||||
color: $icon-color;
|
||||
background-color: $button-bg;
|
||||
width: $button-size;
|
||||
@@ -43,10 +44,31 @@ $button-size: 3rem;
|
||||
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;
|
||||
padding: 0.75rem 1.5rem;
|
||||
gap: 0.5rem;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 1rem;
|
||||
|
||||
&:hover {
|
||||
background-color: $menu-hover-bg;
|
||||
@@ -69,7 +91,7 @@ $button-size: 3rem;
|
||||
}
|
||||
|
||||
.linkIcon {
|
||||
margin-left: auto;
|
||||
display: inline-block;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,17 @@
|
||||
import { memo, useRef } from 'react';
|
||||
import { memo, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Drawer,
|
||||
DrawerBody,
|
||||
DrawerCloseButton,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerOverlay,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
import { useFullscreen } from '@mantine/hooks';
|
||||
import { Link, useLocation, useSearchParams } from 'react-router-dom';
|
||||
import { useDisclosure } from '@chakra-ui/react';
|
||||
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 { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
|
||||
import { IoPencilSharp } from '@react-icons/all-files/io5/IoPencilSharp';
|
||||
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
|
||||
|
||||
import { navigatorConstants } from '../../../viewerConfig';
|
||||
import useClickOutside from '../../hooks/useClickOutside';
|
||||
import useFullscreen from '../../hooks/useFullscreen';
|
||||
import { useViewOptionsStore } from '../../stores/viewOptions';
|
||||
import { isKeyEnter } from '../../utils/keyEvent';
|
||||
|
||||
@@ -26,54 +19,81 @@ import RenameClientModal from './rename-client-modal/RenameClientModal';
|
||||
|
||||
import style from './NavigationMenu.module.scss';
|
||||
|
||||
interface NavigationMenuProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
function NavigationMenu() {
|
||||
const location = useLocation();
|
||||
|
||||
function NavigationMenu(props: NavigationMenuProps) {
|
||||
const { isOpen, onClose } = props;
|
||||
|
||||
const { isOpen: isRenameOpen, onOpen: onRenameOpen, onClose: onRenameClose } = useDisclosure();
|
||||
|
||||
const { fullscreen, toggle } = useFullscreen();
|
||||
const { isFullScreen, toggleFullScreen } = 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, () => onClose);
|
||||
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 = () => toggleFullScreen();
|
||||
const handleMirror = () => toggleMirror();
|
||||
|
||||
const showEditFormDrawer = () => {
|
||||
searchParams.set('edit', 'true');
|
||||
setSearchParams(searchParams);
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div id='navigation-menu-portal' ref={menuRef}>
|
||||
<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}>
|
||||
<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'>
|
||||
<div className={style.buttonsContainer}>
|
||||
<div
|
||||
className={style.link}
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
onClick={toggle}
|
||||
onClick={handleFullscreen}
|
||||
onKeyDown={(event) => {
|
||||
isKeyEnter(event) && toggle();
|
||||
isKeyEnter(event) && handleFullscreen();
|
||||
}}
|
||||
>
|
||||
Toggle Fullscreen
|
||||
{fullscreen ? <IoContract /> : <IoExpand />}
|
||||
{isFullScreen ? <IoContract /> : <IoExpand />}
|
||||
</div>
|
||||
<div
|
||||
className={style.link}
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
onClick={() => toggleMirror()}
|
||||
onClick={handleMirror}
|
||||
onKeyDown={(event) => {
|
||||
isKeyEnter(event) && toggleMirror();
|
||||
isKeyEnter(event) && handleMirror();
|
||||
}}
|
||||
>
|
||||
Flip Screen
|
||||
@@ -83,35 +103,20 @@ function NavigationMenu(props: NavigationMenuProps) {
|
||||
className={style.link}
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
onClick={onRenameOpen}
|
||||
onClick={onOpen}
|
||||
onKeyDown={(event) => {
|
||||
isKeyEnter(event) && onRenameOpen();
|
||||
isKeyEnter(event) && onOpen();
|
||||
}}
|
||||
>
|
||||
Rename Client
|
||||
</div>
|
||||
</div>
|
||||
<hr className={style.separator} />
|
||||
<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 />
|
||||
<Link to='/cuesheet' className={style.link} tabIndex={0}>
|
||||
Cuesheet
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</Link>
|
||||
<Link to='/op' className={`${style.link} ${location.pathname === '/op' ? style.current : ''}`} tabIndex={0}>
|
||||
<IoLockClosedOutline />
|
||||
<Link to='/op' className={style.link} tabIndex={0}>
|
||||
Operator
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</Link>
|
||||
@@ -127,10 +132,11 @@ function NavigationMenu(props: NavigationMenuProps) {
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</Link>
|
||||
))}
|
||||
</DrawerBody>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
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);
|
||||
@@ -1,27 +0,0 @@
|
||||
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);
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
.modalBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
+5
-3
@@ -13,6 +13,8 @@ import {
|
||||
import { setClientName } from '../../../hooks/useSocket';
|
||||
import { useSocketClientName } from '../../../stores/connectionName';
|
||||
|
||||
import style from './RenameClientModal.module.scss';
|
||||
|
||||
interface RenameClientModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -43,18 +45,18 @@ export default function RenameClientModal({ isOpen, onClose }: RenameClientModal
|
||||
motionPreset='slideInBottom'
|
||||
scrollBehavior='inside'
|
||||
preserveScrollBarGap
|
||||
variant='ontime'
|
||||
variant='ontime-small'
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Rename client</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<ModalBody className={style.modalBody}>
|
||||
<Input
|
||||
placeholder='Connection must have a name'
|
||||
defaultValue={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
variant='ontime-filled'
|
||||
variant='ontime-filled-on-light'
|
||||
/>
|
||||
<Button
|
||||
isDisabled={newName === clientName || !newName}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
@use '../../../theme/viewerDefs' as *;
|
||||
|
||||
$progress-bar-size: 12px;
|
||||
$progress-bar-br: 3px;
|
||||
$progress-bar-br: 6px;
|
||||
|
||||
.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;
|
||||
@@ -19,5 +18,6 @@ $progress-bar-br: 3px;
|
||||
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 { IconButton, PinInput, PinInputField } from '@chakra-ui/react';
|
||||
import { HStack, 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}`}
|
||||
<div className={failed ? style.pin__failed : style.pin}>
|
||||
{`Ontime ${permission || ''}`}
|
||||
<HStack spacing='10px' className={failed ? style.pin__failed : style.pin}>
|
||||
<PinInput
|
||||
type='alphanumeric'
|
||||
size='lg'
|
||||
@@ -57,15 +57,8 @@ export default function PinPage(props: PinPageProps) {
|
||||
<PinInputField />
|
||||
<PinInputField />
|
||||
</PinInput>
|
||||
<IconButton
|
||||
variant='ontime-filled'
|
||||
aria-label='Enter'
|
||||
size='lg'
|
||||
isRound
|
||||
icon={<IoCheckmark />}
|
||||
onClick={validate}
|
||||
/>
|
||||
</div>
|
||||
<IconButton aria-label='Enter' size='lg' isRound icon={<IoCheckmark />} onClick={validate} />
|
||||
</HStack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,27 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 25vh;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
height: 100vh;
|
||||
padding-bottom: 30vh;
|
||||
|
||||
background: $bg-container-l1;
|
||||
color: $ontime-color;
|
||||
font-family: $ontime-font-family;
|
||||
font-weight: 200;
|
||||
font-size: 3rem;
|
||||
text-align: center;
|
||||
font-size: 3vw;
|
||||
}
|
||||
|
||||
.pin,
|
||||
.pin__failed {
|
||||
display: flex;
|
||||
gap: 0.125em;
|
||||
padding-block: 0.5em;
|
||||
padding: 20px;
|
||||
|
||||
input {
|
||||
border-radius: 99px;
|
||||
border-color: $gray-500;
|
||||
|
||||
&:hover {
|
||||
border-color: $blue-500;
|
||||
}
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-left: 1em;
|
||||
margin-left: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,9 +33,9 @@
|
||||
|
||||
@keyframes colourFade {
|
||||
from {
|
||||
background: $red-500;
|
||||
background: $action-blue;
|
||||
}
|
||||
to {
|
||||
background: rgba($red-500, 0);
|
||||
background: rgba($action-blue, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,8 +24,6 @@ interface ScheduleProviderProps {
|
||||
time?: number;
|
||||
}
|
||||
|
||||
const numEventsPerPage = 8;
|
||||
|
||||
export const ScheduleProvider = ({
|
||||
children,
|
||||
events,
|
||||
@@ -39,7 +37,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') ?? numEventsPerPage);
|
||||
const eventsPerPage = Number(searchParams.get('eventsPerPage') ?? 7);
|
||||
|
||||
let selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
|
||||
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
.drawerContent {
|
||||
background-color: $gray-1250;
|
||||
}
|
||||
|
||||
.drawerHeader {
|
||||
@extend .drawerContent;
|
||||
color: $section-white;
|
||||
}
|
||||
|
||||
.drawerFooter {
|
||||
@extend .drawerContent;
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
justify-content: start;
|
||||
gap: $section-spacing;
|
||||
|
||||
button {
|
||||
button[type='reset'] {
|
||||
padding: 0 2em;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
button[type='submit'] {
|
||||
padding: 0 2em;
|
||||
}
|
||||
}
|
||||
@@ -11,9 +26,6 @@
|
||||
.label {
|
||||
font-size: $inner-section-text-size;
|
||||
color: $label-gray;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem
|
||||
}
|
||||
|
||||
.columnSection {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FormEvent, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useLocation, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
Drawer,
|
||||
@@ -12,26 +12,30 @@ import {
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { useLocalStorage } from '../../hooks/useLocalStorage';
|
||||
|
||||
import ParamInput from './ParamInput';
|
||||
import { ParamField } from './types';
|
||||
|
||||
import style from './ViewParamsEditor.module.scss';
|
||||
|
||||
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
|
||||
type SavedViewParams = Record<string, ViewParamsObj>;
|
||||
|
||||
/**
|
||||
* Makes a new URLSearchParams object from the given params object
|
||||
*/
|
||||
const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ParamField[]) => {
|
||||
const defaultValues = paramFields.reduce<Record<string, string>>((acc, { id, defaultValue }) => {
|
||||
acc[id] = String(defaultValue);
|
||||
return acc;
|
||||
return { ...acc, [id]: String(defaultValue) };
|
||||
}, {});
|
||||
|
||||
return Object.entries(paramsObj).reduce((newSearchParams, [id, value]) => {
|
||||
if (typeof value === 'string' && value.length && defaultValues[id] !== value) {
|
||||
if (typeof value === 'string' && value.length) {
|
||||
if (defaultValues[id] === value) {
|
||||
return newSearchParams;
|
||||
}
|
||||
newSearchParams.set(id, value);
|
||||
|
||||
return newSearchParams;
|
||||
}
|
||||
|
||||
return newSearchParams;
|
||||
}, new URLSearchParams());
|
||||
};
|
||||
@@ -43,6 +47,8 @@ interface EditFormDrawerProps {
|
||||
export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { isOpen, onClose, onOpen } = useDisclosure();
|
||||
const { pathname } = useLocation();
|
||||
const [storedViewParams, setStoredViewParams] = useLocalStorage<SavedViewParams>('ontime-views', {});
|
||||
|
||||
useEffect(() => {
|
||||
const isEditing = searchParams.get('edit');
|
||||
@@ -52,16 +58,37 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
|
||||
}
|
||||
}, [searchParams, onOpen]);
|
||||
|
||||
const handleClose = () => {
|
||||
/**
|
||||
* disabling this for now, this feature needs more testing
|
||||
* - we seem to have a bug where this is conflicting with the aliases
|
||||
* - I wonder if the logic below needs to be inside an effect,
|
||||
* both localStorage and searchParams should trigger a component update when they change
|
||||
|
||||
useEffect(() => {
|
||||
const viewParamsObjFromLocalStorage = storedViewParams[pathname];
|
||||
|
||||
if (viewParamsObjFromLocalStorage !== undefined) {
|
||||
const defaultSearchParams = getURLSearchParamsFromObj(viewParamsObjFromLocalStorage);
|
||||
setSearchParams(defaultSearchParams);
|
||||
}
|
||||
|
||||
// linter is asking for `setSearchParams` & `storedViewParams` in the useEffect deps
|
||||
// rule is disabled since adding `setSearchParams` & `storedViewParams` results in unnecessary re-renders
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pathname]);
|
||||
|
||||
*/
|
||||
|
||||
const onCloseWithoutSaving = () => {
|
||||
onClose();
|
||||
|
||||
searchParams.delete('edit');
|
||||
setSearchParams(searchParams);
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
const resetParams = () => {
|
||||
setStoredViewParams({ ...storedViewParams, [pathname]: {} });
|
||||
setSearchParams();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
|
||||
@@ -69,21 +96,21 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
|
||||
|
||||
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
|
||||
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, paramFields);
|
||||
setSearchParams(newSearchParams);
|
||||
|
||||
onClose();
|
||||
setStoredViewParams({ ...storedViewParams, [pathname]: newParamsObject });
|
||||
setSearchParams(newSearchParams);
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer isOpen={isOpen} placement='right' onClose={handleClose} variant='ontime' size='lg'>
|
||||
<Drawer isOpen={isOpen} placement='right' onClose={onCloseWithoutSaving} size='lg'>
|
||||
<DrawerOverlay />
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerCloseButton size='lg' />
|
||||
<DrawerHeader className={style.drawerHeader}>
|
||||
<DrawerCloseButton _hover={{ bg: '#ebedf0', color: '#333' }} size='lg' />
|
||||
Customise
|
||||
</DrawerHeader>
|
||||
|
||||
<DrawerBody>
|
||||
<DrawerBody className={style.drawerContent}>
|
||||
<form id='edit-params-form' onSubmit={onParamsFormSubmit}>
|
||||
{paramFields.map((field) => (
|
||||
<div key={field.title} className={style.columnSection}>
|
||||
@@ -99,7 +126,10 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
|
||||
|
||||
<DrawerFooter className={style.drawerFooter}>
|
||||
<Button variant='ontime-ghosted' onClick={resetParams} type='reset'>
|
||||
Reset to default
|
||||
Reset
|
||||
</Button>
|
||||
<Button variant='ontime-subtle' onClick={onCloseWithoutSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='ontime-filled' form='edit-params-form' type='submit'>
|
||||
Save
|
||||
|
||||
@@ -249,11 +249,12 @@ 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',
|
||||
none: 'None',
|
||||
lowerMsg: 'Lower Third Message',
|
||||
});
|
||||
|
||||
return [
|
||||
@@ -266,7 +267,7 @@ export const getLowerThirdOptions = (customFields: CustomFields): ParamField[] =
|
||||
event: 'Event Load',
|
||||
manual: 'Manual',
|
||||
},
|
||||
defaultValue: 'manual',
|
||||
defaultValue: 'event',
|
||||
},
|
||||
{
|
||||
id: 'top-src',
|
||||
@@ -282,7 +283,7 @@ export const getLowerThirdOptions = (customFields: CustomFields): ParamField[] =
|
||||
description: 'Select the data source for the bottom element',
|
||||
type: 'option',
|
||||
values: bottomSourceOptions,
|
||||
defaultValue: 'none',
|
||||
defaultValue: 'lowerMsg',
|
||||
},
|
||||
{
|
||||
id: 'top-colour',
|
||||
@@ -393,9 +394,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 overflow',
|
||||
description: 'Sets the number of events on the page, can cause overlow',
|
||||
type: 'number',
|
||||
placeholder: '8 (default)',
|
||||
placeholder: '7 (default)',
|
||||
},
|
||||
{
|
||||
id: 'secondary-src',
|
||||
@@ -430,9 +431,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 overflow',
|
||||
description: 'Sets the number of events on the page, can cause overlow',
|
||||
type: 'number',
|
||||
placeholder: '8 (default)',
|
||||
placeholder: '7 (default)',
|
||||
},
|
||||
{
|
||||
id: 'secondary-src',
|
||||
|
||||
@@ -26,6 +26,7 @@ export const AppContextProvider = ({ children }: PropsWithChildren) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'pending') return;
|
||||
if (!data) return;
|
||||
const previousEditor = sessionStorage.getItem(storageKeys.editor);
|
||||
|
||||
if (previousEditor && previousEditor === data.editorKey) {
|
||||
@@ -52,6 +53,10 @@ export const AppContextProvider = ({ children }: PropsWithChildren) => {
|
||||
return savedPin == null || savedPin === '' || pin === savedPin;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (permission === 'editor') {
|
||||
const correct = isValid(pin, data.editorKey);
|
||||
if (correct) {
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function useCustomFields() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: CUSTOM_FIELDS,
|
||||
queryFn: getCustomFields,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
placeholderData: placeholder,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchInterval,
|
||||
|
||||
@@ -11,13 +11,14 @@ export function useHttpSettings() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: HTTP_SETTINGS,
|
||||
queryFn: getHTTP,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
placeholderData: httpPlaceholder,
|
||||
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: (previousData, _previousQuery) => previousData,
|
||||
placeholderData: ontimePlaceholderInfo,
|
||||
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: (previousData, _previousQuery) => previousData,
|
||||
placeholderData: oscPlaceholderSettings,
|
||||
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: (previousData, _previousQuery) => previousData,
|
||||
placeholderData: projectDataPlaceholder,
|
||||
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: (previousData, _previousQuery) => previousData,
|
||||
placeholderData: placeholderProjectList,
|
||||
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: (previousData, _previousQuery) => previousData,
|
||||
placeholderData: cachedRundownPlaceholder,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchInterval,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { unobfuscate } from 'ontime-utils';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { APP_SETTINGS } from '../api/constants';
|
||||
@@ -10,22 +9,12 @@ export default function useSettings() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: APP_SETTINGS,
|
||||
queryFn: getSettings,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
placeholderData: ontimePlaceholderSettings,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
networkMode: 'always',
|
||||
select: (data) => {
|
||||
const unobfuscated = { ...data };
|
||||
if (data.editorKey) {
|
||||
unobfuscated.editorKey = unobfuscate(data.editorKey);
|
||||
}
|
||||
if (data.operatorKey) {
|
||||
unobfuscated.operatorKey = unobfuscate(data.operatorKey);
|
||||
}
|
||||
return unobfuscated;
|
||||
},
|
||||
});
|
||||
|
||||
return { data: data ?? ontimePlaceholderSettings, status, isFetching, isError, refetch };
|
||||
return { data, status, isFetching, isError, refetch };
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export default function useUrlPresets() {
|
||||
const { data, status, isError, refetch } = useQuery({
|
||||
queryKey: URL_PRESETS,
|
||||
queryFn: getUrlPresets,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
placeholderData: [],
|
||||
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: (previousData, _previousQuery) => previousData,
|
||||
placeholderData: viewsSettingsPlaceholder,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
return { data: data ?? viewsSettingsPlaceholder, status, isError, refetch, isFetching };
|
||||
return { data, status, isError, refetch, isFetching };
|
||||
}
|
||||
|
||||
@@ -21,11 +21,13 @@ import { useEditorSettings } from '../stores/editorSettings';
|
||||
import { forgivingStringToMillis } from '../utils/dateConfig';
|
||||
|
||||
/**
|
||||
* @description Set of utilities for events //TODO: should this be called useEntryAction and so on
|
||||
* @description Set of utilities for events
|
||||
*/
|
||||
export const useEventAction = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { defaultPublic, linkPrevious, defaultDuration, defaultWarnTime, defaultDangerTime } = useEditorSettings();
|
||||
const eventSettings = useEditorSettings((state) => state.eventSettings);
|
||||
const defaultPublic = eventSettings.defaultPublic;
|
||||
const startTimeIsLastEnd = eventSettings.startTimeIsLastEnd;
|
||||
|
||||
/**
|
||||
* Calls mutation to add new event
|
||||
@@ -43,14 +45,11 @@ export const useEventAction = () => {
|
||||
after?: string;
|
||||
};
|
||||
|
||||
type EventOptions = BaseOptions &
|
||||
Partial<{
|
||||
defaultPublic: boolean;
|
||||
linkPrevious: boolean;
|
||||
lastEventId: string;
|
||||
defaultWarnTime: number;
|
||||
defaultDangerTime: number;
|
||||
}>;
|
||||
type EventOptions = BaseOptions & {
|
||||
defaultPublic?: boolean;
|
||||
lastEventId?: string;
|
||||
startTimeIsLastEnd?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds an event to rundown
|
||||
@@ -62,41 +61,27 @@ export const useEventAction = () => {
|
||||
// ************* CHECK OPTIONS specific to events
|
||||
if (isOntimeEvent(newEvent)) {
|
||||
const applicationOptions = {
|
||||
after: options?.after,
|
||||
defaultPublic: options?.defaultPublic ?? defaultPublic,
|
||||
startTimeIsLastEnd: options?.startTimeIsLastEnd ?? startTimeIsLastEnd,
|
||||
lastEventId: options?.lastEventId,
|
||||
linkPrevious: options?.linkPrevious ?? linkPrevious,
|
||||
defaultWarnTime,
|
||||
defaultDangerTime,
|
||||
after: options?.after,
|
||||
};
|
||||
|
||||
if (applicationOptions.linkPrevious && applicationOptions?.lastEventId) {
|
||||
newEvent.linkStart = applicationOptions.lastEventId;
|
||||
} else if (applicationOptions?.lastEventId) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
|
||||
const rundownData = queryClient.getQueryData<RundownCached>(RUNDOWN)!;
|
||||
const { rundown } = rundownData;
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this has a value
|
||||
const rundownData = queryClient.getQueryData<RundownCached>(RUNDOWN)!;
|
||||
const { rundown } = rundownData;
|
||||
|
||||
if (applicationOptions.startTimeIsLastEnd && applicationOptions?.lastEventId) {
|
||||
const previousEvent = rundown[applicationOptions.lastEventId];
|
||||
if (isOntimeEvent(previousEvent)) {
|
||||
newEvent.timeStart = previousEvent.timeEnd;
|
||||
newEvent.timeEnd = previousEvent.timeEnd;
|
||||
}
|
||||
}
|
||||
|
||||
if (applicationOptions.defaultPublic) {
|
||||
newEvent.isPublic = true;
|
||||
}
|
||||
|
||||
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
|
||||
@@ -110,7 +95,7 @@ export const useEventAction = () => {
|
||||
logAxiosError('Failed adding event', error);
|
||||
}
|
||||
},
|
||||
[_addEventMutation, defaultDangerTime, defaultDuration, defaultPublic, defaultWarnTime, linkPrevious, queryClient],
|
||||
[_addEventMutation, defaultPublic, queryClient, startTimeIsLastEnd],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -167,7 +152,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],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-nocheck -- working on it
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
interface WebkitDocument extends Document {
|
||||
webkitFullscreenElement?: Element | null;
|
||||
webkitIsFullScreen?: boolean;
|
||||
webkitExitFullscreen?: () => Promise<void>;
|
||||
webkitRequestFullscreen?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export default function useFullscreen() {
|
||||
const [isFullScreen, setFullScreen] = useState(
|
||||
document.fullscreenElement || (document as WebkitDocument).webkitFullscreenElement,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleChange = () => {
|
||||
if (typeof (document as WebkitDocument).webkitFullscreenElement !== 'undefined') {
|
||||
setFullScreen((document as WebkitDocument).webkitFullscreenElement);
|
||||
} else {
|
||||
setFullScreen(document.fullscreenElement);
|
||||
}
|
||||
};
|
||||
(document as WebkitDocument).addEventListener('webkitfullscreenchange', handleChange, { passive: true });
|
||||
document.addEventListener('fullscreenchange', handleChange, { passive: true });
|
||||
document.addEventListener('resize', handleChange, { passive: true });
|
||||
|
||||
return () => {
|
||||
(document as WebkitDocument).removeEventListener('webkitfullscreenchange', handleChange);
|
||||
document.removeEventListener('fullscreenchange', handleChange);
|
||||
document.removeEventListener('resize', handleChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleFullScreen = useCallback(() => {
|
||||
if (!document.fullscreenElement && !(document as WebkitDocument).webkitIsFullScreen) {
|
||||
// Fullscreen mode is not active, so we can enter fullscreen mode
|
||||
const element = document.documentElement;
|
||||
if (element.requestFullscreen) {
|
||||
// Standard fullscreen API is supported
|
||||
element.requestFullscreen().catch(() => {
|
||||
/* nothing to do */
|
||||
});
|
||||
} else if (element.webkitRequestFullscreen) {
|
||||
// iOS Safari fullscreen API is supported
|
||||
element.webkitRequestFullscreen?.().catch(() => {
|
||||
/* nothing to do */
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Fullscreen mode is active, so we can exit fullscreen mode
|
||||
if (document.exitFullscreen) {
|
||||
// Standard fullscreen API is supported
|
||||
document.exitFullscreen().catch((error) => {
|
||||
console.error('Error while trying to exit fullscreen:', error);
|
||||
});
|
||||
} else if ((document as WebkitDocument).webkitExitFullscreen) {
|
||||
// iOS Safari fullscreen API is supported
|
||||
(document as WebkitDocument).webkitExitFullscreen?.().catch(() => {
|
||||
/* nothing to do */
|
||||
});
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { isFullScreen, toggleFullScreen };
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export default function useScrollIntoView<T extends HTMLElement>(name: string, location?: string) {
|
||||
const ref = useRef<T>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (location && ref.current) {
|
||||
if (location === name) {
|
||||
ref.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
}, [location, name]);
|
||||
|
||||
return ref;
|
||||
}
|
||||
@@ -25,6 +25,8 @@ 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,
|
||||
});
|
||||
@@ -35,6 +37,10 @@ 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 } }),
|
||||
};
|
||||
@@ -53,12 +59,12 @@ export const setPlayback = {
|
||||
start: () => socketSendJson('start'),
|
||||
pause: () => socketSendJson('pause'),
|
||||
roll: () => socketSendJson('roll'),
|
||||
startNext: () => socketSendJson('start', 'next'),
|
||||
startNext: () => socketSendJson('start', { next: true }),
|
||||
previous: () => {
|
||||
socketSendJson('load', 'previous');
|
||||
socketSendJson('load', { previous: true });
|
||||
},
|
||||
next: () => {
|
||||
socketSendJson('load', 'next');
|
||||
socketSendJson('load', { next: true });
|
||||
},
|
||||
stop: () => {
|
||||
socketSendJson('stop');
|
||||
@@ -83,27 +89,27 @@ export const useInfoPanel = () => {
|
||||
return useRuntimeStore(featureSelector);
|
||||
};
|
||||
|
||||
export const useAuxTimerTime = () => {
|
||||
const featureSelector = (state: RuntimeStore) => state.auxtimer1.current;
|
||||
export const useExtraTimerTime = () => {
|
||||
const featureSelector = (state: RuntimeStore) => state.timer1.current;
|
||||
|
||||
return useRuntimeStore(featureSelector);
|
||||
};
|
||||
|
||||
export const useAuxTimerControl = () => {
|
||||
export const useExtraTimerControl = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
playback: state.auxtimer1.playback,
|
||||
direction: state.auxtimer1.direction,
|
||||
playback: state.timer1.playback,
|
||||
direction: state.timer1.direction,
|
||||
});
|
||||
|
||||
return useRuntimeStore(featureSelector);
|
||||
};
|
||||
|
||||
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 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 useCuesheet = () => {
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Sets tab title
|
||||
* @param title
|
||||
*/
|
||||
export function useWindowTitle(title: string) {
|
||||
useEffect(() => {
|
||||
document.title = `ontime - ${title}`;
|
||||
}, []);
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { ViewSettings } from 'ontime-types';
|
||||
|
||||
export const viewsSettingsPlaceholder: ViewSettings = {
|
||||
overrideStyles: false,
|
||||
normalColor: '#ffffffcc',
|
||||
warningColor: '#FFAB33',
|
||||
dangerColor: '#ED3333',
|
||||
endMessage: '',
|
||||
freezeEnd: false,
|
||||
normalColor: '#ffffffcc',
|
||||
overrideStyles: false,
|
||||
warningColor: '#FFAB33',
|
||||
};
|
||||
|
||||
@@ -33,5 +33,5 @@ export const useAppMode = create<AppModeStore>()((set) => ({
|
||||
return { mode };
|
||||
});
|
||||
},
|
||||
setCursor: (cursor: string | null) => set({ cursor }),
|
||||
setCursor: (cursor: string | null) => set(() => ({ cursor })),
|
||||
}));
|
||||
|
||||
@@ -2,68 +2,56 @@ import { create } from 'zustand';
|
||||
|
||||
import { booleanFromLocalStorage } from '../utils/localStorage';
|
||||
|
||||
type EditorSettingsStore = {
|
||||
defaultDuration: string;
|
||||
linkPrevious: boolean;
|
||||
defaultWarnTime: string;
|
||||
defaultDangerTime: string;
|
||||
type EditorSettings = {
|
||||
showQuickEntry: boolean;
|
||||
startTimeIsLastEnd: boolean;
|
||||
defaultPublic: boolean;
|
||||
setDefaultDuration: (defaultDuration: string) => void;
|
||||
setLinkPrevious: (linkPrevious: boolean) => void;
|
||||
setWarnTime: (warnTime: string) => void;
|
||||
setDangerTime: (dangerTime: string) => void;
|
||||
};
|
||||
|
||||
type EditorSettingsStore = {
|
||||
eventSettings: EditorSettings;
|
||||
setLocalEventSettings: (newState: EditorSettings) => void;
|
||||
setShowQuickEntry: (showQuickEntry: boolean) => void;
|
||||
setStartTimeIsLastEnd: (startTimeIsLastEnd: boolean) => void;
|
||||
setDefaultPublic: (defaultPublic: boolean) => 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 {
|
||||
DefaultDuration = 'ontime-default-duration',
|
||||
LinkPrevious = 'ontime-link-previous',
|
||||
DefaultWarnTime = 'ontime-default-warn-time',
|
||||
DefaultDangerTime = 'ontime-default-danger-time',
|
||||
ShowQuickEntry = 'ontime-show-quick-entry',
|
||||
StartTimeIsLastEnd = 'ontime-start-is-last-end',
|
||||
DefaultPublic = 'ontime-default-public',
|
||||
}
|
||||
|
||||
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),
|
||||
export const useEditorSettings = create<EditorSettingsStore>((set) => ({
|
||||
eventSettings: {
|
||||
showQuickEntry: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, false),
|
||||
startTimeIsLastEnd: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, true),
|
||||
defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, true),
|
||||
},
|
||||
|
||||
setDefaultDuration: (defaultDuration) =>
|
||||
set(() => {
|
||||
localStorage.setItem(EditorSettingsKeys.DefaultDuration, String(defaultDuration));
|
||||
return { defaultDuration };
|
||||
}),
|
||||
setLocalEventSettings: (value) =>
|
||||
set(() => {
|
||||
localStorage.setItem(EditorSettingsKeys.ShowQuickEntry, String(value.showQuickEntry));
|
||||
localStorage.setItem(EditorSettingsKeys.StartTimeIsLastEnd, String(value.startTimeIsLastEnd));
|
||||
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(value.defaultPublic));
|
||||
return { eventSettings: value };
|
||||
}),
|
||||
|
||||
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 };
|
||||
}),
|
||||
};
|
||||
});
|
||||
setShowQuickEntry: (showQuickEntry) =>
|
||||
set((state) => {
|
||||
localStorage.setItem(EditorSettingsKeys.ShowQuickEntry, String(showQuickEntry));
|
||||
return { eventSettings: { ...state.eventSettings, showQuickEntry } };
|
||||
}),
|
||||
|
||||
setStartTimeIsLastEnd: (startTimeIsLastEnd) =>
|
||||
set((state) => {
|
||||
localStorage.setItem(EditorSettingsKeys.StartTimeIsLastEnd, String(startTimeIsLastEnd));
|
||||
return { eventSettings: { ...state.eventSettings, startTimeIsLastEnd } };
|
||||
}),
|
||||
|
||||
setDefaultPublic: (defaultPublic) =>
|
||||
set((state) => {
|
||||
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(defaultPublic));
|
||||
return { eventSettings: { ...state.eventSettings, defaultPublic } };
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
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 }),
|
||||
}));
|
||||
@@ -23,6 +23,14 @@ export const runtimeStorePlaceholder: RuntimeStore = {
|
||||
blink: false,
|
||||
blackout: false,
|
||||
},
|
||||
public: {
|
||||
text: '',
|
||||
visible: false,
|
||||
},
|
||||
lower: {
|
||||
text: '',
|
||||
visible: false,
|
||||
},
|
||||
external: {
|
||||
text: '',
|
||||
visible: false,
|
||||
@@ -41,7 +49,7 @@ export const runtimeStorePlaceholder: RuntimeStore = {
|
||||
eventNext: null,
|
||||
publicEventNow: null,
|
||||
publicEventNext: null,
|
||||
auxtimer1: {
|
||||
timer1: {
|
||||
current: 0,
|
||||
direction: SimpleDirection.CountUp,
|
||||
duration: 0,
|
||||
|
||||
@@ -22,7 +22,7 @@ describe('cloneEvent()', () => {
|
||||
timeWarning: 120000,
|
||||
timeDanger: 60000,
|
||||
custom: {
|
||||
lighting: '3',
|
||||
lighting: { value: '3' },
|
||||
} as EventCustomFields,
|
||||
} as OntimeEvent;
|
||||
|
||||
|
||||
@@ -4,5 +4,3 @@ export function isMacOS() {
|
||||
}
|
||||
|
||||
export const deviceAlt = isMacOS() ? '⌥' : 'Alt';
|
||||
|
||||
export const deviceMod = isMacOS() ? '⌘' : 'Ctrl';
|
||||
|
||||
@@ -8,5 +8,4 @@ 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/;
|
||||
|
||||
@@ -114,9 +114,9 @@ export const connectSocket = (preferredClientName?: string) => {
|
||||
updateDevTools({ publicEventNext: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-auxtimer1': {
|
||||
patchRuntime('auxtimer1', payload);
|
||||
updateDevTools({ auxtimer1: payload });
|
||||
case 'ontime-timer1': {
|
||||
patchRuntime('timer1', payload);
|
||||
updateDevTools({ timer1: payload });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,4 +33,3 @@ export const cx = (classNames: any[]) => classNames.filter(Boolean).join(' ');
|
||||
export const enDash = '–';
|
||||
|
||||
export const timerPlaceholder = '––:––:––';
|
||||
export const timerPlaceholderMin = '––:––';
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Collection of rules for pre-validating a spreadsheet
|
||||
* @param file
|
||||
*/
|
||||
export function validateExcelImport(file: File) {
|
||||
export function validateSpreadsheetImport(file: File) {
|
||||
if (!isExcelFile(file)) {
|
||||
throw new Error('Unknown file type');
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export function validateExcelImport(file: File) {
|
||||
throw new Error('File is empty');
|
||||
}
|
||||
|
||||
// Limit file size of an Excel file to around 10MB
|
||||
// Limit file size of an excel file to around 10MB
|
||||
if (file.size > 10_000_000) {
|
||||
throw new Error('File size limit (10MB) exceeded');
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
gap: 0.25rem;
|
||||
|
||||
overflow: hidden;
|
||||
background-color: $ui-black;
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
@@ -3,36 +3,41 @@ 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 ShutdownPanel from './panel/shutdown-panel/ShutdownPanel';
|
||||
import ProjectSettingsPanel from './panel/project-settings-panel/ProjectSettingsPanel';
|
||||
import SourcesPanel from './panel/sources-panel/SourcesPanel';
|
||||
import PanelContent from './panel-content/PanelContent';
|
||||
import PanelList from './panel-list/PanelList';
|
||||
import useAppSettingsNavigation from './useAppSettingsNavigation';
|
||||
import { useSettingsStore } from './settingsStore';
|
||||
|
||||
import style from './AppSettings.module.scss';
|
||||
|
||||
export default function AppSettings() {
|
||||
const { close, panel, location } = useAppSettingsNavigation();
|
||||
useKeyDown(close, 'Escape');
|
||||
const setShowSettings = useSettingsStore((state) => state.setShowSettings);
|
||||
const selectedPanel = useSettingsStore((state) => state.showSettings);
|
||||
|
||||
const closeSettings = () => {
|
||||
setShowSettings(null);
|
||||
};
|
||||
useKeyDown(closeSettings, 'Escape');
|
||||
|
||||
return (
|
||||
<div className={style.container}>
|
||||
<ErrorBoundary>
|
||||
<PanelList selectedPanel={panel} location={location} />
|
||||
<PanelContent onClose={close}>
|
||||
{panel === 'project' && <ProjectPanel location={location} />}
|
||||
{panel === 'general' && <GeneralPanel location={location} />}
|
||||
{panel === 'feature_settings' && <FeatureSettingsPanel location={location} />}
|
||||
{panel === 'sources' && <SourcesPanel />}
|
||||
{panel === 'integrations' && <IntegrationsPanel location={location} />}
|
||||
{panel === 'about' && <AboutPanel />}
|
||||
{panel === 'log' && <LogPanel />}
|
||||
{panel === 'shutdown' && <ShutdownPanel />}
|
||||
<PanelList />
|
||||
<PanelContent onClose={closeSettings}>
|
||||
{selectedPanel === 'project' && <ProjectPanel />}
|
||||
{selectedPanel === 'general' && <GeneralPanel />}
|
||||
{selectedPanel === 'project_settings' && <ProjectSettingsPanel />}
|
||||
{selectedPanel === 'sources' && <SourcesPanel />}
|
||||
{selectedPanel === 'interface' && <InterfacePanel />}
|
||||
{selectedPanel === 'integrations' && <IntegrationsPanel />}
|
||||
{selectedPanel === 'about' && <AboutPanel />}
|
||||
{selectedPanel === 'log' && <LogPanel />}
|
||||
</PanelContent>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.corner {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 2rem;
|
||||
right: 1rem;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
|
||||
@@ -58,8 +58,4 @@ ul {
|
||||
color: $secondary-text-gray;
|
||||
border-left: 1px solid $white-10;
|
||||
font-size: $inner-section-text-size;
|
||||
|
||||
&.active {
|
||||
color: $blue-400;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,18 +2,16 @@ import { Fragment } from 'react';
|
||||
|
||||
import { isKeyEnter } from '../../../common/utils/keyEvent';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
import { PanelBaseProps, settingPanels, useSettingsStore } from '../settingsStore';
|
||||
import useAppSettingsNavigation from '../useAppSettingsNavigation';
|
||||
import { settingPanels, SettingsOption, useSettingsStore } from '../settingsStore';
|
||||
|
||||
import style from './PanelList.module.scss';
|
||||
|
||||
interface PanelListProps extends PanelBaseProps {
|
||||
selectedPanel: string;
|
||||
}
|
||||
export default function PanelList() {
|
||||
const { showSettings, setShowSettings, hasUnsavedChanges } = useSettingsStore();
|
||||
|
||||
export default function PanelList({ selectedPanel, location }: PanelListProps) {
|
||||
const { setLocation } = useAppSettingsNavigation();
|
||||
const { hasUnsavedChanges } = useSettingsStore();
|
||||
const handleSelect = (panel: SettingsOption) => {
|
||||
setShowSettings(panel.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<ul className={style.tabs}>
|
||||
@@ -22,7 +20,7 @@ export default function PanelList({ selectedPanel, location }: PanelListProps) {
|
||||
|
||||
const classes = cx([
|
||||
style.primary,
|
||||
selectedPanel === panel.id ? style.active : null,
|
||||
showSettings === panel.id ? style.active : null,
|
||||
panel.split ? style.split : null,
|
||||
unsaved ? style.unsaved : null,
|
||||
]);
|
||||
@@ -31,9 +29,9 @@ export default function PanelList({ selectedPanel, location }: PanelListProps) {
|
||||
<Fragment key={panel.id}>
|
||||
<li
|
||||
key={panel.id}
|
||||
onClick={() => setLocation(panel.id)}
|
||||
onClick={() => handleSelect(panel)}
|
||||
onKeyDown={(event) => {
|
||||
isKeyEnter(event) && setLocation(panel.id);
|
||||
isKeyEnter(event) && handleSelect(panel);
|
||||
}}
|
||||
className={classes}
|
||||
tabIndex={0}
|
||||
@@ -42,18 +40,8 @@ export default function PanelList({ selectedPanel, location }: PanelListProps) {
|
||||
{panel.label}
|
||||
</li>
|
||||
{panel.secondary?.map((secondary) => {
|
||||
const id = secondary.id.split('__')[1];
|
||||
const secondaryClasses = cx([style.secondary, location === id ? style.active : null]);
|
||||
return (
|
||||
<li
|
||||
key={secondary.id}
|
||||
onClick={() => setLocation(secondary.id)}
|
||||
onKeyDown={(event) => {
|
||||
isKeyEnter(event) && setLocation(secondary.id);
|
||||
}}
|
||||
className={secondaryClasses}
|
||||
role='button'
|
||||
>
|
||||
<li key={secondary.id} onClick={() => handleSelect(panel)} className={style.secondary} role='button'>
|
||||
{secondary.label}
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -75,6 +75,10 @@ $inner-padding: 1rem;
|
||||
box-shadow: 0 1px $white-10;
|
||||
}
|
||||
|
||||
tr {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: 400;
|
||||
color: $gray-400;
|
||||
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
-98
@@ -1,98 +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 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,3 +2,23 @@
|
||||
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,16 @@
|
||||
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 generalRef = useScrollIntoView<HTMLDivElement>('settings', location);
|
||||
const editorRef = useScrollIntoView<HTMLDivElement>('editor', location);
|
||||
const viewRef = useScrollIntoView<HTMLDivElement>('view', location);
|
||||
|
||||
export default function GeneralPanel() {
|
||||
return (
|
||||
<>
|
||||
<Panel.Header>App Settings</Panel.Header>
|
||||
<div ref={generalRef}>
|
||||
<GeneralPanelForm />
|
||||
</div>
|
||||
<div ref={editorRef}>
|
||||
<EditorSettingsForm />
|
||||
</div>
|
||||
<div ref={viewRef}>
|
||||
<ViewSettingsForm />
|
||||
</div>
|
||||
<Panel.Header>Settings</Panel.Header>
|
||||
<GeneralPanelForm />
|
||||
<ViewSettingsForm />
|
||||
<UrlPresetsForm />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-2
@@ -15,7 +15,7 @@ import { handleLinks } from '../../../../common/utils/linkUtils';
|
||||
import { validateUrlPresetPath } from '../../../../common/utils/urlPresets';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import style from './FeatureSettings.module.scss';
|
||||
import style from './GeneralPanel.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
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Alert, AlertDescription, AlertIcon, Button, Input, Switch } from '@chakra-ui/react';
|
||||
import { ViewSettings } from 'ontime-types';
|
||||
|
||||
@@ -99,13 +99,7 @@ export default function ViewSettingsForm() {
|
||||
title='Override CSS styles'
|
||||
description='Enables overriding view styles with custom stylesheet'
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name='overrideStyles'
|
||||
render={({ field: { onChange, value, ref } }) => (
|
||||
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
|
||||
)}
|
||||
/>
|
||||
<Switch {...register('overrideStyles')} variant='ontime' size='lg' />
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
<Panel.ListGroup>
|
||||
@@ -124,23 +118,7 @@ export default function ViewSettingsForm() {
|
||||
</Panel.ListGroup>
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Freeze timer on end'
|
||||
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} />
|
||||
)}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='End message'
|
||||
description='Message to show on negative timers if not frozen. If not provided, timer will continue'
|
||||
/>
|
||||
<Panel.Field title='End message' description='If no end message is provided, timer will continue' />
|
||||
<Input
|
||||
size='sm'
|
||||
autoComplete='off'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controller, useFieldArray, useForm } from 'react-hook-form';
|
||||
import { 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 settings
|
||||
HTTP
|
||||
<div className={style.flex}>
|
||||
<Button variant='ontime-ghosted' size='sm' onClick={() => reset()} isDisabled={!canSubmit}>
|
||||
Revert to saved
|
||||
@@ -99,13 +99,7 @@ export default function HttpIntegrations() {
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='HTTP Output' description='Provide feedback from Ontime through HTTP' />
|
||||
<Controller
|
||||
control={control}
|
||||
name='enabledOut'
|
||||
render={({ field: { onChange, value, ref } }) => (
|
||||
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
|
||||
)}
|
||||
/>
|
||||
<Switch variant='ontime' size='lg' {...register('enabledOut')} />
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
|
||||
|
||||
-4
@@ -2,10 +2,6 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.halfWidth {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.fitContents.fitContents {
|
||||
width: max-content; /* override chakra */
|
||||
}
|
||||
|
||||
+3
-12
@@ -1,8 +1,6 @@
|
||||
import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react';
|
||||
|
||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||
import { PanelBaseProps } from '../../settingsStore';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import HttpIntegrations from './HttpIntegrations';
|
||||
@@ -10,10 +8,7 @@ import OscIntegrations from './OscIntegrations';
|
||||
|
||||
const integrationDocsUrl = 'https://docs.getontime.no/api/integrations/';
|
||||
|
||||
export default function IntegrationsPanel({ location }: PanelBaseProps) {
|
||||
const oscRef = useScrollIntoView<HTMLDivElement>('osc', location);
|
||||
const httpRef = useScrollIntoView<HTMLDivElement>('http', location);
|
||||
|
||||
export default function IntegrationsPanel() {
|
||||
return (
|
||||
<>
|
||||
<Panel.Header>Integration settings</Panel.Header>
|
||||
@@ -30,12 +25,8 @@ export default function IntegrationsPanel({ location }: PanelBaseProps) {
|
||||
</Alert>
|
||||
</Panel.Section>
|
||||
<Panel.Section>
|
||||
<div ref={oscRef}>
|
||||
<OscIntegrations />
|
||||
</div>
|
||||
<div ref={httpRef}>
|
||||
<HttpIntegrations />
|
||||
</div>
|
||||
<OscIntegrations />
|
||||
<HttpIntegrations />
|
||||
</Panel.Section>
|
||||
</>
|
||||
);
|
||||
|
||||
+18
-50
@@ -1,4 +1,4 @@
|
||||
import { Controller, useFieldArray, useForm } from 'react-hook-form';
|
||||
import { 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 { isASCII, isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex';
|
||||
import { isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import { cycles } from './integrationUtils';
|
||||
@@ -65,8 +65,7 @@ export default function OscIntegrations() {
|
||||
prepend({
|
||||
id: generateId(),
|
||||
cycle: 'onLoad',
|
||||
address: '',
|
||||
payload: '',
|
||||
message: '',
|
||||
enabled: false,
|
||||
});
|
||||
};
|
||||
@@ -81,7 +80,7 @@ export default function OscIntegrations() {
|
||||
return (
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
OSC settings
|
||||
Open Sound Control
|
||||
<div className={style.flex}>
|
||||
<Button variant='ontime-ghosted' size='sm' onClick={() => reset()} isDisabled={!canSubmit}>
|
||||
Revert to saved
|
||||
@@ -103,18 +102,12 @@ export default function OscIntegrations() {
|
||||
|
||||
<Panel.Section as='form' id='osc-form' onSubmit={handleSubmit(onSubmit)} onKeyDown={preventEscape}>
|
||||
<Panel.Loader isLoading={isLoading} />
|
||||
<Panel.Title>General OSC settings</Panel.Title>
|
||||
<Panel.Title>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' />
|
||||
<Controller
|
||||
control={control}
|
||||
name='enabledIn'
|
||||
render={({ field: { onChange, value, ref } }) => (
|
||||
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
|
||||
)}
|
||||
/>
|
||||
<Switch variant='ontime' size='lg' {...register('enabledIn')} />
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
@@ -147,13 +140,7 @@ export default function OscIntegrations() {
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='OSC output' description='Provide feedback from Ontime with OSC' />
|
||||
<Controller
|
||||
control={control}
|
||||
name='enabledOut'
|
||||
render={({ field: { onChange, value, ref } }) => (
|
||||
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
|
||||
)}
|
||||
/>
|
||||
<Switch variant='ontime' size='lg' {...register('enabledOut')} />
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
@@ -208,7 +195,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>
|
||||
@@ -220,15 +207,14 @@ export default function OscIntegrations() {
|
||||
<tr>
|
||||
<th>Enabled</th>
|
||||
<th>Cycle</th>
|
||||
<th className={style.halfWidth}>Address</th>
|
||||
<th className={style.halfWidth}>Payload</th>
|
||||
<th className={style.fullWidth}>Message</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{fields.map((field, index) => {
|
||||
const maybeAddressError = errors.subscriptions?.[index]?.address?.message;
|
||||
const maybePayloadError = errors.subscriptions?.[index]?.payload?.message;
|
||||
// @ts-expect-error -- not sure why it is not finding the type, it is ok
|
||||
const maybeError = errors.subscriptions?.[index]?.message?.message;
|
||||
return (
|
||||
<tr key={field.id}>
|
||||
<td>
|
||||
@@ -248,40 +234,22 @@ export default function OscIntegrations() {
|
||||
))}
|
||||
</Select>
|
||||
</td>
|
||||
<td className={style.halfWidth}>
|
||||
<td className={style.fullWidth}>
|
||||
<Input
|
||||
key={field.id}
|
||||
size='sm'
|
||||
variant='ontime-filled'
|
||||
autoComplete='off'
|
||||
placeholder='/from-ontime/'
|
||||
{...register(`subscriptions.${index}.address`, {
|
||||
placeholder='/from-ontime/{{timer.current}}'
|
||||
{...register(`subscriptions.${index}.message`, {
|
||||
required: { value: true, message: 'Required field' },
|
||||
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',
|
||||
pattern: {
|
||||
value: startsWithSlash,
|
||||
message: 'OSC messages should start with a forward slash',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{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>}
|
||||
{maybeError && <Panel.Error>{maybeError}</Panel.Error>}
|
||||
</td>
|
||||
<td>
|
||||
<IconButton
|
||||
|
||||
+41
-129
@@ -1,142 +1,54 @@
|
||||
import { Select, Switch } from '@chakra-ui/react';
|
||||
import { EndAction, TimerType } from 'ontime-types';
|
||||
import { Switch } from '@chakra-ui/react';
|
||||
|
||||
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
|
||||
import { editorSettingsDefaults, useEditorSettings } from '../../../../common/stores/editorSettings';
|
||||
import { forgivingStringToMillis } from '../../../../common/utils/dateConfig';
|
||||
import { useEditorSettings } from '../../../../common/stores/editorSettings';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
export default function EditorSettingsForm() {
|
||||
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);
|
||||
const eventSettings = useEditorSettings((state) => state.eventSettings);
|
||||
const setShowQuickEntry = useEditorSettings((state) => state.setShowQuickEntry);
|
||||
const setStartTimeIsLastEnd = useEditorSettings((state) => state.setStartTimeIsLastEnd);
|
||||
const setDefaultPublic = useEditorSettings((state) => state.setDefaultPublic);
|
||||
|
||||
return (
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>Editor settings</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<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.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='Start time is last end'
|
||||
description='New events start time will be the previous event end'
|
||||
/>
|
||||
<Switch
|
||||
variant='ontime'
|
||||
size='lg'
|
||||
defaultChecked={eventSettings.startTimeIsLastEnd}
|
||||
onChange={(event) => setStartTimeIsLastEnd(event.target.checked)}
|
||||
/>
|
||||
</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.Card>
|
||||
</Panel.Section>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ import InfoNif from './NetworkInterfaces';
|
||||
export default function LogPanel() {
|
||||
return (
|
||||
<>
|
||||
<Panel.Header>Log</Panel.Header>
|
||||
<Panel.Header>Application 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,21 +29,22 @@ 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>Last Used</th>
|
||||
<th>Date Created</th>
|
||||
<th>Date Modified</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -52,6 +53,7 @@ export default function ProjectList() {
|
||||
<ProjectListItem
|
||||
key={project.filename}
|
||||
filename={project.filename}
|
||||
createdAt={project.createdAt}
|
||||
updatedAt={project.updatedAt}
|
||||
onToggleEditMode={handleToggleEditMode}
|
||||
onSubmit={handleClear}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { IoEllipsisHorizontal } from '@react-icons/all-files/io5/IoEllipsisHoriz
|
||||
import {
|
||||
deleteProject,
|
||||
downloadCSV,
|
||||
downloadProject,
|
||||
downloadRundown,
|
||||
duplicateProject,
|
||||
loadProject,
|
||||
renameProject,
|
||||
@@ -21,6 +21,7 @@ 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;
|
||||
@@ -31,13 +32,14 @@ interface ProjectListItemProps {
|
||||
|
||||
export default function ProjectListItem({
|
||||
current,
|
||||
updatedAt,
|
||||
createdAt,
|
||||
editingFilename,
|
||||
editingMode,
|
||||
filename,
|
||||
onRefetch,
|
||||
onSubmit,
|
||||
onToggleEditMode,
|
||||
updatedAt,
|
||||
}: ProjectListItemProps) {
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
@@ -100,6 +102,7 @@ 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
|
||||
@@ -145,7 +148,7 @@ function ActionMenu({
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
await downloadProject(filename);
|
||||
await downloadRundown(filename);
|
||||
};
|
||||
|
||||
const handleExportCSV = async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.current {
|
||||
background-color: $blue-1100;
|
||||
background-color: $blue-900;
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
|
||||
@@ -1,23 +1,14 @@
|
||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||
import { PanelBaseProps } from '../../settingsStore';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import ManageProjects from './ManageProjects';
|
||||
import ProjectData from './ProjectData';
|
||||
|
||||
export default function ProjectPanel({ location }: PanelBaseProps) {
|
||||
const projectRef = useScrollIntoView<HTMLDivElement>('data', location);
|
||||
const manageRef = useScrollIntoView<HTMLDivElement>('manage', location);
|
||||
|
||||
export default function ProjectPanel() {
|
||||
return (
|
||||
<>
|
||||
<Panel.Header>Project</Panel.Header>
|
||||
<div ref={projectRef}>
|
||||
<ProjectData />
|
||||
</div>
|
||||
<div ref={manageRef}>
|
||||
<ManageProjects />
|
||||
</div>
|
||||
<ProjectData />
|
||||
<ManageProjects />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+7
-6
@@ -4,14 +4,13 @@ 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 '../FeatureSettings.module.scss';
|
||||
import style from './ProjectSettingsPanel.module.scss';
|
||||
|
||||
interface CustomFieldEntryProps {
|
||||
field: string;
|
||||
colour: string;
|
||||
label: string;
|
||||
onEdit: (label: CustomFieldLabel, patch: CustomField) => Promise<void>;
|
||||
@@ -19,11 +18,13 @@ interface CustomFieldEntryProps {
|
||||
}
|
||||
|
||||
export default function CustomFieldEntry(props: CustomFieldEntryProps) {
|
||||
const { colour, label, onEdit, onDelete, field } = props;
|
||||
const { colour, label, onEdit, onDelete } = props;
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const handleEdit = async (patch: CustomField) => {
|
||||
await onEdit(field, patch);
|
||||
const oldLabel = label;
|
||||
await onEdit(oldLabel, patch);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
@@ -63,7 +64,7 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
|
||||
color='#FA5656' // $red-500
|
||||
icon={<IoTrash />}
|
||||
aria-label='Delete entry'
|
||||
onClick={() => onDelete(field)}
|
||||
onClick={() => onDelete(label)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
+4
-4
@@ -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 '../FeatureSettings.module.scss';
|
||||
import style from './ProjectSettingsPanel.module.scss';
|
||||
|
||||
interface CustomFieldsFormProps {
|
||||
onSubmit: (field: CustomField) => Promise<void>;
|
||||
+1
-18
@@ -4,12 +4,7 @@
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5px;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.fieldForm {
|
||||
@@ -25,15 +20,3 @@
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.fit {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.aliasConstrain {
|
||||
min-width: 12em;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
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,10 +1,8 @@
|
||||
import { ChangeEvent, useEffect, useState } from 'react';
|
||||
import { Button, Input, Spinner } from '@chakra-ui/react';
|
||||
import { Button, Input } 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,11 +27,9 @@ 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);
|
||||
const authenticationError = useSheetStore((state) => state.stepData.authenticate.error);
|
||||
|
||||
/** Check if we are authenticated */
|
||||
const getAuthStatus = async () => {
|
||||
@@ -45,7 +41,6 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
||||
|
||||
/** check if the current session has been authenticated */
|
||||
useEffect(() => {
|
||||
patchStepData({ authenticate: { available: false, error: '' } });
|
||||
untilAuthenticated();
|
||||
}, []);
|
||||
|
||||
@@ -58,6 +53,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
||||
};
|
||||
|
||||
const handleCancelFlow = async () => {
|
||||
await handleRevoke();
|
||||
onCancel();
|
||||
};
|
||||
|
||||
@@ -93,15 +89,6 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
||||
if (result?.authenticated) {
|
||||
setAuthenticationStatus(result.authenticated);
|
||||
if (result.authenticated !== 'pending') {
|
||||
if (result.authenticated == 'authenticated') {
|
||||
try {
|
||||
const names = await getWorksheetNames(result.sheetId);
|
||||
setWorksheets(names);
|
||||
} catch (error) {
|
||||
const message = maybeAxiosError(error);
|
||||
patchStepData({ worksheet: { available: false, error: message } });
|
||||
}
|
||||
}
|
||||
setLoading('');
|
||||
return;
|
||||
}
|
||||
@@ -152,7 +139,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
||||
</Panel.Title>
|
||||
<Panel.ListGroup>
|
||||
<Panel.Description>Upload Client Secret provided by Google</Panel.Description>
|
||||
<Panel.Error>{authenticationError}</Panel.Error>
|
||||
<Panel.Error>{undefined}</Panel.Error>
|
||||
<Input
|
||||
type='file'
|
||||
onChange={handleClientSecret}
|
||||
@@ -192,7 +179,6 @@ 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>
|
||||
@@ -201,7 +187,8 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
||||
size='sm'
|
||||
leftIcon={<IoShieldCheckmarkOutline />}
|
||||
onClick={handleAuthenticate}
|
||||
isDisabled={!canAuthenticate}
|
||||
isDisabled={!canAuthenticate || isLoading}
|
||||
isLoading={loading === 'authenticate' || isAuthenticating}
|
||||
>
|
||||
Authenticate
|
||||
</Button>
|
||||
|
||||
@@ -14,11 +14,10 @@ interface ImportReviewProps {
|
||||
rundown: OntimeRundown;
|
||||
customFields: CustomFields;
|
||||
onFinished: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function ImportReview(props: ImportReviewProps) {
|
||||
const { rundown, customFields, onFinished, onCancel } = props;
|
||||
const { rundown, customFields, onFinished } = props;
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { importRundown } = useGoogleSheet();
|
||||
@@ -26,7 +25,7 @@ export default function ImportReview(props: ImportReviewProps) {
|
||||
|
||||
const handleCancel = () => {
|
||||
resetPreview();
|
||||
onCancel();
|
||||
onFinished();
|
||||
};
|
||||
|
||||
const applyImport = async () => {
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
.buttonRow {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user