mirror of
https://github.com/cpvalente/ontime.git
synced 2026-07-26 10:38:55 +00:00
Compare commits
162 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d1260321f | |||
| 7577f307bf | |||
| 997be35815 | |||
| f9b1880e88 | |||
| 8a1474e8d6 | |||
| d432f1e3ff | |||
| 9f207dd39a | |||
| 8b8b3347fb | |||
| 01c2ef4c4d | |||
| 10852eecdd | |||
| e221543dc7 | |||
| 01000f8837 | |||
| 9a03e4850a | |||
| 6bb3fb2931 | |||
| a86496ed27 | |||
| df8b76305e | |||
| ce14aabcd7 | |||
| f5e9a2549e | |||
| f4692db021 | |||
| dddb11ff40 | |||
| 6178ed1e4e | |||
| 8c5aaa0901 | |||
| b06ab15190 | |||
| a48be0f017 | |||
| c2280da61f | |||
| fe30752130 | |||
| a20d63451b | |||
| 93622e9aeb | |||
| 1568af97fc | |||
| 4bb836b6f2 | |||
| 8156da03d5 | |||
| dfade25a7c | |||
| 4b21745fc7 | |||
| a91a8a6358 | |||
| 4e6b833e10 | |||
| 3fdb1bd1c8 | |||
| 4148a3835b | |||
| 4fb9c42aef | |||
| dd0dcfc2a2 | |||
| d41d1b054d | |||
| 8b6e06150a | |||
| a9e0e2b091 | |||
| 0a8155b6e3 | |||
| 118c29e5c2 | |||
| 105f395e6e | |||
| 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 |
@@ -8,37 +8,28 @@
|
||||
"jest": true
|
||||
},
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"prettier",
|
||||
"eslint-config-prettier"
|
||||
],
|
||||
"plugins": [
|
||||
"@typescript-eslint",
|
||||
"prettier"
|
||||
],
|
||||
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier", "eslint-config-prettier"],
|
||||
"plugins": ["@typescript-eslint", "prettier"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"e2e/**/**.spec.ts",
|
||||
"e2e/**/**.test.ts"
|
||||
],
|
||||
"extends": [
|
||||
"plugin:playwright/playwright-test"
|
||||
]
|
||||
"files": ["e2e/**/**.spec.ts", "e2e/**/**.test.ts"],
|
||||
"extends": ["plugin:playwright/playwright-test"]
|
||||
}
|
||||
],
|
||||
"rules": {
|
||||
"no-useless-concat": "warn",
|
||||
"prefer-template": "warn",
|
||||
"no-throw-literal": "error",
|
||||
"no-console": [
|
||||
"warn",
|
||||
{
|
||||
"allow": [
|
||||
"warn",
|
||||
"error"
|
||||
]
|
||||
"allow": ["warn", "error"]
|
||||
}
|
||||
],
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": ["ontime-types/src/*", "ontime-utils/src/*"]
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/no-non-null-assertion": "warn",
|
||||
@@ -59,4 +50,4 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: 18.18.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: 18.18.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: 18.18.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
name: Ontime build v3
|
||||
|
||||
on:
|
||||
# Only trigger manually for now for testing
|
||||
# push:
|
||||
# tags: [ "*" ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build_macos:
|
||||
runs-on: macOS-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.18.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
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@v4
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.18.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
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@v4
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.18.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
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 }}
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: 18.18.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: 18.18.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
@@ -98,3 +98,9 @@ jobs:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: automated-screenshots
|
||||
path: automated-screenshots/
|
||||
retention-days: 14
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
name: Ontime test v3
|
||||
|
||||
on:
|
||||
# Only trigger manually for now for testing
|
||||
# pull_request:
|
||||
# branches: '*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
unit-test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
CI: ''
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.18.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
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@v4
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.18.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
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@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: automated-screenshots
|
||||
path: automated-screenshots/
|
||||
retention-days: 14
|
||||
@@ -27,6 +27,7 @@ ontime.code-workspace
|
||||
test-results
|
||||
playwright-report
|
||||
/playwright/.cache/
|
||||
automated-screenshots
|
||||
|
||||
# production
|
||||
build/
|
||||
@@ -44,3 +45,6 @@ apps/server/src/preloaded-db/db.json
|
||||
|
||||
# versioning file
|
||||
**/ONTIME_VERSION.js
|
||||
|
||||
# temporary write files
|
||||
**.tmp
|
||||
+9
-2
@@ -9,8 +9,8 @@ Ontime consists of 3 distinct parts
|
||||
The steps below will assume you have locally installed the necessary dependencies.
|
||||
Other dependencies will be installed as part of the setup
|
||||
|
||||
- __node__ (>=16.16)
|
||||
- __pnpm__ (>=7)
|
||||
- __node__ (~18.18.2)
|
||||
- __pnpm__ (>=8)
|
||||
- __docker__ (only necessary to run and build docker images)
|
||||
|
||||
## LOCAL DEVELOPMENT
|
||||
@@ -97,3 +97,10 @@ Other useful commands
|
||||
|
||||
- __List running processes__ by running `docker ps`
|
||||
- __Kill running process__ by running `docker kill <process-id>`
|
||||
|
||||
## General Info
|
||||
|
||||
# APP Building
|
||||
|
||||
We build the app from app.js for almost all applications. The output file will still be named index.cjs. This is because of Electron.
|
||||
Building the app from index.ts only applies for applications that don't use electron. index.ts will take over the initialization of the server and UI when electron isn't present.
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM node:16-alpine
|
||||
FROM node:18.18-alpine
|
||||
|
||||
# Set environment variables
|
||||
# Environment Variable to signal that we are running production
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[](https://github.com/cpvalente/ontime/actions/workflows/build_v2.yml)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0) [](https://ontime.gitbook.io)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||
|
||||
## Download the latest releases here
|
||||
|
||||
@@ -17,13 +17,14 @@ Ontime is an application for creating and managing event running order and timer
|
||||
The user inputs a list of events along with scheduling and event information.
|
||||
This will then populate a series of screens which are available to be rendered by any device in the Network.
|
||||
|
||||
This makes for a simple and cheap way to distribute over a venue using a network infrastructure instead of video
|
||||
outputs.
|
||||
This makes for a simple and cheap way to distribute over a venue using a network infrastructure instead of video outputs.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
[Read the docs to learn more](https://docs.getontime.no)
|
||||
|
||||
## Using Ontime
|
||||
|
||||
Once installed and running, Ontime starts a background server that is the heart of all processes.
|
||||
@@ -37,7 +38,7 @@ 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.
|
||||
|
||||
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 aliases feature
|
||||
individual views and extend view settings using the URL presets feature
|
||||
|
||||
```
|
||||
For the presentation views
|
||||
@@ -59,7 +60,7 @@ IP.ADDRESS:4001/editor > the control interface, same as the app
|
||||
IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
|
||||
```
|
||||
|
||||
More documentation is available [in our docs](https://ontime.gitbook.io)
|
||||
More documentation is available [in our docs](https://docs.getontime.no)
|
||||
|
||||
## Feature List (in no specific order)
|
||||
|
||||
@@ -84,7 +85,7 @@ More documentation is available [in our docs](https://ontime.gitbook.io)
|
||||
- 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://ontime.gitbook.io/v2/views/countdown): have
|
||||
- [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)
|
||||
@@ -120,9 +121,14 @@ Ontime broadcasts its data over WebSockets. This allows you to consume its data
|
||||
Writing a new view for the browser can be done with basic knowledge of HTML + CSS + Javascript (or any other language
|
||||
that can run in the browser).
|
||||
<br />
|
||||
See [this repository](https://github.com/cpvalente/ontime-viewer-template-v2) with a small template on
|
||||
how to get you started and read the docs about
|
||||
the [Websocket API](https://ontime.gitbook.io/v2/control-and-feedback/ontime-apis#osc-and-websocket-api)
|
||||
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️
|
||||
|
||||
@@ -130,7 +136,7 @@ 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://ontime.gitbook.io/v2/additional-notes/use-in-raspberry-pi)
|
||||
If you want to run this image in a Raspberry Pi, please see [the docs](https://docs.getontime.no/additional-notes/use-with-rpi/)
|
||||
|
||||
## Roadmap
|
||||
|
||||
@@ -192,7 +198,7 @@ Information about the project setup can be found in the [development documentati
|
||||
|
||||
# Help
|
||||
|
||||
Help is underway! ... and can be found [here](https://ontime.gitbook.io)
|
||||
Help is underway! ... and can be found [here](https://docs.getontime.no)
|
||||
|
||||
# License
|
||||
|
||||
|
||||
+25
-24
@@ -1,36 +1,36 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "2.28.17",
|
||||
"version": "3.0.0-alpha",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@chakra-ui/react": "^2.7.0",
|
||||
"@dnd-kit/core": "^6.0.8",
|
||||
"@dnd-kit/sortable": "^7.0.2",
|
||||
"@dnd-kit/utilities": "^3.2.1",
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/react": "^11.10.6",
|
||||
"@emotion/styled": "^11.10.6",
|
||||
"@mantine/hooks": "^7.6.2",
|
||||
"@react-icons/all-files": "^4.1.0",
|
||||
"@sentry/react": "^7.46.0",
|
||||
"@sentry/tracing": "^7.46.0",
|
||||
"@tanstack/react-query": "^5.8.4",
|
||||
"@tanstack/react-query-devtools": "^5.8.4",
|
||||
"@tanstack/react-table": "^8.9.2",
|
||||
"@sentry/react": "^7.92.0",
|
||||
"@tanstack/react-query": "^5.17.9",
|
||||
"@tanstack/react-query-devtools": "^5.17.9",
|
||||
"@tanstack/react-table": "^8.11.3",
|
||||
"autosize": "^6.0.1",
|
||||
"axios": "^1.2.0",
|
||||
"color": "^4.2.3",
|
||||
"csv-stringify": "^6.2.3",
|
||||
"deepmerge": "^4.3.0",
|
||||
"csv-stringify": "^6.4.5",
|
||||
"framer-motion": "^10.10.0",
|
||||
"react": "^18.2.0",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-fast-compare": "^3.2.0",
|
||||
"react-hook-form": "^7.43.5",
|
||||
"react-qr-code": "^2.0.11",
|
||||
"react-fast-compare": "^3.2.2",
|
||||
"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.3.6"
|
||||
"zustand": "^4.5.0"
|
||||
},
|
||||
"scripts": {
|
||||
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
|
||||
@@ -45,7 +45,8 @@
|
||||
"lint-staged": "eslint",
|
||||
"test": "vitest",
|
||||
"test:pipeline": "vitest run",
|
||||
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build"
|
||||
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build",
|
||||
"analyse": "npx vite-bundle-visualizer"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
@@ -60,7 +61,7 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sentry/vite-plugin": "^0.4.0",
|
||||
"@sentry/vite-plugin": "^2.14.0",
|
||||
"@tanstack/eslint-plugin-query": "^5.8.4",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^13.1.1",
|
||||
@@ -71,7 +72,7 @@
|
||||
"@types/testing-library__jest-dom": "^5.14.5",
|
||||
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||
"@typescript-eslint/parser": "^6.10.0",
|
||||
"@vitejs/plugin-react": "^3.0.1",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-jest": "^27.6.0",
|
||||
@@ -85,11 +86,11 @@
|
||||
"ontime-utils": "workspace:*",
|
||||
"prettier": "^3.0.3",
|
||||
"sass": "^1.57.1",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^4.3.1",
|
||||
"vite-plugin-compression2": "^0.9.0",
|
||||
"vite-plugin-svgr": "^2.4.0",
|
||||
"vite-tsconfig-paths": "^4.2.0",
|
||||
"vitest": "^0.30.1"
|
||||
"typescript": "^5.4.3",
|
||||
"vite": "^5.1.0",
|
||||
"vite-plugin-compression2": "^0.12.0",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
"vite-tsconfig-paths": "^4.3.1",
|
||||
"vitest": "^1.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,9 +47,8 @@ function App() {
|
||||
}
|
||||
};
|
||||
}, [isElectron, sendToElectron]);
|
||||
|
||||
return (
|
||||
<ChakraProvider resetCSS theme={theme}>
|
||||
<ChakraProvider disableGlobalStyle resetCSS theme={theme}>
|
||||
<QueryClientProvider client={ontimeQueryClient}>
|
||||
<AppContextProvider>
|
||||
<BrowserRouter>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import withAlias from './features/AliasWrapper';
|
||||
import Log from './features/log/Log';
|
||||
import withPreset from './features/PresetWrapper';
|
||||
import withData from './features/viewers/ViewWrapper';
|
||||
|
||||
const Editor = lazy(() => import('./features/editors/ProtectedEditor'));
|
||||
const Cuesheet = lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
|
||||
const Operator = lazy(() => import('./features/operator/Operator'));
|
||||
const Operator = lazy(() => import('./features/operator/OperatorExport'));
|
||||
|
||||
const TimerView = lazy(() => import('./features/viewers/timer/Timer'));
|
||||
const MinimalTimerView = lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
|
||||
@@ -15,58 +16,49 @@ const Countdown = lazy(() => import('./features/viewers/countdown/Countdown'));
|
||||
|
||||
const Backstage = lazy(() => import('./features/viewers/backstage/Backstage'));
|
||||
const Public = lazy(() => import('./features/viewers/public/Public'));
|
||||
const Lower = lazy(() => import('./features/viewers/lower-thirds/LowerWrapper'));
|
||||
const Lower = lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
|
||||
const StudioClock = lazy(() => import('./features/viewers/studio/StudioClock'));
|
||||
|
||||
const STimer = withAlias(withData(TimerView));
|
||||
const SMinimalTimer = withAlias(withData(MinimalTimerView));
|
||||
const SClock = withAlias(withData(ClockView));
|
||||
const SCountdown = withAlias(withData(Countdown));
|
||||
const SBackstage = withAlias(withData(Backstage));
|
||||
const SPublic = withAlias(withData(Public));
|
||||
const SLowerThird = withAlias(withData(Lower));
|
||||
const SStudio = withAlias(withData(StudioClock));
|
||||
const STimer = withPreset(withData(TimerView));
|
||||
const SMinimalTimer = withPreset(withData(MinimalTimerView));
|
||||
const SClock = withPreset(withData(ClockView));
|
||||
const SCountdown = withPreset(withData(Countdown));
|
||||
const SBackstage = withPreset(withData(Backstage));
|
||||
const SPublic = withPreset(withData(Public));
|
||||
const SLowerThird = withPreset(withData(Lower));
|
||||
const SStudio = withPreset(withData(StudioClock));
|
||||
|
||||
const EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper'));
|
||||
const RundownPanel = lazy(() => import('./features/rundown/RundownExport'));
|
||||
const TimerControl = lazy(() => import('./features/control/playback/TimerControlExport'));
|
||||
const MessageControl = lazy(() => import('./features/control/message/MessageControlExport'));
|
||||
const Info = lazy(() => import('./features/info/InfoExport'));
|
||||
|
||||
export default function AppRouter() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<Routes>
|
||||
<Route path='/' element={<Navigate to='/timer' />} />
|
||||
<Route path='/speaker' element={<STimer />} />
|
||||
<Route path='/presenter' element={<STimer />} />
|
||||
<Route path='/stage' element={<STimer />} />
|
||||
<Route path='/timer' element={<STimer />} />
|
||||
|
||||
<Route path='/minimal' element={<SMinimalTimer />} />
|
||||
<Route path='/minimalTimer' element={<SMinimalTimer />} />
|
||||
<Route path='/simpleTimer' element={<SMinimalTimer />} />
|
||||
|
||||
<Route path='/clock' element={<SClock />} />
|
||||
|
||||
<Route path='/countdown' element={<SCountdown />} />
|
||||
|
||||
<Route path='/sm' element={<SBackstage />} />
|
||||
<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 />} />
|
||||
<Route path='/cuesheet' element={<Cuesheet />} />
|
||||
<Route path='/cuelist' element={<Cuesheet />} />
|
||||
<Route path='/table' element={<Cuesheet />} />
|
||||
|
||||
{/*/!* Protected Routes - Elements *!/*/}
|
||||
<Route
|
||||
@@ -94,10 +86,10 @@ export default function AppRouter() {
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/info'
|
||||
path='/log'
|
||||
element={
|
||||
<EditorFeatureWrapper>
|
||||
<Info />
|
||||
<Log />
|
||||
</EditorFeatureWrapper>
|
||||
}
|
||||
/>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+14
-8
@@ -1,25 +1,31 @@
|
||||
// REST stuff
|
||||
export const PROJECT_DATA = ['project'];
|
||||
export const ALIASES = ['aliases'];
|
||||
export const USERFIELDS = ['userFields'];
|
||||
export const RUNDOWN = ['rundown'];
|
||||
// keys in tanstack store
|
||||
export const APP_INFO = ['appinfo'];
|
||||
export const OSC_SETTINGS = ['oscSettings'];
|
||||
export const HTTP_SETTINGS = ['httpSettings'];
|
||||
export const APP_SETTINGS = ['appSettings'];
|
||||
export const VIEW_SETTINGS = ['viewSettings'];
|
||||
export const CUSTOM_FIELDS = ['customFields'];
|
||||
export const HTTP_SETTINGS = ['httpSettings'];
|
||||
export const OSC_SETTINGS = ['oscSettings'];
|
||||
export const PROJECT_DATA = ['project'];
|
||||
export const PROJECT_LIST = ['projectList'];
|
||||
export const RUNDOWN = ['rundown'];
|
||||
export const RUNTIME = ['runtimeStore'];
|
||||
export const SHEET_STATE = ['sheetState'];
|
||||
export const URL_PRESETS = ['urlpresets'];
|
||||
export const VIEW_SETTINGS = ['viewSettings'];
|
||||
|
||||
// resolve location
|
||||
const location = window.location;
|
||||
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
export const isProduction = import.meta.env.MODE === 'production';
|
||||
export const isDev = !isProduction;
|
||||
|
||||
// resolve port
|
||||
const STATIC_PORT = 4001;
|
||||
export const serverPort = isProduction ? location.port : STATIC_PORT;
|
||||
export const serverURL = `${location.protocol}//${location.hostname}:${serverPort}`;
|
||||
export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`;
|
||||
|
||||
export const apiEntryUrl = `${serverURL}/data`;
|
||||
|
||||
export const projectDataURL = `${serverURL}/project`;
|
||||
export const rundownURL = `${serverURL}/events`;
|
||||
export const ontimeURL = `${serverURL}/ontime`;
|
||||
@@ -0,0 +1,38 @@
|
||||
import axios from 'axios';
|
||||
import { CustomField, CustomFieldLabel, CustomFields } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
const customFieldsPath = `${apiEntryUrl}/custom-fields`;
|
||||
|
||||
/**
|
||||
* Requests list of known custom fields
|
||||
*/
|
||||
export async function getCustomFields(): Promise<CustomFields> {
|
||||
const res = await axios.get(customFieldsPath);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets list of known custom fields
|
||||
*/
|
||||
export async function postCustomField(newField: CustomField): Promise<CustomFields> {
|
||||
const res = await axios.post(customFieldsPath, { ...newField });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits single custom field
|
||||
*/
|
||||
export async function editCustomField(label: CustomFieldLabel, newField: CustomField): Promise<CustomFields> {
|
||||
const res = await axios.put(`${customFieldsPath}/${label}`, { ...newField });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes single custom field
|
||||
*/
|
||||
export async function deleteCustomField(label: CustomFieldLabel): Promise<CustomFields> {
|
||||
const res = await axios.delete(`${customFieldsPath}/${label}`);
|
||||
return res.data;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { DatabaseModel, GetInfo, MessageResponse, ProjectData, ProjectFileListResponse } from 'ontime-types';
|
||||
|
||||
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
import { createBlob, downloadBlob } from './utils';
|
||||
|
||||
const dbPath = `${apiEntryUrl}/db`;
|
||||
|
||||
/**
|
||||
* HTTP request to the current DB
|
||||
*/
|
||||
async function getDb(): Promise<AxiosResponse<DatabaseModel>> {
|
||||
return axios.get(`${dbPath}/download`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request download of the current project file
|
||||
* @param fileName
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to upload project file
|
||||
*/
|
||||
export async function uploadProjectFile(file: File): Promise<MessageResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append('project', file);
|
||||
const response = await axios.post(`${dbPath}/upload`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make patch changes to the objects in the db
|
||||
*/
|
||||
export async function patchData(patchDb: Partial<DatabaseModel>): Promise<AxiosResponse<DatabaseModel>> {
|
||||
return await axios.patch(dbPath, patchDb);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to create a project file
|
||||
*/
|
||||
export async function createProject(
|
||||
project: Partial<
|
||||
ProjectData & {
|
||||
filename: string;
|
||||
}
|
||||
>,
|
||||
): Promise<MessageResponse> {
|
||||
const res = await axios.post(`${dbPath}/new`, project);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to get the list of available project files
|
||||
*/
|
||||
export async function getProjects(): Promise<ProjectFileListResponse> {
|
||||
const res = await axios.get(`${dbPath}/all`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to load a project file
|
||||
*/
|
||||
export async function loadProject(filename: string): Promise<MessageResponse> {
|
||||
const res = await axios.post(`${dbPath}/load`, {
|
||||
filename,
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to duplicate a project file
|
||||
*/
|
||||
export async function duplicateProject(filename: string, newFilename: string): Promise<MessageResponse> {
|
||||
const url = `${dbPath}/${filename}/duplicate`;
|
||||
const decodedUrl = decodeURIComponent(url);
|
||||
const res = await axios.post(decodedUrl, {
|
||||
newFilename,
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to rename a project file
|
||||
*/
|
||||
export async function renameProject(filename: string, newFilename: string): Promise<MessageResponse> {
|
||||
const url = `${dbPath}/${filename}/rename`;
|
||||
const decodedUrl = decodeURIComponent(url);
|
||||
const res = await axios.put(decodedUrl, {
|
||||
newFilename,
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to delete a project file
|
||||
*/
|
||||
export async function deleteProject(filename: string): Promise<MessageResponse> {
|
||||
const url = `${dbPath}/${filename}`;
|
||||
const decodedUrl = decodeURIComponent(url);
|
||||
const res = await axios.delete(decodedUrl);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to retrieve application info
|
||||
*/
|
||||
export async function getInfo(): Promise<GetInfo> {
|
||||
const res = await axios.get(`${dbPath}/info`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function gets project from db
|
||||
* @param fileName
|
||||
* @returns
|
||||
*/
|
||||
async function fileDownload(fileName: string): Promise<{ data: DatabaseModel; name: string }> {
|
||||
const response = await getDb();
|
||||
|
||||
const headerLine = response.headers['Content-Disposition'];
|
||||
|
||||
// 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 };
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import { GetRundownCached, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
|
||||
|
||||
import { rundownURL } from './apiConstants';
|
||||
|
||||
/**
|
||||
* @description HTTP request to fetch all events
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function fetchCachedRundown(): Promise<GetRundownCached> {
|
||||
const res = await axios.get(`${rundownURL}/cached`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use fetchCachedRundown instead
|
||||
* @description HTTP request to fetch all events
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function fetchRundown(): Promise<OntimeRundown> {
|
||||
const res = await axios.get(rundownURL);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description HTTP request to post new event
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function requestPostEvent(data: OntimeRundownEntry) {
|
||||
return axios.post(rundownURL, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description HTTP request to put new event
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function requestPutEvent(data: Partial<OntimeRundownEntry>) {
|
||||
return axios.put(rundownURL, data);
|
||||
}
|
||||
|
||||
export type ReorderEntry = {
|
||||
eventId: string;
|
||||
from: number;
|
||||
to: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description HTTP request to reorder events
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function requestReorderEvent(data: ReorderEntry) {
|
||||
return axios.patch(`${rundownURL}/reorder`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description HTTP request to request application of delay
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function requestApplyDelay(eventId: string) {
|
||||
return axios.patch(`${rundownURL}/applydelay/${eventId}`);
|
||||
}
|
||||
|
||||
export type SwapEntry = {
|
||||
from: string;
|
||||
to: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description HTTP request to swap two events
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function requestEventSwap(data: SwapEntry) {
|
||||
return axios.patch(`${rundownURL}/swap`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description HTTP request to delete given event
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function requestDelete(eventId: string) {
|
||||
return axios.delete(`${rundownURL}/${eventId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description HTTP request to delete all events
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function requestDeleteAll() {
|
||||
return axios.delete(`${rundownURL}/all`);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import axios from 'axios';
|
||||
|
||||
import { apiRepoLatest } from '../../externals';
|
||||
|
||||
export type HasUpdate = {
|
||||
url: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* HTTP request to get the latest version and url from github
|
||||
*/
|
||||
export async function getLatestVersion(): Promise<HasUpdate> {
|
||||
const res = await axios.get(`${apiRepoLatest}`);
|
||||
return {
|
||||
url: res.data.html_url as string,
|
||||
version: res.data.tag_name as string,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { HttpSettings } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
const httpPath = `${apiEntryUrl}/http`;
|
||||
|
||||
/**
|
||||
* HTTP request to retrieve http settings
|
||||
*/
|
||||
export async function getHTTP(): Promise<HttpSettings> {
|
||||
const res = await axios.get(httpPath);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to mutate http settings
|
||||
*/
|
||||
export async function postHTTP(data: HttpSettings): Promise<AxiosResponse<HttpSettings>> {
|
||||
return axios.post(httpPath, data);
|
||||
}
|
||||
@@ -1,320 +0,0 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import {
|
||||
Alias,
|
||||
DatabaseModel,
|
||||
GetInfo,
|
||||
HttpSettings,
|
||||
OntimeRundown,
|
||||
OSCSettings,
|
||||
OscSubscription,
|
||||
ProjectData,
|
||||
Settings,
|
||||
UserFields,
|
||||
ViewSettings,
|
||||
} from 'ontime-types';
|
||||
import { ExcelImportMap } from 'ontime-utils';
|
||||
|
||||
import { apiRepoLatest } from '../../externals';
|
||||
import fileDownload from '../utils/fileDownload';
|
||||
|
||||
import { ontimeURL } from './apiConstants';
|
||||
|
||||
/**
|
||||
* @description HTTP request to retrieve application settings
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function getSettings(): Promise<Settings> {
|
||||
const res = await axios.get(`${ontimeURL}/settings`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description HTTP request to mutate application settings
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function postSettings(data: Settings) {
|
||||
return axios.post(`${ontimeURL}/settings`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description HTTP request to retrieve application info
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function getInfo(): Promise<GetInfo> {
|
||||
const res = await axios.get(`${ontimeURL}/info`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description HTTP request to retrieve view settings
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function getView(): Promise<ViewSettings> {
|
||||
const res = await axios.get(`${ontimeURL}/views`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description HTTP request to mutate view settings
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function postViewSettings(data: ViewSettings) {
|
||||
return axios.post(`${ontimeURL}/views`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description HTTP request to retrieve aliases
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function getAliases(): Promise<Alias[]> {
|
||||
const res = await axios.get(`${ontimeURL}/aliases`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description HTTP request to mutate aliases
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function postAliases(data: Alias[]) {
|
||||
return axios.post(`${ontimeURL}/aliases`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description HTTP request to retrieve user fields
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function getUserFields(): Promise<UserFields> {
|
||||
const res = await axios.get(`${ontimeURL}/userfields`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description HTTP request to mutate user fields
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function postUserFields(data: UserFields) {
|
||||
return axios.post(`${ontimeURL}/userfields`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description HTTP request to retrieve osc settings
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function getOSC(): Promise<OSCSettings> {
|
||||
const res = await axios.get(`${ontimeURL}/osc`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description HTTP request to retrieve http settings
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function getHTTP(): Promise<HttpSettings> {
|
||||
const res = await axios.get(`${ontimeURL}/http`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description HTTP request to mutate http settings
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function postHTTP(data: HttpSettings) {
|
||||
return axios.post(`${ontimeURL}/http`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description HTTP request to mutate osc settings
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function postOSC(data: OSCSettings) {
|
||||
return axios.post(`${ontimeURL}/osc`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description HTTP request to mutate osc subscriptions
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function postOscSubscriptions(data: OscSubscription) {
|
||||
return axios.post(`${ontimeURL}/osc-subscriptions`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description HTTP request to download db in CSV format
|
||||
*/
|
||||
export const downloadCSV = () => {
|
||||
return fileDownload(ontimeURL, { name: 'rundown', type: 'csv' }, { type: 'text/csv;charset=utf-8;' });
|
||||
};
|
||||
|
||||
/**
|
||||
* @description HTTP request to download db in JSON format
|
||||
*/
|
||||
export const downloadRundown = () => {
|
||||
return fileDownload(ontimeURL, { name: 'rundown', type: 'json' }, { type: 'application/json;charset=utf-8;' });
|
||||
};
|
||||
|
||||
// TODO: should this be extracted to shared code?
|
||||
export type ProjectFileImportOptions = {
|
||||
onlyRundown: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description HTTP request to upload events db
|
||||
* @return {Promise}
|
||||
*/
|
||||
export const uploadProjectFile = async (
|
||||
file: File,
|
||||
setProgress: (value: number) => void,
|
||||
options?: Partial<ProjectFileImportOptions>,
|
||||
) => {
|
||||
const formData = new FormData();
|
||||
formData.append('userFile', file);
|
||||
|
||||
const onlyRundown = Boolean(options?.onlyRundown);
|
||||
|
||||
await axios
|
||||
.post(`${ontimeURL}/db?onlyRundown=${onlyRundown}`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
const complete = progressEvent?.total ? Math.round((progressEvent.loaded * 100) / progressEvent.total) : 0;
|
||||
setProgress(complete);
|
||||
},
|
||||
})
|
||||
.then((response) => response.data.id);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Make patch changes to the objects in the db
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function patchData(patchDb: Partial<DatabaseModel>) {
|
||||
const response = await axios.patch(`${ontimeURL}/db`, patchDb);
|
||||
return response;
|
||||
}
|
||||
|
||||
type PostPreviewExcelResponse = {
|
||||
rundown: OntimeRundown;
|
||||
project: ProjectData;
|
||||
userFields: UserFields;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Make patch changes to the objects in the db
|
||||
* @return {Promise} - returns parsed rundown and userfields
|
||||
*/
|
||||
export async function postPreviewExcel(file: File, setProgress: (value: number) => void, options?: ExcelImportMap) {
|
||||
const formData = new FormData();
|
||||
formData.append('userFile', file);
|
||||
formData.append('options', JSON.stringify(options));
|
||||
|
||||
const response: AxiosResponse<PostPreviewExcelResponse> = await axios.post(
|
||||
`${ontimeURL}/preview-spreadsheet`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
const complete = progressEvent?.total ? Math.round((progressEvent.loaded * 100) / progressEvent.total) : 0;
|
||||
setProgress(complete);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export type HasUpdate = {
|
||||
url: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description HTTP request to get the latest version and url from github
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function getLatestVersion(): Promise<HasUpdate> {
|
||||
const res = await axios.get(`${apiRepoLatest}`);
|
||||
return {
|
||||
url: res.data.html_url as string,
|
||||
version: res.data.tag_name as string,
|
||||
};
|
||||
}
|
||||
|
||||
export async function postNew(initialData: Partial<ProjectData>) {
|
||||
return axios.post(`${ontimeURL}/new`, initialData);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description STEP 1
|
||||
*/
|
||||
export const uploadSheetClientFile = async (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('userFile', file);
|
||||
const res = await axios
|
||||
.post(`${ontimeURL}/sheet/clientsecret`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
.then((response) => response.data.id);
|
||||
return res;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description STEP 1 test
|
||||
*/
|
||||
export const getClientSecrect = async () => {
|
||||
const response = await axios.get(`${ontimeURL}/sheet/clientsecret`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description STEP 2
|
||||
*/
|
||||
export const getSheetsAuthUrl = async () => {
|
||||
const response = await axios.get(`${ontimeURL}/sheet/authentication/url`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description STEP 2 test
|
||||
*/
|
||||
export const getAuthentication = async () => {
|
||||
const response = await axios.get(`${ontimeURL}/sheet/authentication`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description STEP 3
|
||||
* @returns worksheetOptions
|
||||
*/
|
||||
export const postId = async (id: string) => {
|
||||
const response = await axios.post(`${ontimeURL}/sheet/id`, { id });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description STEP 4
|
||||
*/
|
||||
export const postWorksheet = async (id: string, worksheet: string) => {
|
||||
const response = await axios.post(`${ontimeURL}/sheet/worksheet`, { id, worksheet });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description STEP 5
|
||||
*/
|
||||
export const postPreviewSheet = async (id: string, options: ExcelImportMap) => {
|
||||
const response = await axios.post(`${ontimeURL}/sheet/pull`, { id, options });
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description STEP 5
|
||||
*/
|
||||
export const postPushSheet = async (id: string, options: ExcelImportMap) => {
|
||||
const response = await axios.post(`${ontimeURL}/sheet-push`, { id, options });
|
||||
return response.data.data;
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { OSCSettings } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
const oscPath = `${apiEntryUrl}/osc`;
|
||||
|
||||
/**
|
||||
* HTTP request to retrieve osc settings
|
||||
*/
|
||||
export async function getOSC(): Promise<OSCSettings> {
|
||||
const res = await axios.get(oscPath);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to mutate osc settings
|
||||
*/
|
||||
export async function postOSC(data: OSCSettings): Promise<AxiosResponse<OSCSettings>> {
|
||||
return axios.post(oscPath, data);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { ProjectData } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
const projectPath = `${apiEntryUrl}/project`;
|
||||
|
||||
/**
|
||||
* HTTP request to fetch project data
|
||||
*/
|
||||
export async function getProjectData(): Promise<ProjectData> {
|
||||
const res = await axios.get(projectPath);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to mutate project data
|
||||
*/
|
||||
export async function postProjectData(data: ProjectData): Promise<AxiosResponse<ProjectData>> {
|
||||
return axios.post(projectPath, data);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import { ProjectData } from 'ontime-types';
|
||||
|
||||
import { projectDataURL } from './apiConstants';
|
||||
|
||||
/**
|
||||
* @description HTTP request to fetch project data
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function getProjectData(): Promise<ProjectData> {
|
||||
const res = await axios.get(projectDataURL);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description HTTP request to mutate project data
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function postProjectData(data: ProjectData) {
|
||||
return axios.post(projectDataURL, data);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { MessageResponse, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
const rundownPath = `${apiEntryUrl}/rundown`;
|
||||
|
||||
/**
|
||||
* HTTP request to fetch all events
|
||||
*/
|
||||
export async function fetchNormalisedRundown(): Promise<RundownCached> {
|
||||
const res = await axios.get(`${rundownPath}/normalised`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to post new event
|
||||
*/
|
||||
export async function requestPostEvent(data: Partial<OntimeRundownEntry>): Promise<AxiosResponse<OntimeRundownEntry>> {
|
||||
return axios.post(rundownPath, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to put new event
|
||||
*/
|
||||
export async function requestPutEvent(data: Partial<OntimeRundownEntry>): Promise<AxiosResponse<OntimeRundownEntry>> {
|
||||
return axios.put(rundownPath, data);
|
||||
}
|
||||
|
||||
type BatchEditEntry = {
|
||||
data: Partial<OntimeEvent>;
|
||||
ids: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* HTTP request to put multiple events
|
||||
*/
|
||||
export async function requestBatchPutEvents(data: BatchEditEntry): Promise<AxiosResponse<MessageResponse>> {
|
||||
return axios.put(`${rundownPath}/batch`, data);
|
||||
}
|
||||
|
||||
export type ReorderEntry = {
|
||||
eventId: string;
|
||||
from: number;
|
||||
to: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* HTTP request to reorder events
|
||||
*/
|
||||
export async function requestReorderEvent(data: ReorderEntry): Promise<AxiosResponse<OntimeRundownEntry>> {
|
||||
return axios.patch(`${rundownPath}/reorder`, data);
|
||||
}
|
||||
|
||||
export type SwapEntry = {
|
||||
from: string;
|
||||
to: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* HTTP request to swap two events
|
||||
*/
|
||||
export async function requestEventSwap(data: SwapEntry): Promise<AxiosResponse<MessageResponse>> {
|
||||
return axios.patch(`${rundownPath}/swap`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to request application of delay
|
||||
*/
|
||||
export async function requestApplyDelay(eventId: string): Promise<AxiosResponse<MessageResponse>> {
|
||||
return axios.patch(`${rundownPath}/applydelay/${eventId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to delete given event
|
||||
*/
|
||||
export async function requestDelete(eventId: string): Promise<AxiosResponse<MessageResponse>> {
|
||||
return axios.delete(`${rundownPath}/${eventId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to delete all events
|
||||
*/
|
||||
export async function requestDeleteAll(): Promise<AxiosResponse<MessageResponse>> {
|
||||
return axios.delete(`${rundownPath}/all`);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { Settings } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
const settingsPath = `${apiEntryUrl}/settings`;
|
||||
|
||||
/**
|
||||
* HTTP request to retrieve application settings
|
||||
*/
|
||||
export async function getSettings(): Promise<Settings> {
|
||||
const res = await axios.get(settingsPath);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to mutate application settings
|
||||
*/
|
||||
export async function postSettings(data: Settings): Promise<AxiosResponse<Settings>> {
|
||||
return axios.post(settingsPath, data);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types';
|
||||
import { ImportMap } from 'ontime-utils';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
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;
|
||||
}> => {
|
||||
const response = await axios.get(`${sheetsPath}/connect`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* HTTP request to initiate the authentication service with google
|
||||
*/
|
||||
export const requestConnection = async (
|
||||
file: File,
|
||||
sheetId: string,
|
||||
): Promise<{
|
||||
verification_url: string;
|
||||
user_code: string;
|
||||
}> => {
|
||||
const formData = new FormData();
|
||||
formData.append('client_secret', file);
|
||||
|
||||
const response = await axios.post(`${sheetsPath}/${sheetId}/connect`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* HTTP request to revoke authentication to google sheet
|
||||
*/
|
||||
export const revokeAuthentication = async (): Promise<{ authenticated: AuthenticationStatus }> => {
|
||||
const response = await axios.post(`${sheetsPath}/revoke`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* HTTP request to upload preview the contents of a google sheet as rundown
|
||||
*/
|
||||
export const previewRundown = async (
|
||||
sheetId: string,
|
||||
options: ImportMap,
|
||||
): Promise<{
|
||||
rundown: OntimeRundown;
|
||||
customFields: CustomFields;
|
||||
}> => {
|
||||
const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options });
|
||||
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
|
||||
*/
|
||||
export const uploadRundown = async (sheetId: string, options: ImportMap): Promise<void> => {
|
||||
const response = await axios.post(`${sheetsPath}/${sheetId}/write`, { options });
|
||||
return response.data;
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import axios from 'axios';
|
||||
import { URLPreset } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
const urlPresetsPath = `${apiEntryUrl}/url-presets`;
|
||||
|
||||
/**
|
||||
* HTTP request to retrieve aliases
|
||||
*/
|
||||
export async function getUrlPresets(): Promise<URLPreset[]> {
|
||||
const res = await axios.get(urlPresetsPath);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to mutate aliases
|
||||
*/
|
||||
export async function postUrlPresets(data: URLPreset[]): Promise<URLPreset[]> {
|
||||
return axios.post(urlPresetsPath, data);
|
||||
}
|
||||
@@ -6,6 +6,11 @@ import { ontimeQueryClient } from '../queryClient';
|
||||
import { addLog } from '../stores/logger';
|
||||
import { nowInMillis } from '../utils/time';
|
||||
|
||||
/**
|
||||
* Utility unrwap a potential axios error
|
||||
* @param error
|
||||
* @returns
|
||||
*/
|
||||
export function maybeAxiosError(error: unknown) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const statusText = (error as AxiosError).response?.statusText ?? '';
|
||||
@@ -26,6 +31,11 @@ export function maybeAxiosError(error: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility unrwaps a potential axios error and sends to logger
|
||||
* @param prepend
|
||||
* @param error
|
||||
*/
|
||||
export function logAxiosError(prepend: string, error: unknown) {
|
||||
const message = `${prepend}: ${maybeAxiosError(error)}`;
|
||||
|
||||
@@ -42,12 +52,32 @@ export function logAxiosError(prepend: string, error: unknown) {
|
||||
* Utility function invalidates react-query caches
|
||||
*/
|
||||
export async function invalidateAllCaches() {
|
||||
await ontimeQueryClient.invalidateQueries({ queryKey: ['project'] });
|
||||
await ontimeQueryClient.invalidateQueries({ queryKey: ['aliases'] });
|
||||
await ontimeQueryClient.invalidateQueries({ queryKey: ['userFields'] });
|
||||
await ontimeQueryClient.invalidateQueries({ queryKey: ['rundown'] });
|
||||
await ontimeQueryClient.invalidateQueries({ queryKey: ['appinfo'] });
|
||||
await ontimeQueryClient.invalidateQueries({ queryKey: ['oscSettings'] });
|
||||
await ontimeQueryClient.invalidateQueries({ queryKey: ['appSettings'] });
|
||||
await ontimeQueryClient.invalidateQueries({ queryKey: ['viewSettings'] });
|
||||
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 });
|
||||
}
|
||||
|
||||
/**
|
||||
* downloads a blob
|
||||
* @param downloadUrl
|
||||
* @param fileName
|
||||
*/
|
||||
export function downloadBlob(blob: Blob, fileName: string) {
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import axios from 'axios';
|
||||
import { ViewSettings } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
const viewSettingsPath = `${apiEntryUrl}/view-settings`;
|
||||
|
||||
/**
|
||||
* HTTP request to retrieve view settings
|
||||
*/
|
||||
export async function getView(): Promise<ViewSettings> {
|
||||
const res = await axios.get(viewSettingsPath);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to mutate view settings
|
||||
*/
|
||||
export async function postViewSettings(data: ViewSettings) {
|
||||
return axios.post(viewSettingsPath, data);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
@use "../../../theme/mixins" as *;
|
||||
|
||||
.link {
|
||||
@include action-link;
|
||||
font-size: $inner-section-text-size;
|
||||
}
|
||||
|
||||
.linkIcon {
|
||||
margin-left: $element-inner-spacing;
|
||||
display: inline-block;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { MouseEvent, ReactNode } from 'react';
|
||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
||||
|
||||
import { openLink } from '../../utils/linkUtils';
|
||||
|
||||
import style from './AppLink.module.scss';
|
||||
|
||||
interface AppLinkProps {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function AppLink(props: AppLinkProps) {
|
||||
const { href, children } = props;
|
||||
|
||||
const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault();
|
||||
openLink(href);
|
||||
};
|
||||
|
||||
return (
|
||||
<a href='#!' target='_blank' rel='noreferrer' className={style.link} onClick={handleClick}>
|
||||
{children} <IoArrowUp className={style.linkIcon} />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -10,14 +10,12 @@ import {
|
||||
IconButton,
|
||||
Tooltip,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiPower } from '@react-icons/all-files/fi/FiPower';
|
||||
import { IoPowerOutline } from '@react-icons/all-files/io5/IoPowerOutline';
|
||||
|
||||
import { Size } from '../../models/Util.type';
|
||||
import { useEmitLog } from '../../stores/logger';
|
||||
|
||||
interface QuitIconBtnProps {
|
||||
clickHandler: () => void;
|
||||
size?: Size;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -42,7 +40,7 @@ const quitBtnStyle = {
|
||||
};
|
||||
|
||||
export default function QuitIconBtn(props: QuitIconBtnProps) {
|
||||
const { clickHandler, size = 'lg', disabled, ...rest } = props;
|
||||
const { clickHandler, disabled, ...rest } = props;
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { emitInfo } = useEmitLog();
|
||||
const onClose = () => setIsOpen(false);
|
||||
@@ -67,8 +65,8 @@ export default function QuitIconBtn(props: QuitIconBtnProps) {
|
||||
<Tooltip label='Quit Application'>
|
||||
<IconButton
|
||||
aria-label='Quit Application'
|
||||
size={size}
|
||||
icon={<FiPower />}
|
||||
size='lg'
|
||||
icon={<IoPowerOutline />}
|
||||
onClick={() => setIsOpen(true)}
|
||||
isDisabled={disabled}
|
||||
{...quitBtnStyle}
|
||||
@@ -81,7 +79,7 @@ export default function QuitIconBtn(props: QuitIconBtnProps) {
|
||||
<AlertDialogHeader fontSize='lg' fontWeight='bold'>
|
||||
Ontime Shutdown
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogBody>This will shutdown the program and all running servers. Are you sure?</AlertDialogBody>
|
||||
<AlertDialogBody>This will shutdown the Ontime server. Are you sure?</AlertDialogBody>
|
||||
<AlertDialogFooter>
|
||||
<Button ref={cancelRef} onClick={onClose} variant='ghost'>
|
||||
Cancel
|
||||
|
||||
@@ -2,7 +2,7 @@ import { MouseEvent } from 'react';
|
||||
import { IconButton, IconButtonProps, Tooltip } from '@chakra-ui/react';
|
||||
|
||||
interface TooltipActionBtnProps extends IconButtonProps {
|
||||
clickHandler: (event?: MouseEvent) => void | Promise<void>;
|
||||
clickHandler: (event: MouseEvent) => void | Promise<void>;
|
||||
tooltip: string;
|
||||
openDelay?: number;
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
@use '../../../theme/v2Styles' as *;
|
||||
|
||||
.header {
|
||||
font-size: $inner-section-text-size;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: $section-white;
|
||||
margin-top: $main-spacing;
|
||||
border-bottom: 1px solid $border-color-ondark;
|
||||
padding-bottom: $element-inner-spacing;
|
||||
margin-bottom: $element-spacing;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.moreExpanded {
|
||||
transform: scaleY(-1);
|
||||
transition: transform $transition-time-feedback;
|
||||
}
|
||||
|
||||
.moreCollapsed {
|
||||
transform: scaleY(1);
|
||||
transition: transform $transition-time-feedback;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { FiChevronUp } from '@react-icons/all-files/fi/FiChevronUp';
|
||||
|
||||
import style from './CollapseBar.module.scss';
|
||||
|
||||
interface CollapseBarProps {
|
||||
title: string;
|
||||
isCollapsed: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export default function CollapseBar(props: CollapseBarProps) {
|
||||
const { title = 'Collapse bar', isCollapsed, onClick } = props;
|
||||
|
||||
return (
|
||||
<div className={style.header} onClick={onClick}>
|
||||
{title}
|
||||
<FiChevronUp className={isCollapsed ? style.moreCollapsed : style.moreExpanded} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
.contextMenuButton {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.contextMenuBackdrop {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// logic (with some modifications) culled from:
|
||||
// https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx
|
||||
|
||||
import { Fragment, ReactElement } from 'react';
|
||||
import { Menu, MenuButton, MenuDivider, MenuItem, MenuList } from '@chakra-ui/react';
|
||||
import { ReactElement } from 'react';
|
||||
import { Menu, MenuButton, MenuGroup, MenuList } from '@chakra-ui/react';
|
||||
import { IconType } from '@react-icons/all-files';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { ContextMenuOption } from './ContextMenuOption';
|
||||
|
||||
import style from './ContextMenu.module.scss';
|
||||
|
||||
type ContextMenuCoords = {
|
||||
@@ -13,14 +15,23 @@ type ContextMenuCoords = {
|
||||
y: number;
|
||||
};
|
||||
|
||||
export type Option = {
|
||||
export type OptionWithoutGroup = {
|
||||
label: string;
|
||||
isDisabled?: boolean;
|
||||
icon: IconType;
|
||||
onClick: () => void;
|
||||
withDivider?: boolean;
|
||||
isDisabled?: boolean;
|
||||
};
|
||||
|
||||
export type OptionWithGroup = {
|
||||
label: string;
|
||||
group: Omit<OptionWithoutGroup, 'isGroup'>[];
|
||||
};
|
||||
|
||||
export type Option = OptionWithoutGroup | OptionWithGroup;
|
||||
|
||||
const isOptionWithGroup = (option: Option): option is OptionWithGroup => 'group' in option;
|
||||
|
||||
type ContextMenuStore = {
|
||||
coords: ContextMenuCoords;
|
||||
options: Option[];
|
||||
@@ -57,7 +68,7 @@ export const ContextMenu = ({ children }: ContextMenuProps) => {
|
||||
<>
|
||||
{children}
|
||||
<div className={style.contextMenuBackdrop} />
|
||||
<Menu isOpen gutter={0} onClose={onClose} isLazy lazyBehavior='unmount' variant='ontime-on-dark'>
|
||||
<Menu isOpen size='sm' gutter={0} onClose={onClose} isLazy lazyBehavior='unmount' variant='ontime-on-dark'>
|
||||
<MenuButton
|
||||
className={style.contextMenuButton}
|
||||
aria-hidden
|
||||
@@ -69,14 +80,17 @@ export const ContextMenu = ({ children }: ContextMenuProps) => {
|
||||
}}
|
||||
/>
|
||||
<MenuList>
|
||||
{options.map(({ label, icon: Icon, onClick, withDivider, isDisabled }, i) => (
|
||||
<Fragment key={label}>
|
||||
{withDivider && <MenuDivider />}
|
||||
<MenuItem key={i} icon={<Icon />} onClick={onClick} isDisabled={isDisabled}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
</Fragment>
|
||||
))}
|
||||
{options.map((option) =>
|
||||
isOptionWithGroup(option) ? (
|
||||
<MenuGroup key={option.label} title={option.label}>
|
||||
{option.group.map((groupOption) => (
|
||||
<ContextMenuOption key={groupOption.label} {...groupOption} />
|
||||
))}
|
||||
</MenuGroup>
|
||||
) : (
|
||||
<ContextMenuOption key={option.label} {...option} />
|
||||
),
|
||||
)}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { MenuDivider, MenuItem } from '@chakra-ui/react';
|
||||
|
||||
import { OptionWithoutGroup } from './ContextMenu';
|
||||
|
||||
export const ContextMenuOption = ({ label, onClick, isDisabled, icon: Icon, withDivider }: OptionWithoutGroup) => (
|
||||
<>
|
||||
{withDivider && <MenuDivider />}
|
||||
<MenuItem icon={<Icon />} onClick={onClick} isDisabled={isDisabled}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
</>
|
||||
);
|
||||
@@ -10,20 +10,28 @@ interface CopyTagProps {
|
||||
label: string;
|
||||
className?: string;
|
||||
size?: Size;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function CopyTag(props: PropsWithChildren<CopyTagProps>) {
|
||||
const { label, className, size = 'xs', children } = props;
|
||||
const { label, className, size = 'xs', disabled, children } = props;
|
||||
|
||||
const handleClick = () => copyToClipboard(children as string);
|
||||
|
||||
return (
|
||||
<Tooltip label={label} openDelay={tooltipDelayFast}>
|
||||
<ButtonGroup size={size} isAttached className={className}>
|
||||
<Button variant='ontime-subtle' tabIndex={-1}>
|
||||
<Button variant='ontime-subtle' tabIndex={-1} isDisabled={disabled}>
|
||||
{children}
|
||||
</Button>
|
||||
<IconButton aria-label={label} icon={<IoCopy />} variant='ontime-filled' tabIndex={-1} onClick={handleClick} />
|
||||
<IconButton
|
||||
aria-label={label}
|
||||
icon={<IoCopy />}
|
||||
variant='ontime-filled'
|
||||
tabIndex={-1}
|
||||
onClick={handleClick}
|
||||
isDisabled={disabled}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
@use '../../../theme/v2Styles' as *;
|
||||
|
||||
.delaySymbol {
|
||||
svg {
|
||||
font-size: 1.5rem;
|
||||
|
||||
@@ -3,7 +3,7 @@ import React from 'react';
|
||||
// skipcq: JS-C1003 - sentry does not expose itself as an ES Module.
|
||||
import * as Sentry from '@sentry/react';
|
||||
|
||||
import { runtime } from '@/common/stores/runtime';
|
||||
import { runtimeStore } from '@/common/stores/runtime';
|
||||
import { hasConnected, reconnectAttempts, shouldReconnect } from '@/common/utils/socket';
|
||||
|
||||
import style from './ErrorBoundary.module.scss';
|
||||
@@ -23,13 +23,13 @@ class ErrorBoundary extends React.Component {
|
||||
|
||||
componentDidCatch(error, info) {
|
||||
this.setState({
|
||||
error: error,
|
||||
error,
|
||||
errorInfo: info,
|
||||
});
|
||||
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setExtras('error', error);
|
||||
scope.setExtras('store', runtime.getState());
|
||||
scope.setExtras('store', runtimeStore.getState());
|
||||
scope.setExtras('hasSocket', { hasConnected, shouldReconnect, reconnectAttempts });
|
||||
const eventId = Sentry.captureException(error);
|
||||
this.setState({ eventId, info });
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
@use '../../../theme/v2Styles' as *;
|
||||
|
||||
.errorContainer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
+2
-4
@@ -1,14 +1,12 @@
|
||||
@use '../../theme/v2Styles' as *;
|
||||
@use '../../theme/ontimeColours' as *;
|
||||
|
||||
.link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: 0.25rem;
|
||||
|
||||
color: $blue-500;
|
||||
transition-property: color;
|
||||
transition-duration: $transition-time-action;
|
||||
width: fit-content;
|
||||
|
||||
&.inline {
|
||||
display: inline-flex;
|
||||
+5
-5
@@ -1,18 +1,18 @@
|
||||
import { MouseEvent, ReactNode } from 'react';
|
||||
import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline';
|
||||
|
||||
import { openLink } from '../../common/utils/linkUtils';
|
||||
import { cx } from '../../common/utils/styleUtils';
|
||||
import { openLink } from '../../utils/linkUtils';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
|
||||
import style from './ModalLink.module.scss';
|
||||
import style from './ExternalLink.module.scss';
|
||||
|
||||
interface ModalLinkProps {
|
||||
interface ExternalLinkProps {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
export default function ModalLink(props: ModalLinkProps) {
|
||||
export default function ExternalLink(props: ExternalLinkProps) {
|
||||
const { href, inline, children } = props;
|
||||
const classes = cx([style.link, inline ? style.inline : null]);
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Textarea, TextareaProps } from '@chakra-ui/react';
|
||||
import autosize from 'autosize/dist/autosize';
|
||||
|
||||
export const AutoTextArea = (props: TextareaProps) => {
|
||||
const { value } = props;
|
||||
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -12,7 +14,7 @@ export const AutoTextArea = (props: TextareaProps) => {
|
||||
return () => {
|
||||
autosize.destroy(node);
|
||||
};
|
||||
}, []);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
|
||||
@@ -6,21 +6,30 @@ import style from './SwatchSelect.module.scss';
|
||||
|
||||
interface SwatchProps {
|
||||
color: string;
|
||||
onClick: (color: string) => void;
|
||||
onClick?: (color: string) => void;
|
||||
isSelected?: boolean;
|
||||
}
|
||||
|
||||
export default function Swatch(props: SwatchProps) {
|
||||
const { color, isSelected, onClick } = props;
|
||||
|
||||
const classes = cx([style.swatch, isSelected ? style.selected : null]);
|
||||
const handleClick = () => {
|
||||
onClick?.(color);
|
||||
};
|
||||
const classes = cx([style.swatch, isSelected ? style.selected : null, onClick ? style.selectable : null]);
|
||||
|
||||
if (!color) {
|
||||
return (
|
||||
<div className={`${classes} ${style.center}`} onClick={() => onClick('')}>
|
||||
<div className={`${classes} ${style.center}`} onClick={handleClick}>
|
||||
<IoBan />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div className={classes} style={{ backgroundColor: `${color}` }} onClick={() => onClick(color)} />;
|
||||
|
||||
if (color === 'unkown') {
|
||||
<div className={`${classes} ${style.center}`} onClick={handleClick}>
|
||||
?
|
||||
</div>;
|
||||
}
|
||||
return <div className={classes} style={{ backgroundColor: `${color}` }} onClick={handleClick} />;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem
|
||||
}
|
||||
|
||||
.swatch {
|
||||
cursor: pointer;
|
||||
aspect-ratio: 1;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 16px;
|
||||
border: 4px solid #262626;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 99px;
|
||||
border: 2px solid $gray-1200;
|
||||
|
||||
&.selected {
|
||||
border: 2px solid #578AF4;
|
||||
border: 2px solid $blue-500;
|
||||
}
|
||||
|
||||
&.selectable {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.center {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
color: #578AF4;
|
||||
}
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { EditorUpdateFields } from '../../../../features/event-editor/EventEditor';
|
||||
|
||||
import Swatch from './Swatch';
|
||||
|
||||
import style from './SwatchSelect.module.scss';
|
||||
|
||||
interface ColourInputProps {
|
||||
value: string;
|
||||
name: EditorUpdateFields;
|
||||
handleChange: (newValue: EditorUpdateFields, name: string) => void;
|
||||
name: 'colour';
|
||||
handleChange: (newValue: 'colour', name: string) => void;
|
||||
isMultiple?: boolean;
|
||||
}
|
||||
|
||||
const colours = [
|
||||
'',
|
||||
'unkown',
|
||||
'#FFCC78', // $orange-400
|
||||
'#FFAB33', // $orange-600
|
||||
'#77C785', // $green-400
|
||||
@@ -29,7 +29,7 @@ const colours = [
|
||||
];
|
||||
|
||||
export default function SwatchSelect(props: ColourInputProps) {
|
||||
const { value, name, handleChange } = props;
|
||||
const { value, name, handleChange, isMultiple } = props;
|
||||
|
||||
const setColour = useCallback(
|
||||
(newValue: string) => {
|
||||
@@ -42,9 +42,15 @@ export default function SwatchSelect(props: ColourInputProps) {
|
||||
|
||||
return (
|
||||
<div className={style.list}>
|
||||
{colours.map((colour) => (
|
||||
<Swatch key={colour} color={colour} onClick={setColour} isSelected={value === colour} />
|
||||
))}
|
||||
{colours
|
||||
.filter((colour) => {
|
||||
// Only include unkown if it is multiple
|
||||
if (!isMultiple && colour === 'unkown') return false;
|
||||
return true;
|
||||
})
|
||||
.map((colour) => (
|
||||
<Swatch key={colour} color={colour} onClick={setColour} isSelected={value === colour} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
@use '../../../../theme/v2Styles' as *;
|
||||
|
||||
$input-font-size: 15px;
|
||||
|
||||
.delayInput {
|
||||
|
||||
@@ -21,6 +21,7 @@ export default function DelayInput(props: DelayInputProps) {
|
||||
// avoid wrong submit on cancel
|
||||
let ignoreChange = false;
|
||||
|
||||
// set internal value on duration change
|
||||
useEffect(() => {
|
||||
if (typeof duration === 'undefined') {
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
@use "../../../../theme/_ontimeColours" as *;
|
||||
@use "../../../../theme/_v2Styles" as *;
|
||||
|
||||
.swatch {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { Input, Textarea } from '@chakra-ui/react';
|
||||
|
||||
import { EventEditorSubmitActions } from '../../../../features/event-editor/EventEditor';
|
||||
import { EventEditorSubmitActions } from '../../../../features/rundown/event-editor/EventEditorWrapper';
|
||||
import { Size } from '../../../models/Util.type';
|
||||
|
||||
import useReactiveTextInput from './useReactiveTextInput';
|
||||
|
||||
@@ -1,37 +1,4 @@
|
||||
@use "../../../../theme/v2Styles" as *;
|
||||
|
||||
$input-font-size: 15px;
|
||||
$input-delayed-border-color: #E69056;
|
||||
|
||||
.timeInput {
|
||||
width: fit-content !important;
|
||||
|
||||
.inputLeft {
|
||||
max-width: fit-content;
|
||||
}
|
||||
|
||||
.inputLeft,
|
||||
.inputButton {
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.inputField {
|
||||
font-size: $input-font-size;
|
||||
letter-spacing: 1px;
|
||||
width: 7.5em;
|
||||
padding: 0 0 0 2.6em;
|
||||
}
|
||||
|
||||
.warn {
|
||||
&::after {
|
||||
content: "*";
|
||||
color: $warning-orange;
|
||||
}
|
||||
}
|
||||
|
||||
&.delayed {
|
||||
.inputField {
|
||||
border: 1px solid $input-delayed-border-color;
|
||||
}
|
||||
}
|
||||
width: 6.5em;
|
||||
}
|
||||
|
||||
@@ -1,42 +1,23 @@
|
||||
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button, Input, InputGroup, InputLeftElement, Tooltip } from '@chakra-ui/react';
|
||||
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Input } from '@chakra-ui/react';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
|
||||
import { tooltipDelayFast } from '../../../../ontimeConfig';
|
||||
import { useEmitLog } from '../../../stores/logger';
|
||||
import { forgivingStringToMillis } from '../../../utils/dateConfig';
|
||||
import { cx } from '../../../utils/styleUtils';
|
||||
import { TimeEntryField } from '../../../utils/timesManager';
|
||||
|
||||
import style from './TimeInput.module.scss';
|
||||
|
||||
interface TimeInputProps {
|
||||
id?: TimeEntryField;
|
||||
name: TimeEntryField;
|
||||
submitHandler: (field: TimeEntryField, value: number) => void;
|
||||
interface TimeInputProps<T extends string> {
|
||||
name: T;
|
||||
submitHandler: (field: T, value: string) => void;
|
||||
time?: number;
|
||||
delay?: number;
|
||||
placeholder: string;
|
||||
previousEnd?: number;
|
||||
tooltip?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function ButtonInitial(name: TimeEntryField) {
|
||||
if (name === 'timeStart') return 'S';
|
||||
if (name === 'timeEnd') return 'E';
|
||||
if (name === 'durationOverride') return 'D';
|
||||
return '';
|
||||
}
|
||||
|
||||
function ButtonTooltip(name: TimeEntryField, tooltip?: string) {
|
||||
if (name === 'timeStart') return `Start${tooltip ? `: ${tooltip}` : ''}`;
|
||||
if (name === 'timeEnd') return `End${tooltip ? `: ${tooltip}` : ''}`;
|
||||
if (name === 'durationOverride') return `Duration${tooltip ? `: ${tooltip}` : ''}`;
|
||||
return '';
|
||||
}
|
||||
|
||||
export default function TimeInput(props: TimeInputProps) {
|
||||
const { id, name, submitHandler, time = 0, delay = 0, placeholder, previousEnd = 0 } = props;
|
||||
export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
|
||||
const { name, submitHandler, time = 0, placeholder, disabled, className } = props;
|
||||
const { emitError } = useEmitLog();
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [value, setValue] = useState<string>('');
|
||||
@@ -72,32 +53,20 @@ export default function TimeInput(props: TimeInputProps) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let newValMillis = 0;
|
||||
|
||||
// check for known aliases
|
||||
if (newValue === 'p' || newValue === 'prev' || newValue === 'previous') {
|
||||
// string to pass should be the time of the end before
|
||||
if (previousEnd != null) {
|
||||
newValMillis = previousEnd;
|
||||
}
|
||||
} else if (newValue.startsWith('+') || newValue.startsWith('p+') || newValue.startsWith('p +')) {
|
||||
// string to pass should add to the end before
|
||||
const val = newValue.substring(1);
|
||||
newValMillis = previousEnd + forgivingStringToMillis(val);
|
||||
} else {
|
||||
// convert entered value to milliseconds
|
||||
newValMillis = forgivingStringToMillis(newValue);
|
||||
// we dont know the values in the rundown, escalate to handler
|
||||
if (newValue.startsWith('p') || newValue.startsWith('+')) {
|
||||
submitHandler(name, newValue);
|
||||
}
|
||||
|
||||
// check if time is different from before
|
||||
if (newValMillis === time) return false;
|
||||
|
||||
// update entry
|
||||
submitHandler(name, newValMillis);
|
||||
const valueInMillis = forgivingStringToMillis(newValue);
|
||||
if (valueInMillis === time) {
|
||||
return false;
|
||||
}
|
||||
|
||||
submitHandler(name, newValue);
|
||||
return true;
|
||||
},
|
||||
[name, previousEnd, submitHandler, time],
|
||||
[name, submitHandler, time],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -107,15 +76,11 @@ export default function TimeInput(props: TimeInputProps) {
|
||||
const validateAndSubmit = useCallback(
|
||||
(newValue: string) => {
|
||||
const success = handleSubmit(newValue);
|
||||
if (success) {
|
||||
const ms = forgivingStringToMillis(newValue);
|
||||
const delayed = name === 'timeEnd' ? Math.max(0, ms + delay) : Math.max(0, ms + delay);
|
||||
setValue(millisToString(delayed));
|
||||
} else {
|
||||
if (!success) {
|
||||
resetValue();
|
||||
}
|
||||
},
|
||||
[delay, handleSubmit, name, resetValue],
|
||||
[handleSubmit, resetValue],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -126,9 +91,6 @@ export default function TimeInput(props: TimeInputProps) {
|
||||
(event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
inputRef.current?.blur();
|
||||
validateAndSubmit((event.target as HTMLInputElement).value);
|
||||
} else if (event.key === 'Tab') {
|
||||
validateAndSubmit((event.target as HTMLInputElement).value);
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
ignoreChange.current = true;
|
||||
@@ -136,7 +98,7 @@ export default function TimeInput(props: TimeInputProps) {
|
||||
resetValue();
|
||||
}
|
||||
},
|
||||
[resetValue, validateAndSubmit],
|
||||
[resetValue],
|
||||
);
|
||||
|
||||
const onBlurHandler = useCallback(
|
||||
@@ -155,51 +117,26 @@ export default function TimeInput(props: TimeInputProps) {
|
||||
resetValue();
|
||||
}, [resetValue, time]);
|
||||
|
||||
const isDelayed = delay !== 0;
|
||||
const inputClasses = cx([style.timeInput, isDelayed ? style.delayed : null]);
|
||||
const buttonClasses = cx([style.inputButton, isDelayed ? style.delayed : null]);
|
||||
|
||||
const TooltipLabel = useMemo(() => {
|
||||
return ButtonTooltip(name, '');
|
||||
}, [name]);
|
||||
|
||||
const ButtonText = useMemo(() => {
|
||||
return ButtonInitial(name);
|
||||
}, [name]);
|
||||
const timeInputClasses = cx([style.timeInput, className]);
|
||||
|
||||
return (
|
||||
<InputGroup size='sm' className={inputClasses}>
|
||||
<InputLeftElement className={style.inputLeft}>
|
||||
<Tooltip label={TooltipLabel} openDelay={tooltipDelayFast} variant='ontime-ondark'>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='ontime-subtle-white'
|
||||
className={buttonClasses}
|
||||
tabIndex={-1}
|
||||
border={isDelayed ? '1px solid #E69056' : '1px solid transparent'}
|
||||
borderRight='1px solid transparent'
|
||||
borderRadius='2px 0 0 2px'
|
||||
>
|
||||
{ButtonText}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
data-testid={`time-input-${name}`}
|
||||
className={style.inputField}
|
||||
type='text'
|
||||
placeholder={placeholder}
|
||||
variant='ontime-filled'
|
||||
onFocus={handleFocus}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onBlur={onBlurHandler}
|
||||
onKeyDown={onKeyDownHandler}
|
||||
value={value}
|
||||
maxLength={8}
|
||||
autoComplete='off'
|
||||
/>
|
||||
</InputGroup>
|
||||
<Input
|
||||
disabled={disabled}
|
||||
size='sm'
|
||||
ref={inputRef}
|
||||
data-testid={`time-input-${name}`}
|
||||
className={timeInputClasses}
|
||||
fontSize='1rem'
|
||||
type='text'
|
||||
placeholder={placeholder}
|
||||
variant='ontime-filled'
|
||||
onFocus={handleFocus}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onBlur={onBlurHandler}
|
||||
onKeyDown={onKeyDownHandler}
|
||||
value={value}
|
||||
maxLength={8}
|
||||
autoComplete='off'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
$input-delayed-border-color: $ontime-delay-text;
|
||||
|
||||
.timeInput {
|
||||
border: 1px solid transparent;
|
||||
&.delayed {
|
||||
border: 1px solid $input-delayed-border-color;
|
||||
}
|
||||
}
|
||||
|
||||
.inputField {
|
||||
max-width: 7.75em;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { InputGroup } from '@chakra-ui/react';
|
||||
|
||||
import { cx } from '../../../utils/styleUtils';
|
||||
|
||||
import TimeInput from './TimeInput';
|
||||
|
||||
import style from './TimeInputWithButton.module.scss';
|
||||
|
||||
interface TimeInputWithButtonProps<T extends string> {
|
||||
name: T;
|
||||
submitHandler: (field: T, value: string) => void;
|
||||
time?: number;
|
||||
hasDelay?: boolean;
|
||||
disabled?: boolean;
|
||||
placeholder: string;
|
||||
}
|
||||
|
||||
export default function TimeInputWithButton<T extends string>(props: PropsWithChildren<TimeInputWithButtonProps<T>>) {
|
||||
const { name, submitHandler, time, hasDelay, placeholder, disabled, children } = props;
|
||||
|
||||
const inputClasses = cx([style.timeInput, hasDelay ? style.delayed : null]);
|
||||
|
||||
return (
|
||||
<InputGroup size='sm' className={inputClasses} width='fit-content'>
|
||||
<TimeInput<T>
|
||||
name={name}
|
||||
submitHandler={submitHandler}
|
||||
time={time}
|
||||
placeholder={placeholder}
|
||||
className={style.inputField}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{children}
|
||||
</InputGroup>
|
||||
);
|
||||
}
|
||||
+4
-5
@@ -1,17 +1,16 @@
|
||||
@use '../../../theme/ontimeColours' as *;
|
||||
$loader-size: 4rem;
|
||||
|
||||
.screenLoader {
|
||||
.overlay {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
top: 0;
|
||||
background-color: $white-60;
|
||||
background-color: $black-10;
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
$loader-size: 48px;
|
||||
|
||||
.loader {
|
||||
width: $loader-size;
|
||||
@@ -0,0 +1,9 @@
|
||||
import style from './LoaderOverlay.module.scss';
|
||||
|
||||
export default function LoaderOverlay() {
|
||||
return (
|
||||
<div className={style.overlay}>
|
||||
<span className={style.loader} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
import { MaybeNumber } from 'ontime-types';
|
||||
|
||||
import { clamp } from '../../utils/math';
|
||||
|
||||
import './MultiPartProgressBar.scss';
|
||||
|
||||
interface MultiPartProgressBar {
|
||||
now: number | null;
|
||||
now: MaybeNumber;
|
||||
complete: number;
|
||||
normalColor: string;
|
||||
warning: number;
|
||||
warning?: MaybeNumber;
|
||||
warningColor: string;
|
||||
danger: number;
|
||||
danger?: MaybeNumber;
|
||||
dangerColor: string;
|
||||
hidden?: boolean;
|
||||
className?: string;
|
||||
@@ -19,8 +21,8 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
|
||||
|
||||
const percentComplete = 100 - clamp(100 - (Math.max(now ?? 0, 0) * 100) / complete, 0, 100);
|
||||
|
||||
const dangerWidth = clamp((danger / complete) * 100, 0, 100);
|
||||
const warningWidth = clamp((warning / complete) * 100, 0, 100);
|
||||
const dangerWidth = danger ? clamp((danger / complete) * 100, 0, 100) : 0;
|
||||
const warningWidth = warning ? clamp((warning / complete) * 100, 0, 100) : 0;
|
||||
|
||||
return (
|
||||
<div className={`multiprogress-bar ${hidden ? 'multiprogress-bar--hidden' : ''} ${className}`}>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { IoApps } from '@react-icons/all-files/io5/IoApps';
|
||||
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
||||
|
||||
import { debounce } from '../../utils/debounce';
|
||||
|
||||
import style from './NavigationMenu.module.scss';
|
||||
|
||||
interface FloatingNavigationProps {
|
||||
toggleMenu: () => void;
|
||||
toggleSettings: () => void;
|
||||
}
|
||||
|
||||
export default function FloatingNavigation(props: FloatingNavigationProps) {
|
||||
const { toggleMenu, toggleSettings } = props;
|
||||
const [showButton, setShowButton] = useState(false);
|
||||
|
||||
// show on mouse move
|
||||
useEffect(() => {
|
||||
let fadeOut: NodeJS.Timeout | null = null;
|
||||
const setShowMenuTrue = () => {
|
||||
setShowButton(true);
|
||||
if (fadeOut) {
|
||||
clearTimeout(fadeOut);
|
||||
}
|
||||
fadeOut = setTimeout(() => setShowButton(false), 3000);
|
||||
};
|
||||
|
||||
const debouncedShowMenu = debounce(setShowMenuTrue, 1000);
|
||||
|
||||
document.addEventListener('mousemove', debouncedShowMenu);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', debouncedShowMenu);
|
||||
if (fadeOut) {
|
||||
clearTimeout(fadeOut);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`${style.buttonContainer} ${!showButton ? style.hidden : ''}`}>
|
||||
<button
|
||||
onClick={toggleMenu}
|
||||
aria-label='toggle menu'
|
||||
className={style.navButton}
|
||||
data-testid='navigation__toggle-menu'
|
||||
>
|
||||
<IoApps />
|
||||
</button>
|
||||
<button
|
||||
className={style.button}
|
||||
onClick={toggleSettings}
|
||||
aria-label='toggle settings'
|
||||
data-testid='navigation__toggle-settings'
|
||||
>
|
||||
<IoSettingsOutline />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
@use '../../../theme/v2Styles' as *;
|
||||
@use '../../../theme/mixins' as *;
|
||||
@use '../../../theme/ontimeColours' as *;
|
||||
|
||||
$menu-bg: $gray-1200;
|
||||
$menu-hover-bg: $gray-1350;
|
||||
$menu-focus-bg: $gray-1300;
|
||||
|
||||
$icon-color: $ui-white;
|
||||
$button-bg: $gray-1050;
|
||||
$button-size: 48px;
|
||||
$button-size: 3rem;
|
||||
|
||||
.buttonContainer {
|
||||
display: flex;
|
||||
@@ -23,7 +20,7 @@ $button-size: 48px;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
z-index: 12;
|
||||
|
||||
&.hidden {
|
||||
opacity: 0;
|
||||
@@ -31,7 +28,7 @@ $button-size: 48px;
|
||||
}
|
||||
|
||||
.button {
|
||||
font-size: 24px;
|
||||
font-size: 1.5rem;
|
||||
color: $icon-color;
|
||||
background-color: $button-bg;
|
||||
width: $button-size;
|
||||
@@ -46,32 +43,10 @@ $button-size: 48px;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.menuContainer {
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: fit-content;
|
||||
position: absolute;
|
||||
background-color: $menu-bg;
|
||||
min-width: 200px;
|
||||
border-radius: 0 0 24px 0;
|
||||
border-right: 1px solid $border-color-ondark;
|
||||
|
||||
box-shadow: $box-shadow-l2;
|
||||
padding-bottom: 1rem;
|
||||
|
||||
max-height: 100vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.buttonsContainer {
|
||||
margin-top: calc(56px + 1rem);
|
||||
}
|
||||
|
||||
.link {
|
||||
@include action-link;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
padding: 0.75rem 1.5rem;
|
||||
gap: 0.5rem;
|
||||
|
||||
&:hover {
|
||||
background-color: $menu-hover-bg;
|
||||
@@ -94,7 +69,7 @@ $button-size: 48px;
|
||||
}
|
||||
|
||||
.linkIcon {
|
||||
display: inline-block;
|
||||
margin-left: auto;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,99 +1,79 @@
|
||||
import { KeyboardEvent, memo, useEffect, useRef, useState } from 'react';
|
||||
import { memo, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Link, useLocation, useSearchParams } from 'react-router-dom';
|
||||
import { useDisclosure } from '@chakra-ui/react';
|
||||
import { IoApps } from '@react-icons/all-files/io5/IoApps';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Drawer,
|
||||
DrawerBody,
|
||||
DrawerCloseButton,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerOverlay,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
import { useFullscreen } from '@mantine/hooks';
|
||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
||||
import { IoContract } from '@react-icons/all-files/io5/IoContract';
|
||||
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
|
||||
import { IoPencilSharp } from '@react-icons/all-files/io5/IoPencilSharp';
|
||||
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
|
||||
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
|
||||
|
||||
import { navigatorConstants } from '../../../viewerConfig';
|
||||
import useClickOutside from '../../hooks/useClickOutside';
|
||||
import useFullscreen from '../../hooks/useFullscreen';
|
||||
import { useViewOptionsStore } from '../../stores/viewOptions';
|
||||
import { isKeyEnter } from '../../utils/keyEvent';
|
||||
|
||||
import RenameClientModal from './rename-client-modal/RenameClientModal';
|
||||
|
||||
import style from './NavigationMenu.module.scss';
|
||||
|
||||
function NavigationMenu() {
|
||||
const location = useLocation();
|
||||
interface NavigationMenuProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const { isFullScreen, toggleFullScreen } = useFullscreen();
|
||||
function NavigationMenu(props: NavigationMenuProps) {
|
||||
const { isOpen, onClose } = props;
|
||||
|
||||
const { isOpen: isRenameOpen, onOpen: onRenameOpen, onClose: onRenameClose } = useDisclosure();
|
||||
|
||||
const { fullscreen, toggle } = useFullscreen();
|
||||
const { toggleMirror } = useViewOptionsStore();
|
||||
const [showButton, setShowButton] = useState(false);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useClickOutside(menuRef, () => setShowMenu(false));
|
||||
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
const toggleMenu = () => setShowMenu((prev) => !prev);
|
||||
|
||||
// show on mouse move
|
||||
useEffect(() => {
|
||||
let fadeOut: NodeJS.Timeout | null = null;
|
||||
const setShowMenuTrue = () => {
|
||||
setShowButton(true);
|
||||
if (fadeOut) {
|
||||
clearTimeout(fadeOut);
|
||||
}
|
||||
fadeOut = setTimeout(() => setShowButton(false), 3000);
|
||||
};
|
||||
document.addEventListener('mousemove', setShowMenuTrue);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', setShowMenuTrue);
|
||||
if (fadeOut) {
|
||||
clearTimeout(fadeOut);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isKeyEnter = (event: KeyboardEvent<HTMLDivElement>) => event.key === 'Enter';
|
||||
const handleFullscreen = () => toggleFullScreen();
|
||||
const handleMirror = () => toggleMirror();
|
||||
|
||||
const showEditFormDrawer = () => {
|
||||
searchParams.set('edit', 'true');
|
||||
setSearchParams(searchParams);
|
||||
};
|
||||
useClickOutside(menuRef, () => onClose);
|
||||
|
||||
return createPortal(
|
||||
<div id='navigation-menu-portal' ref={menuRef}>
|
||||
<RenameClientModal isOpen={isOpen} onClose={onClose} />
|
||||
<div className={`${style.buttonContainer} ${!showButton && !showMenu ? style.hidden : ''}`}>
|
||||
<button onClick={toggleMenu} aria-label='toggle menu' className={style.navButton}>
|
||||
<IoApps />
|
||||
</button>
|
||||
<button className={style.button} onClick={showEditFormDrawer}>
|
||||
<IoPencilSharp />
|
||||
</button>
|
||||
{showMenu && (
|
||||
<div className={style.menuContainer} data-testid='navigation-menu'>
|
||||
<RenameClientModal isOpen={isRenameOpen} onClose={onRenameClose} />
|
||||
<Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
|
||||
<DrawerOverlay />
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerCloseButton size='lg' />
|
||||
Ontime
|
||||
</DrawerHeader>
|
||||
<DrawerBody padding={0}>
|
||||
<div className={style.buttonsContainer}>
|
||||
<div
|
||||
className={style.link}
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
onClick={handleFullscreen}
|
||||
onClick={toggle}
|
||||
onKeyDown={(event) => {
|
||||
isKeyEnter(event) && handleFullscreen();
|
||||
isKeyEnter(event) && toggle();
|
||||
}}
|
||||
>
|
||||
Toggle Fullscreen
|
||||
{isFullScreen ? <IoContract /> : <IoExpand />}
|
||||
{fullscreen ? <IoContract /> : <IoExpand />}
|
||||
</div>
|
||||
<div
|
||||
className={style.link}
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
onClick={handleMirror}
|
||||
onClick={() => toggleMirror()}
|
||||
onKeyDown={(event) => {
|
||||
isKeyEnter(event) && handleMirror();
|
||||
isKeyEnter(event) && toggleMirror();
|
||||
}}
|
||||
>
|
||||
Flip Screen
|
||||
@@ -103,20 +83,35 @@ function NavigationMenu() {
|
||||
className={style.link}
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
onClick={onOpen}
|
||||
onClick={onRenameOpen}
|
||||
onKeyDown={(event) => {
|
||||
isKeyEnter(event) && onOpen();
|
||||
isKeyEnter(event) && onRenameOpen();
|
||||
}}
|
||||
>
|
||||
Rename Client
|
||||
</div>
|
||||
</div>
|
||||
<hr className={style.separator} />
|
||||
<Link to='/cuesheet' className={style.link} tabIndex={0}>
|
||||
<Link
|
||||
to='/editor'
|
||||
className={`${style.link} ${location.pathname === '/editor' ? style.current : ''}`}
|
||||
tabIndex={0}
|
||||
>
|
||||
<IoLockClosedOutline />
|
||||
Editor
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</Link>
|
||||
<Link
|
||||
to='/cuesheet'
|
||||
className={`${style.link} ${location.pathname === '/cuesheet' ? style.current : ''}`}
|
||||
tabIndex={0}
|
||||
>
|
||||
<IoLockClosedOutline />
|
||||
Cuesheet
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</Link>
|
||||
<Link to='/op' className={style.link} tabIndex={0}>
|
||||
<Link to='/op' className={`${style.link} ${location.pathname === '/op' ? style.current : ''}`} tabIndex={0}>
|
||||
<IoLockClosedOutline />
|
||||
Operator
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</Link>
|
||||
@@ -132,11 +127,10 @@ function NavigationMenu() {
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DrawerBody>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</div>,
|
||||
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
import NavigationMenu from './NavigationMenu';
|
||||
|
||||
interface ProductionNavigationMenuProps {
|
||||
isMenuOpen: boolean;
|
||||
onMenuClose: () => void;
|
||||
}
|
||||
|
||||
function ProductionNavigationMenu(props: ProductionNavigationMenuProps) {
|
||||
const { isMenuOpen, onMenuClose } = props;
|
||||
|
||||
return <NavigationMenu isOpen={isMenuOpen} onClose={onMenuClose} />;
|
||||
}
|
||||
|
||||
export default memo(ProductionNavigationMenu);
|
||||
@@ -0,0 +1,27 @@
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useDisclosure } from '@chakra-ui/react';
|
||||
|
||||
import FloatingNavigation from './FloatingNavigation';
|
||||
import NavigationMenu from './NavigationMenu';
|
||||
|
||||
function ViewNavigationMenu() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { isOpen: isMenuOpen, onOpen: onMenuOpen, onClose: onMenuClose } = useDisclosure();
|
||||
|
||||
const showEditFormDrawer = useCallback(() => {
|
||||
searchParams.set('edit', 'true');
|
||||
setSearchParams(searchParams);
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
const toggleMenu = () => (isMenuOpen ? onMenuClose() : onMenuOpen());
|
||||
|
||||
return (
|
||||
<>
|
||||
<FloatingNavigation toggleMenu={toggleMenu} toggleSettings={showEditFormDrawer} />
|
||||
<NavigationMenu isOpen={isMenuOpen} onClose={onMenuClose} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ViewNavigationMenu);
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
.modalBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
+4
-6
@@ -13,8 +13,6 @@ import {
|
||||
import { setClientName } from '../../../hooks/useSocket';
|
||||
import { useSocketClientName } from '../../../stores/connectionName';
|
||||
|
||||
import style from './RenameClientModal.module.scss';
|
||||
|
||||
interface RenameClientModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -30,7 +28,7 @@ export default function RenameClientModal({ isOpen, onClose }: RenameClientModal
|
||||
|
||||
const handleRename = async () => {
|
||||
if (newName) {
|
||||
await setClientName(newName);
|
||||
setClientName(newName);
|
||||
persistName(newName);
|
||||
onClose();
|
||||
}
|
||||
@@ -45,18 +43,18 @@ export default function RenameClientModal({ isOpen, onClose }: RenameClientModal
|
||||
motionPreset='slideInBottom'
|
||||
scrollBehavior='inside'
|
||||
preserveScrollBarGap
|
||||
variant='ontime-small'
|
||||
variant='ontime'
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Rename client</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody className={style.modalBody}>
|
||||
<ModalBody>
|
||||
<Input
|
||||
placeholder='Connection must have a name'
|
||||
defaultValue={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
variant='ontime-filled-on-light'
|
||||
variant='ontime-filled'
|
||||
/>
|
||||
<Button
|
||||
isDisabled={newName === clientName || !newName}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { HStack, IconButton, PinInput, PinInputField } from '@chakra-ui/react';
|
||||
import { IconButton, PinInput, PinInputField } from '@chakra-ui/react';
|
||||
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
|
||||
|
||||
import style from './ProtectRoute.module.scss';
|
||||
@@ -39,8 +39,8 @@ export default function PinPage(props: PinPageProps) {
|
||||
|
||||
return (
|
||||
<div className={style.container}>
|
||||
{`Ontime ${permission || ''}`}
|
||||
<HStack spacing='10px' className={failed ? style.pin__failed : style.pin}>
|
||||
{`Ontime ${permission}`}
|
||||
<div className={failed ? style.pin__failed : style.pin}>
|
||||
<PinInput
|
||||
type='alphanumeric'
|
||||
size='lg'
|
||||
@@ -57,8 +57,15 @@ export default function PinPage(props: PinPageProps) {
|
||||
<PinInputField />
|
||||
<PinInputField />
|
||||
</PinInput>
|
||||
<IconButton aria-label='Enter' size='lg' isRound icon={<IoCheckmark />} onClick={validate} />
|
||||
</HStack>
|
||||
<IconButton
|
||||
variant='ontime-filled'
|
||||
aria-label='Enter'
|
||||
size='lg'
|
||||
isRound
|
||||
icon={<IoCheckmark />}
|
||||
onClick={validate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,31 @@
|
||||
@use '../../../theme/v2Styles' as *;
|
||||
|
||||
.container {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
height: 100vh;
|
||||
padding-bottom: 30vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 25vh;
|
||||
|
||||
background: $bg-container-l1;
|
||||
color: $ontime-color;
|
||||
font-family: $ontime-font-family;
|
||||
font-weight: 200;
|
||||
text-align: center;
|
||||
font-size: 3vw;
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
.pin,
|
||||
.pin__failed {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
gap: 0.125em;
|
||||
padding-block: 0.5em;
|
||||
|
||||
input {
|
||||
border-radius: 50%;
|
||||
border-radius: 99px;
|
||||
border-color: $gray-500;
|
||||
|
||||
&:hover {
|
||||
border-color: $blue-500;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
margin-left: 20px;
|
||||
margin-left: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,9 +37,9 @@
|
||||
|
||||
@keyframes colourFade {
|
||||
from {
|
||||
background: $action-blue;
|
||||
background: $red-500;
|
||||
}
|
||||
to {
|
||||
background: rgba($action-blue, 0);
|
||||
background: rgba($red-500, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,12 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.entry-secondary {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
@@ -68,4 +74,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import { createContext, PropsWithChildren, useContext, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { OntimeEvent } from 'ontime-types';
|
||||
|
||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||
import { useInterval } from '../../hooks/useInterval';
|
||||
import { isStringBoolean } from '../../utils/viewUtils';
|
||||
|
||||
interface ScheduleContextState {
|
||||
events: OntimeEvent[];
|
||||
|
||||
@@ -4,7 +4,8 @@ import { formatTime } from '../../utils/time';
|
||||
import './Schedule.scss';
|
||||
|
||||
const formatOptions = {
|
||||
format: 'hh:mm a',
|
||||
format12: 'hh:mm a',
|
||||
format24: 'HH:mm',
|
||||
};
|
||||
|
||||
interface ScheduleItemProps {
|
||||
@@ -12,14 +13,13 @@ interface ScheduleItemProps {
|
||||
timeStart: number;
|
||||
timeEnd: number;
|
||||
title: string;
|
||||
presenter?: string;
|
||||
backstageEvent: boolean;
|
||||
colour: string;
|
||||
skip: boolean;
|
||||
}
|
||||
|
||||
export default function ScheduleItem(props: ScheduleItemProps) {
|
||||
const { selected, timeStart, timeEnd, title, presenter, backstageEvent, colour, skip } = props;
|
||||
const { selected, timeStart, timeEnd, title, backstageEvent, colour, skip } = props;
|
||||
|
||||
const start = formatTime(timeStart, formatOptions);
|
||||
const end = formatTime(timeEnd, formatOptions);
|
||||
@@ -38,7 +38,6 @@ export default function ScheduleItem(props: ScheduleItemProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className='entry-title'>{title}</div>
|
||||
{presenter && <div className='entry-presenter'>{presenter}</div>}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
@use '../../../theme/ontimeColours' as *;
|
||||
|
||||
.emptyContainer {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
color: $gray-1350;
|
||||
color: $white-10;
|
||||
|
||||
.empty {
|
||||
width: 100%;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { CSSProperties } from 'react';
|
||||
|
||||
import { ReactComponent as Emptyimage } from '@/assets/images/empty.svg';
|
||||
import EmptyImage from '../../../assets/images/empty.svg?react';
|
||||
|
||||
import style from './Empty.module.scss';
|
||||
|
||||
interface EmptyProps {
|
||||
text: string;
|
||||
text?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ export default function Empty(props: EmptyProps) {
|
||||
const { text, ...rest } = props;
|
||||
return (
|
||||
<div className={style.emptyContainer} {...rest}>
|
||||
<Emptyimage className={style.empty} />
|
||||
<span className={style.text}>{text}</span>
|
||||
<EmptyImage className={style.empty} />
|
||||
{text && <span className={style.text}>{text}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-4
@@ -1,10 +1,8 @@
|
||||
@use "../../../../theme/_ontimeColours" as *;
|
||||
|
||||
.tag {
|
||||
font-size: calc(1rem - 3px);
|
||||
letter-spacing: 0.5px;
|
||||
background-color: $gray-100;
|
||||
color: $ui-black;
|
||||
background-color: $gray-900;
|
||||
color: $ui-white;
|
||||
border-radius: 2px;
|
||||
padding: 0 0.25rem;
|
||||
white-space: nowrap;
|
||||
@@ -1,33 +0,0 @@
|
||||
import { memo } from 'react';
|
||||
import { formatDisplay } from 'ontime-utils';
|
||||
|
||||
import './TimerDisplay.scss';
|
||||
|
||||
interface TimerDisplayProps {
|
||||
time?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays time in ms in formatted timetag
|
||||
* @param props
|
||||
* @constructor
|
||||
*/
|
||||
const TimerDisplay = (props: TimerDisplayProps) => {
|
||||
const { time } = props;
|
||||
|
||||
let display = '';
|
||||
|
||||
if (time === null || typeof time === 'undefined' || isNaN(time)) {
|
||||
display = '-- : -- : --';
|
||||
} else {
|
||||
display = formatDisplay(time);
|
||||
}
|
||||
|
||||
const isNegative = (time ?? 0) < 0;
|
||||
|
||||
const classes = `timer ${isNegative ? 'timer--finished' : ''}`;
|
||||
|
||||
return <div className={classes}>{display}</div>;
|
||||
};
|
||||
|
||||
export default memo(TimerDisplay);
|
||||
@@ -3,34 +3,38 @@
|
||||
.title-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.inline {
|
||||
display: flex;
|
||||
}
|
||||
.inline {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: clamp(32px, 3.5vw, 50px);
|
||||
color: var(--color-override, $viewer-color);
|
||||
line-height: 1.1em;
|
||||
}
|
||||
.title-card__title {
|
||||
font-weight: 600;
|
||||
font-size: clamp(32px, 3.5vw, 50px);
|
||||
color: var(--color-override, $viewer-color);
|
||||
line-height: 1.1em;
|
||||
}
|
||||
|
||||
.subtitle, .presenter {
|
||||
font-size: clamp(24px, 2vw, 35px);
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
line-height: 1.1em;
|
||||
}
|
||||
.title-card__label {
|
||||
font-size: clamp(1rem, 1.5vw, 1.5rem);
|
||||
font-weight: 400;
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
margin-left: auto;
|
||||
text-transform: uppercase;
|
||||
|
||||
.label {
|
||||
font-size: clamp(16px, 1.5vw, 24px);
|
||||
font-weight: 400;
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
margin-left: auto;
|
||||
text-transform: uppercase;
|
||||
|
||||
&.accent {
|
||||
color: var(--accent-color-override, $accent-color);
|
||||
}
|
||||
&--accent {
|
||||
color: var(--accent-color-override, $accent-color);
|
||||
}
|
||||
}
|
||||
|
||||
.title-card__secondary {
|
||||
font-size: clamp(1.5rem, 2vw, 2.25rem);
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
line-height: 1.1em;
|
||||
|
||||
&::after {
|
||||
content: '\200b';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,12 @@ import './TitleCard.scss';
|
||||
|
||||
interface TitleCardProps {
|
||||
label: 'now' | 'next';
|
||||
title: string | null;
|
||||
subtitle: string | null;
|
||||
presenter: string | null;
|
||||
title: string;
|
||||
secondary?: string;
|
||||
}
|
||||
|
||||
export default function TitleCard(props: TitleCardProps) {
|
||||
const { label, title, subtitle, presenter } = props;
|
||||
const { label, title, secondary } = props;
|
||||
const { getLocalizedString } = useTranslation();
|
||||
|
||||
const accent = label === 'now';
|
||||
@@ -18,11 +17,12 @@ export default function TitleCard(props: TitleCardProps) {
|
||||
return (
|
||||
<div className='title-card'>
|
||||
<div className='inline'>
|
||||
<span className='presenter'>{presenter}</span>
|
||||
<span className={accent ? 'label accent' : 'label'}>{getLocalizedString(`common.${label}`)}</span>
|
||||
<span className='title-card__title'>{title}</span>
|
||||
<span className={accent ? 'title-card__label title-card__label--accent' : 'title-card__label'}>
|
||||
{getLocalizedString(`common.${label}`)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='title'>{title}</div>
|
||||
<div className='subtitle'>{subtitle}</div>
|
||||
<div className='title-card__secondary'>{secondary}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Input, InputGroup, InputLeftElement, Select, Switch } from '@chakra-ui/react';
|
||||
|
||||
import { isStringBoolean } from '../../utils/viewUtils';
|
||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||
|
||||
import { ParamField } from './types';
|
||||
|
||||
|
||||
@@ -1,27 +1,9 @@
|
||||
@use '../../../theme/v2Styles' as *;
|
||||
@use '../../../theme/ontimeColours' as *;
|
||||
|
||||
.drawerContent {
|
||||
background-color: $gray-1250;
|
||||
}
|
||||
|
||||
.drawerHeader {
|
||||
@extend .drawerContent;
|
||||
color: $section-white;
|
||||
}
|
||||
|
||||
.drawerFooter {
|
||||
@extend .drawerContent;
|
||||
display: flex;
|
||||
justify-content: start;
|
||||
gap: $element-spacing;
|
||||
justify-content: end;
|
||||
gap: $section-spacing;
|
||||
|
||||
button[type='reset'] {
|
||||
padding: 0 2em;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
button[type='submit'] {
|
||||
button {
|
||||
padding: 0 2em;
|
||||
}
|
||||
}
|
||||
@@ -29,13 +11,16 @@
|
||||
.label {
|
||||
font-size: $inner-section-text-size;
|
||||
color: $label-gray;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem
|
||||
}
|
||||
|
||||
.columnSection {
|
||||
display: flex;
|
||||
padding: $element-spacing;
|
||||
padding: $section-spacing 0;
|
||||
flex-direction: column;
|
||||
gap: $element-inner-spacing;
|
||||
gap: $element-spacing;
|
||||
}
|
||||
|
||||
.title {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FormEvent, useEffect } from 'react';
|
||||
import { useLocation, useSearchParams } from 'react-router-dom';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
Drawer,
|
||||
@@ -12,30 +12,26 @@ import {
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { useLocalStorage } from '../../../common/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.map(({ defaultValue }) => String(defaultValue));
|
||||
const defaultValues = paramFields.reduce<Record<string, string>>((acc, { id, defaultValue }) => {
|
||||
acc[id] = String(defaultValue);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return Object.entries(paramsObj).reduce((newSearchParams, [id, value]) => {
|
||||
if (typeof value === 'string' && value.length) {
|
||||
if (defaultValues.includes(value)) {
|
||||
return newSearchParams;
|
||||
}
|
||||
|
||||
if (typeof value === 'string' && value.length && defaultValues[id] !== value) {
|
||||
newSearchParams.set(id, value);
|
||||
|
||||
return newSearchParams;
|
||||
}
|
||||
|
||||
return newSearchParams;
|
||||
}, new URLSearchParams());
|
||||
};
|
||||
@@ -47,8 +43,6 @@ 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');
|
||||
@@ -58,37 +52,16 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
|
||||
}
|
||||
}, [searchParams, onOpen]);
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
const handleClose = () => {
|
||||
searchParams.delete('edit');
|
||||
setSearchParams(searchParams);
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
const resetParams = () => {
|
||||
setStoredViewParams({ ...storedViewParams, [pathname]: {} });
|
||||
setSearchParams();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
|
||||
@@ -96,21 +69,21 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
|
||||
|
||||
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
|
||||
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, paramFields);
|
||||
|
||||
setStoredViewParams({ ...storedViewParams, [pathname]: newParamsObject });
|
||||
setSearchParams(newSearchParams);
|
||||
|
||||
handleClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer isOpen={isOpen} placement='right' onClose={onCloseWithoutSaving} size='lg'>
|
||||
<Drawer isOpen={isOpen} placement='right' onClose={handleClose} variant='ontime' size='lg'>
|
||||
<DrawerOverlay />
|
||||
<DrawerContent>
|
||||
<DrawerHeader className={style.drawerHeader}>
|
||||
<DrawerCloseButton _hover={{ bg: '#ebedf0', color: '#333' }} size='lg' />
|
||||
<DrawerHeader>
|
||||
<DrawerCloseButton size='lg' />
|
||||
Customise
|
||||
</DrawerHeader>
|
||||
|
||||
<DrawerBody className={style.drawerContent}>
|
||||
<DrawerBody>
|
||||
<form id='edit-params-form' onSubmit={onParamsFormSubmit}>
|
||||
{paramFields.map((field) => (
|
||||
<div key={field.title} className={style.columnSection}>
|
||||
@@ -126,10 +99,7 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
|
||||
|
||||
<DrawerFooter className={style.drawerFooter}>
|
||||
<Button variant='ontime-ghosted' onClick={resetParams} type='reset'>
|
||||
Reset
|
||||
</Button>
|
||||
<Button variant='ontime-subtle' onClick={onCloseWithoutSaving}>
|
||||
Cancel
|
||||
Reset to default
|
||||
</Button>
|
||||
<Button variant='ontime-filled' form='edit-params-form' type='submit'>
|
||||
Save
|
||||
|
||||
@@ -1,18 +1,36 @@
|
||||
import { UserFields } from 'ontime-types';
|
||||
import { TimeFormat } from 'ontime-types/src/definitions/core/TimeFormat.type';
|
||||
import { CustomFields } from 'ontime-types';
|
||||
|
||||
import { capitaliseFirstLetter } from '../../../features/viewers/common/viewUtils';
|
||||
|
||||
import { ParamField } from './types';
|
||||
|
||||
export const getTimeOption = (timeFormat: TimeFormat): ParamField => ({
|
||||
id: 'format',
|
||||
title: '12 / 24 hour timer',
|
||||
description: 'Whether to show the time in 12 or 24 hour mode. Overrides the global setting from preferences',
|
||||
type: 'option',
|
||||
values: { '12': '12 hour AM/PM', '24': '24 hour' },
|
||||
defaultValue: timeFormat,
|
||||
});
|
||||
const makeOptionsFromCustomFields = (customFields: CustomFields, additionalOptions?: Record<string, string>) => {
|
||||
const customFieldOptions = Object.keys(customFields).reduce((acc, key) => {
|
||||
return { ...acc, [`custom-${key}`]: `Custom: ${capitaliseFirstLetter(key)}` };
|
||||
}, additionalOptions ?? {});
|
||||
return customFieldOptions;
|
||||
};
|
||||
|
||||
export const getClockOptions = (timeFormat: TimeFormat): ParamField[] => [
|
||||
const getTimeOption = (timeFormat: string): ParamField => {
|
||||
const placeholder = `${timeFormat} (default)`;
|
||||
return {
|
||||
id: 'timeformat',
|
||||
title: 'Time format string, taken from the Application Settings',
|
||||
description: 'Format for auxiliar time fields (not the running), eg. HH:mm:ss or hh:mm:ss a, see docs for help',
|
||||
type: 'string',
|
||||
placeholder,
|
||||
};
|
||||
};
|
||||
|
||||
const hideTimerSeconds: ParamField = {
|
||||
id: 'hideTimerSeconds',
|
||||
title: 'Hide seconds in timer',
|
||||
description: 'Whether to hide seconds in the running timer',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
};
|
||||
|
||||
export const getClockOptions = (timeFormat: string): ParamField[] => [
|
||||
getTimeOption(timeFormat),
|
||||
{
|
||||
id: 'key',
|
||||
@@ -84,46 +102,59 @@ export const getClockOptions = (timeFormat: TimeFormat): ParamField[] => [
|
||||
},
|
||||
];
|
||||
|
||||
export const getTimerOptions = (timeFormat: TimeFormat): ParamField[] => [
|
||||
getTimeOption(timeFormat),
|
||||
{
|
||||
id: 'hideClock',
|
||||
title: 'Hide Time Now',
|
||||
description: 'Hides the Time Now field',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
id: 'hideCards',
|
||||
title: 'Hide Cards',
|
||||
description: 'Hides the Now and Next cards',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
id: 'hideProgress',
|
||||
title: 'Hide progress bar',
|
||||
description: 'Hides the progress bar',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
id: 'hideMessage',
|
||||
title: 'Hide Presenter Message',
|
||||
description: 'Prevents the screen from displaying messages from the presenter',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
id: 'hideExternal',
|
||||
title: 'Hide External',
|
||||
description: 'Prevents the screen from displaying the external field',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
];
|
||||
export const getTimerOptions = (timeFormat: string, customFields: CustomFields): ParamField[] => {
|
||||
const secondaryOptions = makeOptionsFromCustomFields(customFields);
|
||||
return [
|
||||
getTimeOption(timeFormat),
|
||||
hideTimerSeconds,
|
||||
{
|
||||
id: 'hideClock',
|
||||
title: 'Hide Time Now',
|
||||
description: 'Hides the Time Now field',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
id: 'secondary-src',
|
||||
title: 'Secondary text',
|
||||
description: 'Select the data source for the secondary text',
|
||||
type: 'option',
|
||||
values: secondaryOptions,
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
id: 'hideCards',
|
||||
title: 'Hide Cards',
|
||||
description: 'Hides the Now and Next cards',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
id: 'hideProgress',
|
||||
title: 'Hide progress bar',
|
||||
description: 'Hides the progress bar',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
id: 'hideMessage',
|
||||
title: 'Hide Presenter Message',
|
||||
description: 'Prevents the screen from displaying messages from the presenter',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
id: 'hideExternal',
|
||||
title: 'Hide External',
|
||||
description: 'Prevents the screen from displaying the external field',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
|
||||
hideTimerSeconds,
|
||||
{
|
||||
id: 'key',
|
||||
title: 'Key Colour',
|
||||
@@ -215,124 +246,220 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const LOWER_THIRDS_OPTIONS: ParamField[] = [
|
||||
{
|
||||
id: 'size',
|
||||
title: 'Size',
|
||||
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
|
||||
type: 'number',
|
||||
placeholder: '1 (default)',
|
||||
},
|
||||
{
|
||||
id: 'transition',
|
||||
title: 'Transition',
|
||||
description: 'Transition in time in seconds (default 3)',
|
||||
type: 'number',
|
||||
placeholder: '3 (default)',
|
||||
},
|
||||
{
|
||||
id: 'text',
|
||||
title: 'Text Colour',
|
||||
description: 'Text colour in hexadecimal',
|
||||
prefix: '#',
|
||||
type: 'string',
|
||||
placeholder: 'fffffa (default)',
|
||||
},
|
||||
{
|
||||
id: 'bg',
|
||||
title: 'Text Background',
|
||||
description: 'Text background colour in hexadecimal',
|
||||
prefix: '#',
|
||||
type: 'string',
|
||||
placeholder: '00000033 (default)',
|
||||
},
|
||||
{
|
||||
id: 'key',
|
||||
title: 'Key Colour',
|
||||
description: 'Screen background colour in hexadecimal',
|
||||
prefix: '#',
|
||||
type: 'string',
|
||||
placeholder: '00000033 (default)',
|
||||
},
|
||||
{
|
||||
id: 'fadeout',
|
||||
title: 'Fadeout',
|
||||
description: 'Time (in seconds) the lower third displays before fading out',
|
||||
type: 'number',
|
||||
placeholder: '3 (default)',
|
||||
},
|
||||
];
|
||||
export const getLowerThirdOptions = (customFields: CustomFields): ParamField[] => {
|
||||
const topSourceOptions = makeOptionsFromCustomFields(customFields, {
|
||||
title: 'Title',
|
||||
lowerMsg: 'Lower Third Message',
|
||||
});
|
||||
|
||||
export const getBackstageOptions = (timeFormat: TimeFormat): ParamField[] => [
|
||||
getTimeOption(timeFormat),
|
||||
{
|
||||
id: 'hidePast',
|
||||
title: 'Hide past events',
|
||||
description: 'Scheduler will only show upcoming events',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
id: 'stopCycle',
|
||||
title: 'Stop cycling through event pages',
|
||||
description: 'Schedule will not auto-cycle through events',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
id: 'eventsPerPage',
|
||||
title: 'Events per page',
|
||||
description: 'Sets the number of events on the page, can cause overlow',
|
||||
type: 'number',
|
||||
placeholder: '7 (default)',
|
||||
},
|
||||
];
|
||||
const bottomSourceOptions = makeOptionsFromCustomFields(customFields, {
|
||||
title: 'Title',
|
||||
lowerMsg: 'Lower Third Message',
|
||||
});
|
||||
|
||||
export const getPublicOptions = (timeFormat: TimeFormat): ParamField[] => [
|
||||
getTimeOption(timeFormat),
|
||||
{
|
||||
id: 'hidePast',
|
||||
title: 'Hide past events',
|
||||
description: 'Scheduler will only show upcoming events',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
id: 'stopCycle',
|
||||
title: 'Stop cycling through event pages',
|
||||
description: 'Schedule will not auto-cycle through events',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
id: 'eventsPerPage',
|
||||
title: 'Events per page',
|
||||
description: 'Sets the number of events on the page, can cause overlow',
|
||||
type: 'number',
|
||||
placeholder: '7 (default)',
|
||||
},
|
||||
];
|
||||
export const getStudioClockOptions = (timeFormat: TimeFormat): ParamField[] => [
|
||||
getTimeOption(timeFormat),
|
||||
{
|
||||
id: 'seconds',
|
||||
title: 'Show Seconds',
|
||||
description: 'Shows seconds in clock',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
];
|
||||
return [
|
||||
{
|
||||
id: 'trigger',
|
||||
title: 'Animation Trigger',
|
||||
description: '',
|
||||
type: 'option',
|
||||
values: {
|
||||
event: 'Event Load',
|
||||
manual: 'Manual',
|
||||
},
|
||||
defaultValue: 'event',
|
||||
},
|
||||
{
|
||||
id: 'top-src',
|
||||
title: 'Top Text',
|
||||
description: '',
|
||||
type: 'option',
|
||||
values: topSourceOptions,
|
||||
defaultValue: 'title',
|
||||
},
|
||||
{
|
||||
id: 'bottom-src',
|
||||
title: 'Bottom Text',
|
||||
description: 'Select the data source for the bottom element',
|
||||
type: 'option',
|
||||
values: bottomSourceOptions,
|
||||
defaultValue: 'lowerMsg',
|
||||
},
|
||||
{
|
||||
id: 'top-colour',
|
||||
title: 'Top Text Colour',
|
||||
description: 'Top text colour in hexadecimal',
|
||||
prefix: '#',
|
||||
type: 'string',
|
||||
placeholder: '0000ff (default)',
|
||||
},
|
||||
{
|
||||
id: 'bottom-colour',
|
||||
title: 'Bottom Text Colour',
|
||||
description: 'Bottom text colour in hexadecimal',
|
||||
prefix: '#',
|
||||
type: 'string',
|
||||
placeholder: '0000ff (default)',
|
||||
},
|
||||
{
|
||||
id: 'top-bg',
|
||||
title: 'Top Background Colour',
|
||||
description: 'Top text background colour in hexadecimal',
|
||||
prefix: '#',
|
||||
type: 'string',
|
||||
placeholder: '00000000 (default)',
|
||||
},
|
||||
{
|
||||
id: 'bottom-bg',
|
||||
title: 'Bottom Background Colour',
|
||||
description: 'Bottom text background colour in hexadecimal',
|
||||
prefix: '#',
|
||||
type: 'string',
|
||||
placeholder: '00000000 (default)',
|
||||
},
|
||||
{
|
||||
id: 'top-size',
|
||||
title: 'Top Text Size',
|
||||
description: 'Font size of the top text',
|
||||
type: 'string',
|
||||
placeholder: '65px',
|
||||
},
|
||||
{
|
||||
id: 'bottom-size',
|
||||
title: 'Bottom Text Size',
|
||||
description: 'Font size of the bottom text',
|
||||
type: 'string',
|
||||
placeholder: '64px',
|
||||
},
|
||||
{
|
||||
id: 'width',
|
||||
title: 'Minimum Width',
|
||||
description: 'Minimum Width of the element',
|
||||
type: 'number',
|
||||
prefix: '%',
|
||||
placeholder: '45 (default)',
|
||||
},
|
||||
{
|
||||
id: 'transition',
|
||||
title: 'Transition',
|
||||
description: 'Transition in time in seconds (default 3)',
|
||||
type: 'number',
|
||||
placeholder: '3 (default)',
|
||||
},
|
||||
{
|
||||
id: 'delay',
|
||||
title: 'Delay',
|
||||
description: 'Delay between transition in and out in seconds (default 3)',
|
||||
type: 'number',
|
||||
placeholder: '3 (default)',
|
||||
},
|
||||
{
|
||||
id: 'key',
|
||||
title: 'Key Colour',
|
||||
description: 'Colour of the background',
|
||||
prefix: '#',
|
||||
type: 'string',
|
||||
placeholder: 'ffffffff (default)',
|
||||
},
|
||||
{
|
||||
id: 'line-colour',
|
||||
title: 'Line Colour',
|
||||
description: 'Colour of the line',
|
||||
prefix: '#',
|
||||
type: 'string',
|
||||
placeholder: 'ff0000ff (default)',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const getBackstageOptions = (timeFormat: string, customFields: CustomFields): ParamField[] => {
|
||||
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
|
||||
|
||||
export const getOperatorOptions = (userFields: UserFields, timeFormat: TimeFormat): ParamField[] => {
|
||||
return [
|
||||
getTimeOption(timeFormat),
|
||||
{
|
||||
id: 'showseconds',
|
||||
title: 'Show seconds',
|
||||
description: 'Schedule shows hh:mm:ss',
|
||||
id: 'hidePast',
|
||||
title: 'Hide past events',
|
||||
description: 'Scheduler will only show upcoming events',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
id: 'stopCycle',
|
||||
title: 'Stop cycling through event pages',
|
||||
description: 'Schedule will not auto-cycle through events',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
id: 'eventsPerPage',
|
||||
title: 'Events per page',
|
||||
description: 'Sets the number of events on the page, can cause overlow',
|
||||
type: 'number',
|
||||
placeholder: '7 (default)',
|
||||
},
|
||||
{
|
||||
id: 'secondary-src',
|
||||
title: 'Event secondary text',
|
||||
description: 'Select the data source for auxiliary text shown in now and next cards',
|
||||
type: 'option',
|
||||
values: secondaryOptions,
|
||||
defaultValue: '',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const getPublicOptions = (timeFormat: string, customFields: CustomFields): ParamField[] => {
|
||||
const secondaryOptions = makeOptionsFromCustomFields(customFields);
|
||||
|
||||
return [
|
||||
getTimeOption(timeFormat),
|
||||
{
|
||||
id: 'hidePast',
|
||||
title: 'Hide past events',
|
||||
description: 'Scheduler will only show upcoming events',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
id: 'stopCycle',
|
||||
title: 'Stop cycling through event pages',
|
||||
description: 'Schedule will not auto-cycle through events',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
id: 'eventsPerPage',
|
||||
title: 'Events per page',
|
||||
description: 'Sets the number of events on the page, can cause overlow',
|
||||
type: 'number',
|
||||
placeholder: '7 (default)',
|
||||
},
|
||||
{
|
||||
id: 'secondary-src',
|
||||
title: 'Event secondary text',
|
||||
description: 'Select the data source for auxiliary text shown in now and next cards',
|
||||
type: 'option',
|
||||
values: secondaryOptions,
|
||||
defaultValue: '',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const getStudioClockOptions = (timeFormat: string): ParamField[] => [
|
||||
getTimeOption(timeFormat),
|
||||
hideTimerSeconds,
|
||||
];
|
||||
|
||||
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ParamField[] => {
|
||||
const fieldOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
|
||||
|
||||
const customFieldSelect = Object.keys(customFields).reduce((acc, key) => {
|
||||
return { ...acc, [key]: `Custom: ${capitaliseFirstLetter(key)}` };
|
||||
}, {});
|
||||
|
||||
return [
|
||||
getTimeOption(timeFormat),
|
||||
{
|
||||
id: 'hidepast',
|
||||
title: 'Hide Past Events',
|
||||
@@ -345,47 +472,33 @@ export const getOperatorOptions = (userFields: UserFields, timeFormat: TimeForma
|
||||
title: 'Main data field',
|
||||
description: 'Field to be shown in the first line of text',
|
||||
type: 'option',
|
||||
values: {
|
||||
title: 'Title',
|
||||
subtitle: 'Subtitle',
|
||||
presenter: 'Presenter',
|
||||
},
|
||||
values: fieldOptions,
|
||||
defaultValue: 'title',
|
||||
},
|
||||
{
|
||||
id: 'secondary',
|
||||
title: 'Secondary data field',
|
||||
description: 'Field to be shown in the second line of text',
|
||||
type: 'option',
|
||||
values: {
|
||||
title: 'Title',
|
||||
subtitle: 'Subtitle',
|
||||
presenter: 'Presenter',
|
||||
},
|
||||
values: fieldOptions,
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
id: 'subscribe',
|
||||
title: 'Highlight Field',
|
||||
description: 'Choose a field to highlight',
|
||||
description: 'Choose a custom field to highlight',
|
||||
type: 'option',
|
||||
values: {
|
||||
user0: userFields.user0 || 'user0',
|
||||
user1: userFields.user1 || 'user1',
|
||||
user2: userFields.user2 || 'user2',
|
||||
user3: userFields.user3 || 'user3',
|
||||
user4: userFields.user4 || 'user4',
|
||||
user5: userFields.user5 || 'user5',
|
||||
user6: userFields.user6 || 'user6',
|
||||
user7: userFields.user7 || 'user7',
|
||||
user8: userFields.user8 || 'user8',
|
||||
user9: userFields.user9 || 'user9',
|
||||
},
|
||||
values: customFieldSelect,
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
id: 'shouldEdit',
|
||||
title: 'Edit user field',
|
||||
description: 'Allows editing an events user field by long pressing on it. Needs a selected highlighted field',
|
||||
title: 'Edit custom field',
|
||||
description: 'Allows editing an events selected custom field by long pressing.',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const getCountdownOptions = (timeFormat: string): ParamField[] => [getTimeOption(timeFormat), hideTimerSeconds];
|
||||
|
||||
@@ -26,7 +26,6 @@ export const AppContextProvider = ({ children }: PropsWithChildren) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'pending') return;
|
||||
if (!data) return;
|
||||
const previousEditor = sessionStorage.getItem(storageKeys.editor);
|
||||
|
||||
if (previousEditor && previousEditor === data.editorKey) {
|
||||
@@ -53,10 +52,6 @@ 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) {
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { ALIASES } from '../api/apiConstants';
|
||||
import { getAliases } from '../api/ontimeApi';
|
||||
|
||||
export default function useAliases() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: ALIASES,
|
||||
queryFn: getAliases,
|
||||
placeholderData: [],
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
return { data, status, isFetching, isError, refetch };
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { CustomFields } from 'ontime-types';
|
||||
|
||||
import { queryRefetchInterval } from '../../ontimeConfig';
|
||||
import { CUSTOM_FIELDS } from '../api/constants';
|
||||
import { getCustomFields } from '../api/customFields';
|
||||
|
||||
const placeholder: CustomFields = {};
|
||||
|
||||
export default function useCustomFields() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: CUSTOM_FIELDS,
|
||||
queryFn: getCustomFields,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchInterval,
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
return { data: data ?? placeholder, status, isFetching, isError, refetch };
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { HttpSettings } from 'ontime-types';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { HTTP_SETTINGS } from '../api/apiConstants';
|
||||
import { logAxiosError } from '../api/apiUtils';
|
||||
import { getHTTP, postHTTP } from '../api/ontimeApi';
|
||||
import { HTTP_SETTINGS } from '../api/constants';
|
||||
import { getHTTP, postHTTP } from '../api/http';
|
||||
import { logAxiosError } from '../api/utils';
|
||||
import { httpPlaceholder } from '../models/Http';
|
||||
import { ontimeQueryClient } from '../queryClient';
|
||||
|
||||
@@ -12,21 +11,23 @@ export function useHttpSettings() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: HTTP_SETTINGS,
|
||||
queryFn: getHTTP,
|
||||
placeholderData: httpPlaceholder,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt: number) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
// we need to jump through some hoops because of the type op port
|
||||
return { data: data! as unknown as HttpSettings, status, isFetching, isError, refetch };
|
||||
return { data: data ?? httpPlaceholder, status, isFetching, isError, refetch };
|
||||
}
|
||||
|
||||
export function usePostHttpSettings() {
|
||||
const { isPending, mutateAsync } = useMutation({
|
||||
mutationFn: postHTTP,
|
||||
onError: (error) => logAxiosError('Error saving HTTP settings', error),
|
||||
onSuccess: (res) => {
|
||||
ontimeQueryClient.setQueryData(HTTP_SETTINGS, res.data);
|
||||
},
|
||||
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: HTTP_SETTINGS }),
|
||||
});
|
||||
return { isPending, mutateAsync };
|
||||
|
||||
@@ -2,20 +2,20 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { GetInfo } from 'ontime-types';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { APP_INFO } from '../api/apiConstants';
|
||||
import { getInfo } from '../api/ontimeApi';
|
||||
import { APP_INFO } from '../api/constants';
|
||||
import { getInfo } from '../api/db';
|
||||
import { ontimePlaceholderInfo } from '../models/Info';
|
||||
|
||||
export default function useInfo() {
|
||||
const { data, status, isError, refetch, isFetching } = useQuery<GetInfo>({
|
||||
queryKey: APP_INFO,
|
||||
queryFn: getInfo,
|
||||
placeholderData: ontimePlaceholderInfo,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
return { data, status, isError, refetch, isFetching };
|
||||
return { data: data ?? ontimePlaceholderInfo, status, isError, refetch, isFetching };
|
||||
}
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { OSC_SETTINGS } from '../api/apiConstants';
|
||||
import { logAxiosError } from '../api/apiUtils';
|
||||
import { getOSC, postOSC, postOscSubscriptions } from '../api/ontimeApi';
|
||||
import { OSC_SETTINGS } from '../api/constants';
|
||||
import { getOSC, postOSC } from '../api/osc';
|
||||
import { logAxiosError } from '../api/utils';
|
||||
import { oscPlaceholderSettings } from '../models/OscSettings';
|
||||
import { ontimeQueryClient } from '../queryClient';
|
||||
|
||||
export default function useOscSettings() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: OSC_SETTINGS,
|
||||
queryFn: async () => {
|
||||
const oscData = await getOSC();
|
||||
return { ...oscData, portIn: String(oscData.portIn), portOut: String(oscData.portOut) };
|
||||
},
|
||||
placeholderData: oscPlaceholderSettings,
|
||||
queryFn: getOSC,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt: number) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
@@ -28,16 +25,9 @@ export function useOscSettingsMutation() {
|
||||
const { isPending, mutateAsync } = useMutation({
|
||||
mutationFn: postOSC,
|
||||
onError: (error) => logAxiosError('Error saving OSC settings', error),
|
||||
onSuccess: (res) => ontimeQueryClient.setQueryData(OSC_SETTINGS, res.data),
|
||||
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: OSC_SETTINGS }),
|
||||
});
|
||||
return { isPending, mutateAsync };
|
||||
}
|
||||
|
||||
export function usePostOscSubscriptions() {
|
||||
const { isPending, mutateAsync } = useMutation({
|
||||
mutationFn: postOscSubscriptions,
|
||||
onError: (error) => logAxiosError('Error saving OSC settings', error),
|
||||
onSuccess: (res) => {
|
||||
ontimeQueryClient.setQueryData(OSC_SETTINGS, res.data);
|
||||
},
|
||||
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: OSC_SETTINGS }),
|
||||
});
|
||||
return { isPending, mutateAsync };
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { PROJECT_DATA } from '../api/apiConstants';
|
||||
import { getProjectData } from '../api/projectDataApi';
|
||||
import { PROJECT_DATA } from '../api/constants';
|
||||
import { getProjectData } from '../api/project';
|
||||
import { projectDataPlaceholder } from '../models/ProjectData';
|
||||
|
||||
export default function useProjectData() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: PROJECT_DATA,
|
||||
queryFn: getProjectData,
|
||||
placeholderData: projectDataPlaceholder,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
return { data, status, isFetching, isError, refetch };
|
||||
return { data: data ?? projectDataPlaceholder, status, isFetching, isError, refetch };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ProjectFileListResponse } from 'ontime-types';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { PROJECT_LIST } from '../api/constants';
|
||||
import { getProjects } from '../api/db';
|
||||
|
||||
const placeholderProjectList: ProjectFileListResponse = {
|
||||
files: [],
|
||||
lastLoadedProject: '',
|
||||
};
|
||||
|
||||
export function useProjectList() {
|
||||
const { data, status, refetch } = useQuery({
|
||||
queryKey: PROJECT_LIST,
|
||||
queryFn: getProjects,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt: number) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
networkMode: 'always',
|
||||
});
|
||||
return { data: data ?? placeholderProjectList, status, refetch };
|
||||
}
|
||||
@@ -1,29 +1,41 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { GetRundownCached } from 'ontime-types';
|
||||
import { NormalisedRundown, OntimeRundown, RundownCached } from 'ontime-types';
|
||||
|
||||
import { queryRefetchInterval } from '../../ontimeConfig';
|
||||
import { RUNDOWN } from '../api/apiConstants';
|
||||
import { fetchCachedRundown } from '../api/eventsApi';
|
||||
import { RUNDOWN } from '../api/constants';
|
||||
import { fetchNormalisedRundown } from '../api/rundown';
|
||||
|
||||
const cachedRundownPlaceholder = { rundown: [], revision: -1 };
|
||||
// revision is -1 so that the remote revision is higher
|
||||
const cachedRundownPlaceholder = { order: [] as string[], rundown: {} as NormalisedRundown, revision: -1 };
|
||||
|
||||
// TODO: can we leverage structural sharing to see if data has changed?
|
||||
export default function useRundown() {
|
||||
const { data, status, isError, refetch, isFetching } = useQuery<GetRundownCached>({
|
||||
const { data, status, isError, refetch, isFetching } = useQuery<RundownCached>({
|
||||
queryKey: RUNDOWN,
|
||||
queryFn: fetchCachedRundown,
|
||||
placeholderData: cachedRundownPlaceholder,
|
||||
queryFn: fetchNormalisedRundown,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchInterval,
|
||||
networkMode: 'always',
|
||||
// structuralSharing: (oldData: GetRundownCached | undefined, newData: GetRundownCached) => {
|
||||
// if (oldData === undefined) {
|
||||
// return cachedRundownPlaceholder;
|
||||
// }
|
||||
// const hasDataChanged = oldData?.revision === newData.revision;
|
||||
// return hasDataChanged ? oldData : newData;
|
||||
// },
|
||||
});
|
||||
return { data: data?.rundown ?? [], status, isError, refetch, isFetching };
|
||||
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
|
||||
}
|
||||
|
||||
export function useFlatRundown() {
|
||||
const { data, status } = useRundown();
|
||||
|
||||
const [prevRevision, setPrevRevision] = useState<number>(-1);
|
||||
const [flatRunDown, setFlatRunDown] = useState<OntimeRundown>([]);
|
||||
|
||||
// update data whenever the revision changes
|
||||
useEffect(() => {
|
||||
if (data.revision !== -1 && data.revision !== prevRevision) {
|
||||
const flatRundown = data.order.map((id) => data.rundown[id]);
|
||||
setFlatRunDown(flatRundown);
|
||||
setPrevRevision(data.revision);
|
||||
}
|
||||
}, [data.order, data.revision, data.rundown, prevRevision]);
|
||||
|
||||
return { data: flatRunDown, status };
|
||||
}
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { unobfuscate } from 'ontime-utils';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { APP_SETTINGS } from '../api/apiConstants';
|
||||
import { getSettings } from '../api/ontimeApi';
|
||||
import { APP_SETTINGS } from '../api/constants';
|
||||
import { getSettings } from '../api/settings';
|
||||
import { ontimePlaceholderSettings } from '../models/OntimeSettings';
|
||||
|
||||
export default function useSettings() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: APP_SETTINGS,
|
||||
queryFn: getSettings,
|
||||
placeholderData: ontimePlaceholderSettings,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
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, status, isFetching, isError, refetch };
|
||||
return { data: data ?? ontimePlaceholderSettings, status, isFetching, isError, refetch };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { URL_PRESETS } from '../api/constants';
|
||||
import { getUrlPresets } from '../api/urlPresets';
|
||||
|
||||
export default function useUrlPresets() {
|
||||
const { data, status, isError, refetch } = useQuery({
|
||||
queryKey: URL_PRESETS,
|
||||
queryFn: getUrlPresets,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
return { data: data ?? [], status, isError, refetch };
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { queryRefetchInterval } from '../../ontimeConfig';
|
||||
import { USERFIELDS } from '../api/apiConstants';
|
||||
import { getUserFields } from '../api/ontimeApi';
|
||||
import { userFieldsPlaceholder } from '../models/UserFields';
|
||||
|
||||
export default function useUserFields() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: USERFIELDS,
|
||||
queryFn: getUserFields,
|
||||
placeholderData: userFieldsPlaceholder,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchInterval,
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
return { data, status, isFetching, isError, refetch };
|
||||
}
|
||||
@@ -1,20 +1,20 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { VIEW_SETTINGS } from '../api/apiConstants';
|
||||
import { getView } from '../api/ontimeApi';
|
||||
import { VIEW_SETTINGS } from '../api/constants';
|
||||
import { getView } from '../api/viewSettings';
|
||||
import { viewsSettingsPlaceholder } from '../models/ViewSettings.type';
|
||||
|
||||
export default function useViewSettings() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: VIEW_SETTINGS,
|
||||
queryFn: getView,
|
||||
placeholderData: viewsSettingsPlaceholder,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
return { data, status, isError, refetch, isFetching };
|
||||
return { data: data ?? viewsSettingsPlaceholder, status, isError, refetch, isFetching };
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { GetRundownCached, isOntimeEvent, OntimeRundownEntry } from 'ontime-types';
|
||||
import { getCueCandidate, swapOntimeEvents } from 'ontime-utils';
|
||||
import { isOntimeEvent, MaybeString, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
|
||||
import { getLinkedTimes, getPreviousEventNormal, reorderArray, swapEventData } from 'ontime-utils';
|
||||
|
||||
import { RUNDOWN } from '../api/apiConstants';
|
||||
import { logAxiosError } from '../api/apiUtils';
|
||||
import { RUNDOWN } from '../api/constants';
|
||||
import {
|
||||
ReorderEntry,
|
||||
requestApplyDelay,
|
||||
requestBatchPutEvents,
|
||||
requestDelete,
|
||||
requestDeleteAll,
|
||||
requestEventSwap,
|
||||
@@ -15,25 +15,23 @@ import {
|
||||
requestPutEvent,
|
||||
requestReorderEvent,
|
||||
SwapEntry,
|
||||
} from '../api/eventsApi';
|
||||
} from '../api/rundown';
|
||||
import { logAxiosError } from '../api/utils';
|
||||
import { useEditorSettings } from '../stores/editorSettings';
|
||||
import { forgivingStringToMillis } from '../utils/dateConfig';
|
||||
|
||||
/**
|
||||
* @description Set of utilities for events
|
||||
*/
|
||||
export const useEventAction = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const eventSettings = useEditorSettings((state) => state.eventSettings);
|
||||
const defaultPublic = eventSettings.defaultPublic;
|
||||
const startTimeIsLastEnd = eventSettings.startTimeIsLastEnd;
|
||||
const { defaultPublic, linkPrevious, defaultDuration } = useEditorSettings((state) => state.eventSettings);
|
||||
|
||||
/**
|
||||
* Calls mutation to add new event
|
||||
* @private
|
||||
*/
|
||||
const _addEventMutation = useMutation({
|
||||
// Mutation finished, failed or successful
|
||||
// Fetch anyway, just to be sure
|
||||
mutationFn: requestPostEvent,
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||
@@ -45,11 +43,12 @@ export const useEventAction = () => {
|
||||
after?: string;
|
||||
};
|
||||
|
||||
type EventOptions = BaseOptions & {
|
||||
defaultPublic?: boolean;
|
||||
lastEventId?: string;
|
||||
startTimeIsLastEnd?: boolean;
|
||||
};
|
||||
type EventOptions = BaseOptions &
|
||||
Partial<{
|
||||
defaultPublic: boolean;
|
||||
linkPrevious: boolean;
|
||||
lastEventId: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Adds an event to rundown
|
||||
@@ -61,35 +60,31 @@ export const useEventAction = () => {
|
||||
// ************* CHECK OPTIONS specific to events
|
||||
if (isOntimeEvent(newEvent)) {
|
||||
const applicationOptions = {
|
||||
defaultPublic: options?.defaultPublic ?? defaultPublic,
|
||||
startTimeIsLastEnd: options?.startTimeIsLastEnd ?? startTimeIsLastEnd,
|
||||
lastEventId: options?.lastEventId,
|
||||
after: options?.after,
|
||||
defaultPublic: options?.defaultPublic ?? defaultPublic,
|
||||
lastEventId: options?.lastEventId,
|
||||
linkPrevious: options?.linkPrevious ?? linkPrevious,
|
||||
};
|
||||
|
||||
const rundown = queryClient.getQueryData<GetRundownCached>(RUNDOWN)?.rundown ?? [];
|
||||
|
||||
if (newEvent?.cue === undefined) {
|
||||
newEvent.cue = getCueCandidate(rundown, options?.after);
|
||||
}
|
||||
|
||||
// hard coding duration value to be as expected for now
|
||||
// this until timeOptions gets implemented
|
||||
if (newEvent?.timeStart !== undefined && newEvent.timeEnd !== undefined) {
|
||||
newEvent.duration = Math.max(0, newEvent?.timeEnd - newEvent?.timeStart) || 0;
|
||||
}
|
||||
|
||||
if (applicationOptions.startTimeIsLastEnd && applicationOptions?.lastEventId) {
|
||||
const previousEvent = rundown.find((event) => event.id === applicationOptions.lastEventId);
|
||||
if (previousEvent !== undefined && previousEvent.type === 'event') {
|
||||
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;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// handle adding options that concern all event type
|
||||
@@ -98,13 +93,12 @@ export const useEventAction = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
// @ts-expect-error -- we know that the object is well formed now
|
||||
await _addEventMutation.mutateAsync(newEvent);
|
||||
} catch (error) {
|
||||
logAxiosError('Failed adding event', error);
|
||||
}
|
||||
},
|
||||
[_addEventMutation, defaultPublic, queryClient, startTimeIsLastEnd],
|
||||
[_addEventMutation, defaultDuration, defaultPublic, linkPrevious],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -119,19 +113,15 @@ export const useEventAction = () => {
|
||||
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
|
||||
const eventId = newEvent.id;
|
||||
|
||||
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
|
||||
|
||||
if (previousData) {
|
||||
if (previousData && eventId) {
|
||||
// optimistically update object
|
||||
const optimisticRundown = [...previousData.rundown];
|
||||
const index = optimisticRundown.findIndex((event) => event.id === newEvent.id);
|
||||
if (index > -1) {
|
||||
// @ts-expect-error -- we expect the event types to match
|
||||
optimisticRundown[index] = { ...optimisticRundown[index], ...newEvent };
|
||||
|
||||
queryClient.setQueryData(RUNDOWN, { rundown: optimisticRundown, revision: -1 });
|
||||
}
|
||||
const newRundown = { ...previousData.rundown };
|
||||
// @ts-expect-error -- we expect the events to be of same type
|
||||
newRundown[eventId] = { ...newRundown[eventId], ...newEvent };
|
||||
queryClient.setQueryData(RUNDOWN, { order: previousData.order, rundown: newRundown, revision: -1 });
|
||||
}
|
||||
|
||||
// Return a context with the previous and new events
|
||||
@@ -163,6 +153,161 @@ export const useEventAction = () => {
|
||||
[_updateEventMutation],
|
||||
);
|
||||
|
||||
const updateCustomField = useCallback(
|
||||
async (eventId: string, field: string, value: string) => {
|
||||
updateEvent({ id: eventId, custom: { [field]: { value } } });
|
||||
},
|
||||
[updateEvent],
|
||||
);
|
||||
|
||||
type TimeField = 'timeStart' | 'timeEnd' | 'duration';
|
||||
/**
|
||||
* Updates time of existing event
|
||||
*/
|
||||
const updateTimer = useCallback(
|
||||
async (eventId: string, field: TimeField, value: string) => {
|
||||
const getPreviousEnd = (): number => {
|
||||
const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN);
|
||||
|
||||
if (!cachedRundown?.order || !cachedRundown?.rundown) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const index = cachedRundown.order.indexOf(eventId);
|
||||
if (index === 0) {
|
||||
return 0;
|
||||
}
|
||||
let previousEnd = 0;
|
||||
for (let i = index - 1; i >= 0; i--) {
|
||||
const event = cachedRundown.rundown[cachedRundown.order[i]];
|
||||
if (isOntimeEvent(event)) {
|
||||
previousEnd = event.timeEnd;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return previousEnd;
|
||||
};
|
||||
|
||||
let newValMillis = 0;
|
||||
|
||||
// check for previous keyword
|
||||
if (value === 'p' || value === 'prev' || value === 'previous') {
|
||||
newValMillis = getPreviousEnd();
|
||||
|
||||
// check for adding time keyword
|
||||
} else if (value.startsWith('+') || value.startsWith('p+') || value.startsWith('p +')) {
|
||||
// TODO: is this logic solid?
|
||||
const remainingString = value.substring(1);
|
||||
newValMillis = getPreviousEnd() + forgivingStringToMillis(remainingString);
|
||||
} else {
|
||||
newValMillis = forgivingStringToMillis(value);
|
||||
}
|
||||
|
||||
const newEvent = {
|
||||
id: eventId,
|
||||
[field]: newValMillis,
|
||||
};
|
||||
try {
|
||||
await _updateEventMutation.mutateAsync(newEvent);
|
||||
} catch (error) {
|
||||
logAxiosError('Error updating event', error);
|
||||
}
|
||||
},
|
||||
[_updateEventMutation, queryClient],
|
||||
);
|
||||
|
||||
/**
|
||||
* Toggles link of an event to the previous
|
||||
*/
|
||||
const linkTimer = useCallback(
|
||||
async (eventId: string, linkStart: MaybeString) => {
|
||||
let newEvent: Partial<OntimeEvent> = { id: eventId };
|
||||
|
||||
if (!linkStart) {
|
||||
newEvent.linkStart = null;
|
||||
} else {
|
||||
const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN);
|
||||
if (!cachedRundown) {
|
||||
return;
|
||||
}
|
||||
const currentEvent = cachedRundown.rundown[eventId] as OntimeEvent;
|
||||
if (!isOntimeEvent(currentEvent)) {
|
||||
return;
|
||||
}
|
||||
const { previousEvent } = getPreviousEventNormal(cachedRundown.rundown, cachedRundown.order, eventId);
|
||||
|
||||
if (!previousEvent) {
|
||||
newEvent.linkStart = null;
|
||||
} else {
|
||||
newEvent.linkStart = previousEvent.id;
|
||||
const timePatch = getLinkedTimes(currentEvent, previousEvent);
|
||||
newEvent = { ...newEvent, ...timePatch };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await _updateEventMutation.mutateAsync(newEvent);
|
||||
} catch (error) {
|
||||
logAxiosError('Error updating event', error);
|
||||
}
|
||||
},
|
||||
[_updateEventMutation, queryClient],
|
||||
);
|
||||
|
||||
/**
|
||||
* Calls mutation to edit multiple events
|
||||
* @private
|
||||
*/
|
||||
const _batchUpdateEventsMutation = useMutation({
|
||||
mutationFn: requestBatchPutEvents,
|
||||
onMutate: async ({ ids, data }) => {
|
||||
// cancel ongoing queries
|
||||
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousEvents = queryClient.getQueryData<RundownCached>(RUNDOWN);
|
||||
|
||||
if (previousEvents) {
|
||||
const eventIds = new Set(ids);
|
||||
const newRundown = { ...previousEvents.rundown };
|
||||
|
||||
eventIds.forEach((eventId) => {
|
||||
if (Object.hasOwn(newRundown, eventId)) {
|
||||
const event = newRundown[eventId];
|
||||
if (isOntimeEvent(event)) {
|
||||
newRundown[eventId] = {
|
||||
...event,
|
||||
...data,
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
queryClient.setQueryData(RUNDOWN, { order: previousEvents.order, rundown: newRundown, revision: -1 });
|
||||
}
|
||||
// Return a context with the previous and new events
|
||||
return { previousEvents };
|
||||
},
|
||||
onSettled: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||
},
|
||||
onError: (_error, _newEvent, context) => {
|
||||
queryClient.setQueryData(RUNDOWN, context?.previousEvents);
|
||||
},
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
const batchUpdateEvents = useCallback(
|
||||
async (data: Partial<OntimeEvent>, eventIds: string[]) => {
|
||||
try {
|
||||
await _batchUpdateEventsMutation.mutateAsync({ ids: eventIds, data });
|
||||
} catch (error) {
|
||||
logAxiosError('Error updating events', error);
|
||||
}
|
||||
},
|
||||
[_batchUpdateEventsMutation],
|
||||
);
|
||||
|
||||
/**
|
||||
* Calls mutation to delete an event
|
||||
* @private
|
||||
@@ -175,20 +320,19 @@ export const useEventAction = () => {
|
||||
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
|
||||
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
|
||||
|
||||
if (previousData) {
|
||||
// optimistically update object
|
||||
const optimisticRundown = [...previousData.rundown];
|
||||
const index = optimisticRundown.findIndex((event) => event.id === eventId);
|
||||
if (index > -1) {
|
||||
optimisticRundown.splice(index, 1);
|
||||
const newOrder = previousData.order.filter((id) => id !== eventId);
|
||||
const newRundown = { ...previousData.rundown };
|
||||
delete newRundown[eventId];
|
||||
|
||||
queryClient.setQueryData(RUNDOWN, {
|
||||
rundown: optimisticRundown,
|
||||
revision: -1,
|
||||
});
|
||||
}
|
||||
queryClient.setQueryData(RUNDOWN, {
|
||||
order: newOrder,
|
||||
rundown: newRundown,
|
||||
revision: -1,
|
||||
});
|
||||
}
|
||||
|
||||
// Return a context with the previous and new events
|
||||
@@ -233,10 +377,10 @@ export const useEventAction = () => {
|
||||
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
|
||||
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
|
||||
|
||||
// optimistically update object
|
||||
queryClient.setQueryData(RUNDOWN, { rundown: [], revision: -1 });
|
||||
queryClient.setQueryData(RUNDOWN, { rundown: {}, order: [], revision: -1 });
|
||||
|
||||
// Return a context with the previous and new events
|
||||
return { previousData };
|
||||
@@ -304,15 +448,13 @@ export const useEventAction = () => {
|
||||
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
|
||||
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
|
||||
|
||||
if (previousData) {
|
||||
// optimistically update object
|
||||
const optimisticRundown = [...previousData.rundown];
|
||||
const [reorderedItem] = optimisticRundown.splice(data.from, 1);
|
||||
optimisticRundown.splice(data.to, 0, reorderedItem);
|
||||
const newOrder = reorderArray(previousData.order, data.from, data.to);
|
||||
|
||||
queryClient.setQueryData(RUNDOWN, { rundown: optimisticRundown, revision: -1 });
|
||||
queryClient.setQueryData(RUNDOWN, { order: newOrder, rundown: previousData.rundown, revision: -1 });
|
||||
}
|
||||
|
||||
// Return a context with the previous and new events
|
||||
@@ -338,9 +480,9 @@ export const useEventAction = () => {
|
||||
async (eventId: string, from: number, to: number) => {
|
||||
try {
|
||||
const reorderObject: ReorderEntry = {
|
||||
eventId: eventId,
|
||||
from: from,
|
||||
to: to,
|
||||
eventId,
|
||||
from,
|
||||
to,
|
||||
};
|
||||
await _reorderEventMutation.mutateAsync(reorderObject);
|
||||
} catch (error) {
|
||||
@@ -362,15 +504,22 @@ export const useEventAction = () => {
|
||||
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
|
||||
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
|
||||
if (previousData) {
|
||||
// optimistically update object
|
||||
const fromEventIndex = previousData.rundown.findIndex((event) => event.id === from);
|
||||
const toEventIndex = previousData.rundown.findIndex((event) => event.id === to);
|
||||
const newRundown = { ...previousData.rundown };
|
||||
const eventA = previousData.rundown[from];
|
||||
const eventB = previousData.rundown[to];
|
||||
|
||||
const optimisticRundown = swapOntimeEvents(previousData.rundown, fromEventIndex, toEventIndex);
|
||||
if (!isOntimeEvent(eventA) || !isOntimeEvent(eventB)) {
|
||||
return;
|
||||
}
|
||||
|
||||
queryClient.setQueryData(RUNDOWN, { rundown: optimisticRundown, revision: -1 });
|
||||
const { newA, newB } = swapEventData(eventA, eventB);
|
||||
newRundown[from] = newA;
|
||||
newRundown[to] = newB;
|
||||
|
||||
queryClient.setQueryData(RUNDOWN, { order: previousData.order, rundown: newRundown, revision: -1 });
|
||||
}
|
||||
|
||||
// Return a context with the previous events
|
||||
@@ -394,8 +543,6 @@ export const useEventAction = () => {
|
||||
*/
|
||||
const swapEvents = useCallback(
|
||||
async ({ from, to }: SwapEntry) => {
|
||||
// TODO: before calling `/swapEvents`,
|
||||
// we should determine the events are of type `OntimeEvent`
|
||||
try {
|
||||
await _swapEvents.mutateAsync({ from, to });
|
||||
} catch (error) {
|
||||
@@ -407,11 +554,15 @@ export const useEventAction = () => {
|
||||
|
||||
return {
|
||||
addEvent,
|
||||
updateEvent,
|
||||
applyDelay,
|
||||
batchUpdateEvents,
|
||||
deleteEvent,
|
||||
deleteAllEvents,
|
||||
applyDelay,
|
||||
linkTimer,
|
||||
reorderEvent,
|
||||
swapEvents,
|
||||
updateEvent,
|
||||
updateTimer,
|
||||
updateCustomField,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
// 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 };
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user