Compare commits
309 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e5a34ff03f | |||
| aa4ee546ec | |||
| 0abaf7724d | |||
| 7b932ef0f7 | |||
| 8bd7645c95 | |||
| 81f0e61953 | |||
| 3877e207d0 | |||
| d78e8838c3 | |||
| 16c7107069 | |||
| e20cd87673 | |||
| db5fbd8019 | |||
| 25aefe322b | |||
| 73db14c383 | |||
| f5936e5254 | |||
| 25c7915cf7 | |||
| c43b6f37f9 | |||
| 6e5034ef72 | |||
| 66774321a5 | |||
| d71df886f2 | |||
| abcee741e2 | |||
| ee1b5b7fdd | |||
| 1b1823e0fe | |||
| 340f5478fb | |||
| f708dc0bc4 | |||
| 49467c66b7 | |||
| b539b2ecd5 | |||
| dc06a9afc3 | |||
| d3620b52f4 | |||
| 66ed92e215 | |||
| 347f333b3e | |||
| c3fb3061f6 | |||
| a26aa9e01c | |||
| da11ef64c2 | |||
| 3012d73285 | |||
| bad491063d | |||
| a103f8b347 | |||
| dffccd258f | |||
| ce8b0d9850 | |||
| e500fa6b5e | |||
| 67be4a5631 | |||
| c7893938eb | |||
| 158ef05ff0 | |||
| 20838c038a | |||
| 1ddcec993a | |||
| 91d6adf8e0 | |||
| a7fe5eceef | |||
| e6e00c0d4a | |||
| edc7f20d7a | |||
| cb773ded9f | |||
| 2f65711078 | |||
| 3056b75960 | |||
| dd9423abf4 | |||
| dee6a67018 | |||
| a79fb4ecbb | |||
| a53a1f87be | |||
| ef9afcbf3e | |||
| 33b04e01c9 | |||
| c5e821df35 | |||
| 0fd71c9a00 | |||
| 53b453abc8 | |||
| fe0665506e | |||
| 9bd600c4b1 | |||
| bbd4254637 | |||
| 2603c777ef | |||
| 8942a4ba4f | |||
| f97be749dc | |||
| 5836d5ffc7 | |||
| 91f21fdeb1 | |||
| 844eec132b | |||
| adc4b3f8bf | |||
| fb09631db9 | |||
| 19febb98a5 | |||
| 6bfa11dd8d | |||
| 4758bc84b5 | |||
| 917b182e39 | |||
| e6c3322321 | |||
| 00d34eec9b | |||
| 84792bc00d | |||
| dae32e89a3 | |||
| 367997fd16 | |||
| 751c3329f0 | |||
| 9c5e403b18 | |||
| c944e77bc1 | |||
| d94c1d4252 | |||
| 9f395cb16d | |||
| 46195fc043 | |||
| c9ef8d55c1 | |||
| ad0e821cc0 | |||
| 21454947e0 | |||
| ad69c0ff80 | |||
| 20d9df2501 | |||
| de9af5aaa2 | |||
| 61280b06b7 | |||
| ce4a48cd8d | |||
| 2877475a35 | |||
| 73e23ee323 | |||
| 938c5fbb4c | |||
| e90e94828d | |||
| 1f0401632e | |||
| 5c3989c9b6 | |||
| 6ad26f06c7 | |||
| a63fa68d39 | |||
| 20503de6fd | |||
| 0335da9786 | |||
| 9f1a64f53c | |||
| 975356f4f4 | |||
| 1b448835cd | |||
| cbf3d566b9 | |||
| 6c20cb5e99 | |||
| cfc3aab893 | |||
| b7c48f38a9 | |||
| dcb6bfa62d | |||
| 0abc4de3cd | |||
| f750ed3cdc | |||
| f026c1c1e3 | |||
| ce176b01a2 | |||
| 337dd7892a | |||
| b7daf2926a | |||
| 81a07099ae | |||
| 8f88ca5053 | |||
| 82a885dc44 | |||
| c4be814de5 | |||
| 9f3186e249 | |||
| 5159554697 | |||
| cf690aff08 | |||
| 48c4e5f429 | |||
| ad0da6def4 | |||
| a15eb0db4c | |||
| 70e8fbf327 | |||
| 1044dfac01 | |||
| b5e5c113e2 | |||
| cbcff5e7ab | |||
| 83cb2f82f5 | |||
| 837fd53dfd | |||
| 4962288a53 | |||
| 75a35e8537 | |||
| 6e839fcefb | |||
| 052a8e4ccb | |||
| 969c2bad14 | |||
| 9717222491 | |||
| 081c0bdac4 | |||
| 8b99e91883 | |||
| 4af2e688d1 | |||
| cf8c8d4b2e | |||
| 9e63261b35 | |||
| b1838a5e9a | |||
| 9d7b5568e4 | |||
| 657aed1244 | |||
| 13c49f3652 | |||
| 86b4ddadb5 | |||
| e9f806b88a | |||
| 4f672670ed | |||
| 8742b790f3 | |||
| d41cd78f2e | |||
| 715600a514 | |||
| b99cf5f255 | |||
| 9a9a0eb7cf | |||
| 4bf2422ebc | |||
| add769fe1e | |||
| 3af4c64ff3 | |||
| f405e65c55 | |||
| 0e32b3a3af | |||
| c2fa115946 | |||
| edb48a2fc7 | |||
| 6f7846bbf7 | |||
| 48ebd31c54 | |||
| 6d1a911ab8 | |||
| ee1753ef29 | |||
| 147e5f5a27 | |||
| a02b3faacc | |||
| a3b21072eb | |||
| 2076080c5f | |||
| 8b3abe9d61 | |||
| c2039866b4 | |||
| 73e4a15718 | |||
| c4359af2a0 | |||
| f75f45b19a | |||
| fd2eee32aa | |||
| a53d354b16 | |||
| ec55236eb2 | |||
| 3895b37572 | |||
| d8626ff324 | |||
| 6c3870c15b | |||
| 4fadb23b07 | |||
| 199ed1617f | |||
| 2d618e0657 | |||
| f928c1dc95 | |||
| 8d526c7d31 | |||
| 686c6108bf | |||
| ea3d33ca93 | |||
| 6bf94ff645 | |||
| 930cd71ffd | |||
| 45cf1cf199 | |||
| 354f464263 | |||
| c46a00febc | |||
| e991e151db | |||
| b5993b91e1 | |||
| 4beac19c4a | |||
| be852f4546 | |||
| 84e224dd71 | |||
| 7cbb2112ed | |||
| ce910c9cc6 | |||
| 9d04954d87 | |||
| 345ea6722e | |||
| f43221cf7a | |||
| a09aa922bb | |||
| 16f31a07b7 | |||
| 77d111650a | |||
| 72891a69f4 | |||
| b8d5a30caf | |||
| 9cbd4c085b | |||
| afe0323384 | |||
| 1907abc512 | |||
| 834aafe06d | |||
| 98418014d3 | |||
| 3d13e784ea | |||
| e397500d07 | |||
| c7c0241859 | |||
| 71a7fe96d1 | |||
| d0f958c13d | |||
| 7ee98fb062 | |||
| c2b66a4215 | |||
| 1d0ee10c00 | |||
| 248c952ec7 | |||
| 3cda6b222e | |||
| d161411416 | |||
| 97b8e6a290 | |||
| 036d4051fc | |||
| 7cefe07594 | |||
| d05cc88748 | |||
| 119e6caca3 | |||
| bb199128aa | |||
| dade6f6692 | |||
| 6bedd7ab1d | |||
| eb2c4d0780 | |||
| 4069038ed5 | |||
| ac430602b4 | |||
| 082449bede | |||
| cfbd540a14 | |||
| a431fab3c4 | |||
| 770d975610 | |||
| 6f95aac798 | |||
| dae6f84af0 | |||
| ad46fac2d7 | |||
| 37786c3528 | |||
| c6d36d2497 | |||
| 2394eced93 | |||
| 13aeeb278d | |||
| af5d6e1aee | |||
| bc036bdae7 | |||
| 266a84e5d3 | |||
| 92c3148e49 | |||
| efd05bcc34 | |||
| 3c7e061e51 | |||
| 07074260a4 | |||
| c57b0423af | |||
| 52145d5e1e | |||
| b2668e61a5 | |||
| 7da3480dfe | |||
| e8cfdbe3b9 | |||
| fd052355c3 | |||
| 00f04fb78c | |||
| 6f80de58ca | |||
| 5977266332 | |||
| a40fa0dbf8 | |||
| e92f59ac55 | |||
| 077c67e912 | |||
| 9644cbde92 | |||
| 8b0e03e980 | |||
| 0966659596 | |||
| 1123e29499 | |||
| 3d2248cceb | |||
| 043ba38916 | |||
| 5b6d4c5b28 | |||
| 07e8bedff4 | |||
| 6e61a7ecf9 | |||
| accdf2c396 | |||
| 5e39c9b51f | |||
| b52fb0f9ee | |||
| 54f30e69a1 | |||
| 189cee0104 | |||
| 9e04a1dcfa | |||
| 0c3c08ac98 | |||
| 95b437e18c | |||
| 75b5118b0f | |||
| dcbb92c470 | |||
| 8da8a08464 | |||
| 8b5034efbf | |||
| 74418ce17e | |||
| d8a8f0ad92 | |||
| 817ebcdd26 | |||
| c4f51192de | |||
| 5cf5b2fae3 | |||
| 8f249b9d51 | |||
| bef69e7d0f | |||
| dd81b92584 | |||
| b14ac07568 | |||
| c4136a0ec7 | |||
| cf14b0b427 | |||
| 16a169b22c | |||
| 8af5feb9af | |||
| 88b45fd952 | |||
| a8e9962eed | |||
| 70c93a69fe | |||
| 0ef1b44d9e | |||
| 84839824fb | |||
| b63e3ed8f3 | |||
| 7eab0bb300 | |||
| 55d1aca9b6 |
@@ -11,4 +11,13 @@
|
|||||||
|
|
||||||
# Ignore git and cache folders
|
# Ignore git and cache folders
|
||||||
.git
|
.git
|
||||||
|
.gitignore
|
||||||
.cache
|
.cache
|
||||||
|
|
||||||
|
# Ignore build folders
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Ignore default volumes created by running docker compose up
|
||||||
|
ontime-db
|
||||||
|
ontime-styles
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"rules": {
|
"rules": {
|
||||||
"no-useless-concat": "warn",
|
"no-useless-concat": "warn",
|
||||||
"prefer-template": "warn",
|
"prefer-template": "warn",
|
||||||
|
"no-throw-literal": "error",
|
||||||
"no-console": [
|
"no-console": [
|
||||||
"warn",
|
"warn",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
github: cpvalente
|
||||||
|
buy_me_a_coffee: cpvalente
|
||||||
|
Before Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 336 KiB |
|
Before Width: | Height: | Size: 660 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
@@ -1,9 +1,8 @@
|
|||||||
name: Ontime build v3
|
name: Ontime build
|
||||||
|
|
||||||
on:
|
on:
|
||||||
# Only trigger manually for now for testing
|
push:
|
||||||
# push:
|
tags: [ "*" ]
|
||||||
# tags: [ "*" ]
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -15,12 +14,12 @@ jobs:
|
|||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 18.18.2
|
node-version: 20
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v3
|
uses: pnpm/action-setup@v3
|
||||||
with:
|
with:
|
||||||
version: 8
|
version: 9
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
@@ -31,7 +30,14 @@ jobs:
|
|||||||
run: pnpm build
|
run: pnpm build
|
||||||
|
|
||||||
- name: Electron - Build app
|
- name: Electron - Build app
|
||||||
|
env:
|
||||||
|
APPLE_ID: ${{ secrets.APPLEID }}
|
||||||
|
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLEIDPASS }}
|
||||||
|
APPLE_TEAM_ID: ${{ secrets.TEAMID }}
|
||||||
|
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||||
|
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||||
run: pnpm dist-mac
|
run: pnpm dist-mac
|
||||||
|
timeout-minutes: 60
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v1
|
||||||
@@ -50,12 +56,12 @@ jobs:
|
|||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 18.18.2
|
node-version: 20
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v3
|
uses: pnpm/action-setup@v3
|
||||||
with:
|
with:
|
||||||
version: 8
|
version: 9
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
@@ -83,12 +89,12 @@ jobs:
|
|||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 18.18.2
|
node-version: 20
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v3
|
uses: pnpm/action-setup@v3
|
||||||
with:
|
with:
|
||||||
version: 8
|
version: 9
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
name: Ontime CLI build
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build_cli:
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Use Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build project packages
|
||||||
|
env:
|
||||||
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: Copy server
|
||||||
|
run: mkdir -p apps/cli/server && cp apps/server/dist/index.cjs apps/cli/server/index.cjs
|
||||||
|
|
||||||
|
- name: Copy client
|
||||||
|
run: cp -R apps/client/build apps/cli/client
|
||||||
|
|
||||||
|
- name: Copy external
|
||||||
|
run: cp -R apps/server/src/external apps/cli/external
|
||||||
|
|
||||||
|
- name: Publish to NPM
|
||||||
|
run: pnpm publish --access public --no-git-checks
|
||||||
|
env:
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
NPM_CONFIG_PROVENANCE: true
|
||||||
|
working-directory: ./apps/cli
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
name: Docker Image CI Ontime
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
|
||||||
|
publish_docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
CI: ''
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Docker Login
|
||||||
|
uses: docker/login-action@v2.1.0
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Docker Setup Buildx
|
||||||
|
uses: docker/setup-buildx-action@v2.5.0
|
||||||
|
|
||||||
|
- name: Build and push stable release
|
||||||
|
if: github.event.release.prerelease == false
|
||||||
|
uses: docker/build-push-action@v4.0.0
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||||
|
# Push is a shorthand for --output=type=registry
|
||||||
|
push: true
|
||||||
|
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
|
||||||
|
|
||||||
|
- name: Build and push pre-release
|
||||||
|
if: github.event.release.prerelease == true
|
||||||
|
uses: docker/build-push-action@v4.0.0
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||||
|
# Push is a shorthand for --output=type=registry
|
||||||
|
push: true
|
||||||
|
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
|
||||||
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
name: Docker Image CI Ontime V2
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags: [ "*" ]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
|
|
||||||
publish_docker:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
CI: ''
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- name: Setup env
|
|
||||||
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Setup Node.js environment
|
|
||||||
uses: actions/setup-node@v3.6.0
|
|
||||||
with:
|
|
||||||
version: 16.16.0
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v2
|
|
||||||
with:
|
|
||||||
version: 8
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Build project packages
|
|
||||||
env:
|
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
|
||||||
run: pnpm turbo build:docker
|
|
||||||
|
|
||||||
- name: Docker Login
|
|
||||||
uses: docker/login-action@v2.1.0
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Docker Setup Buildx
|
|
||||||
uses: docker/setup-buildx-action@v2.5.0
|
|
||||||
|
|
||||||
- name: Build and push stable release
|
|
||||||
if: github.event.release.prerelease == false
|
|
||||||
uses: docker/build-push-action@v4.0.0
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./Dockerfile
|
|
||||||
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6
|
|
||||||
# Push is a shorthand for --output=type=registry
|
|
||||||
push: true
|
|
||||||
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ env.RELEASE_VERSION }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
|
|
||||||
|
|
||||||
- name: Build and push pre-release
|
|
||||||
if: github.event.release.prerelease == true
|
|
||||||
uses: docker/build-push-action@v4.0.0
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./Dockerfile
|
|
||||||
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6
|
|
||||||
# Push is a shorthand for --output=type=registry
|
|
||||||
push: true
|
|
||||||
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ env.RELEASE_VERSION }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
|
|
||||||
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
name: Ontime build v2
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags: [ "*" ]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build_macos:
|
|
||||||
runs-on: macOS-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- name: Use Node.js
|
|
||||||
uses: actions/setup-node@v3
|
|
||||||
with:
|
|
||||||
node-version: 18.18.2
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v2
|
|
||||||
with:
|
|
||||||
version: 8
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Build project packages
|
|
||||||
env:
|
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
|
||||||
run: pnpm build
|
|
||||||
|
|
||||||
- name: Electron - Build app
|
|
||||||
run: pnpm dist-mac
|
|
||||||
|
|
||||||
- name: Release
|
|
||||||
uses: softprops/action-gh-release@v1
|
|
||||||
with:
|
|
||||||
files: |
|
|
||||||
./apps/electron/dist/ontime-macOS-x64.dmg
|
|
||||||
./apps/electron/dist/ontime-macOS-arm64.dmg
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
build_windows:
|
|
||||||
runs-on: windows-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- name: Use Node.js
|
|
||||||
uses: actions/setup-node@v3
|
|
||||||
with:
|
|
||||||
node-version: 18.18.2
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v2
|
|
||||||
with:
|
|
||||||
version: 8
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Build project packages
|
|
||||||
env:
|
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
|
||||||
run: pnpm build
|
|
||||||
|
|
||||||
- name: Electron - Build app
|
|
||||||
run: pnpm dist-win
|
|
||||||
|
|
||||||
- name: Release
|
|
||||||
uses: softprops/action-gh-release@v1
|
|
||||||
with:
|
|
||||||
files: './apps/electron/dist/ontime-win64.exe'
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
build_ubuntu:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- name: Use Node.js
|
|
||||||
uses: actions/setup-node@v3
|
|
||||||
with:
|
|
||||||
node-version: 18.18.2
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v2
|
|
||||||
with:
|
|
||||||
version: 8
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Build project packages
|
|
||||||
env:
|
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
|
||||||
run: pnpm build
|
|
||||||
|
|
||||||
- name: Electron - Build app
|
|
||||||
run: pnpm dist-linux
|
|
||||||
|
|
||||||
- name: Release
|
|
||||||
uses: softprops/action-gh-release@v1
|
|
||||||
with:
|
|
||||||
files: './apps/electron/dist/ontime-linux.AppImage'
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
name: Ontime test v3
|
name: Ontime test
|
||||||
|
|
||||||
on:
|
on:
|
||||||
# Only trigger manually for now for testing
|
pull_request:
|
||||||
# pull_request:
|
branches: '*'
|
||||||
# branches: '*'
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -18,12 +17,12 @@ jobs:
|
|||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 18.18.2
|
node-version: 20
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v3
|
uses: pnpm/action-setup@v3
|
||||||
with:
|
with:
|
||||||
version: 8
|
version: 9
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
@@ -74,12 +73,12 @@ jobs:
|
|||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 18.18.2
|
node-version: 20
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v3
|
uses: pnpm/action-setup@v3
|
||||||
with:
|
with:
|
||||||
version: 8
|
version: 9
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
name: Ontime test v2
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches: '*'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
unit-test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 20
|
|
||||||
env:
|
|
||||||
CI: ''
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- name: Use Node.js
|
|
||||||
uses: actions/setup-node@v3
|
|
||||||
with:
|
|
||||||
node-version: 18.18.2
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v2
|
|
||||||
with:
|
|
||||||
version: 8
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
# Run code quality per package
|
|
||||||
- name: React - Run linter + TypeScript checks
|
|
||||||
if: always()
|
|
||||||
run: pnpm lint && tsc --noEmit
|
|
||||||
working-directory: ./apps/client
|
|
||||||
|
|
||||||
- name: Server - Run linter + TypeScript checks
|
|
||||||
if: always()
|
|
||||||
run: pnpm lint && tsc --noEmit
|
|
||||||
working-directory: ./apps/server
|
|
||||||
|
|
||||||
- name: Utils - Run linter + TypeScript checks
|
|
||||||
if: always()
|
|
||||||
run: pnpm lint && tsc --noEmit
|
|
||||||
working-directory: ./packages/utils
|
|
||||||
|
|
||||||
- name: Types - Run linter
|
|
||||||
if: always()
|
|
||||||
run: pnpm lint
|
|
||||||
working-directory: ./packages/types
|
|
||||||
|
|
||||||
# We choose to run tests separately
|
|
||||||
- name: React - Run unit tests
|
|
||||||
if: always()
|
|
||||||
run: pnpm test:pipeline
|
|
||||||
working-directory: ./apps/client
|
|
||||||
|
|
||||||
- name: Server - Run unit tests
|
|
||||||
if: always()
|
|
||||||
run: pnpm test:pipeline
|
|
||||||
working-directory: ./apps/server
|
|
||||||
|
|
||||||
- name: Utils - Run unit tests
|
|
||||||
if: always()
|
|
||||||
run: pnpm test:pipeline
|
|
||||||
working-directory: ./packages/utils
|
|
||||||
|
|
||||||
e2e-test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 20
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- name: Use Node.js
|
|
||||||
uses: actions/setup-node@v3
|
|
||||||
with:
|
|
||||||
node-version: 18.18.2
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v2
|
|
||||||
with:
|
|
||||||
version: 8
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Build client
|
|
||||||
run: pnpm build:local
|
|
||||||
|
|
||||||
- name: Install Playwright Browsers
|
|
||||||
run: npx playwright install --with-deps
|
|
||||||
|
|
||||||
- name: Run Playwright tests
|
|
||||||
run: pnpm e2e
|
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v3
|
|
||||||
if: always()
|
|
||||||
with:
|
|
||||||
name: playwright-report
|
|
||||||
path: playwright-report/
|
|
||||||
retention-days: 30
|
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: automated-screenshots
|
|
||||||
path: automated-screenshots/
|
|
||||||
retention-days: 14
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
. "$(dirname -- "$0")/_/husky.sh"
|
|
||||||
|
|
||||||
pnpm lint-staged
|
|
||||||
@@ -9,8 +9,8 @@ Ontime consists of 3 distinct parts
|
|||||||
The steps below will assume you have locally installed the necessary dependencies.
|
The steps below will assume you have locally installed the necessary dependencies.
|
||||||
Other dependencies will be installed as part of the setup
|
Other dependencies will be installed as part of the setup
|
||||||
|
|
||||||
- __node__ (~18.18.2)
|
- __node__ (~20)
|
||||||
- __pnpm__ (>=8)
|
- __pnpm__ (~9)
|
||||||
- __docker__ (only necessary to run and build docker images)
|
- __docker__ (only necessary to run and build docker images)
|
||||||
|
|
||||||
## LOCAL DEVELOPMENT
|
## LOCAL DEVELOPMENT
|
||||||
@@ -81,6 +81,9 @@ From the project root, run the following commands
|
|||||||
|
|
||||||
The build distribution assets will be at `.apps/electron/dist`
|
The build distribution assets will be at `.apps/electron/dist`
|
||||||
|
|
||||||
|
Note: The MacOS build will only work in CI, locally it will fail due to notarisation issues.
|
||||||
|
Use the `turbo dist-mac:local` command to build a MacOS distribution locally.
|
||||||
|
|
||||||
## DOCKER
|
## DOCKER
|
||||||
|
|
||||||
Ontime provides a docker-compose file to aid with building and running docker images.
|
Ontime provides a docker-compose file to aid with building and running docker images.
|
||||||
@@ -88,8 +91,6 @@ While it should allow for a generic setup, it might need to be modified to fit y
|
|||||||
|
|
||||||
From the project root, run the following commands
|
From the project root, run the following commands
|
||||||
|
|
||||||
- __Install the project dependencies__ by running `pnpm i`
|
|
||||||
- __Build packages__ by running `pnpm build:localdocker`
|
|
||||||
- __Build docker image from__ by running `docker build -t getontime/ontime`
|
- __Build docker image from__ by running `docker build -t getontime/ontime`
|
||||||
- __Run docker image from compose__ by running `docker-compose up -d`
|
- __Run docker image from compose__ by running `docker-compose up -d`
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,28 @@
|
|||||||
FROM node:18.18-alpine
|
FROM node:20-bullseye AS builder
|
||||||
|
ENV PNPM_HOME="/pnpm"
|
||||||
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
|
RUN npm install -g pnpm@9.5.0
|
||||||
|
COPY . /app
|
||||||
|
WORKDIR /app
|
||||||
|
RUN pnpm --filter=ontime-ui --filter=ontime-server --filter=ontime-utils install --config.dedupe-peer-dependents=false --frozen-lockfile
|
||||||
|
RUN pnpm --filter=ontime-ui --filter=ontime-server run build:docker
|
||||||
|
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
# Set environment variables
|
# Set environment variables
|
||||||
# Environment Variable to signal that we are running production
|
# Environment Variable to signal that we are running production
|
||||||
ENV NODE_ENV=docker
|
ENV NODE_ENV=docker
|
||||||
# Ontime Data path
|
# Ontime Data path
|
||||||
ENV ONTIME_DATA=/external/
|
ENV ONTIME_DATA=/data/
|
||||||
|
|
||||||
WORKDIR /app/
|
WORKDIR /app/
|
||||||
|
|
||||||
# Prepare UI
|
# Prepare UI
|
||||||
COPY /apps/client/build ./client/
|
COPY --from=builder /app/apps/client/build ./client/
|
||||||
|
|
||||||
# Prepare Backend
|
# Prepare Backend
|
||||||
COPY /apps/server/dist/ ./server/
|
COPY --from=builder /app/apps/server/dist/ ./server/
|
||||||
COPY /demo-db/ ./preloaded-db/
|
COPY --from=builder /app/apps/server/src/external/ ./external/
|
||||||
COPY /apps/server/src/external/ ./external/
|
|
||||||
|
|
||||||
# Export default ports
|
# Export default ports
|
||||||
EXPOSE 4001/tcp 8888/udp 9999/udp
|
EXPOSE 4001/tcp 8888/udp 9999/udp
|
||||||
|
|||||||
@@ -1,205 +1,179 @@
|
|||||||
[](https://github.com/cpvalente/ontime/actions/workflows/build_v2.yml)
|
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||||
[](https://www.gnu.org/licenses/gpl-3.0)
|

|
||||||
|

|
||||||
|

|
||||||
|
[](https://github.com/sponsors/cpvalente)
|
||||||
|
[](https://www.buymeacoffee.com/cpvalente)
|
||||||
|
|
||||||
## Download the latest releases here
|
## Download the latest release
|
||||||
|
<a href="https://www.buymeacoffee.com/cpvalente" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="32"></a>
|
||||||
|
|
||||||
<div style="display: flex; justify-content: space-around">
|
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-win64.exe">Windows</a>
|
||||||
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg"><img alt="Download MacOS" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/mac-download.png"/></a>
|
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg">MacOS Arm</a>
|
||||||
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-win64.exe"><img alt="Download Windows" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/win-download.png"/></a>
|
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-x64.dmg">MacOS Intel</a>
|
||||||
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage"><img alt="Download Linux" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/linux-download.png"/></a>
|
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage">Linux</a>
|
||||||
<a href="https://hub.docker.com/r/getontime/ontime"><img alt="Get from Dockerhub" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/dockerhub.png"/></a>
|
- Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a>
|
||||||
</div>
|
|
||||||
|
## Using Ontime?
|
||||||
|
|
||||||
|
Let us know!
|
||||||
|
Ontime improves from the collaboration with its users. We would like to understand how you use Ontime and appreciate your feedback.
|
||||||
|
|
||||||
|
We would also like to include a testimonials section in our ✨new website✨. It would be great to showcase the diversity of users running Ontime.
|
||||||
|
|
||||||
# Ontime
|
# Ontime
|
||||||
|
|
||||||
Ontime is an application for creating and managing event running order and timers.
|
Ontime is a browser-based application that manages event rundowns, scheduling, and cueing.
|
||||||
|
|
||||||
The user inputs a list of events along with scheduling and event information.
|
With Ontime, you can plan, track your schedule, manage automation and cross-department show information all in one place.
|
||||||
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.
|
Ontime is made by entertainment and broadcast engineers and used by
|
||||||
|
|
||||||

|
- Conference organisers
|
||||||
|
- Touring shows and receiving venues
|
||||||
|
- Broadcasters and streamers
|
||||||
|
- Theatres and opera houses
|
||||||
|
- Houses of worship
|
||||||
|
|
||||||

|
### For live environments
|
||||||
|
|
||||||
|
Ontime is designed for use in live environments. \
|
||||||
|
This guides the application into being flexible and efficiently integrating into different workflows.
|
||||||
|
|
||||||
|
### For teams
|
||||||
|
|
||||||
|
All information added in Ontime is shared with the production team and other software / hardware in your workflow. \
|
||||||
|
Ontime also improves team collaboration with dedicated views for cuesheets and operators, and for public and production
|
||||||
|
signage.
|
||||||
|
|
||||||
|
### Simple infrastructure
|
||||||
|
|
||||||
|
All the data is distributed over the network, making its distribution and infrastructure flexible and cheap. \
|
||||||
|
With the availability of the docker image, you can also leverage IT infrastructure to make Ontime available online for
|
||||||
|
your team and clients.
|
||||||
|
|
||||||
|
Ontime is made by video engineers and entertainment technicians.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
[Read the docs to learn more](https://docs.getontime.no)
|
[Read the docs to learn more](https://docs.getontime.no)
|
||||||
|
|
||||||
## Using Ontime
|
## Using Ontime
|
||||||
|
|
||||||
Once installed and running, Ontime starts a background server that is the heart of all processes.
|
Ontime can be started by downloading the latest release for your platform. \
|
||||||
From the app, you can add / edit your running order and control the timer playback.
|
Alternatively you can also use the docker image, available at [Docker Hub](https://hub.docker.com/r/getontime/ontime)
|
||||||
|
|
||||||
Any device with a browser in the same network can choose one of the supported views to render the available data.
|
Once installed and running, any device in the network has access to Ontime.
|
||||||
This is done by reaching the ontime server at the _default port 4001_ eg: `localhost:4001`
|
|
||||||
or `192.168.1.3:4001`
|
|
||||||
<br />
|
|
||||||
You can then use the menu in the top left corner to select the desired view.
|
|
||||||
The menu will be initially hidden until there is mouse interaction.
|
|
||||||
|
|
||||||
In the case of unattended machines or automation, it is possible to use different URL to recall
|
Ontime provides different screens which allow for different types of interactions with the data. These are called
|
||||||
individual views and extend view settings using the URL presets feature
|
views. \
|
||||||
|
Each view in Ontime focuses on empowering a specific role or achieving a particular task.
|
||||||
|
|
||||||
|
You can access the different views by reaching the ontime server, in your browser, at (_default port
|
||||||
|
4001_) `http://localhost:4001` or `http://192.168.1.3:4001`
|
||||||
|
|
||||||
```
|
```
|
||||||
For the presentation views
|
For the backstage views
|
||||||
-------------------------------------------------------------
|
-------------------------------------------------------------
|
||||||
IP.ADDRESS:4001 > Web server default to presenter timer view
|
|
||||||
IP.ADDRESS:4001/timer > Presenter / Stage timer view
|
IP.ADDRESS:4001/timer > Presenter / Stage timer view
|
||||||
IP.ADDRESS:4001/minimal > Simple timer view
|
IP.ADDRESS:4001/minimal > Simple timer view
|
||||||
IP.ADDRESS:4001/clock > Simple clock view
|
IP.ADDRESS:4001/clock > Simple clock view
|
||||||
IP.ADDRESS:4001/sm > Stage Manager / Backstage view
|
IP.ADDRESS:4001/backstage > Stage Manager / Backstage view
|
||||||
IP.ADDRESS:4001/public > Public / Foyer view
|
IP.ADDRESS:4001/countdown > Countdown to anything
|
||||||
IP.ADDRESS:4001/lower > Lower Thirds
|
|
||||||
IP.ADDRESS:4001/studio > Studio Clock
|
IP.ADDRESS:4001/studio > Studio Clock
|
||||||
```
|
```
|
||||||
|
|
||||||
```
|
```
|
||||||
For management views
|
For the public views
|
||||||
|
-------------------------------------------------------------
|
||||||
|
IP.ADDRESS:4001/public > Public / Foyer view
|
||||||
|
IP.ADDRESS:4001/lower > Lower Thirds
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
For production views
|
||||||
-------------------------------------------------------------
|
-------------------------------------------------------------
|
||||||
IP.ADDRESS:4001/editor > the control interface, same as the app
|
IP.ADDRESS:4001/editor > the control interface, same as the app
|
||||||
IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
|
IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
|
||||||
|
IP.ADDRESS:4001/operator > automated views for operators
|
||||||
```
|
```
|
||||||
|
|
||||||
More documentation is available [in our docs](https://docs.getontime.no)
|
More documentation is available [in our docs](https://docs.getontime.no)
|
||||||
|
|
||||||
## Feature List (in no specific order)
|
## Main features
|
||||||
|
|
||||||
- [x] Distribute data over network and render it in the browser
|
- [x] Distribute data over network and render it in the browser
|
||||||
- [x] Different screen types
|
- [x] Collaborative
|
||||||
- Stage Timer
|
- [x] Extendable
|
||||||
- Minimal Timer
|
- [x] Send messages to different screen types
|
||||||
- Clock
|
|
||||||
- Backstage Info
|
|
||||||
- Public Info
|
|
||||||
- Studio Clock
|
|
||||||
- Countdown
|
|
||||||
- [Make your own?](#make-your-own-viewer)
|
|
||||||
- [x] Configurable Lower Thirds
|
|
||||||
- [x] Collaborative editing with the cuesheet view
|
|
||||||
- [x] Send live messages to different screen types
|
|
||||||
- [x] Differentiate between backstage and public data
|
- [x] Differentiate between backstage and public data
|
||||||
- [x] Workflow for managing delays
|
- [x] Workflow for managing delays
|
||||||
- [x] Rich protocol integrations for Control and Feedback
|
- [x] Rich protocol integrations for Control and Feedback
|
||||||
- OSC (Open Sound Control)
|
- [x] For servers: use docker to run Ontime in in a server, configure from a browser anywhere
|
||||||
- HTTP
|
|
||||||
- WebSockets
|
|
||||||
- [x] Roll mode: run standalone using the system clock
|
|
||||||
- [x] [Headless run](#headless-run): run server in a separate machine, configure from a browser locally
|
|
||||||
- [x] [Countdown to anything!](https://docs.getontime.no/features/count-to-anything/): have
|
|
||||||
a countdown to any scheduled event
|
|
||||||
- [x] Multi-platform (available on Windows, MacOS and Linux)
|
- [x] Multi-platform (available on Windows, MacOS and Linux)
|
||||||
- [x] [Companion integration](https://bitfocus.io/connections/getontime-ontime)
|
- [x] Companion integration [follow link](https://bitfocus.io/connections/getontime-ontime)
|
||||||
|
|
||||||
## Unopinionated
|
|
||||||
|
|
||||||
We want Ontime to be unique by targeting freelancers instead of roles.
|
|
||||||
|
|
||||||
We believe most freelancers work in different fields and we want to give you a tool that you can leverage across your
|
|
||||||
many environments and workflows.
|
|
||||||
|
|
||||||
We are not interested in forcing workflows and have made Ontime so, it is flexible to whichever way you would like to
|
|
||||||
work.
|
|
||||||
|
|
||||||
## Rich APIs for workflow integrations
|
|
||||||
|
|
||||||
The app is currently being developed for a broad user base, from broadcast to entertainment and
|
|
||||||
conference halls.
|
|
||||||
|
|
||||||
Taking advantage of the integrations, we currently use Ontime with:
|
|
||||||
|
|
||||||
- `disguise`: trigger Ontime from d3's timeline using the **OSC API**, and **render views** using d3's
|
|
||||||
webmodule
|
|
||||||
- `OBS`: **render views** using the Browser Module
|
|
||||||
- `QLab`: trigger Ontime using **OSC API**
|
|
||||||
- `Companion`: Ontime has a **companion module**. Issue report and feature requests should be done
|
|
||||||
in the [repository getontime/ontime](https://github.com/bitfocus/companion-module-getontime-ontime)
|
|
||||||
|
|
||||||
### Make your own viewer
|
|
||||||
|
|
||||||
Ontime broadcasts its data over WebSockets. This allows you to consume its data outside the application.
|
|
||||||
|
|
||||||
Writing a new view for the browser can be done with basic knowledge of HTML + CSS + Javascript (or any other language
|
|
||||||
that can run in the browser).
|
|
||||||
<br />
|
|
||||||
We have prepared a few resources to help here:
|
|
||||||
- Shipped with Ontime there is a small clock to get you started, it is available at `http://localhost:4001/external/demo` and the [code can be found here](https://github.com/cpvalente/ontime/tree/master/apps/server/src/external/demo)
|
|
||||||
- See [this repository](https://github.com/cpvalente/ontime-viewer-template-v2) with a template on
|
|
||||||
how to get you started
|
|
||||||
- See information about the [Websocket API](https://docs.getontime.no/api/osc-and-ws/)
|
|
||||||
<br />
|
|
||||||
More information [in the docs](https://docs.getontime.no/features/custom-views/)
|
|
||||||
|
|
||||||
|
|
||||||
### Headless run️
|
|
||||||
|
|
||||||
You can self-host and run Ontime in a docker image.
|
|
||||||
|
|
||||||
The docker image along with documentation is [available Docker Hub at getontime/ontime](https://hub.docker.com/r/getontime/ontime)
|
|
||||||
|
|
||||||
If you want to run this image in a Raspberry Pi, please see [the docs](https://docs.getontime.no/additional-notes/use-with-rpi/)
|
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
### Continued development
|
### Continued development
|
||||||
|
|
||||||
Several features are planned in the roadmap, and we continuously adjust this to match how users interact with the app.
|
Ontime is under active development. We continue adding and tweaking features in collaboration with users.
|
||||||
<br />
|
|
||||||
Have an idea? Reach out via [email](mail@getontime.no)
|
Have an idea? Reach out via [email](mail@getontime.no)
|
||||||
or [open an issue](https://github.com/cpvalente/ontime/issues/new)
|
or [open an issue](https://github.com/cpvalente/ontime/issues/new)
|
||||||
|
|
||||||
### Issues
|
### Issues
|
||||||
|
|
||||||
We use Github's issue tracking for bug reporting and feature requests. <br />
|
We use Github's issue tracking for bug reporting and feature requests. \
|
||||||
Found a bug? [Open an issue](https://github.com/cpvalente/ontime/issues/new).
|
Found a bug? [Open an issue](https://github.com/cpvalente/ontime/issues/new).
|
||||||
|
|
||||||
#### Unsigned App
|
#### Unsigned App
|
||||||
|
|
||||||
When installing the app you would see warning screens from the Operating System like:
|
When installing the app you would see warning screens from the Operating System like:
|
||||||
|
|
||||||
|
in Windows
|
||||||
|
|
||||||
`Microsoft Defender SmartScreen prevented an unrecognised app from starting. Running this app might put your PC at risk.`
|
`Microsoft Defender SmartScreen prevented an unrecognised app from starting. Running this app might put your PC at risk.`
|
||||||
|
|
||||||
or
|
|
||||||
|
|
||||||
`Ontime can't be opened because it is from an unidentified developer`
|
|
||||||
|
|
||||||
or in Linux
|
or in Linux
|
||||||
|
|
||||||
`Could Not Display "ontime-linux.AppImage`
|
`Could Not Display "ontime-linux.AppImage`
|
||||||
|
|
||||||
|
We currently only sign MacOS releases. \
|
||||||
|
Purchasing the certificates for both Mac and Windows would mean a recurrent expense which we are not able to cover.
|
||||||
|
|
||||||
You can circumvent this by allowing the execution of the app manually.
|
You can circumvent this by allowing the execution of the app manually.
|
||||||
|
|
||||||
- In Windows: click more and select "Run Anyway"
|
- In Windows: click `more` -> `Run Anyway`
|
||||||
- in macOS: the solution in macOS is different across versions, please refer to the [apple documentation](https://support.apple.com/en-gb/guide/mac-help/mh40616/mac)
|
- In Linux: right-click the AppImage file: `Properties` -> `Permissions` -> `Allow Executing File as a Program`
|
||||||
- In Linux: right-click the AppImage file -> Properties -> Permissions -> select Allow Executing
|
|
||||||
File as a Program
|
|
||||||
|
|
||||||
Long story short: Ontime app is unsigned. </br>Purchasing the certificates for both Mac and Windows
|
If you have tips on how to improve this or would like to sponsor the code signing,
|
||||||
would mean a recurrent expense and is not a priority. This is unlikely to change in future. If you
|
|
||||||
have tips on how to improve this or would like to sponsor the code signing,
|
|
||||||
please [open an issue](https://github.com/cpvalente/ontime/issues/new)
|
please [open an issue](https://github.com/cpvalente/ontime/issues/new)
|
||||||
|
|
||||||
#### Safari
|
## Contributing
|
||||||
|
|
||||||
There are known issues with Safari versions lower than 13:
|
|
||||||
|
|
||||||
- Spacing and text styles might have small inconsistencies
|
|
||||||
- Table view does not work
|
|
||||||
|
|
||||||
There is no plan for any further work on this.
|
|
||||||
|
|
||||||
# Contributing
|
|
||||||
|
|
||||||
Looking to contribute? All types of help are appreciated, from coding to testing and feature specification.
|
Looking to contribute? All types of help are appreciated, from coding to testing and feature specification.
|
||||||
<br /><br />
|
|
||||||
If you are a developer and would like to contribute with some code, please open an issue to discuss before opening a
|
If you are a developer and would like to contribute with code, please open an issue to discuss before opening a Pull Request.
|
||||||
Pull Request.
|
|
||||||
<br />
|
|
||||||
Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md)
|
Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md)
|
||||||
|
|
||||||
# Help
|
## Links
|
||||||
|
|
||||||
Help is underway! ... and can be found [here](https://docs.getontime.no)
|
See the [Ontime website](https://getontime.no) here and the link to the [documentation](https://docs.getontime.no)
|
||||||
|
|
||||||
# License
|
## License
|
||||||
|
|
||||||
This project is licensed under the terms of the GNU GPL v3
|
This project is licensed under the terms of the GNU GPL v3
|
||||||
|
|
||||||
|
## Sponsor
|
||||||
|
|
||||||
|
You can help the development of this project or say thank you with a one time donation. \
|
||||||
|
See [the terms of donations](https://github.com/cpvalente/ontime/blob/master/.github/FUNDING.md).
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<br>
|
||||||
|
<a href="https://www.buymeacoffee.com/cpvalente" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" width="200"></a>
|
||||||
|
</p>
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Terms of sponsorship
|
||||||
|
Ontime does not exist as a fiscal entity. The sponsorship recipient is the project owner. In this light, contributors are funding the project owner's capacity to work on the project, not the project itself. As a contribution recipient, the code owner reserves the right to redistribute.
|
||||||
|
|
||||||
|
## Why sponsor
|
||||||
|
Ontime is and will remain free, regardless of your decision to contribute.
|
||||||
|
Regardless, you may choose to provide a monetary contribution:
|
||||||
|
|
||||||
|
### Improve user experience
|
||||||
|
Certain parts of the application user experience are tied to our capacity to pay for services. App signing and Google Sheets integration are good examples of this.
|
||||||
|
|
||||||
|
### Cool stuff
|
||||||
|
We may choose not to implement some features. This could be related to the complexity required, to the skill set, or to the fact that we just feel it doesn't improve the app for a significant percentage of users. In those cases, a feature may be considered "not worth it," regardless of its pertinence.
|
||||||
|
|
||||||
|
As a user, you can choose to sponsor a particular feature or donate to the app so that we can create bounties for features.
|
||||||
|
|
||||||
|
### Priorities
|
||||||
|
Monetary contributions are most immediately needed to offset the costs of presenting and distributing Ontime, i.e., hosting, domains, and certificates.
|
||||||
|
|
||||||
|
Additional funding will be considered according to the needs at the time.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"parserOptions": {
|
||||||
|
"sourceType": "module"
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"node": true
|
||||||
|
},
|
||||||
|
"extends": [
|
||||||
|
"eslint:recommended"
|
||||||
|
],
|
||||||
|
"plugins": [],
|
||||||
|
"rules": {
|
||||||
|
"@typescript-eslint/no-var-requires": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
server
|
||||||
|
external
|
||||||
|
client
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"trailingComma": "all",
|
||||||
|
"tabWidth": 2,
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"jsxSingleQuote": true,
|
||||||
|
"printWidth": 120
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Ontime CLI
|
||||||
|
|
||||||
|

|
||||||
|
> Ontime is an application for managing rundown and event timers.
|
||||||
|
|
||||||
|
Congratulations! You got this far into Ontime's rabbit hole and want to manage your installation.
|
||||||
|
|
||||||
|
The CLI tool is a fast and lightweight way of installing Ontime and is perfect for self-hosting and headless installs.
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
You will need to have installed the correct version of node.js
|
||||||
|
Please check [the app engines declaration](https://github.com/cpvalente/ontime/blob/master/package.json) for the correct version.
|
||||||
|
|
||||||
|
### Running Ontime
|
||||||
|
To get Ontime running, all you need to do is install it in your system using your package manager of choice:
|
||||||
|
|
||||||
|
Install globally in your system
|
||||||
|
```bash
|
||||||
|
npm install -g @getontime/cli
|
||||||
|
```
|
||||||
|
|
||||||
|
... and run using the installed script
|
||||||
|
```bash
|
||||||
|
ontime
|
||||||
|
```
|
||||||
|
|
||||||
|
Or install and run ontime (the installation here is temporary for the duration of the session)
|
||||||
|
```bash
|
||||||
|
npx install @getontime/cli
|
||||||
|
```
|
||||||
|
|
||||||
|
## Links
|
||||||
|
- [Ontime's repository](https://github.com/cpvalente/ontime)
|
||||||
|
- [Ontime's documentation](https://docs.getontime.no/)
|
||||||
|
- [Ontime's website](https://getontime.no/)
|
||||||
|
|
||||||
|
## Sponsoring
|
||||||
|
You can help the development of this project or say thank you with a one time donation. \
|
||||||
|
See the [terms of donations](https://github.com/cpvalente/ontime/blob/master/SPONSOR.md)
|
||||||
|
|
||||||
|
[](https://github.com/sponsors/cpvalente)
|
||||||
|
[](https://www.buymeacoffee.com/cpvalente)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
// NOTE: for now the following needs to be in place: ./server/index.cjs, ./client, ./external
|
||||||
|
|
||||||
|
const ontimeServer = require('./server/index.cjs');
|
||||||
|
const { initAssets, startServer, startIntegrations, shutdown } = ontimeServer;
|
||||||
|
|
||||||
|
async function startOntime() {
|
||||||
|
await initAssets();
|
||||||
|
|
||||||
|
await startServer();
|
||||||
|
|
||||||
|
await startIntegrations();
|
||||||
|
}
|
||||||
|
|
||||||
|
startOntime();
|
||||||
|
|
||||||
|
process.on(['SIGHUP', 'SIGINT', 'SIGTERM'], () => {
|
||||||
|
shutdown();
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "@getontime/cli",
|
||||||
|
"version": "3.5.0",
|
||||||
|
"author": "Carlos Valente",
|
||||||
|
"description": "Time keeping for live events",
|
||||||
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
"keywords": [
|
||||||
|
"lighdev",
|
||||||
|
"ontime",
|
||||||
|
"timer"
|
||||||
|
],
|
||||||
|
"license": "AGPL-3.0-only",
|
||||||
|
"main": "main.js",
|
||||||
|
"bin": {
|
||||||
|
"ontime": "main.js"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"client",
|
||||||
|
"external",
|
||||||
|
"server"
|
||||||
|
],
|
||||||
|
"devDependencies": {
|
||||||
|
"eslint": "catalog:",
|
||||||
|
"eslint-config-prettier": "catalog:",
|
||||||
|
"prettier": "catalog:"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-ui",
|
"name": "ontime-ui",
|
||||||
"version": "3.0.0-alpha",
|
"version": "3.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -10,9 +10,10 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@emotion/react": "^11.10.6",
|
"@emotion/react": "^11.10.6",
|
||||||
"@emotion/styled": "^11.10.6",
|
"@emotion/styled": "^11.10.6",
|
||||||
|
"@fontsource/open-sans": "^5.0.28",
|
||||||
"@mantine/hooks": "^7.6.2",
|
"@mantine/hooks": "^7.6.2",
|
||||||
"@react-icons/all-files": "^4.1.0",
|
"@react-icons/all-files": "^4.1.0",
|
||||||
"@sentry/react": "^7.92.0",
|
"@sentry/react": "^8.19.0",
|
||||||
"@tanstack/react-query": "^5.17.9",
|
"@tanstack/react-query": "^5.17.9",
|
||||||
"@tanstack/react-query-devtools": "^5.17.9",
|
"@tanstack/react-query-devtools": "^5.17.9",
|
||||||
"@tanstack/react-table": "^8.11.3",
|
"@tanstack/react-table": "^8.11.3",
|
||||||
@@ -21,16 +22,15 @@
|
|||||||
"color": "^4.2.3",
|
"color": "^4.2.3",
|
||||||
"csv-stringify": "^6.4.5",
|
"csv-stringify": "^6.4.5",
|
||||||
"framer-motion": "^10.10.0",
|
"framer-motion": "^10.10.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.3.1",
|
||||||
"react-colorful": "^5.6.1",
|
"react-colorful": "^5.6.1",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.3.1",
|
||||||
"react-fast-compare": "^3.2.2",
|
"react-fast-compare": "^3.2.2",
|
||||||
"react-hook-form": "^7.49.2",
|
"react-hook-form": "^7.49.2",
|
||||||
"react-qr-code": "^2.0.12",
|
"react-qr-code": "^2.0.12",
|
||||||
"react-router-dom": "^6.3.0",
|
"react-router-dom": "^6.3.0",
|
||||||
"typeface-open-sans": "^1.1.13",
|
|
||||||
"web-vitals": "^3.1.1",
|
"web-vitals": "^3.1.1",
|
||||||
"zustand": "^4.5.0"
|
"zustand": "^4.5.2"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
|
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
|
||||||
@@ -42,7 +42,6 @@
|
|||||||
"build:docker": "vite build",
|
"build:docker": "vite build",
|
||||||
"build:localdocker": "cross-env NODE_ENV=local vite build",
|
"build:localdocker": "cross-env NODE_ENV=local vite build",
|
||||||
"lint": "eslint . --quiet",
|
"lint": "eslint . --quiet",
|
||||||
"lint-staged": "eslint",
|
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:pipeline": "vitest run",
|
"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",
|
||||||
@@ -61,7 +60,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sentry/vite-plugin": "^2.14.0",
|
"@sentry/vite-plugin": "^2.16.1",
|
||||||
"@tanstack/eslint-plugin-query": "^5.8.4",
|
"@tanstack/eslint-plugin-query": "^5.8.4",
|
||||||
"@testing-library/jest-dom": "^5.16.5",
|
"@testing-library/jest-dom": "^5.16.5",
|
||||||
"@testing-library/react": "^13.1.1",
|
"@testing-library/react": "^13.1.1",
|
||||||
@@ -70,13 +69,13 @@
|
|||||||
"@types/react": "^18.0.26",
|
"@types/react": "^18.0.26",
|
||||||
"@types/react-dom": "^18.0.10",
|
"@types/react-dom": "^18.0.10",
|
||||||
"@types/testing-library__jest-dom": "^5.14.5",
|
"@types/testing-library__jest-dom": "^5.14.5",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
"@typescript-eslint/eslint-plugin": "catalog:",
|
||||||
"@typescript-eslint/parser": "^6.10.0",
|
"@typescript-eslint/parser": "catalog:",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
"eslint": "^8.53.0",
|
"eslint": "catalog:",
|
||||||
"eslint-config-prettier": "^9.0.0",
|
"eslint-config-prettier": "catalog:",
|
||||||
"eslint-plugin-jest": "^27.6.0",
|
"eslint-plugin-jest": "^28.6.0",
|
||||||
"eslint-plugin-prettier": "^5.0.1",
|
"eslint-plugin-prettier": "catalog:",
|
||||||
"eslint-plugin-react": "^7.32.0",
|
"eslint-plugin-react": "^7.32.0",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"eslint-plugin-simple-import-sort": "^8.0.0",
|
"eslint-plugin-simple-import-sort": "^8.0.0",
|
||||||
@@ -84,13 +83,13 @@
|
|||||||
"jsdom": "^21.1.0",
|
"jsdom": "^21.1.0",
|
||||||
"ontime-types": "workspace:*",
|
"ontime-types": "workspace:*",
|
||||||
"ontime-utils": "workspace:*",
|
"ontime-utils": "workspace:*",
|
||||||
"prettier": "^3.0.3",
|
"prettier": "catalog:",
|
||||||
"sass": "^1.57.1",
|
"sass": "^1.57.1",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "catalog:",
|
||||||
"vite": "^5.1.0",
|
"vite": "^5.2.11",
|
||||||
"vite-plugin-compression2": "^0.12.0",
|
"vite-plugin-compression2": "^0.12.0",
|
||||||
"vite-plugin-svgr": "^4.2.0",
|
"vite-plugin-svgr": "^4.2.0",
|
||||||
"vite-tsconfig-paths": "^4.3.1",
|
"vite-tsconfig-paths": "^4.3.1",
|
||||||
"vitest": "^1.2.2"
|
"vitest": "^1.6.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +1,20 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import { ChakraProvider } from '@chakra-ui/react';
|
import { ChakraProvider } from '@chakra-ui/react';
|
||||||
import { QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||||
|
|
||||||
import { ContextMenu } from './common/components/context-menu/ContextMenu';
|
|
||||||
import ErrorBoundary from './common/components/error-boundary/ErrorBoundary';
|
import ErrorBoundary from './common/components/error-boundary/ErrorBoundary';
|
||||||
|
import IdentifyOverlay from './common/components/identify-overlay/IdentifyOverlay';
|
||||||
import { AppContextProvider } from './common/context/AppContext';
|
import { AppContextProvider } from './common/context/AppContext';
|
||||||
import useElectronEvent from './common/hooks/useElectronEvent';
|
|
||||||
import { ontimeQueryClient } from './common/queryClient';
|
import { ontimeQueryClient } from './common/queryClient';
|
||||||
import { socketClientName } from './common/stores/connectionName';
|
|
||||||
import { connectSocket } from './common/utils/socket';
|
import { connectSocket } from './common/utils/socket';
|
||||||
import theme from './theme/theme';
|
import theme from './theme/theme';
|
||||||
import { TranslationProvider } from './translation/TranslationProvider';
|
import { TranslationProvider } from './translation/TranslationProvider';
|
||||||
import AppRouter from './AppRouter';
|
import AppRouter from './AppRouter';
|
||||||
|
|
||||||
// Load Open Sans typeface
|
connectSocket();
|
||||||
// @ts-expect-error no types from font import
|
|
||||||
import('typeface-open-sans');
|
|
||||||
|
|
||||||
const preferredClientName = socketClientName.getState().name;
|
|
||||||
connectSocket(preferredClientName);
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { isElectron, sendToElectron } = useElectronEvent();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyPress = (event: KeyboardEvent) => {
|
|
||||||
// handle held key
|
|
||||||
if (event.repeat) return;
|
|
||||||
// check if the alt key is pressed
|
|
||||||
if (event.altKey) {
|
|
||||||
if (event.code === 'KeyT') {
|
|
||||||
// ask to see debug
|
|
||||||
sendToElectron('set-window', 'show-dev');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isElectron) {
|
|
||||||
document.addEventListener('keydown', handleKeyPress);
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
if (isElectron) {
|
|
||||||
document.removeEventListener('keydown', handleKeyPress);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [isElectron, sendToElectron]);
|
|
||||||
return (
|
return (
|
||||||
<ChakraProvider disableGlobalStyle resetCSS theme={theme}>
|
<ChakraProvider disableGlobalStyle resetCSS theme={theme}>
|
||||||
<QueryClientProvider client={ontimeQueryClient}>
|
<QueryClientProvider client={ontimeQueryClient}>
|
||||||
@@ -55,13 +23,15 @@ function App() {
|
|||||||
<div className='App'>
|
<div className='App'>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<TranslationProvider>
|
<TranslationProvider>
|
||||||
<ContextMenu>
|
<IdentifyOverlay />
|
||||||
<AppRouter />
|
<AppRouter />
|
||||||
</ContextMenu>
|
|
||||||
</TranslationProvider>
|
</TranslationProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
</div>
|
</div>
|
||||||
|
<ErrorBoundary>
|
||||||
|
<div id='identify-portal' />
|
||||||
|
</ErrorBoundary>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</AppContextProvider>
|
</AppContextProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
@@ -1,23 +1,36 @@
|
|||||||
import { lazy, Suspense } from 'react';
|
import React from 'react';
|
||||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
import {
|
||||||
|
createRoutesFromChildren,
|
||||||
|
matchRoutes,
|
||||||
|
Navigate,
|
||||||
|
Route,
|
||||||
|
Routes,
|
||||||
|
useLocation,
|
||||||
|
useNavigationType,
|
||||||
|
} from 'react-router-dom';
|
||||||
|
import * as Sentry from '@sentry/react';
|
||||||
|
|
||||||
|
import { useClientPath } from './common/hooks/useClientPath';
|
||||||
import Log from './features/log/Log';
|
import Log from './features/log/Log';
|
||||||
import withPreset from './features/PresetWrapper';
|
import withPreset from './features/PresetWrapper';
|
||||||
import withData from './features/viewers/ViewWrapper';
|
import withData from './features/viewers/ViewWrapper';
|
||||||
|
import { ONTIME_VERSION } from './ONTIME_VERSION';
|
||||||
|
import { sentryDsn, sentryRecommendedIgnore } from './sentry.config';
|
||||||
|
|
||||||
const Editor = lazy(() => import('./features/editors/ProtectedEditor'));
|
const Editor = React.lazy(() => import('./features/editors/ProtectedEditor'));
|
||||||
const Cuesheet = lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
|
const Cuesheet = React.lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
|
||||||
const Operator = lazy(() => import('./features/operator/Operator'));
|
const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
|
||||||
|
|
||||||
const TimerView = lazy(() => import('./features/viewers/timer/Timer'));
|
const TimerView = React.lazy(() => import('./features/viewers/timer/Timer'));
|
||||||
const MinimalTimerView = lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
|
const MinimalTimerView = React.lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
|
||||||
const ClockView = lazy(() => import('./features/viewers/clock/Clock'));
|
const ClockView = React.lazy(() => import('./features/viewers/clock/Clock'));
|
||||||
const Countdown = lazy(() => import('./features/viewers/countdown/Countdown'));
|
const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown'));
|
||||||
|
|
||||||
const Backstage = lazy(() => import('./features/viewers/backstage/Backstage'));
|
const Backstage = React.lazy(() => import('./features/viewers/backstage/Backstage'));
|
||||||
const Public = lazy(() => import('./features/viewers/public/Public'));
|
const Timeline = React.lazy(() => import('./features/viewers/timeline/TimelinePage'));
|
||||||
const Lower = lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
|
const Public = React.lazy(() => import('./features/viewers/public/Public'));
|
||||||
const StudioClock = lazy(() => import('./features/viewers/studio/StudioClock'));
|
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
|
||||||
|
const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
|
||||||
|
|
||||||
const STimer = withPreset(withData(TimerView));
|
const STimer = withPreset(withData(TimerView));
|
||||||
const SMinimalTimer = withPreset(withData(MinimalTimerView));
|
const SMinimalTimer = withPreset(withData(MinimalTimerView));
|
||||||
@@ -27,38 +40,56 @@ const SBackstage = withPreset(withData(Backstage));
|
|||||||
const SPublic = withPreset(withData(Public));
|
const SPublic = withPreset(withData(Public));
|
||||||
const SLowerThird = withPreset(withData(Lower));
|
const SLowerThird = withPreset(withData(Lower));
|
||||||
const SStudio = withPreset(withData(StudioClock));
|
const SStudio = withPreset(withData(StudioClock));
|
||||||
|
const STimeline = withPreset(withData(Timeline));
|
||||||
|
|
||||||
const EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper'));
|
const EditorFeatureWrapper = React.lazy(() => import('./features/EditorFeatureWrapper'));
|
||||||
const RundownPanel = lazy(() => import('./features/rundown/RundownExport'));
|
const RundownPanel = React.lazy(() => import('./features/rundown/RundownExport'));
|
||||||
const TimerControl = lazy(() => import('./features/control/playback/TimerControlExport'));
|
const TimerControl = React.lazy(() => import('./features/control/playback/TimerControlExport'));
|
||||||
const MessageControl = lazy(() => import('./features/control/message/MessageControlExport'));
|
const MessageControl = React.lazy(() => import('./features/control/message/MessageControlExport'));
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: sentryDsn,
|
||||||
|
integrations: [
|
||||||
|
Sentry.reactRouterV6BrowserTracingIntegration({
|
||||||
|
useEffect: React.useEffect,
|
||||||
|
useLocation,
|
||||||
|
useNavigationType,
|
||||||
|
createRoutesFromChildren,
|
||||||
|
matchRoutes,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
tracesSampleRate: 0.3,
|
||||||
|
release: ONTIME_VERSION,
|
||||||
|
enabled: import.meta.env.PROD,
|
||||||
|
ignoreErrors: [...sentryRecommendedIgnore, /Unable to preload CSS/i, /dynamically imported module/i],
|
||||||
|
denyUrls: [/extensions\//i, /^chrome:\/\//i, /^chrome-extension:\/\//i],
|
||||||
|
});
|
||||||
|
|
||||||
|
const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes);
|
||||||
|
|
||||||
export default function AppRouter() {
|
export default function AppRouter() {
|
||||||
|
// handle client path changes
|
||||||
|
useClientPath();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={null}>
|
<React.Suspense fallback={null}>
|
||||||
<Routes>
|
<SentryRoutes>
|
||||||
<Route path='/' element={<Navigate to='/timer' />} />
|
<Route path='/' element={<Navigate to='/timer' />} />
|
||||||
<Route path='/timer' element={<STimer />} />
|
<Route path='/timer' element={<STimer />} />
|
||||||
|
<Route path='/public' element={<SPublic />} />
|
||||||
<Route path='/minimal' element={<SMinimalTimer />} />
|
<Route path='/minimal' element={<SMinimalTimer />} />
|
||||||
|
|
||||||
<Route path='/clock' element={<SClock />} />
|
<Route path='/clock' element={<SClock />} />
|
||||||
|
|
||||||
<Route path='/countdown' element={<SCountdown />} />
|
<Route path='/countdown' element={<SCountdown />} />
|
||||||
|
|
||||||
<Route path='/backstage' element={<SBackstage />} />
|
<Route path='/backstage' element={<SBackstage />} />
|
||||||
|
|
||||||
<Route path='/public' element={<SPublic />} />
|
|
||||||
<Route path='/studio' element={<SStudio />} />
|
<Route path='/studio' element={<SStudio />} />
|
||||||
{/*/!* Lower cannot have fallback *!/*/}
|
|
||||||
<Route path='/lower' element={<SLowerThird />} />
|
<Route path='/lower' element={<SLowerThird />} />
|
||||||
|
<Route path='/timeline' element={<STimeline />} />
|
||||||
<Route path='/op' element={<Operator />} />
|
|
||||||
<Route path='/operator' element={<Operator />} />
|
|
||||||
|
|
||||||
{/*/!* Protected Routes *!/*/}
|
{/*/!* Protected Routes *!/*/}
|
||||||
<Route path='/editor' element={<Editor />} />
|
<Route path='/editor' element={<Editor />} />
|
||||||
<Route path='/cuesheet' element={<Cuesheet />} />
|
<Route path='/cuesheet' element={<Cuesheet />} />
|
||||||
|
<Route path='/op' element={<Operator />} />
|
||||||
|
|
||||||
{/*/!* Protected Routes - Elements *!/*/}
|
{/*/!* Protected Routes - Elements *!/*/}
|
||||||
<Route
|
<Route
|
||||||
@@ -95,7 +126,7 @@ export default function AppRouter() {
|
|||||||
/>
|
/>
|
||||||
{/*/!* Send to default if nothing found *!/*/}
|
{/*/!* Send to default if nothing found *!/*/}
|
||||||
<Route path='*' element={<STimer />} />
|
<Route path='*' element={<STimer />} />
|
||||||
</Routes>
|
</SentryRoutes>
|
||||||
</Suspense>
|
</React.Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const RUNTIME = ['runtimeStore'];
|
|||||||
export const SHEET_STATE = ['sheetState'];
|
export const SHEET_STATE = ['sheetState'];
|
||||||
export const URL_PRESETS = ['urlpresets'];
|
export const URL_PRESETS = ['urlpresets'];
|
||||||
export const VIEW_SETTINGS = ['viewSettings'];
|
export const VIEW_SETTINGS = ['viewSettings'];
|
||||||
|
export const CLIENT_LIST = ['clientList'];
|
||||||
|
|
||||||
// resolve location
|
// resolve location
|
||||||
const location = window.location;
|
const location = window.location;
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
import axios, { AxiosResponse } from 'axios';
|
import axios, { AxiosResponse } from 'axios';
|
||||||
import {
|
import { DatabaseModel, GetInfo, MessageResponse, ProjectData, ProjectFileListResponse } from 'ontime-types';
|
||||||
DatabaseModel,
|
|
||||||
GetInfo,
|
|
||||||
MessageResponse,
|
|
||||||
ProjectData,
|
|
||||||
ProjectFileListResponse,
|
|
||||||
} from 'ontime-types';
|
|
||||||
|
|
||||||
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
|
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
|
||||||
|
|
||||||
@@ -17,15 +11,15 @@ const dbPath = `${apiEntryUrl}/db`;
|
|||||||
/**
|
/**
|
||||||
* HTTP request to the current DB
|
* HTTP request to the current DB
|
||||||
*/
|
*/
|
||||||
async function getDb(): Promise<AxiosResponse<DatabaseModel>> {
|
export function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> {
|
||||||
return axios.get(`${dbPath}/download`);
|
return axios.post(`${dbPath}/download/`, { filename });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request download of the current project file
|
* Request download of the current project file
|
||||||
* @param fileName
|
* @param fileName
|
||||||
*/
|
*/
|
||||||
export async function downloadProject(fileName: string = 'ontime-project') {
|
export async function downloadProject(fileName: string) {
|
||||||
try {
|
try {
|
||||||
const { data, name } = await fileDownload(fileName);
|
const { data, name } = await fileDownload(fileName);
|
||||||
|
|
||||||
@@ -158,7 +152,7 @@ export async function getInfo(): Promise<GetInfo> {
|
|||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
async function fileDownload(fileName: string): Promise<{ data: DatabaseModel; name: string }> {
|
async function fileDownload(fileName: string): Promise<{ data: DatabaseModel; name: string }> {
|
||||||
const response = await getDb();
|
const response = await getDb(fileName);
|
||||||
|
|
||||||
const headerLine = response.headers['Content-Disposition'];
|
const headerLine = response.headers['Content-Disposition'];
|
||||||
|
|
||||||
|
|||||||
@@ -74,8 +74,8 @@ export async function requestApplyDelay(eventId: string): Promise<AxiosResponse<
|
|||||||
/**
|
/**
|
||||||
* HTTP request to delete given event
|
* HTTP request to delete given event
|
||||||
*/
|
*/
|
||||||
export async function requestDelete(eventId: string): Promise<AxiosResponse<MessageResponse>> {
|
export async function requestDelete(eventIds: string[]): Promise<AxiosResponse<MessageResponse>> {
|
||||||
return axios.delete(`${rundownPath}/${eventId}`);
|
return axios.delete(rundownPath, { data: { ids: eventIds } });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogBody,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogOverlay,
|
|
||||||
Button,
|
|
||||||
IconButton,
|
|
||||||
Tooltip,
|
|
||||||
} from '@chakra-ui/react';
|
|
||||||
import { IoPowerOutline } from '@react-icons/all-files/io5/IoPowerOutline';
|
|
||||||
|
|
||||||
import { useEmitLog } from '../../stores/logger';
|
|
||||||
|
|
||||||
interface QuitIconBtnProps {
|
|
||||||
clickHandler: () => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const quitBtnStyle = {
|
|
||||||
color: '#D20300', // $red-700
|
|
||||||
borderColor: '#D20300', // $red-700
|
|
||||||
_focus: { boxShadow: 'none' },
|
|
||||||
_hover: {
|
|
||||||
background: '#D20300', // $red-700
|
|
||||||
color: 'white',
|
|
||||||
_disabled: {
|
|
||||||
color: '#D20300', // $red-700
|
|
||||||
background: 'none',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
_active: {
|
|
||||||
background: '#9A0000', // $red-1000
|
|
||||||
color: 'white',
|
|
||||||
},
|
|
||||||
variant: 'outline',
|
|
||||||
isRound: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function QuitIconBtn(props: QuitIconBtnProps) {
|
|
||||||
const { clickHandler, disabled, ...rest } = props;
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const { emitInfo } = useEmitLog();
|
|
||||||
const onClose = () => setIsOpen(false);
|
|
||||||
const cancelRef = useRef<HTMLButtonElement | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (window.process?.type === 'renderer') {
|
|
||||||
window.ipcRenderer.on('user-request-shutdown', () => {
|
|
||||||
emitInfo('Shutdown request');
|
|
||||||
setIsOpen(true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [emitInfo]);
|
|
||||||
|
|
||||||
const handleShutdown = useCallback(() => {
|
|
||||||
onClose();
|
|
||||||
clickHandler();
|
|
||||||
}, [clickHandler]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Tooltip label='Quit Application'>
|
|
||||||
<IconButton
|
|
||||||
aria-label='Quit Application'
|
|
||||||
size='lg'
|
|
||||||
icon={<IoPowerOutline />}
|
|
||||||
onClick={() => setIsOpen(true)}
|
|
||||||
isDisabled={disabled}
|
|
||||||
{...quitBtnStyle}
|
|
||||||
{...rest}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
<AlertDialog isOpen={isOpen} leastDestructiveRef={cancelRef} onClose={onClose}>
|
|
||||||
<AlertDialogOverlay>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader fontSize='lg' fontWeight='bold'>
|
|
||||||
Ontime Shutdown
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogBody>This will shutdown the Ontime server. Are you sure?</AlertDialogBody>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<Button ref={cancelRef} onClick={onClose} variant='ghost'>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button colorScheme='red' onClick={handleShutdown} ml={3}>
|
|
||||||
Shutdown
|
|
||||||
</Button>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialogOverlay>
|
|
||||||
</AlertDialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.buttonSection {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: $element-spacing;
|
||||||
|
margin-top: -$element-spacing;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
InputGroup,
|
||||||
|
InputLeftAddon,
|
||||||
|
Modal,
|
||||||
|
ModalBody,
|
||||||
|
ModalCloseButton,
|
||||||
|
ModalContent,
|
||||||
|
ModalFooter,
|
||||||
|
ModalHeader,
|
||||||
|
ModalOverlay,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import { setClientRemote } from '../../hooks/useSocket';
|
||||||
|
|
||||||
|
import style from './ClientModal.module.scss';
|
||||||
|
|
||||||
|
interface RedirectClientModalProps {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
path?: string;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RedirectClientModal(props: RedirectClientModalProps) {
|
||||||
|
const { id, isOpen, name = '', path: currentPath = '', onClose } = props;
|
||||||
|
const [path, setPath] = useState(currentPath);
|
||||||
|
|
||||||
|
const { setRedirect } = setClientRemote;
|
||||||
|
|
||||||
|
const handleRedirect = () => {
|
||||||
|
if (path !== currentPath && path !== '') {
|
||||||
|
setRedirect({ target: id, redirect: path });
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const host = window.location.origin;
|
||||||
|
const canSubmit = path !== currentPath && path !== '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} variant='ontime'>
|
||||||
|
<ModalOverlay />
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader>Redirect: {name}</ModalHeader>
|
||||||
|
<ModalCloseButton />
|
||||||
|
<ModalBody>
|
||||||
|
<InputGroup variant='ontime-filled' size='md'>
|
||||||
|
<InputLeftAddon>{host}</InputLeftAddon>
|
||||||
|
<Input placeholder='minimal?key=0000ffff' value={path} onChange={(event) => setPath(event.target.value)} />
|
||||||
|
</InputGroup>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<div className={style.buttonSection}>
|
||||||
|
<Button size='md' variant='ontime-subtle' onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size='md' variant='ontime-filled' onClick={handleRedirect} isDisabled={!canSubmit}>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
ModalBody,
|
||||||
|
ModalCloseButton,
|
||||||
|
ModalContent,
|
||||||
|
ModalFooter,
|
||||||
|
ModalHeader,
|
||||||
|
ModalOverlay,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import { setClientRemote } from '../../hooks/useSocket';
|
||||||
|
|
||||||
|
import style from './ClientModal.module.scss';
|
||||||
|
|
||||||
|
interface RenameClientModalProps {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RenameClientModal(props: RenameClientModalProps) {
|
||||||
|
const { id, name: currentName = '', isOpen, onClose } = props;
|
||||||
|
const [name, setName] = useState(currentName);
|
||||||
|
|
||||||
|
const { setClientName } = setClientRemote;
|
||||||
|
|
||||||
|
const handleRename = () => {
|
||||||
|
if (name !== currentName && name !== '') {
|
||||||
|
setClientName({ target: id, rename: name });
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const canSubmit = name !== currentName && name !== '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} variant='ontime'>
|
||||||
|
<ModalOverlay />
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader>Rename: {currentName}</ModalHeader>
|
||||||
|
<ModalCloseButton />
|
||||||
|
<ModalBody>
|
||||||
|
<Input
|
||||||
|
variant='ontime-filled'
|
||||||
|
size='md'
|
||||||
|
placeholder='new name'
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<div className={style.buttonSection}>
|
||||||
|
<Button size='md' variant='ontime-subtle' onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size='md' variant='ontime-filled' onClick={handleRename} isDisabled={!canSubmit}>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -75,6 +75,7 @@ export const ContextMenu = ({ children }: ContextMenuProps) => {
|
|||||||
w={1}
|
w={1}
|
||||||
h={1}
|
h={1}
|
||||||
style={{
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
left: coords.x,
|
left: coords.x,
|
||||||
top: coords.y,
|
top: coords.y,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { OptionWithoutGroup } from './ContextMenu';
|
|||||||
export const ContextMenuOption = ({ label, onClick, isDisabled, icon: Icon, withDivider }: OptionWithoutGroup) => (
|
export const ContextMenuOption = ({ label, onClick, isDisabled, icon: Icon, withDivider }: OptionWithoutGroup) => (
|
||||||
<>
|
<>
|
||||||
{withDivider && <MenuDivider />}
|
{withDivider && <MenuDivider />}
|
||||||
<MenuItem icon={<Icon />} onClick={onClick} isDisabled={isDisabled}>
|
<MenuItem icon={<Icon style={{ fontSize: '1rem' }} />} onClick={onClick} isDisabled={isDisabled}>
|
||||||
{label}
|
{label}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -28,9 +28,11 @@ class ErrorBoundary extends React.Component {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Sentry.withScope((scope) => {
|
Sentry.withScope((scope) => {
|
||||||
scope.setExtras('error', error);
|
scope.setExtras({
|
||||||
scope.setExtras('store', runtimeStore.getState());
|
error,
|
||||||
scope.setExtras('hasSocket', { hasConnected, shouldReconnect, reconnectAttempts });
|
store: runtimeStore.getState(),
|
||||||
|
hasSocket: { hasConnected, shouldReconnect, reconnectAttempts },
|
||||||
|
});
|
||||||
const eventId = Sentry.captureException(error);
|
const eventId = Sentry.captureException(error);
|
||||||
this.setState({ eventId, info });
|
this.setState({ eventId, info });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
.overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
margin: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
border: 10px solid $ui-white;
|
||||||
|
background-color: $ui-black;
|
||||||
|
|
||||||
|
color: $ui-white;
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
text-align: center;
|
||||||
|
z-index: 100;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: -10vh;
|
||||||
|
line-height: 1em;
|
||||||
|
font-size: 12vw;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
color: $label-gray;
|
||||||
|
line-height: 2em;
|
||||||
|
font-size: 3vw;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||||
|
|
||||||
|
import { setClientRemote } from '../../hooks/useSocket';
|
||||||
|
import { useClientStore } from '../../stores/clientStore';
|
||||||
|
|
||||||
|
import style from './IdentifyOverlay.module.scss';
|
||||||
|
|
||||||
|
export default function IdentifyOverlay() {
|
||||||
|
const clients = useClientStore((store) => store.clients);
|
||||||
|
const id = useClientStore((store) => store.id);
|
||||||
|
const showOverlay = clients[id]?.identify;
|
||||||
|
|
||||||
|
if (!showOverlay) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const portalRoot = document.getElementById('identify-portal');
|
||||||
|
|
||||||
|
if (!portalRoot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return createPortal(<Overlay />, portalRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Overlay() {
|
||||||
|
const clients = useClientStore((store) => store.clients);
|
||||||
|
const id = useClientStore((store) => store.id);
|
||||||
|
const name = useClientStore((store) => store.name);
|
||||||
|
|
||||||
|
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const { setIdentify } = setClientRemote;
|
||||||
|
const showOverlay = clients[id]?.identify;
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIdentify({ target: id, identify: false });
|
||||||
|
}, [id, setIdentify]);
|
||||||
|
|
||||||
|
// start a timer that will close the overlay after some time
|
||||||
|
useEffect(() => {
|
||||||
|
if (showOverlay) {
|
||||||
|
timerRef.current = setTimeout(handleClose, MILLIS_PER_MINUTE);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [showOverlay, id, setIdentify, handleClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.overlay} data-testid='identify-overlay' onClick={handleClose}>
|
||||||
|
<div className={style.name}>{name}</div>
|
||||||
|
<div className={style.message}>Click to close</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,27 +1,25 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { RefObject, useEffect } from 'react';
|
||||||
import { Textarea, TextareaProps } from '@chakra-ui/react';
|
import { Textarea, TextareaProps } from '@chakra-ui/react';
|
||||||
// @ts-expect-error no types from library
|
// @ts-expect-error no types from library
|
||||||
import autosize from 'autosize/dist/autosize';
|
import autosize from 'autosize/dist/autosize';
|
||||||
|
|
||||||
export const AutoTextArea = (props: TextareaProps) => {
|
export const AutoTextArea = (props: TextareaProps & { inputref: RefObject<unknown> }) => {
|
||||||
const { value } = props;
|
const { value, inputref } = props;
|
||||||
|
|
||||||
const ref = useRef<HTMLTextAreaElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const node = ref.current;
|
const node = inputref.current;
|
||||||
autosize(ref.current);
|
autosize(inputref.current);
|
||||||
return () => {
|
return () => {
|
||||||
autosize.destroy(node);
|
autosize.destroy(node);
|
||||||
};
|
};
|
||||||
}, [value]);
|
}, [inputref, value]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Textarea
|
<Textarea
|
||||||
overflow='hidden'
|
overflow='hidden'
|
||||||
w='100%'
|
w='100%'
|
||||||
|
ref={inputref}
|
||||||
resize='none'
|
resize='none'
|
||||||
ref={ref}
|
|
||||||
transition='height none'
|
transition='height none'
|
||||||
variant='ontime-transparent'
|
variant='ontime-transparent'
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { KeyboardEvent, useEffect, useRef, useState } from 'react';
|
import { KeyboardEvent, useEffect, useRef, useState } from 'react';
|
||||||
import { Input, Radio, RadioGroup } from '@chakra-ui/react';
|
import { Input, Radio, RadioGroup } from '@chakra-ui/react';
|
||||||
import { millisToString } from 'ontime-utils';
|
import { millisToString, parseUserTime } from 'ontime-utils';
|
||||||
|
|
||||||
import { useEventAction } from '../../../hooks/useEventAction';
|
import { useEventAction } from '../../../hooks/useEventAction';
|
||||||
import { forgivingStringToMillis } from '../../../utils/dateConfig';
|
|
||||||
|
|
||||||
import style from './DelayInput.module.scss';
|
import style from './DelayInput.module.scss';
|
||||||
|
|
||||||
@@ -40,7 +39,7 @@ export default function DelayInput(props: DelayInputProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isNegative = newValue.startsWith('-');
|
const isNegative = newValue.startsWith('-');
|
||||||
let newMillis = forgivingStringToMillis(newValue);
|
let newMillis = parseUserTime(newValue);
|
||||||
|
|
||||||
if (isNegative) {
|
if (isNegative) {
|
||||||
newMillis = newMillis * -1;
|
newMillis = newMillis * -1;
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
import { useCallback, useRef } from 'react';
|
|
||||||
import { Input, Textarea } from '@chakra-ui/react';
|
|
||||||
|
|
||||||
import { EventEditorSubmitActions } from '../../../../features/rundown/event-editor/EventEditor';
|
|
||||||
import { Size } from '../../../models/Util.type';
|
|
||||||
|
|
||||||
import useReactiveTextInput from './useReactiveTextInput';
|
|
||||||
|
|
||||||
interface BaseProps {
|
|
||||||
isTextArea?: boolean;
|
|
||||||
isFullHeight?: boolean;
|
|
||||||
size?: Size;
|
|
||||||
field: EventEditorSubmitActions;
|
|
||||||
initialText?: string;
|
|
||||||
submitHandler: (field: EventEditorSubmitActions, newValue: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TextInputProps extends BaseProps {
|
|
||||||
isTextArea?: false;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ResizeOptions = 'horizontal' | 'vertical' | 'none';
|
|
||||||
|
|
||||||
interface TextAreaProps extends BaseProps {
|
|
||||||
isTextArea: true;
|
|
||||||
resize?: ResizeOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
type InputProps = TextInputProps | TextAreaProps;
|
|
||||||
|
|
||||||
export default function TextInput(props: InputProps) {
|
|
||||||
const { isTextArea, isFullHeight, size = 'sm', field, initialText = '', submitHandler } = props;
|
|
||||||
const inputRef = useRef(null);
|
|
||||||
|
|
||||||
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
|
|
||||||
|
|
||||||
const textInputProps = useReactiveTextInput(initialText, submitCallback, { submitOnEnter: true });
|
|
||||||
const textAreaProps = useReactiveTextInput(initialText, submitCallback);
|
|
||||||
|
|
||||||
let resize: ResizeOptions = 'none';
|
|
||||||
if (isTextArea) {
|
|
||||||
resize = (props as TextAreaProps)?.resize ?? 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
return isTextArea ? (
|
|
||||||
<Textarea
|
|
||||||
ref={inputRef}
|
|
||||||
size={size}
|
|
||||||
resize={resize}
|
|
||||||
variant='ontime-filled'
|
|
||||||
{...textAreaProps}
|
|
||||||
style={{ height: isFullHeight ? '100%' : undefined }}
|
|
||||||
data-testid='input-textarea'
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Input ref={inputRef} size={size} variant='ontime-filled' {...textInputProps} data-testid='input-textfield' />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,20 @@
|
|||||||
import { ChangeEvent, KeyboardEvent, useCallback, useEffect, useState } from 'react';
|
import { ChangeEvent, KeyboardEvent, RefObject, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { getHotkeyHandler, HotkeyItem } from '@mantine/hooks';
|
||||||
|
|
||||||
interface UseReactiveTextInputReturn {
|
interface UseReactiveTextInputReturn {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (event: ChangeEvent) => void;
|
onChange: (event: ChangeEvent) => void;
|
||||||
onBlur: (event: ChangeEvent) => void;
|
onBlur: (event: ChangeEvent) => void;
|
||||||
onKeyDown: (event: KeyboardEvent) => void;
|
onKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function useReactiveTextInput(
|
export default function useReactiveTextInput(
|
||||||
initialText: string,
|
initialText: string,
|
||||||
submitCallback: (newValue: string) => void,
|
submitCallback: (newValue: string) => void,
|
||||||
|
ref: RefObject<HTMLInputElement>,
|
||||||
options?: {
|
options?: {
|
||||||
submitOnEnter?: boolean;
|
submitOnEnter?: boolean;
|
||||||
|
submitOnCtrlEnter?: boolean;
|
||||||
},
|
},
|
||||||
): UseReactiveTextInputReturn {
|
): UseReactiveTextInputReturn {
|
||||||
const [text, setText] = useState<string>(initialText);
|
const [text, setText] = useState<string>(initialText);
|
||||||
@@ -44,43 +47,45 @@ export default function useReactiveTextInput(
|
|||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(valueToSubmit: string) => {
|
(valueToSubmit: string) => {
|
||||||
// No need to update if it hasn't changed
|
// No need to update if it hasn't changed
|
||||||
if (valueToSubmit === initialText) {
|
if (valueToSubmit !== initialText) {
|
||||||
return;
|
const cleanVal = valueToSubmit.trim();
|
||||||
}
|
submitCallback(cleanVal);
|
||||||
const cleanVal = valueToSubmit.trim();
|
if (cleanVal !== valueToSubmit) {
|
||||||
submitCallback(cleanVal);
|
setText(cleanVal);
|
||||||
|
}
|
||||||
if (cleanVal !== valueToSubmit) {
|
|
||||||
setText(cleanVal);
|
|
||||||
}
|
}
|
||||||
|
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
|
||||||
},
|
},
|
||||||
[initialText, submitCallback],
|
[initialText, ref, submitCallback],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Handles common keys for submit and cancel
|
* @description Handles escape events
|
||||||
* @param {string} key
|
* @param {string} valueToSubmit
|
||||||
*/
|
*/
|
||||||
const keyHandler = useCallback(
|
const handleEscape = useCallback(() => {
|
||||||
(key: string) => {
|
// No need to update if it hasn't changed
|
||||||
switch (key) {
|
setText(initialText);
|
||||||
case 'Escape':
|
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
|
||||||
setText(initialText);
|
}, [initialText, ref]);
|
||||||
break;
|
|
||||||
case 'Enter':
|
const keyHandler = useMemo(() => {
|
||||||
if (options?.submitOnEnter) {
|
const hotKeys: HotkeyItem[] = [['Escape', handleEscape, { preventDefault: true }]];
|
||||||
handleSubmit(text);
|
|
||||||
}
|
if (options?.submitOnEnter) {
|
||||||
break;
|
hotKeys.push(['Enter', () => handleSubmit(text)]);
|
||||||
}
|
}
|
||||||
},
|
|
||||||
[initialText, options?.submitOnEnter, handleSubmit, text],
|
if (options?.submitOnCtrlEnter) {
|
||||||
);
|
hotKeys.push(['mod + Enter', () => handleSubmit(text)]);
|
||||||
|
}
|
||||||
|
return getHotkeyHandler(hotKeys);
|
||||||
|
}, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, text]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
value: text,
|
value: text,
|
||||||
onChange: (event: ChangeEvent) => handleChange((event.target as HTMLInputElement).value),
|
onChange: (event: ChangeEvent) => handleChange((event.target as HTMLInputElement).value),
|
||||||
onBlur: (event: ChangeEvent) => handleSubmit((event.target as HTMLInputElement).value),
|
onBlur: (event: ChangeEvent) => handleSubmit((event.target as HTMLInputElement).value),
|
||||||
onKeyDown: (event: KeyboardEvent) => keyHandler(event.key),
|
onKeyDown: keyHandler,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,21 @@
|
|||||||
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
|
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Input } from '@chakra-ui/react';
|
import { Input } from '@chakra-ui/react';
|
||||||
import { millisToString } from 'ontime-utils';
|
import { millisToString, parseUserTime } from 'ontime-utils';
|
||||||
|
|
||||||
import { useEmitLog } from '../../../stores/logger';
|
import { useEmitLog } from '../../../stores/logger';
|
||||||
import { forgivingStringToMillis } from '../../../utils/dateConfig';
|
|
||||||
import { cx } from '../../../utils/styleUtils';
|
|
||||||
|
|
||||||
import style from './TimeInput.module.scss';
|
|
||||||
interface TimeInputProps<T extends string> {
|
interface TimeInputProps<T extends string> {
|
||||||
name: T;
|
name: T;
|
||||||
submitHandler: (field: T, value: string) => void;
|
submitHandler: (field: T, value: string) => void;
|
||||||
time?: number;
|
time?: number;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
align?: 'left' | 'center';
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
|
export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
|
||||||
const { name, submitHandler, time = 0, placeholder, disabled, className } = props;
|
const { name, submitHandler, time = 0, placeholder, disabled, align = 'center', className } = props;
|
||||||
const { emitError } = useEmitLog();
|
const { emitError } = useEmitLog();
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const [value, setValue] = useState<string>('');
|
const [value, setValue] = useState<string>('');
|
||||||
@@ -58,7 +56,7 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
|
|||||||
submitHandler(name, newValue);
|
submitHandler(name, newValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
const valueInMillis = forgivingStringToMillis(newValue);
|
const valueInMillis = parseUserTime(newValue);
|
||||||
if (valueInMillis === time) {
|
if (valueInMillis === time) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -117,15 +115,13 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
|
|||||||
resetValue();
|
resetValue();
|
||||||
}, [resetValue, time]);
|
}, [resetValue, time]);
|
||||||
|
|
||||||
const timeInputClasses = cx([style.timeInput, className]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
size='sm'
|
size='sm'
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
data-testid={`time-input-${name}`}
|
data-testid={`time-input-${name}`}
|
||||||
className={timeInputClasses}
|
className={className}
|
||||||
fontSize='1rem'
|
fontSize='1rem'
|
||||||
type='text'
|
type='text'
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
@@ -136,7 +132,10 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
|
|||||||
onKeyDown={onKeyDownHandler}
|
onKeyDown={onKeyDownHandler}
|
||||||
value={value}
|
value={value}
|
||||||
maxLength={8}
|
maxLength={8}
|
||||||
|
maxWidth='7.5em'
|
||||||
|
letterSpacing='1px'
|
||||||
autoComplete='off'
|
autoComplete='off'
|
||||||
|
textAlign={align}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,3 @@ $input-delayed-border-color: $ontime-delay-text;
|
|||||||
border: 1px solid $input-delayed-border-color;
|
border: 1px solid $input-delayed-border-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.inputField {
|
|
||||||
max-width: 7.75em;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export default function TimeInputWithButton<T extends string>(props: PropsWithCh
|
|||||||
submitHandler={submitHandler}
|
submitHandler={submitHandler}
|
||||||
time={time}
|
time={time}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className={style.inputField}
|
align='left'
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -7,10 +7,6 @@ $progress-bar-br: 3px;
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: $progress-bar-size;
|
height: $progress-bar-size;
|
||||||
border-radius: $progress-bar-br;
|
|
||||||
background-color: var(--timer-progress-bg-override, $viewer-card-bg-color);
|
|
||||||
display: flex;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
&--hidden {
|
&--hidden {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -18,31 +14,42 @@ $progress-bar-br: 3px;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.multiprogress-bar__bg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--progress-bar-br, $progress-bar-br);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.multiprogress-bar__indicator {
|
.multiprogress-bar__indicator {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
height: inherit;
|
inset: 0;
|
||||||
background-color: black;
|
margin: -1px;
|
||||||
|
margin-left: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiprogress-bar__indicator-bar {
|
||||||
|
background-color: var(--background-color-override, $ui-black);
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
transition: 1s linear;
|
transition: 1s linear;
|
||||||
transition-property: width;
|
transition-property: width;
|
||||||
right: 0;
|
|
||||||
|
.multiprogress-bar--ignore-css-override & {
|
||||||
|
background-color: $ui-black;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.multiprogress-bar__bg-normal {
|
.multiprogress-bar__bg-normal {
|
||||||
position: absolute;
|
flex: 1;
|
||||||
height: inherit;
|
|
||||||
right: 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.multiprogress-bar__bg-warning {
|
|
||||||
position: absolute;
|
|
||||||
height: inherit;
|
|
||||||
right: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.multiprogress-bar__bg-danger {
|
.multiprogress-bar__bg-danger {
|
||||||
position: absolute;
|
flex-shrink: 0;
|
||||||
height: inherit;
|
}
|
||||||
right: 0;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,43 +1,62 @@
|
|||||||
import { MaybeNumber } from 'ontime-types';
|
import { MaybeNumber } from 'ontime-types';
|
||||||
|
|
||||||
import { clamp } from '../../utils/math';
|
import { getProgress } from '../../utils/getProgress';
|
||||||
|
|
||||||
import './MultiPartProgressBar.scss';
|
import './MultiPartProgressBar.scss';
|
||||||
|
|
||||||
interface MultiPartProgressBar {
|
interface MultiPartProgressBar {
|
||||||
now: MaybeNumber;
|
now: MaybeNumber;
|
||||||
complete: number;
|
complete: MaybeNumber;
|
||||||
normalColor: string;
|
normalColor: string;
|
||||||
warning?: MaybeNumber;
|
warning?: MaybeNumber;
|
||||||
warningColor: string;
|
warningColor: string;
|
||||||
danger?: MaybeNumber;
|
danger?: MaybeNumber;
|
||||||
dangerColor: string;
|
dangerColor: string;
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
|
ignoreCssOverride?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MultiPartProgressBar(props: MultiPartProgressBar) {
|
export default function MultiPartProgressBar(props: MultiPartProgressBar) {
|
||||||
const { now, complete, normalColor, warning, warningColor, danger, dangerColor, hidden, className = '' } = props;
|
const {
|
||||||
|
now,
|
||||||
|
complete,
|
||||||
|
normalColor,
|
||||||
|
warning,
|
||||||
|
warningColor,
|
||||||
|
danger,
|
||||||
|
dangerColor,
|
||||||
|
hidden,
|
||||||
|
ignoreCssOverride,
|
||||||
|
className = '',
|
||||||
|
} = props;
|
||||||
|
|
||||||
const percentComplete = 100 - clamp(100 - (Math.max(now ?? 0, 0) * 100) / complete, 0, 100);
|
const percentRemaining = 100 - getProgress(now, complete);
|
||||||
|
const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0;
|
||||||
const dangerWidth = danger ? clamp((danger / complete) * 100, 0, 100) : 0;
|
const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0;
|
||||||
const warningWidth = warning ? clamp((warning / complete) * 100, 0, 100) : 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`multiprogress-bar ${hidden ? 'multiprogress-bar--hidden' : ''} ${className}`}>
|
<div
|
||||||
|
className={`multiprogress-bar ${hidden ? 'multiprogress-bar--hidden' : ''} ${
|
||||||
|
ignoreCssOverride ? 'multiprogress-bar--ignore-css-override' : ''
|
||||||
|
} ${className}`}
|
||||||
|
>
|
||||||
{now !== null && (
|
{now !== null && (
|
||||||
<>
|
<>
|
||||||
<div className='multiprogress-bar__bg-normal' style={{ backgroundColor: normalColor }} />
|
<div className='multiprogress-bar__bg'>
|
||||||
<div
|
<div className='multiprogress-bar__bg-normal' style={{ backgroundColor: normalColor }} />
|
||||||
className='multiprogress-bar__bg-warning'
|
<div
|
||||||
style={{ width: `${warningWidth}%`, backgroundColor: warningColor }}
|
className='multiprogress-bar__bg-warning'
|
||||||
/>
|
style={{ width: `${warningWidth}%`, backgroundColor: warningColor }}
|
||||||
<div
|
/>
|
||||||
className='multiprogress-bar__bg-danger'
|
<div
|
||||||
style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }}
|
className='multiprogress-bar__bg-danger'
|
||||||
/>
|
style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }}
|
||||||
<div className='multiprogress-bar__indicator' style={{ width: `${percentComplete}%` }} />
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='multiprogress-bar__indicator'>
|
||||||
|
<div className='multiprogress-bar__indicator-bar' style={{ width: `${percentRemaining}%` }} />
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { IoApps } from '@react-icons/all-files/io5/IoApps';
|
||||||
|
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
||||||
|
|
||||||
|
import { debounce } from '../../utils/debounce';
|
||||||
|
|
||||||
|
import style from './NavigationMenu.module.scss';
|
||||||
|
|
||||||
|
interface FloatingNavigationProps {
|
||||||
|
toggleMenu: () => void;
|
||||||
|
toggleSettings: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FloatingNavigation(props: FloatingNavigationProps) {
|
||||||
|
const { toggleMenu, toggleSettings } = props;
|
||||||
|
const [showButton, setShowButton] = useState(false);
|
||||||
|
|
||||||
|
// show on mouse move
|
||||||
|
useEffect(() => {
|
||||||
|
let fadeOut: NodeJS.Timeout | null = null;
|
||||||
|
const setShowMenuTrue = () => {
|
||||||
|
setShowButton(true);
|
||||||
|
if (fadeOut) {
|
||||||
|
clearTimeout(fadeOut);
|
||||||
|
}
|
||||||
|
fadeOut = setTimeout(() => setShowButton(false), 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const debouncedShowMenu = debounce(setShowMenuTrue, 1000);
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', debouncedShowMenu);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousemove', debouncedShowMenu);
|
||||||
|
if (fadeOut) {
|
||||||
|
clearTimeout(fadeOut);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${style.buttonContainer} ${!showButton ? style.hidden : ''}`}>
|
||||||
|
<button
|
||||||
|
onClick={toggleMenu}
|
||||||
|
aria-label='toggle menu'
|
||||||
|
className={style.navButton}
|
||||||
|
data-testid='navigation__toggle-menu'
|
||||||
|
>
|
||||||
|
<IoApps />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={style.button}
|
||||||
|
onClick={toggleSettings}
|
||||||
|
aria-label='toggle settings'
|
||||||
|
data-testid='navigation__toggle-settings'
|
||||||
|
>
|
||||||
|
<IoSettingsOutline />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
@use '../../../theme/mixins' as *;
|
@use '../../../theme/mixins' as *;
|
||||||
|
|
||||||
$menu-bg: $gray-1200;
|
|
||||||
$menu-hover-bg: $gray-1350;
|
$menu-hover-bg: $gray-1350;
|
||||||
$menu-focus-bg: $gray-1300;
|
$menu-focus-bg: $gray-1300;
|
||||||
|
|
||||||
$icon-color: $ui-white;
|
$icon-color: $ui-white;
|
||||||
$button-bg: $gray-1050;
|
$button-bg: $gray-1050;
|
||||||
$button-size: 48px;
|
$button-size: 3rem;
|
||||||
|
|
||||||
.buttonContainer {
|
.buttonContainer {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -21,7 +20,7 @@ $button-size: 48px;
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 2;
|
z-index: 12;
|
||||||
|
|
||||||
&.hidden {
|
&.hidden {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -29,7 +28,7 @@ $button-size: 48px;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
font-size: 24px;
|
font-size: 1.5rem;
|
||||||
color: $icon-color;
|
color: $icon-color;
|
||||||
background-color: $button-bg;
|
background-color: $button-bg;
|
||||||
width: $button-size;
|
width: $button-size;
|
||||||
@@ -44,31 +43,10 @@ $button-size: 48px;
|
|||||||
z-index: 3;
|
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 {
|
.link {
|
||||||
@include action-link;
|
@include action-link;
|
||||||
justify-content: space-between;
|
padding: 0.75rem 1.5rem;
|
||||||
padding: 0.5rem 1rem;
|
gap: 0.5rem;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: $menu-hover-bg;
|
background-color: $menu-hover-bg;
|
||||||
@@ -91,7 +69,7 @@ $button-size: 48px;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.linkIcon {
|
.linkIcon {
|
||||||
display: inline-block;
|
margin-left: auto;
|
||||||
transform: rotate(45deg);
|
transform: rotate(45deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,87 +1,69 @@
|
|||||||
import { memo, useEffect, useRef, useState } from 'react';
|
import { memo, useRef } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { Link, useLocation, useSearchParams } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useDisclosure } from '@chakra-ui/react';
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerBody,
|
||||||
|
DrawerCloseButton,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerOverlay,
|
||||||
|
useDisclosure,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
import { useFullscreen } from '@mantine/hooks';
|
import { useFullscreen } from '@mantine/hooks';
|
||||||
import { IoApps } from '@react-icons/all-files/io5/IoApps';
|
|
||||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
||||||
import { IoContract } from '@react-icons/all-files/io5/IoContract';
|
import { IoContract } from '@react-icons/all-files/io5/IoContract';
|
||||||
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
|
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 { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
|
||||||
|
|
||||||
import { navigatorConstants } from '../../../viewerConfig';
|
import { navigatorConstants } from '../../../viewerConfig';
|
||||||
import useClickOutside from '../../hooks/useClickOutside';
|
import useClickOutside from '../../hooks/useClickOutside';
|
||||||
|
import { useClientStore } from '../../stores/clientStore';
|
||||||
import { useViewOptionsStore } from '../../stores/viewOptions';
|
import { useViewOptionsStore } from '../../stores/viewOptions';
|
||||||
import { isKeyEnter } from '../../utils/keyEvent';
|
import { isKeyEnter } from '../../utils/keyEvent';
|
||||||
|
import { RenameClientModal } from '../client-modal/RenameClientModal';
|
||||||
import RenameClientModal from './rename-client-modal/RenameClientModal';
|
|
||||||
|
|
||||||
import style from './NavigationMenu.module.scss';
|
import style from './NavigationMenu.module.scss';
|
||||||
|
|
||||||
function NavigationMenu() {
|
interface NavigationMenuProps {
|
||||||
const location = useLocation();
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenu(props: NavigationMenuProps) {
|
||||||
|
const { isOpen, onClose } = props;
|
||||||
|
|
||||||
|
const id = useClientStore((store) => store.id);
|
||||||
|
const name = useClientStore((store) => store.name);
|
||||||
|
|
||||||
|
const { isOpen: isOpenRename, onOpen: onRenameOpen, onClose: onCloseRename } = useDisclosure();
|
||||||
const { fullscreen, toggle } = useFullscreen();
|
const { fullscreen, toggle } = useFullscreen();
|
||||||
const { toggleMirror } = useViewOptionsStore();
|
const { toggleMirror } = useViewOptionsStore();
|
||||||
const [showButton, setShowButton] = useState(false);
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
const [showMenu, setShowMenu] = useState(false);
|
|
||||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
useClickOutside(menuRef, () => setShowMenu(false));
|
useClickOutside(menuRef, () => onClose);
|
||||||
|
|
||||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
|
||||||
|
|
||||||
const toggleMenu = () => setShowMenu((prev) => !prev);
|
|
||||||
|
|
||||||
// show on mouse move
|
|
||||||
useEffect(() => {
|
|
||||||
let fadeOut: NodeJS.Timeout | null = null;
|
|
||||||
const setShowMenuTrue = () => {
|
|
||||||
setShowButton(true);
|
|
||||||
if (fadeOut) {
|
|
||||||
clearTimeout(fadeOut);
|
|
||||||
}
|
|
||||||
fadeOut = setTimeout(() => setShowButton(false), 3000);
|
|
||||||
};
|
|
||||||
document.addEventListener('mousemove', setShowMenuTrue);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousemove', setShowMenuTrue);
|
|
||||||
if (fadeOut) {
|
|
||||||
clearTimeout(fadeOut);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleFullscreen = () => toggle();
|
|
||||||
const handleMirror = () => toggleMirror();
|
|
||||||
|
|
||||||
const showEditFormDrawer = () => {
|
|
||||||
searchParams.set('edit', 'true');
|
|
||||||
setSearchParams(searchParams);
|
|
||||||
};
|
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div id='navigation-menu-portal' ref={menuRef}>
|
<div id='navigation-menu-portal' ref={menuRef}>
|
||||||
<RenameClientModal isOpen={isOpen} onClose={onClose} />
|
<RenameClientModal id={id} name={name} isOpen={isOpenRename} onClose={onCloseRename} />
|
||||||
<div className={`${style.buttonContainer} ${!showButton && !showMenu ? style.hidden : ''}`}>
|
<Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
|
||||||
<button onClick={toggleMenu} aria-label='toggle menu' className={style.navButton}>
|
<DrawerOverlay />
|
||||||
<IoApps />
|
<DrawerContent>
|
||||||
</button>
|
<DrawerHeader>
|
||||||
<button className={style.button} onClick={showEditFormDrawer}>
|
<DrawerCloseButton size='lg' />
|
||||||
<IoPencilSharp />
|
Ontime
|
||||||
</button>
|
</DrawerHeader>
|
||||||
{showMenu && (
|
<DrawerBody padding={0}>
|
||||||
<div className={style.menuContainer} data-testid='navigation-menu'>
|
|
||||||
<div className={style.buttonsContainer}>
|
<div className={style.buttonsContainer}>
|
||||||
<div
|
<div
|
||||||
className={style.link}
|
className={style.link}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
role='button'
|
role='button'
|
||||||
onClick={handleFullscreen}
|
onClick={toggle}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
isKeyEnter(event) && handleFullscreen();
|
isKeyEnter(event) && toggle();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Toggle Fullscreen
|
Toggle Fullscreen
|
||||||
@@ -91,9 +73,9 @@ function NavigationMenu() {
|
|||||||
className={style.link}
|
className={style.link}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
role='button'
|
role='button'
|
||||||
onClick={handleMirror}
|
onClick={() => toggleMirror()}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
isKeyEnter(event) && handleMirror();
|
isKeyEnter(event) && toggleMirror();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Flip Screen
|
Flip Screen
|
||||||
@@ -103,20 +85,35 @@ function NavigationMenu() {
|
|||||||
className={style.link}
|
className={style.link}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
role='button'
|
role='button'
|
||||||
onClick={onOpen}
|
onClick={onRenameOpen}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
isKeyEnter(event) && onOpen();
|
isKeyEnter(event) && onRenameOpen();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Rename Client
|
Rename Client
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<hr className={style.separator} />
|
<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
|
Cuesheet
|
||||||
<IoArrowUp className={style.linkIcon} />
|
<IoArrowUp className={style.linkIcon} />
|
||||||
</Link>
|
</Link>
|
||||||
<Link to='/op' className={style.link} tabIndex={0}>
|
<Link to='/op' className={`${style.link} ${location.pathname === '/op' ? style.current : ''}`} tabIndex={0}>
|
||||||
|
<IoLockClosedOutline />
|
||||||
Operator
|
Operator
|
||||||
<IoArrowUp className={style.linkIcon} />
|
<IoArrowUp className={style.linkIcon} />
|
||||||
</Link>
|
</Link>
|
||||||
@@ -132,11 +129,10 @@ function NavigationMenu() {
|
|||||||
<IoArrowUp className={style.linkIcon} />
|
<IoArrowUp className={style.linkIcon} />
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</DrawerBody>
|
||||||
)}
|
</DrawerContent>
|
||||||
</div>
|
</Drawer>
|
||||||
</div>,
|
</div>,
|
||||||
|
|
||||||
document.body,
|
document.body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
|
||||||
|
import NavigationMenu from './NavigationMenu';
|
||||||
|
|
||||||
|
interface ProductionNavigationMenuProps {
|
||||||
|
isMenuOpen: boolean;
|
||||||
|
onMenuClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProductionNavigationMenu(props: ProductionNavigationMenuProps) {
|
||||||
|
const { isMenuOpen, onMenuClose } = props;
|
||||||
|
|
||||||
|
return <NavigationMenu isOpen={isMenuOpen} onClose={onMenuClose} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(ProductionNavigationMenu);
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { memo, useCallback } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { useDisclosure } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import FloatingNavigation from './FloatingNavigation';
|
||||||
|
import NavigationMenu from './NavigationMenu';
|
||||||
|
|
||||||
|
function ViewNavigationMenu() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const { isOpen: isMenuOpen, onOpen: onMenuOpen, onClose: onMenuClose } = useDisclosure();
|
||||||
|
|
||||||
|
const showEditFormDrawer = useCallback(() => {
|
||||||
|
searchParams.set('edit', 'true');
|
||||||
|
setSearchParams(searchParams);
|
||||||
|
}, [searchParams, setSearchParams]);
|
||||||
|
|
||||||
|
const toggleMenu = () => (isMenuOpen ? onMenuClose() : onMenuOpen());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FloatingNavigation toggleMenu={toggleMenu} toggleSettings={showEditFormDrawer} />
|
||||||
|
<NavigationMenu isOpen={isMenuOpen} onClose={onMenuClose} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(ViewNavigationMenu);
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
.modalBody {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Input,
|
|
||||||
Modal,
|
|
||||||
ModalBody,
|
|
||||||
ModalCloseButton,
|
|
||||||
ModalContent,
|
|
||||||
ModalHeader,
|
|
||||||
ModalOverlay,
|
|
||||||
} from '@chakra-ui/react';
|
|
||||||
|
|
||||||
import { setClientName } from '../../../hooks/useSocket';
|
|
||||||
import { useSocketClientName } from '../../../stores/connectionName';
|
|
||||||
|
|
||||||
import style from './RenameClientModal.module.scss';
|
|
||||||
|
|
||||||
interface RenameClientModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RenameClientModal({ isOpen, onClose }: RenameClientModalProps) {
|
|
||||||
const { name: clientName, persistName } = useSocketClientName();
|
|
||||||
const [newName, setNewName] = useState(clientName);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setNewName(clientName);
|
|
||||||
}, [isOpen, clientName]);
|
|
||||||
|
|
||||||
const handleRename = async () => {
|
|
||||||
if (newName) {
|
|
||||||
setClientName(newName);
|
|
||||||
persistName(newName);
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
isOpen={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
size='sm'
|
|
||||||
closeOnOverlayClick={false}
|
|
||||||
motionPreset='slideInBottom'
|
|
||||||
scrollBehavior='inside'
|
|
||||||
preserveScrollBarGap
|
|
||||||
variant='ontime-small'
|
|
||||||
>
|
|
||||||
<ModalOverlay />
|
|
||||||
<ModalContent>
|
|
||||||
<ModalHeader>Rename client</ModalHeader>
|
|
||||||
<ModalCloseButton />
|
|
||||||
<ModalBody className={style.modalBody}>
|
|
||||||
<Input
|
|
||||||
placeholder='Connection must have a name'
|
|
||||||
defaultValue={newName}
|
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
|
||||||
variant='ontime-filled-on-light'
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
isDisabled={newName === clientName || !newName}
|
|
||||||
onClick={handleRename}
|
|
||||||
width='100%'
|
|
||||||
variant='ontime-filled'
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</ModalBody>
|
|
||||||
</ModalContent>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
@use '../../../theme/viewerDefs' as *;
|
@use '../../../theme/viewerDefs' as *;
|
||||||
|
|
||||||
$progress-bar-size: 12px;
|
$progress-bar-size: 12px;
|
||||||
$progress-bar-br: 6px;
|
$progress-bar-br: 3px;
|
||||||
|
|
||||||
.progress-bar__bg {
|
.progress-bar__bg {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: $progress-bar-size;
|
height: $progress-bar-size;
|
||||||
border-radius: $progress-bar-br;
|
border-radius: $progress-bar-br;
|
||||||
background-color: var(--timer-progress-bg-override, $viewer-card-bg-color);
|
background-color: var(--timer-progress-bg-override, $viewer-card-bg-color);
|
||||||
|
overflow: clip;
|
||||||
|
|
||||||
&--hidden {
|
&--hidden {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -18,6 +19,5 @@ $progress-bar-br: 6px;
|
|||||||
height: $progress-bar-size;
|
height: $progress-bar-size;
|
||||||
background-color: var(--timer-progress-override, $accent-color);
|
background-color: var(--timer-progress-override, $accent-color);
|
||||||
transition: 1s linear;
|
transition: 1s linear;
|
||||||
border-radius: $progress-bar-br 0 0 $progress-bar-br;
|
|
||||||
transition-property: width;
|
transition-property: width;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
import { clamp } from '../../utils/math';
|
import { MaybeNumber } from 'ontime-types';
|
||||||
|
|
||||||
|
import { getProgress } from '../../utils/getProgress';
|
||||||
|
|
||||||
import './ProgressBar.scss';
|
import './ProgressBar.scss';
|
||||||
|
|
||||||
interface ProgressBarProps {
|
interface ProgressBarProps {
|
||||||
now?: number;
|
current: MaybeNumber;
|
||||||
complete?: number;
|
duration: MaybeNumber;
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProgressBar(props: ProgressBarProps) {
|
export default function ProgressBar(props: ProgressBarProps) {
|
||||||
const { now = 0, complete = 100, hidden, className = '' } = props;
|
const { current, duration, hidden, className = '' } = props;
|
||||||
|
const progress = getProgress(current, duration);
|
||||||
const percentComplete = clamp(100 - (Math.max(now, 0) * 100) / complete, 0, 100);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`progress-bar__bg ${hidden ? 'progress-bar__bg--hidden' : ''} ${className}`}>
|
<div className={`progress-bar__bg ${hidden ? 'progress-bar__bg--hidden' : ''} ${className}`}>
|
||||||
<div className='progress-bar__indicator' style={{ width: `${percentComplete}%` }} />
|
<div className='progress-bar__indicator' style={{ width: `${progress}%` }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
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 { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
|
||||||
|
|
||||||
import style from './ProtectRoute.module.scss';
|
import style from './ProtectRoute.module.scss';
|
||||||
@@ -39,8 +39,8 @@ export default function PinPage(props: PinPageProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.container}>
|
<div className={style.container}>
|
||||||
{`Ontime ${permission || ''}`}
|
{`Ontime ${permission}`}
|
||||||
<HStack spacing='10px' className={failed ? style.pin__failed : style.pin}>
|
<div className={failed ? style.pin__failed : style.pin}>
|
||||||
<PinInput
|
<PinInput
|
||||||
type='alphanumeric'
|
type='alphanumeric'
|
||||||
size='lg'
|
size='lg'
|
||||||
@@ -57,8 +57,15 @@ export default function PinPage(props: PinPageProps) {
|
|||||||
<PinInputField />
|
<PinInputField />
|
||||||
<PinInputField />
|
<PinInputField />
|
||||||
</PinInput>
|
</PinInput>
|
||||||
<IconButton aria-label='Enter' size='lg' isRound icon={<IoCheckmark />} onClick={validate} />
|
<IconButton
|
||||||
</HStack>
|
variant='ontime-filled'
|
||||||
|
aria-label='Enter'
|
||||||
|
size='lg'
|
||||||
|
isRound
|
||||||
|
icon={<IoCheckmark />}
|
||||||
|
onClick={validate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,31 @@
|
|||||||
.container {
|
.container {
|
||||||
display: grid;
|
display: flex;
|
||||||
place-content: center;
|
flex-direction: column;
|
||||||
height: 100vh;
|
align-items: center;
|
||||||
padding-bottom: 30vh;
|
padding-top: 25vh;
|
||||||
|
|
||||||
background: $bg-container-l1;
|
|
||||||
color: $ontime-color;
|
color: $ontime-color;
|
||||||
font-family: $ontime-font-family;
|
|
||||||
font-weight: 200;
|
font-weight: 200;
|
||||||
text-align: center;
|
font-size: 3rem;
|
||||||
font-size: 3vw;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pin,
|
.pin,
|
||||||
.pin__failed {
|
.pin__failed {
|
||||||
padding: 20px;
|
display: flex;
|
||||||
|
gap: 0.125em;
|
||||||
|
padding-block: 0.5em;
|
||||||
|
|
||||||
input {
|
input {
|
||||||
border-radius: 50%;
|
border-radius: 99px;
|
||||||
|
border-color: $gray-500;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: $blue-500;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
margin-left: 20px;
|
margin-left: 1em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,9 +37,9 @@
|
|||||||
|
|
||||||
@keyframes colourFade {
|
@keyframes colourFade {
|
||||||
from {
|
from {
|
||||||
background: $action-blue;
|
background: $red-500;
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
background: rgba($action-blue, 0);
|
background: rgba($red-500, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -24,6 +24,8 @@ interface ScheduleProviderProps {
|
|||||||
time?: number;
|
time?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const numEventsPerPage = 8;
|
||||||
|
|
||||||
export const ScheduleProvider = ({
|
export const ScheduleProvider = ({
|
||||||
children,
|
children,
|
||||||
events,
|
events,
|
||||||
@@ -37,7 +39,7 @@ export const ScheduleProvider = ({
|
|||||||
// look for overrides from views
|
// look for overrides from views
|
||||||
const hidePast = isStringBoolean(searchParams.get('hidePast'));
|
const hidePast = isStringBoolean(searchParams.get('hidePast'));
|
||||||
const stopCycle = isStringBoolean(searchParams.get('stopCycle'));
|
const stopCycle = isStringBoolean(searchParams.get('stopCycle'));
|
||||||
const eventsPerPage = Number(searchParams.get('eventsPerPage') ?? 7);
|
const eventsPerPage = Number(searchParams.get('eventsPerPage') ?? numEventsPerPage);
|
||||||
|
|
||||||
let selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
|
let selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,37 @@
|
|||||||
@use '../../../theme/viewerDefs' as *;
|
@use '../../../theme/viewerDefs' as *;
|
||||||
|
|
||||||
.title-card {
|
.title-card {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inline {
|
.title-card__title,
|
||||||
display: flex;
|
.title-card__secondary {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title-card__title {
|
.title-card__title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: clamp(32px, 3.5vw, 50px);
|
font-size: clamp(1.5rem, 3vw, 3rem);
|
||||||
color: var(--color-override, $viewer-color);
|
color: var(--color-override, $viewer-color);
|
||||||
line-height: 1.1em;
|
line-height: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-card__secondary {
|
||||||
|
font-size: clamp(1rem, 2vw, 2.25rem);
|
||||||
|
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||||
|
line-height: 1.2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title-card__label {
|
.title-card__label {
|
||||||
|
position: absolute;
|
||||||
|
right: 1rem;
|
||||||
|
top: 0.5rem;
|
||||||
font-size: clamp(1rem, 1.5vw, 1.5rem);
|
font-size: clamp(1rem, 1.5vw, 1.5rem);
|
||||||
font-weight: 400;
|
|
||||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -28,13 +40,3 @@
|
|||||||
color: var(--accent-color-override, $accent-color);
|
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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,28 +1,32 @@
|
|||||||
|
import { ForwardedRef, forwardRef } from 'react';
|
||||||
|
|
||||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||||
|
|
||||||
import './TitleCard.scss';
|
import './TitleCard.scss';
|
||||||
|
|
||||||
interface TitleCardProps {
|
interface TitleCardProps {
|
||||||
label: 'now' | 'next';
|
|
||||||
title: string;
|
title: string;
|
||||||
|
label?: 'now' | 'next';
|
||||||
secondary?: string;
|
secondary?: string;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TitleCard(props: TitleCardProps) {
|
const TitleCard = forwardRef((props: TitleCardProps, ref: ForwardedRef<HTMLDivElement>) => {
|
||||||
const { label, title, secondary } = props;
|
const { label, title, secondary, className = '' } = props;
|
||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
|
|
||||||
const accent = label === 'now';
|
const accent = label === 'now';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='title-card'>
|
<div className={`title-card ${className}`} ref={ref}>
|
||||||
<div className='inline'>
|
<span className='title-card__title'>{title}</span>
|
||||||
<span className='title-card__title'>{title}</span>
|
<span className={accent ? 'title-card__label title-card__label--accent' : 'title-card__label'}>
|
||||||
<span className={accent ? 'title-card__label title-card__label--accent' : 'title-card__label'}>
|
{label && getLocalizedString(`common.${label}`)}
|
||||||
{getLocalizedString(`common.${label}`)}
|
</span>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className='title-card__secondary'>{secondary}</div>
|
<div className='title-card__secondary'>{secondary}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
TitleCard.displayName = 'TitleCard';
|
||||||
|
export default TitleCard;
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Input, InputGroup, InputLeftElement, Select, Switch } from '@chakra-ui/react';
|
import {
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
InputGroup,
|
||||||
|
InputLeftElement,
|
||||||
|
Menu,
|
||||||
|
MenuButton,
|
||||||
|
MenuItemOption,
|
||||||
|
MenuList,
|
||||||
|
MenuOptionGroup,
|
||||||
|
Select,
|
||||||
|
Switch,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
|
||||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||||
|
|
||||||
@@ -14,6 +27,10 @@ export default function ParamInput(props: EditFormInputProps) {
|
|||||||
const { paramField } = props;
|
const { paramField } = props;
|
||||||
const { id, type, defaultValue } = paramField;
|
const { id, type, defaultValue } = paramField;
|
||||||
|
|
||||||
|
if (type === 'persist') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (type === 'option') {
|
if (type === 'option') {
|
||||||
const optionFromParams = searchParams.get(id);
|
const optionFromParams = searchParams.get(id);
|
||||||
const defaultOptionValue = optionFromParams || defaultValue;
|
const defaultOptionValue = optionFromParams || defaultValue;
|
||||||
@@ -34,6 +51,10 @@ export default function ParamInput(props: EditFormInputProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (type === 'multi-option') {
|
||||||
|
return <MultiOption paramField={paramField} />;
|
||||||
|
}
|
||||||
|
|
||||||
if (type === 'boolean') {
|
if (type === 'boolean') {
|
||||||
const defaultCheckedValue = isStringBoolean(searchParams.get(id)) || defaultValue;
|
const defaultCheckedValue = isStringBoolean(searchParams.get(id)) || defaultValue;
|
||||||
|
|
||||||
@@ -70,3 +91,47 @@ export default function ParamInput(props: EditFormInputProps) {
|
|||||||
</InputGroup>
|
</InputGroup>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface EditFormMultiOptionProps {
|
||||||
|
paramField: ParamField & { type: 'multi-option' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function MultiOption(props: EditFormMultiOptionProps) {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const { paramField } = props;
|
||||||
|
const { id, defaultValue } = paramField;
|
||||||
|
|
||||||
|
const optionFromParams = (searchParams.get(id) ?? '').toLocaleLowerCase();
|
||||||
|
const defaultOptionValue = optionFromParams || defaultValue?.toLocaleLowerCase() || '';
|
||||||
|
|
||||||
|
const [paramState, setParamState] = useState<string>(defaultOptionValue);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input name={id} hidden readOnly value={paramState} />
|
||||||
|
<Menu isLazy closeOnSelect={false} variant='ontime-on-dark'>
|
||||||
|
<MenuButton as={Button} variant='ontime-subtle-white' position='relative' width='fit-content' fontWeight={400}>
|
||||||
|
{paramField.title}
|
||||||
|
</MenuButton>
|
||||||
|
<MenuList>
|
||||||
|
<MenuOptionGroup
|
||||||
|
type='checkbox'
|
||||||
|
value={paramState.split('_')}
|
||||||
|
onChange={(value) => {
|
||||||
|
setParamState(typeof value === 'object' ? value.filter((v) => v !== '').join('_') : value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Object.values(paramField.values).map((option) => {
|
||||||
|
const { value, label } = option;
|
||||||
|
return (
|
||||||
|
<MenuItemOption value={value} key={value}>
|
||||||
|
{label}
|
||||||
|
</MenuItemOption>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</MenuOptionGroup>
|
||||||
|
</MenuList>
|
||||||
|
</Menu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,24 +1,9 @@
|
|||||||
.drawerContent {
|
|
||||||
background-color: $gray-1250;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawerHeader {
|
|
||||||
@extend .drawerContent;
|
|
||||||
color: $section-white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawerFooter {
|
.drawerFooter {
|
||||||
@extend .drawerContent;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: start;
|
justify-content: end;
|
||||||
gap: $section-spacing;
|
gap: $section-spacing;
|
||||||
|
|
||||||
button[type='reset'] {
|
button {
|
||||||
padding: 0 2em;
|
|
||||||
margin-right: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
button[type='submit'] {
|
|
||||||
padding: 0 2em;
|
padding: 0 2em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -26,13 +11,26 @@
|
|||||||
.label {
|
.label {
|
||||||
font-size: $inner-section-text-size;
|
font-size: $inner-section-text-size;
|
||||||
color: $label-gray;
|
color: $label-gray;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.columnSection {
|
.section {
|
||||||
|
color: $ui-white;
|
||||||
|
font-size: 1rem;
|
||||||
|
|
||||||
|
&:not(:first-child) {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldSet {
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: $section-spacing 0;
|
padding: $section-spacing 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: $element-spacing;
|
gap: $element-spacing;
|
||||||
|
margin-left: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
|
|
||||||
import ParamInput from './ParamInput';
|
import ParamInput from './ParamInput';
|
||||||
import { ParamField } from './types';
|
import { isSection, ViewOption } from './types';
|
||||||
|
|
||||||
import style from './ViewParamsEditor.module.scss';
|
import style from './ViewParamsEditor.module.scss';
|
||||||
|
|
||||||
@@ -22,25 +22,37 @@ type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
|
|||||||
/**
|
/**
|
||||||
* Makes a new URLSearchParams object from the given params object
|
* Makes a new URLSearchParams object from the given params object
|
||||||
*/
|
*/
|
||||||
const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ParamField[]) => {
|
const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ViewOption[]) => {
|
||||||
const defaultValues = paramFields.reduce<Record<string, string>>((acc, { id, defaultValue }) => {
|
const newSearchParams = new URLSearchParams();
|
||||||
acc[id] = String(defaultValue);
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
return Object.entries(paramsObj).reduce((newSearchParams, [id, value]) => {
|
// Convert paramFields to an object that contains default values
|
||||||
|
const defaultValues: Record<string, string> = {};
|
||||||
|
paramFields.forEach((option) => {
|
||||||
|
if (!isSection(option)) {
|
||||||
|
defaultValues[option.id] = String(option.defaultValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// extract persisted values
|
||||||
|
if ('type' in option && option.type === 'persist') {
|
||||||
|
newSearchParams.set(option.id, option.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// compare which values are different from the default values
|
||||||
|
Object.entries(paramsObj).forEach(([id, value]) => {
|
||||||
if (typeof value === 'string' && value.length && defaultValues[id] !== value) {
|
if (typeof value === 'string' && value.length && defaultValues[id] !== value) {
|
||||||
newSearchParams.set(id, value);
|
newSearchParams.set(id, value);
|
||||||
}
|
}
|
||||||
return newSearchParams;
|
});
|
||||||
}, new URLSearchParams());
|
return newSearchParams;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface EditFormDrawerProps {
|
interface EditFormDrawerProps {
|
||||||
paramFields: ParamField[];
|
viewOptions: ViewOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
|
// TODO: this is a good candidate for memoisation, but needs the paramFields to be stable
|
||||||
|
export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { isOpen, onClose, onOpen } = useDisclosure();
|
const { isOpen, onClose, onOpen } = useDisclosure();
|
||||||
|
|
||||||
@@ -52,54 +64,68 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
|
|||||||
}
|
}
|
||||||
}, [searchParams, onOpen]);
|
}, [searchParams, onOpen]);
|
||||||
|
|
||||||
const onCloseWithoutSaving = () => {
|
const handleClose = () => {
|
||||||
onClose();
|
|
||||||
|
|
||||||
searchParams.delete('edit');
|
searchParams.delete('edit');
|
||||||
setSearchParams(searchParams);
|
setSearchParams(searchParams);
|
||||||
|
|
||||||
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetParams = () => {
|
const resetParams = () => {
|
||||||
setSearchParams();
|
setSearchParams();
|
||||||
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
|
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
|
||||||
formEvent.preventDefault();
|
formEvent.preventDefault();
|
||||||
|
|
||||||
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
|
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
|
||||||
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, paramFields);
|
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
|
||||||
setSearchParams(newSearchParams);
|
setSearchParams(newSearchParams);
|
||||||
|
|
||||||
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer isOpen={isOpen} placement='right' onClose={onCloseWithoutSaving} size='lg'>
|
<Drawer isOpen={isOpen} placement='right' onClose={handleClose} variant='ontime' size='lg'>
|
||||||
<DrawerOverlay />
|
<DrawerOverlay />
|
||||||
<DrawerContent>
|
<DrawerContent>
|
||||||
<DrawerHeader className={style.drawerHeader}>
|
<DrawerHeader>
|
||||||
<DrawerCloseButton _hover={{ bg: '#ebedf0', color: '#333' }} size='lg' />
|
<DrawerCloseButton size='lg' />
|
||||||
Customise
|
Customise
|
||||||
</DrawerHeader>
|
</DrawerHeader>
|
||||||
|
|
||||||
<DrawerBody className={style.drawerContent}>
|
<DrawerBody>
|
||||||
<form id='edit-params-form' onSubmit={onParamsFormSubmit}>
|
<form id='edit-params-form' onSubmit={onParamsFormSubmit}>
|
||||||
{paramFields.map((field) => (
|
{viewOptions.map((option) => {
|
||||||
<div key={field.title} className={style.columnSection}>
|
if (isSection(option)) {
|
||||||
<label className={style.label}>
|
return (
|
||||||
<span className={style.title}>{field.title}</span>
|
<div key={option.section} className={style.section}>
|
||||||
<span className={style.description}>{field.description}</span>
|
{option.section}
|
||||||
<ParamInput key={field.title} paramField={field} />
|
</div>
|
||||||
</label>
|
);
|
||||||
</div>
|
}
|
||||||
))}
|
|
||||||
|
if (option.type === 'persist') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={option.title} className={style.fieldSet}>
|
||||||
|
<label className={style.label}>
|
||||||
|
<span className={style.title}>{option.title}</span>
|
||||||
|
<span className={style.description}>{option.description}</span>
|
||||||
|
<ParamInput key={option.title} paramField={option} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</form>
|
</form>
|
||||||
</DrawerBody>
|
</DrawerBody>
|
||||||
|
|
||||||
<DrawerFooter className={style.drawerFooter}>
|
<DrawerFooter className={style.drawerFooter}>
|
||||||
<Button variant='ontime-ghosted' onClick={resetParams} type='reset'>
|
<Button variant='ontime-ghosted' onClick={resetParams} type='reset'>
|
||||||
Reset
|
Reset to default
|
||||||
</Button>
|
|
||||||
<Button variant='ontime-subtle' onClick={onCloseWithoutSaving}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant='ontime-filled' form='edit-params-form' type='submit'>
|
<Button variant='ontime-filled' form='edit-params-form' type='submit'>
|
||||||
Save
|
Save
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import { CustomFields } from 'ontime-types';
|
import { CustomFields } from 'ontime-types';
|
||||||
|
|
||||||
import { capitaliseFirstLetter } from '../../../features/viewers/common/viewUtils';
|
import type { ParamField } from './types';
|
||||||
|
|
||||||
import { ParamField } from './types';
|
export const makeOptionsFromCustomFields = (
|
||||||
|
customFields: CustomFields,
|
||||||
const makeOptionsFromCustomFields = (customFields: CustomFields, additionalOptions?: Record<string, string>) => {
|
additionalOptions: Record<string, string> = {},
|
||||||
const customFieldOptions = Object.keys(customFields).reduce((acc, key) => {
|
) => {
|
||||||
return { ...acc, [`custom-${key}`]: `Custom: ${capitaliseFirstLetter(key)}` };
|
return Object.entries(customFields).reduce((options, [key, value]) => {
|
||||||
}, additionalOptions ?? {});
|
options[`custom-${key}`] = `Custom: ${value.label}`;
|
||||||
return customFieldOptions;
|
return options;
|
||||||
|
}, additionalOptions);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTimeOption = (timeFormat: string): ParamField => {
|
export const getTimeOption = (timeFormat: string): ParamField => {
|
||||||
const placeholder = `${timeFormat} (default)`;
|
const placeholder = `${timeFormat} (default)`;
|
||||||
return {
|
return {
|
||||||
id: 'timeformat',
|
id: 'timeformat',
|
||||||
@@ -22,7 +23,7 @@ const getTimeOption = (timeFormat: string): ParamField => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const hideTimerSeconds: ParamField = {
|
export const hideTimerSeconds: ParamField = {
|
||||||
id: 'hideTimerSeconds',
|
id: 'hideTimerSeconds',
|
||||||
title: 'Hide seconds in timer',
|
title: 'Hide seconds in timer',
|
||||||
description: 'Whether to hide seconds in the running timer',
|
description: 'Whether to hide seconds in the running timer',
|
||||||
@@ -30,475 +31,10 @@ const hideTimerSeconds: ParamField = {
|
|||||||
defaultValue: false,
|
defaultValue: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getClockOptions = (timeFormat: string): ParamField[] => [
|
export const showLeadingZeros: ParamField = {
|
||||||
getTimeOption(timeFormat),
|
id: 'showLeadingZeros',
|
||||||
{
|
title: 'Show leading zeros in timer',
|
||||||
id: 'key',
|
description: 'Whether to show leading zeros in the running timer',
|
||||||
title: 'Key Colour',
|
type: 'boolean',
|
||||||
description: 'Background colour in hexadecimal',
|
defaultValue: false,
|
||||||
prefix: '#',
|
|
||||||
type: 'string',
|
|
||||||
placeholder: '00000000 (default)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'text',
|
|
||||||
title: 'Text Colour',
|
|
||||||
description: 'Text colour in hexadecimal',
|
|
||||||
prefix: '#',
|
|
||||||
type: 'string',
|
|
||||||
placeholder: 'fffff (default)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'textbg',
|
|
||||||
title: 'Text Background',
|
|
||||||
description: 'Colour of text background in hexadecimal',
|
|
||||||
prefix: '#',
|
|
||||||
type: 'string',
|
|
||||||
placeholder: '00000000 (default)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'font',
|
|
||||||
title: 'Font',
|
|
||||||
description: 'Font family, will use the fonts available in the system',
|
|
||||||
type: 'string',
|
|
||||||
placeholder: 'Arial Black (default)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'size',
|
|
||||||
title: 'Text Size',
|
|
||||||
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
|
|
||||||
type: 'number',
|
|
||||||
placeholder: '1 (default)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'alignx',
|
|
||||||
title: 'Align Horizontal',
|
|
||||||
description: 'Moves the horizontally in page to start = left | center | end = right',
|
|
||||||
type: 'option',
|
|
||||||
values: { start: 'Start', center: 'Center', end: 'End' },
|
|
||||||
defaultValue: 'center',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'offsetx',
|
|
||||||
title: 'Offset Horizontal',
|
|
||||||
description: 'Offsets the timer horizontal position by a given amount in pixels',
|
|
||||||
type: 'number',
|
|
||||||
placeholder: '0 (default)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'aligny',
|
|
||||||
title: 'Align Vertical',
|
|
||||||
description: 'Moves the vertically in page to start = left | center | end = right',
|
|
||||||
type: 'option',
|
|
||||||
values: { start: 'Start', center: 'Center', end: 'End' },
|
|
||||||
defaultValue: 'center',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'offsety',
|
|
||||||
title: 'Offset Vertical',
|
|
||||||
description: 'Offsets the timer vertical position by a given amount in pixels',
|
|
||||||
type: 'number',
|
|
||||||
placeholder: '0 (default)',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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',
|
|
||||||
description: 'Background colour in hexadecimal',
|
|
||||||
prefix: '#',
|
|
||||||
type: 'string',
|
|
||||||
placeholder: '00000000 (default)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'text',
|
|
||||||
title: 'Text Colour',
|
|
||||||
description: 'Text colour in hexadecimal',
|
|
||||||
prefix: '#',
|
|
||||||
type: 'string',
|
|
||||||
placeholder: 'fffff (default)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'textbg',
|
|
||||||
title: 'Text Background',
|
|
||||||
description: 'Colour of text background in hexadecimal',
|
|
||||||
prefix: '#',
|
|
||||||
type: 'string',
|
|
||||||
placeholder: '00000000 (default)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'font',
|
|
||||||
title: 'Font',
|
|
||||||
description: 'Font family, will use the fonts available in the system',
|
|
||||||
type: 'string',
|
|
||||||
placeholder: 'Arial Black (default)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'size',
|
|
||||||
title: 'Text Size',
|
|
||||||
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
|
|
||||||
type: 'number',
|
|
||||||
placeholder: '1 (default)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'alignx',
|
|
||||||
title: 'Align Horizontal',
|
|
||||||
description: 'Moves the horizontally in page to start = left | center | end = right',
|
|
||||||
type: 'option',
|
|
||||||
values: { start: 'Start', center: 'Center', end: 'End' },
|
|
||||||
defaultValue: 'center',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'offsetx',
|
|
||||||
title: 'Offset Horizontal',
|
|
||||||
description: 'Offsets the timer horizontal position by a given amount in pixels',
|
|
||||||
type: 'number',
|
|
||||||
placeholder: '0 (default)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'aligny',
|
|
||||||
title: 'Align Vertical',
|
|
||||||
description: 'Moves the vertically in page to start = left | center | end = right',
|
|
||||||
type: 'option',
|
|
||||||
values: { start: 'Start', center: 'Center', end: 'End' },
|
|
||||||
defaultValue: 'center',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'offsety',
|
|
||||||
title: 'Offset Vertical',
|
|
||||||
description: 'Offsets the timer vertical position by a given amount in pixels',
|
|
||||||
type: 'number',
|
|
||||||
placeholder: '0 (default)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'hideovertime',
|
|
||||||
title: 'Hide Overtime',
|
|
||||||
description: 'Whether to suppress overtime styles (red borders and red text)',
|
|
||||||
type: 'boolean',
|
|
||||||
defaultValue: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'hidemessages',
|
|
||||||
title: 'Hide Message Overlay',
|
|
||||||
description: 'Whether to hide the overlay from showing timer screen messages',
|
|
||||||
type: 'boolean',
|
|
||||||
defaultValue: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'hideendmessage',
|
|
||||||
title: 'Hide End Message',
|
|
||||||
description: 'Whether to hide end message and continue showing the clock if timer is in overtime',
|
|
||||||
type: 'boolean',
|
|
||||||
defaultValue: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const getLowerThirdOptions = (customFields: CustomFields): ParamField[] => {
|
|
||||||
const topSourceOptions = makeOptionsFromCustomFields(customFields, {
|
|
||||||
title: 'Title',
|
|
||||||
lowerMsg: 'Lower Third Message',
|
|
||||||
});
|
|
||||||
|
|
||||||
const bottomSourceOptions = makeOptionsFromCustomFields(customFields, {
|
|
||||||
title: 'Title',
|
|
||||||
lowerMsg: 'Lower Third Message',
|
|
||||||
});
|
|
||||||
|
|
||||||
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' });
|
|
||||||
|
|
||||||
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 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',
|
|
||||||
description: 'Whether to events that have passed',
|
|
||||||
type: 'boolean',
|
|
||||||
defaultValue: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'main',
|
|
||||||
title: 'Main data field',
|
|
||||||
description: 'Field to be shown in the first line of text',
|
|
||||||
type: 'option',
|
|
||||||
values: fieldOptions,
|
|
||||||
defaultValue: 'title',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'secondary',
|
|
||||||
title: 'Secondary data field',
|
|
||||||
description: 'Field to be shown in the second line of text',
|
|
||||||
type: 'option',
|
|
||||||
values: fieldOptions,
|
|
||||||
defaultValue: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'subscribe',
|
|
||||||
title: 'Highlight Field',
|
|
||||||
description: 'Choose a custom field to highlight',
|
|
||||||
type: 'option',
|
|
||||||
values: customFieldSelect,
|
|
||||||
defaultValue: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'shouldEdit',
|
|
||||||
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];
|
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
type ParamSection = {
|
||||||
|
section: string;
|
||||||
|
};
|
||||||
|
|
||||||
type BaseField = {
|
type BaseField = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -9,8 +13,25 @@ type OptionsField = {
|
|||||||
values: Record<string, string>;
|
values: Record<string, string>;
|
||||||
defaultValue?: string;
|
defaultValue?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type MultiOptionsField = {
|
||||||
|
type: 'multi-option';
|
||||||
|
values: Record<string, { value: string; label: string; colour: string }>;
|
||||||
|
defaultValue?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type StringField = { type: 'string'; defaultValue?: string; prefix?: string; placeholder?: string };
|
type StringField = { type: 'string'; defaultValue?: string; prefix?: string; placeholder?: string };
|
||||||
type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string };
|
type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string };
|
||||||
type BooleanField = { type: 'boolean'; defaultValue: boolean };
|
type BooleanField = { type: 'boolean'; defaultValue: boolean };
|
||||||
|
type PersistedField = { type: 'persist'; defaultValue?: string; value: string };
|
||||||
|
|
||||||
export type ParamField = BaseField & (StringField | BooleanField | NumberField | OptionsField);
|
export type ParamField = BaseField &
|
||||||
|
(StringField | BooleanField | NumberField | OptionsField | MultiOptionsField | PersistedField);
|
||||||
|
export type ViewOption = ParamSection | ParamField;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type assertion utility checks whether an entry is a section separator
|
||||||
|
*/
|
||||||
|
export function isSection(entry: ViewOption): entry is ParamSection {
|
||||||
|
return 'section' in entry;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { CustomFields } from 'ontime-types';
|
import { CustomFields } from 'ontime-types';
|
||||||
|
|
||||||
import { queryRefetchInterval } from '../../ontimeConfig';
|
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||||
import { CUSTOM_FIELDS } from '../api/constants';
|
import { CUSTOM_FIELDS } from '../api/constants';
|
||||||
import { getCustomFields } from '../api/customFields';
|
import { getCustomFields } from '../api/customFields';
|
||||||
|
|
||||||
@@ -11,10 +11,10 @@ export default function useCustomFields() {
|
|||||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||||
queryKey: CUSTOM_FIELDS,
|
queryKey: CUSTOM_FIELDS,
|
||||||
queryFn: getCustomFields,
|
queryFn: getCustomFields,
|
||||||
placeholderData: placeholder,
|
placeholderData: (previousData, _previousQuery) => previousData,
|
||||||
retry: 5,
|
retry: 5,
|
||||||
retryDelay: (attempt) => attempt * 2500,
|
retryDelay: (attempt) => attempt * 2500,
|
||||||
refetchInterval: queryRefetchInterval,
|
refetchInterval: queryRefetchIntervalSlow,
|
||||||
networkMode: 'always',
|
networkMode: 'always',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,14 +11,13 @@ export function useHttpSettings() {
|
|||||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||||
queryKey: HTTP_SETTINGS,
|
queryKey: HTTP_SETTINGS,
|
||||||
queryFn: getHTTP,
|
queryFn: getHTTP,
|
||||||
placeholderData: httpPlaceholder,
|
placeholderData: (previousData, _previousQuery) => previousData,
|
||||||
retry: 5,
|
retry: 5,
|
||||||
retryDelay: (attempt: number) => attempt * 2500,
|
retryDelay: (attempt: number) => attempt * 2500,
|
||||||
refetchInterval: queryRefetchIntervalSlow,
|
refetchInterval: queryRefetchIntervalSlow,
|
||||||
networkMode: 'always',
|
networkMode: 'always',
|
||||||
});
|
});
|
||||||
|
|
||||||
// we need to jump through some hoops because of the type op port
|
|
||||||
return { data: data ?? httpPlaceholder, status, isFetching, isError, refetch };
|
return { data: data ?? httpPlaceholder, status, isFetching, isError, refetch };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default function useInfo() {
|
|||||||
const { data, status, isError, refetch, isFetching } = useQuery<GetInfo>({
|
const { data, status, isError, refetch, isFetching } = useQuery<GetInfo>({
|
||||||
queryKey: APP_INFO,
|
queryKey: APP_INFO,
|
||||||
queryFn: getInfo,
|
queryFn: getInfo,
|
||||||
placeholderData: ontimePlaceholderInfo,
|
placeholderData: (previousData, _previousQuery) => previousData,
|
||||||
retry: 5,
|
retry: 5,
|
||||||
retryDelay: (attempt) => attempt * 2500,
|
retryDelay: (attempt) => attempt * 2500,
|
||||||
refetchInterval: queryRefetchIntervalSlow,
|
refetchInterval: queryRefetchIntervalSlow,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export default function useOscSettings() {
|
|||||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||||
queryKey: OSC_SETTINGS,
|
queryKey: OSC_SETTINGS,
|
||||||
queryFn: getOSC,
|
queryFn: getOSC,
|
||||||
placeholderData: oscPlaceholderSettings,
|
placeholderData: (previousData, _previousQuery) => previousData,
|
||||||
retry: 5,
|
retry: 5,
|
||||||
retryDelay: (attempt: number) => attempt * 2500,
|
retryDelay: (attempt: number) => attempt * 2500,
|
||||||
refetchInterval: queryRefetchIntervalSlow,
|
refetchInterval: queryRefetchIntervalSlow,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export default function useProjectData() {
|
|||||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||||
queryKey: PROJECT_DATA,
|
queryKey: PROJECT_DATA,
|
||||||
queryFn: getProjectData,
|
queryFn: getProjectData,
|
||||||
placeholderData: projectDataPlaceholder,
|
placeholderData: (previousData, _previousQuery) => previousData,
|
||||||
retry: 5,
|
retry: 5,
|
||||||
retryDelay: (attempt) => attempt * 2500,
|
retryDelay: (attempt) => attempt * 2500,
|
||||||
refetchInterval: queryRefetchIntervalSlow,
|
refetchInterval: queryRefetchIntervalSlow,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function useProjectList() {
|
|||||||
const { data, status, refetch } = useQuery({
|
const { data, status, refetch } = useQuery({
|
||||||
queryKey: PROJECT_LIST,
|
queryKey: PROJECT_LIST,
|
||||||
queryFn: getProjects,
|
queryFn: getProjects,
|
||||||
placeholderData: placeholderProjectList,
|
placeholderData: (previousData, _previousQuery) => previousData,
|
||||||
retry: 5,
|
retry: 5,
|
||||||
retryDelay: (attempt: number) => attempt * 2500,
|
retryDelay: (attempt: number) => attempt * 2500,
|
||||||
refetchInterval: queryRefetchIntervalSlow,
|
refetchInterval: queryRefetchIntervalSlow,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { NormalisedRundown, OntimeRundown, RundownCached } from 'ontime-types';
|
import { NormalisedRundown, OntimeRundown, RundownCached } from 'ontime-types';
|
||||||
|
|
||||||
import { queryRefetchInterval } from '../../ontimeConfig';
|
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||||
import { RUNDOWN } from '../api/constants';
|
import { RUNDOWN } from '../api/constants';
|
||||||
import { fetchNormalisedRundown } from '../api/rundown';
|
import { fetchNormalisedRundown } from '../api/rundown';
|
||||||
|
|
||||||
@@ -13,10 +13,10 @@ export default function useRundown() {
|
|||||||
const { data, status, isError, refetch, isFetching } = useQuery<RundownCached>({
|
const { data, status, isError, refetch, isFetching } = useQuery<RundownCached>({
|
||||||
queryKey: RUNDOWN,
|
queryKey: RUNDOWN,
|
||||||
queryFn: fetchNormalisedRundown,
|
queryFn: fetchNormalisedRundown,
|
||||||
placeholderData: cachedRundownPlaceholder,
|
placeholderData: (previousData, _previousQuery) => previousData,
|
||||||
retry: 5,
|
retry: 5,
|
||||||
retryDelay: (attempt) => attempt * 2500,
|
retryDelay: (attempt) => attempt * 2500,
|
||||||
refetchInterval: queryRefetchInterval,
|
refetchInterval: queryRefetchIntervalSlow,
|
||||||
networkMode: 'always',
|
networkMode: 'always',
|
||||||
});
|
});
|
||||||
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
|
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default function useSettings() {
|
|||||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||||
queryKey: APP_SETTINGS,
|
queryKey: APP_SETTINGS,
|
||||||
queryFn: getSettings,
|
queryFn: getSettings,
|
||||||
placeholderData: ontimePlaceholderSettings,
|
placeholderData: (previousData, _previousQuery) => previousData,
|
||||||
retry: 5,
|
retry: 5,
|
||||||
retryDelay: (attempt) => attempt * 2500,
|
retryDelay: (attempt) => attempt * 2500,
|
||||||
refetchInterval: queryRefetchIntervalSlow,
|
refetchInterval: queryRefetchIntervalSlow,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export default function useUrlPresets() {
|
|||||||
const { data, status, isError, refetch } = useQuery({
|
const { data, status, isError, refetch } = useQuery({
|
||||||
queryKey: URL_PRESETS,
|
queryKey: URL_PRESETS,
|
||||||
queryFn: getUrlPresets,
|
queryFn: getUrlPresets,
|
||||||
placeholderData: [],
|
placeholderData: (previousData, _previousQuery) => previousData,
|
||||||
retry: 5,
|
retry: 5,
|
||||||
retryDelay: (attempt) => attempt * 2500,
|
retryDelay: (attempt) => attempt * 2500,
|
||||||
refetchInterval: queryRefetchIntervalSlow,
|
refetchInterval: queryRefetchIntervalSlow,
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ export default function useViewSettings() {
|
|||||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||||
queryKey: VIEW_SETTINGS,
|
queryKey: VIEW_SETTINGS,
|
||||||
queryFn: getView,
|
queryFn: getView,
|
||||||
placeholderData: viewsSettingsPlaceholder,
|
placeholderData: (previousData, _previousQuery) => previousData,
|
||||||
retry: 5,
|
retry: 5,
|
||||||
retryDelay: (attempt) => attempt * 2500,
|
retryDelay: (attempt) => attempt * 2500,
|
||||||
refetchInterval: queryRefetchIntervalSlow,
|
refetchInterval: queryRefetchIntervalSlow,
|
||||||
networkMode: 'always',
|
networkMode: 'always',
|
||||||
});
|
});
|
||||||
|
|
||||||
return { data, status, isError, refetch, isFetching };
|
return { data: data ?? viewsSettingsPlaceholder, status, isError, refetch, isFetching };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { useClientStore } from '../stores/clientStore';
|
||||||
|
import { socketSendJson } from '../utils/socket';
|
||||||
|
|
||||||
|
export const useClientPath = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { pathname, search } = useLocation();
|
||||||
|
const redirect = useClientStore((store) => store.redirect);
|
||||||
|
const setRedirect = useClientStore((store) => store.setRedirect);
|
||||||
|
|
||||||
|
// notify of client path changes
|
||||||
|
useEffect(() => {
|
||||||
|
socketSendJson('set-client-path', pathname + search);
|
||||||
|
}, [pathname, search]);
|
||||||
|
|
||||||
|
// navigate to new path when received from server
|
||||||
|
useEffect(() => {
|
||||||
|
if (redirect === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// clear redirect
|
||||||
|
setRedirect('');
|
||||||
|
|
||||||
|
// navigate if there is a path change
|
||||||
|
if (redirect !== pathname + search) {
|
||||||
|
navigate(redirect);
|
||||||
|
}
|
||||||
|
}, [navigate, pathname, redirect, search, setRedirect]);
|
||||||
|
};
|
||||||
@@ -1,7 +1,15 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { isOntimeEvent, MaybeString, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
|
import { isOntimeEvent, MaybeString, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
|
||||||
import { getLinkedTimes, getPreviousEventNormal, reorderArray, swapEventData } from 'ontime-utils';
|
import {
|
||||||
|
dayInMs,
|
||||||
|
getLinkedTimes,
|
||||||
|
getPreviousEventNormal,
|
||||||
|
MILLIS_PER_SECOND,
|
||||||
|
parseUserTime,
|
||||||
|
reorderArray,
|
||||||
|
swapEventData,
|
||||||
|
} from 'ontime-utils';
|
||||||
|
|
||||||
import { RUNDOWN } from '../api/constants';
|
import { RUNDOWN } from '../api/constants';
|
||||||
import {
|
import {
|
||||||
@@ -18,14 +26,13 @@ import {
|
|||||||
} from '../api/rundown';
|
} from '../api/rundown';
|
||||||
import { logAxiosError } from '../api/utils';
|
import { logAxiosError } from '../api/utils';
|
||||||
import { useEditorSettings } from '../stores/editorSettings';
|
import { useEditorSettings } from '../stores/editorSettings';
|
||||||
import { forgivingStringToMillis } from '../utils/dateConfig';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Set of utilities for events
|
* @description Set of utilities for events //TODO: should this be called useEntryAction and so on
|
||||||
*/
|
*/
|
||||||
export const useEventAction = () => {
|
export const useEventAction = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { defaultPublic, linkPrevious, defaultDuration } = useEditorSettings((state) => state.eventSettings);
|
const { defaultPublic, linkPrevious, defaultDuration, defaultWarnTime, defaultDangerTime } = useEditorSettings();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calls mutation to add new event
|
* Calls mutation to add new event
|
||||||
@@ -48,6 +55,8 @@ export const useEventAction = () => {
|
|||||||
defaultPublic: boolean;
|
defaultPublic: boolean;
|
||||||
linkPrevious: boolean;
|
linkPrevious: boolean;
|
||||||
lastEventId: string;
|
lastEventId: string;
|
||||||
|
defaultWarnTime: number;
|
||||||
|
defaultDangerTime: number;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,6 +73,8 @@ export const useEventAction = () => {
|
|||||||
defaultPublic: options?.defaultPublic ?? defaultPublic,
|
defaultPublic: options?.defaultPublic ?? defaultPublic,
|
||||||
lastEventId: options?.lastEventId,
|
lastEventId: options?.lastEventId,
|
||||||
linkPrevious: options?.linkPrevious ?? linkPrevious,
|
linkPrevious: options?.linkPrevious ?? linkPrevious,
|
||||||
|
defaultWarnTime,
|
||||||
|
defaultDangerTime,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (applicationOptions.linkPrevious && applicationOptions?.lastEventId) {
|
if (applicationOptions.linkPrevious && applicationOptions?.lastEventId) {
|
||||||
@@ -83,7 +94,15 @@ export const useEventAction = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (newEvent.duration === undefined && newEvent.timeEnd === undefined) {
|
if (newEvent.duration === undefined && newEvent.timeEnd === undefined) {
|
||||||
newEvent.duration = forgivingStringToMillis(defaultDuration);
|
newEvent.duration = parseUserTime(defaultDuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newEvent.timeDanger === undefined) {
|
||||||
|
newEvent.timeDanger = parseUserTime(defaultDangerTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newEvent.timeWarning === undefined) {
|
||||||
|
newEvent.timeWarning = parseUserTime(defaultWarnTime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +117,7 @@ export const useEventAction = () => {
|
|||||||
logAxiosError('Failed adding event', error);
|
logAxiosError('Failed adding event', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[_addEventMutation, defaultDuration, defaultPublic, linkPrevious],
|
[_addEventMutation, defaultDangerTime, defaultDuration, defaultPublic, defaultWarnTime, linkPrevious, queryClient],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -155,7 +174,7 @@ export const useEventAction = () => {
|
|||||||
|
|
||||||
const updateCustomField = useCallback(
|
const updateCustomField = useCallback(
|
||||||
async (eventId: string, field: string, value: string) => {
|
async (eventId: string, field: string, value: string) => {
|
||||||
updateEvent({ id: eventId, custom: { [field]: { value } } });
|
updateEvent({ id: eventId, custom: { [field]: value } });
|
||||||
},
|
},
|
||||||
[updateEvent],
|
[updateEvent],
|
||||||
);
|
);
|
||||||
@@ -198,14 +217,17 @@ export const useEventAction = () => {
|
|||||||
} else if (value.startsWith('+') || value.startsWith('p+') || value.startsWith('p +')) {
|
} else if (value.startsWith('+') || value.startsWith('p+') || value.startsWith('p +')) {
|
||||||
// TODO: is this logic solid?
|
// TODO: is this logic solid?
|
||||||
const remainingString = value.substring(1);
|
const remainingString = value.substring(1);
|
||||||
newValMillis = getPreviousEnd() + forgivingStringToMillis(remainingString);
|
newValMillis = getPreviousEnd() + parseUserTime(remainingString);
|
||||||
} else {
|
} else {
|
||||||
newValMillis = forgivingStringToMillis(value);
|
newValMillis = parseUserTime(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dont allow timer values over 23:59:59
|
||||||
|
const cappedMillis = Math.min(newValMillis, dayInMs - MILLIS_PER_SECOND);
|
||||||
|
|
||||||
const newEvent = {
|
const newEvent = {
|
||||||
id: eventId,
|
id: eventId,
|
||||||
[field]: newValMillis,
|
[field]: cappedMillis,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
await _updateEventMutation.mutateAsync(newEvent);
|
await _updateEventMutation.mutateAsync(newEvent);
|
||||||
@@ -315,7 +337,7 @@ export const useEventAction = () => {
|
|||||||
const _deleteEventMutation = useMutation({
|
const _deleteEventMutation = useMutation({
|
||||||
mutationFn: requestDelete,
|
mutationFn: requestDelete,
|
||||||
// we optimistically update here
|
// we optimistically update here
|
||||||
onMutate: async (eventId) => {
|
onMutate: async (eventIds: string[]) => {
|
||||||
// cancel ongoing queries
|
// cancel ongoing queries
|
||||||
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
||||||
|
|
||||||
@@ -324,9 +346,11 @@ export const useEventAction = () => {
|
|||||||
|
|
||||||
if (previousData) {
|
if (previousData) {
|
||||||
// optimistically update object
|
// optimistically update object
|
||||||
const newOrder = previousData.order.filter((id) => id !== eventId);
|
const newOrder = previousData.order.filter((id) => !eventIds.includes(id));
|
||||||
const newRundown = { ...previousData.rundown };
|
const newRundown = { ...previousData.rundown };
|
||||||
delete newRundown[eventId];
|
for (const eventId of eventIds) {
|
||||||
|
delete newRundown[eventId];
|
||||||
|
}
|
||||||
|
|
||||||
queryClient.setQueryData(RUNDOWN, {
|
queryClient.setQueryData(RUNDOWN, {
|
||||||
order: newOrder,
|
order: newOrder,
|
||||||
@@ -355,9 +379,9 @@ export const useEventAction = () => {
|
|||||||
* Deletes an event form the list
|
* Deletes an event form the list
|
||||||
*/
|
*/
|
||||||
const deleteEvent = useCallback(
|
const deleteEvent = useCallback(
|
||||||
async (eventId: string) => {
|
async (eventIds: string[]) => {
|
||||||
try {
|
try {
|
||||||
await _deleteEventMutation.mutateAsync(eventId);
|
await _deleteEventMutation.mutateAsync(eventIds);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logAxiosError('Error deleting event', error);
|
logAxiosError('Error deleting event', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
import { useSyncExternalStore } from 'react';
|
|
||||||
|
|
||||||
const STORAGE_EVENT = 'ontime-storage';
|
|
||||||
|
|
||||||
function getSnapshot(key: string): string | null {
|
|
||||||
try {
|
|
||||||
return window.localStorage.getItem(`ontime-${key}`);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getParsedJson<T>(localStorageValue: string | null, initialValue: T): T {
|
|
||||||
try {
|
|
||||||
return localStorageValue ? JSON.parse(localStorageValue) : initialValue;
|
|
||||||
} catch {
|
|
||||||
return initialValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useLocalStorage = <T>(key: string, initialValue: T) => {
|
|
||||||
const localStorageValue = useSyncExternalStore(subscribe, () => getSnapshot(key));
|
|
||||||
const parsedLocalStorageValue = getParsedJson(localStorageValue, initialValue);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description Set value to local storage
|
|
||||||
* @param value
|
|
||||||
*/
|
|
||||||
const setLocalStorageValue = (value: T | ((val: T) => T)) => {
|
|
||||||
// Allow value to be a function so we have same API as useState
|
|
||||||
const valueToStore = value instanceof Function ? value(parsedLocalStorageValue) : value;
|
|
||||||
|
|
||||||
localStorage.setItem(`ontime-${key}`, JSON.stringify(valueToStore));
|
|
||||||
window.dispatchEvent(new StorageEvent(STORAGE_EVENT));
|
|
||||||
};
|
|
||||||
|
|
||||||
return [parsedLocalStorageValue, setLocalStorageValue] as const;
|
|
||||||
};
|
|
||||||
|
|
||||||
function subscribe(callback: () => void) {
|
|
||||||
window.addEventListener(STORAGE_EVENT, callback);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener(STORAGE_EVENT, callback);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -19,7 +19,7 @@ export default function useLongPress(callback: () => void, options: LongPressOpt
|
|||||||
const { threshold = 400, onStart, onFinish, onCancel } = options;
|
const { threshold = 400, onStart, onFinish, onCancel } = options;
|
||||||
const isLongPressActive = useRef(false);
|
const isLongPressActive = useRef(false);
|
||||||
const isPressed = useRef(false);
|
const isPressed = useRef(false);
|
||||||
const timerId = useRef<NodeJS.Timer>();
|
const timerId = useRef<NodeJS.Timeout>();
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const start = (event: SyntheticEvent) => {
|
const start = (event: SyntheticEvent) => {
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ import { RuntimeStore, SimpleDirection, SimplePlayback } from 'ontime-types';
|
|||||||
import { useRuntimeStore } from '../stores/runtime';
|
import { useRuntimeStore } from '../stores/runtime';
|
||||||
import { socketSendJson } from '../utils/socket';
|
import { socketSendJson } from '../utils/socket';
|
||||||
|
|
||||||
|
export const setClientRemote = {
|
||||||
|
setIdentify: (payload: { target: string; identify: boolean }) => socketSendJson('client', payload),
|
||||||
|
setRedirect: (payload: { target: string; redirect: string }) => socketSendJson('client', payload),
|
||||||
|
setClientName: (payload: { target: string; rename: string }) => socketSendJson('client', payload),
|
||||||
|
};
|
||||||
|
|
||||||
export const useRundownEditor = () => {
|
export const useRundownEditor = () => {
|
||||||
const featureSelector = (state: RuntimeStore) => ({
|
const featureSelector = (state: RuntimeStore) => ({
|
||||||
playback: state.timer.playback,
|
playback: state.timer.playback,
|
||||||
@@ -25,8 +31,6 @@ export const useOperator = () => {
|
|||||||
export const useMessageControl = () => {
|
export const useMessageControl = () => {
|
||||||
const featureSelector = (state: RuntimeStore) => ({
|
const featureSelector = (state: RuntimeStore) => ({
|
||||||
timer: state.message.timer,
|
timer: state.message.timer,
|
||||||
public: state.message.public,
|
|
||||||
lower: state.message.lower,
|
|
||||||
external: state.message.external,
|
external: state.message.external,
|
||||||
onAir: state.onAir,
|
onAir: state.onAir,
|
||||||
});
|
});
|
||||||
@@ -37,10 +41,6 @@ export const useMessageControl = () => {
|
|||||||
export const setMessage = {
|
export const setMessage = {
|
||||||
timerText: (payload: string) => socketSendJson('message', { timer: { text: payload } }),
|
timerText: (payload: string) => socketSendJson('message', { timer: { text: payload } }),
|
||||||
timerVisible: (payload: boolean) => socketSendJson('message', { timer: { visible: payload } }),
|
timerVisible: (payload: boolean) => socketSendJson('message', { timer: { visible: payload } }),
|
||||||
publicText: (payload: string) => socketSendJson('message', { public: { text: payload } }),
|
|
||||||
publicVisible: (payload: boolean) => socketSendJson('message', { public: { visible: payload } }),
|
|
||||||
lowerText: (payload: string) => socketSendJson('message', { lower: { text: payload } }),
|
|
||||||
lowerVisible: (payload: boolean) => socketSendJson('message', { lower: { visible: payload } }),
|
|
||||||
timerBlink: (payload: boolean) => socketSendJson('message', { timer: { blink: payload } }),
|
timerBlink: (payload: boolean) => socketSendJson('message', { timer: { blink: payload } }),
|
||||||
timerBlackout: (payload: boolean) => socketSendJson('message', { timer: { blackout: payload } }),
|
timerBlackout: (payload: boolean) => socketSendJson('message', { timer: { blackout: payload } }),
|
||||||
};
|
};
|
||||||
@@ -50,6 +50,7 @@ export const usePlaybackControl = () => {
|
|||||||
playback: state.timer.playback,
|
playback: state.timer.playback,
|
||||||
selectedEventIndex: state.runtime.selectedEventIndex,
|
selectedEventIndex: state.runtime.selectedEventIndex,
|
||||||
numEvents: state.runtime.numEvents,
|
numEvents: state.runtime.numEvents,
|
||||||
|
timerPhase: state.timer.phase,
|
||||||
});
|
});
|
||||||
|
|
||||||
return useRuntimeStore(featureSelector);
|
return useRuntimeStore(featureSelector);
|
||||||
@@ -59,12 +60,12 @@ export const setPlayback = {
|
|||||||
start: () => socketSendJson('start'),
|
start: () => socketSendJson('start'),
|
||||||
pause: () => socketSendJson('pause'),
|
pause: () => socketSendJson('pause'),
|
||||||
roll: () => socketSendJson('roll'),
|
roll: () => socketSendJson('roll'),
|
||||||
startNext: () => socketSendJson('start', { next: true }),
|
startNext: () => socketSendJson('start', 'next'),
|
||||||
previous: () => {
|
previous: () => {
|
||||||
socketSendJson('load', { previous: true });
|
socketSendJson('load', 'previous');
|
||||||
},
|
},
|
||||||
next: () => {
|
next: () => {
|
||||||
socketSendJson('load', { next: true });
|
socketSendJson('load', 'next');
|
||||||
},
|
},
|
||||||
stop: () => {
|
stop: () => {
|
||||||
socketSendJson('stop');
|
socketSendJson('stop');
|
||||||
@@ -89,32 +90,33 @@ export const useInfoPanel = () => {
|
|||||||
return useRuntimeStore(featureSelector);
|
return useRuntimeStore(featureSelector);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useExtraTimerTime = () => {
|
export const useAuxTimerTime = () => {
|
||||||
const featureSelector = (state: RuntimeStore) => state.timer1.current;
|
const featureSelector = (state: RuntimeStore) => state.auxtimer1.current;
|
||||||
|
|
||||||
return useRuntimeStore(featureSelector);
|
return useRuntimeStore(featureSelector);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useExtraTimerControl = () => {
|
export const useAuxTimerControl = () => {
|
||||||
const featureSelector = (state: RuntimeStore) => ({
|
const featureSelector = (state: RuntimeStore) => ({
|
||||||
playback: state.timer1.playback,
|
playback: state.auxtimer1.playback,
|
||||||
direction: state.timer1.direction,
|
direction: state.auxtimer1.direction,
|
||||||
});
|
});
|
||||||
|
|
||||||
return useRuntimeStore(featureSelector);
|
return useRuntimeStore(featureSelector);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const setExtraTimer = {
|
export const setAuxTimer = {
|
||||||
start: () => socketSendJson('extratimer', SimplePlayback.Start),
|
start: () => socketSendJson('auxtimer', { '1': SimplePlayback.Start }),
|
||||||
pause: () => socketSendJson('extratimer', SimplePlayback.Pause),
|
pause: () => socketSendJson('auxtimer', { '1': SimplePlayback.Pause }),
|
||||||
stop: () => socketSendJson('extratimer', SimplePlayback.Stop),
|
stop: () => socketSendJson('auxtimer', { '1': SimplePlayback.Stop }),
|
||||||
setDirection: (direction: SimpleDirection) => socketSendJson('extratimer', { direction }),
|
setDirection: (direction: SimpleDirection) => socketSendJson('auxtimer', { '1': { direction } }),
|
||||||
setTime: (time: number) => socketSendJson('extratimer', { settime: time }),
|
setDuration: (time: number) => socketSendJson('auxtimer', { '1': { duration: time } }),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useCuesheet = () => {
|
export const useCuesheet = () => {
|
||||||
const featureSelector = (state: RuntimeStore) => ({
|
const featureSelector = (state: RuntimeStore) => ({
|
||||||
playback: state.timer.playback,
|
playback: state.timer.playback,
|
||||||
|
currentBlockId: state.currentBlock.block?.id ?? null,
|
||||||
selectedEventId: state.eventNow?.id ?? null,
|
selectedEventId: state.eventNow?.id ?? null,
|
||||||
selectedEventIndex: state.runtime.selectedEventIndex,
|
selectedEventIndex: state.runtime.selectedEventIndex,
|
||||||
numEvents: state.runtime.numEvents,
|
numEvents: state.runtime.numEvents,
|
||||||
@@ -149,7 +151,6 @@ export const useClock = () => {
|
|||||||
/** Used by the progress bar components */
|
/** Used by the progress bar components */
|
||||||
export const useProgressData = () => {
|
export const useProgressData = () => {
|
||||||
const featureSelector = (state: RuntimeStore) => ({
|
const featureSelector = (state: RuntimeStore) => ({
|
||||||
addedTime: state.timer.addedTime,
|
|
||||||
current: state.timer.current,
|
current: state.timer.current,
|
||||||
duration: state.timer.duration,
|
duration: state.timer.duration,
|
||||||
timeWarning: state.eventNow?.timeWarning ?? null,
|
timeWarning: state.eventNow?.timeWarning ?? null,
|
||||||
@@ -179,6 +180,26 @@ export const useRuntimePlaybackOverview = () => {
|
|||||||
numEvents: state.runtime.numEvents,
|
numEvents: state.runtime.numEvents,
|
||||||
selectedEventIndex: state.runtime.selectedEventIndex,
|
selectedEventIndex: state.runtime.selectedEventIndex,
|
||||||
offset: state.runtime.offset,
|
offset: state.runtime.offset,
|
||||||
|
|
||||||
|
currentBlock: state.currentBlock,
|
||||||
|
});
|
||||||
|
|
||||||
|
return useRuntimeStore(featureSelector);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useTimelineOverview = () => {
|
||||||
|
const featureSelector = (state: RuntimeStore) => ({
|
||||||
|
plannedStart: state.runtime.plannedStart,
|
||||||
|
plannedEnd: state.runtime.plannedEnd,
|
||||||
|
});
|
||||||
|
|
||||||
|
return useRuntimeStore(featureSelector);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useTimelineStatus = () => {
|
||||||
|
const featureSelector = (state: RuntimeStore) => ({
|
||||||
|
clock: state.clock,
|
||||||
|
offset: state.runtime.offset,
|
||||||
});
|
});
|
||||||
|
|
||||||
return useRuntimeStore(featureSelector);
|
return useRuntimeStore(featureSelector);
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets tab title
|
||||||
|
* @param title
|
||||||
|
*/
|
||||||
|
export function useWindowTitle(title: string) {
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = `ontime - ${title}`;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { MaybeNumber, Playback, TimerType } from 'ontime-types';
|
import { MaybeNumber, Playback, TimerPhase, TimerType } from 'ontime-types';
|
||||||
|
|
||||||
// first set extends TimerState
|
// first set extends TimerState
|
||||||
export type ViewExtendedTimer = {
|
export type ViewExtendedTimer = {
|
||||||
@@ -8,12 +8,11 @@ export type ViewExtendedTimer = {
|
|||||||
elapsed: MaybeNumber;
|
elapsed: MaybeNumber;
|
||||||
expectedFinish: MaybeNumber;
|
expectedFinish: MaybeNumber;
|
||||||
finishedAt: MaybeNumber;
|
finishedAt: MaybeNumber;
|
||||||
|
phase: TimerPhase;
|
||||||
playback: Playback;
|
playback: Playback;
|
||||||
secondaryTimer: MaybeNumber;
|
secondaryTimer: MaybeNumber;
|
||||||
startedAt: MaybeNumber;
|
startedAt: MaybeNumber;
|
||||||
|
|
||||||
clock: number;
|
clock: number;
|
||||||
timeDanger: MaybeNumber;
|
|
||||||
timeWarning: MaybeNumber;
|
|
||||||
timerType: TimerType;
|
timerType: TimerType;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,13 +19,10 @@ function persistModeToSession(mode: AppMode) {
|
|||||||
type AppModeStore = {
|
type AppModeStore = {
|
||||||
mode: AppMode;
|
mode: AppMode;
|
||||||
setMode: (mode: AppMode) => void;
|
setMode: (mode: AppMode) => void;
|
||||||
cursor: string | null;
|
|
||||||
setCursor: (cursor: string | null) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useAppMode = create<AppModeStore>()((set) => ({
|
export const useAppMode = create<AppModeStore>()((set) => ({
|
||||||
mode: getModeFromSession(),
|
mode: getModeFromSession(),
|
||||||
cursor: null,
|
|
||||||
setMode: (mode: AppMode) => {
|
setMode: (mode: AppMode) => {
|
||||||
persistModeToSession(mode);
|
persistModeToSession(mode);
|
||||||
|
|
||||||
@@ -33,5 +30,4 @@ export const useAppMode = create<AppModeStore>()((set) => ({
|
|||||||
return { mode };
|
return { mode };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
setCursor: (cursor: string | null) => set(() => ({ cursor })),
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { ClientList } from 'ontime-types';
|
||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
interface ClientStore {
|
||||||
|
name?: string;
|
||||||
|
setName: (newValue: string) => void;
|
||||||
|
|
||||||
|
id: string;
|
||||||
|
setId: (newValue: string) => void;
|
||||||
|
|
||||||
|
redirect: string;
|
||||||
|
setRedirect: (newValue: string) => void;
|
||||||
|
|
||||||
|
clients: ClientList;
|
||||||
|
setClients: (clients: ClientList) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientNameKey = 'ontime-client-name';
|
||||||
|
|
||||||
|
function persistNameInStorage(newValue: string) {
|
||||||
|
localStorage.setItem(clientNameKey, newValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useClientStore = create<ClientStore>((set) => ({
|
||||||
|
name: localStorage.getItem(clientNameKey) ?? undefined,
|
||||||
|
setName: (name: string) =>
|
||||||
|
set(() => {
|
||||||
|
persistNameInStorage(name);
|
||||||
|
return { name };
|
||||||
|
}),
|
||||||
|
|
||||||
|
id: '',
|
||||||
|
setId: (id) => set({ id }),
|
||||||
|
|
||||||
|
redirect: '',
|
||||||
|
setRedirect: (redirect: string) => set({ redirect }),
|
||||||
|
|
||||||
|
clients: {},
|
||||||
|
setClients: (clients: ClientList) => set({ clients }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows getting client name (outside react)
|
||||||
|
*/
|
||||||
|
export function getClientName(): string | undefined {
|
||||||
|
return useClientStore.getState().name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows updating current client name (outside react)
|
||||||
|
*/
|
||||||
|
export function setClientName(name: string): void {
|
||||||
|
useClientStore.getState().setName(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows updating current client name (outside react)
|
||||||
|
*/
|
||||||
|
export function setClientId(id: string): void {
|
||||||
|
useClientStore.getState().setId(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows getting client name (outside react)
|
||||||
|
*/
|
||||||
|
export function getClientId(): string | undefined {
|
||||||
|
return useClientStore.getState().id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows updating redirect (outside react)
|
||||||
|
*/
|
||||||
|
export function setClientRedirect(path: string): void {
|
||||||
|
useClientStore.getState().setRedirect(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows setting clients (outside react)
|
||||||
|
*/
|
||||||
|
export function setClients(clients: ClientList): void {
|
||||||
|
return useClientStore.getState().setClients(clients);
|
||||||
|
}
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { useStore } from 'zustand';
|
|
||||||
import { createStore } from 'zustand/vanilla';
|
|
||||||
|
|
||||||
interface SocketClientNameState {
|
|
||||||
name?: string;
|
|
||||||
setName: (newValue: string) => void;
|
|
||||||
persistName: (newValue: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const clientNameKey = 'ontime-client-name';
|
|
||||||
|
|
||||||
function persistKeyToStorage(newValue: string) {
|
|
||||||
localStorage.setItem(clientNameKey, newValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const socketClientName = createStore<SocketClientNameState>((set) => ({
|
|
||||||
name: localStorage.getItem(clientNameKey) ?? undefined,
|
|
||||||
setName: (newValue: string) => set(() => ({ name: newValue })),
|
|
||||||
persistName: (newValue: string) =>
|
|
||||||
set(() => {
|
|
||||||
persistKeyToStorage(newValue);
|
|
||||||
return { name: newValue };
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const useSocketClientName = () => useStore(socketClientName);
|
|
||||||
@@ -2,66 +2,68 @@ import { create } from 'zustand';
|
|||||||
|
|
||||||
import { booleanFromLocalStorage } from '../utils/localStorage';
|
import { booleanFromLocalStorage } from '../utils/localStorage';
|
||||||
|
|
||||||
type EditorSettings = {
|
type EditorSettingsStore = {
|
||||||
showQuickEntry: boolean;
|
|
||||||
linkPrevious: boolean;
|
|
||||||
defaultPublic: boolean;
|
|
||||||
defaultDuration: string;
|
defaultDuration: string;
|
||||||
|
linkPrevious: boolean;
|
||||||
|
defaultWarnTime: string;
|
||||||
|
defaultDangerTime: string;
|
||||||
|
defaultPublic: boolean;
|
||||||
|
setDefaultDuration: (defaultDuration: string) => void;
|
||||||
|
setLinkPrevious: (linkPrevious: boolean) => void;
|
||||||
|
setWarnTime: (warnTime: string) => void;
|
||||||
|
setDangerTime: (dangerTime: string) => void;
|
||||||
|
setDefaultPublic: (defaultPublic: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EditorSettingsStore = {
|
export const editorSettingsDefaults = {
|
||||||
eventSettings: EditorSettings;
|
duration: '00:10:00',
|
||||||
setLocalEventSettings: (newState: EditorSettings) => void;
|
linkPrevious: true,
|
||||||
setShowQuickEntry: (showQuickEntry: boolean) => void;
|
warnTime: '00:02:00', // 120000 same as backend
|
||||||
setLinkPrevious: (linkPrevious: boolean) => void;
|
dangerTime: '00:01:00', // 60000 same as backend
|
||||||
setDefaultPublic: (defaultPublic: boolean) => void;
|
isPublic: true,
|
||||||
setDefaultDuration: (defaultDuration: string) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
enum EditorSettingsKeys {
|
enum EditorSettingsKeys {
|
||||||
ShowQuickEntry = 'ontime-show-quick-entry',
|
|
||||||
LinkPrevious = 'ontime-link-previous',
|
|
||||||
DefaultPublic = 'ontime-default-public',
|
|
||||||
DefaultDuration = 'ontime-default-duration',
|
DefaultDuration = 'ontime-default-duration',
|
||||||
|
LinkPrevious = 'ontime-link-previous',
|
||||||
|
DefaultWarnTime = 'ontime-default-warn-time',
|
||||||
|
DefaultDangerTime = 'ontime-default-danger-time',
|
||||||
|
DefaultPublic = 'ontime-default-public',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useEditorSettings = create<EditorSettingsStore>((set) => ({
|
export const useEditorSettings = create<EditorSettingsStore>((set) => {
|
||||||
eventSettings: {
|
return {
|
||||||
showQuickEntry: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, false),
|
defaultDuration: localStorage.getItem(EditorSettingsKeys.DefaultDuration) ?? editorSettingsDefaults.duration,
|
||||||
linkPrevious: booleanFromLocalStorage(EditorSettingsKeys.LinkPrevious, true),
|
linkPrevious: booleanFromLocalStorage(EditorSettingsKeys.LinkPrevious, editorSettingsDefaults.linkPrevious),
|
||||||
defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.DefaultPublic, true),
|
defaultWarnTime: localStorage.getItem(EditorSettingsKeys.DefaultWarnTime) ?? editorSettingsDefaults.warnTime,
|
||||||
defaultDuration: localStorage.getItem(EditorSettingsKeys.DefaultDuration) ?? '00:10:00',
|
defaultDangerTime: localStorage.getItem(EditorSettingsKeys.DefaultDangerTime) ?? editorSettingsDefaults.dangerTime,
|
||||||
},
|
defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.DefaultPublic, editorSettingsDefaults.isPublic),
|
||||||
|
|
||||||
setLocalEventSettings: (value) =>
|
setDefaultDuration: (defaultDuration) =>
|
||||||
set(() => {
|
set(() => {
|
||||||
localStorage.setItem(EditorSettingsKeys.ShowQuickEntry, String(value.showQuickEntry));
|
localStorage.setItem(EditorSettingsKeys.DefaultDuration, String(defaultDuration));
|
||||||
localStorage.setItem(EditorSettingsKeys.LinkPrevious, String(value.linkPrevious));
|
return { defaultDuration };
|
||||||
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(value.defaultPublic));
|
}),
|
||||||
return { eventSettings: value };
|
|
||||||
}),
|
|
||||||
|
|
||||||
setShowQuickEntry: (showQuickEntry) =>
|
setLinkPrevious: (linkPrevious) =>
|
||||||
set((state) => {
|
set(() => {
|
||||||
localStorage.setItem(EditorSettingsKeys.ShowQuickEntry, String(showQuickEntry));
|
localStorage.setItem(EditorSettingsKeys.LinkPrevious, String(linkPrevious));
|
||||||
return { eventSettings: { ...state.eventSettings, showQuickEntry } };
|
return { linkPrevious };
|
||||||
}),
|
}),
|
||||||
|
setWarnTime: (defaultWarnTime) =>
|
||||||
setLinkPrevious: (linkPrevious) =>
|
set(() => {
|
||||||
set((state) => {
|
localStorage.setItem(EditorSettingsKeys.DefaultWarnTime, String(defaultWarnTime));
|
||||||
localStorage.setItem(EditorSettingsKeys.LinkPrevious, String(linkPrevious));
|
return { defaultWarnTime };
|
||||||
return { eventSettings: { ...state.eventSettings, linkPrevious } };
|
}),
|
||||||
}),
|
setDangerTime: (defaultDangerTime) =>
|
||||||
|
set(() => {
|
||||||
setDefaultPublic: (defaultPublic) =>
|
localStorage.setItem(EditorSettingsKeys.DefaultDangerTime, String(defaultDangerTime));
|
||||||
set((state) => {
|
return { defaultDangerTime };
|
||||||
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(defaultPublic));
|
}),
|
||||||
return { eventSettings: { ...state.eventSettings, defaultPublic } };
|
setDefaultPublic: (defaultPublic) =>
|
||||||
}),
|
set(() => {
|
||||||
|
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(defaultPublic));
|
||||||
setDefaultDuration: (defaultDuration) =>
|
return { defaultPublic };
|
||||||
set((state) => {
|
}),
|
||||||
localStorage.setItem(EditorSettingsKeys.DefaultDuration, String(defaultDuration));
|
};
|
||||||
return { eventSettings: { ...state.eventSettings, defaultDuration } };
|
});
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
type EntryCopyStore = {
|
||||||
|
entryCopyId: string | null;
|
||||||
|
setEntryCopyId: (eventId: string | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useEntryCopy = create<EntryCopyStore>()((set) => ({
|
||||||
|
entryCopyId: null,
|
||||||
|
setEntryCopyId: (entryCopyId: string | null) => set({ entryCopyId }),
|
||||||
|
}));
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import isEqual from 'react-fast-compare';
|
import isEqual from 'react-fast-compare';
|
||||||
import { Playback, RuntimeStore, SimpleDirection, SimplePlayback } from 'ontime-types';
|
import { Playback, RuntimeStore, SimpleDirection, SimplePlayback, TimerPhase } from 'ontime-types';
|
||||||
import { createWithEqualityFn, useStoreWithEqualityFn } from 'zustand/traditional';
|
import { createWithEqualityFn, useStoreWithEqualityFn } from 'zustand/traditional';
|
||||||
|
|
||||||
export const runtimeStorePlaceholder: RuntimeStore = {
|
export const runtimeStorePlaceholder: RuntimeStore = {
|
||||||
@@ -11,6 +11,7 @@ export const runtimeStorePlaceholder: RuntimeStore = {
|
|||||||
elapsed: null,
|
elapsed: null,
|
||||||
expectedFinish: null,
|
expectedFinish: null,
|
||||||
finishedAt: null,
|
finishedAt: null,
|
||||||
|
phase: TimerPhase.None,
|
||||||
playback: Playback.Stop,
|
playback: Playback.Stop,
|
||||||
secondaryTimer: null,
|
secondaryTimer: null,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
@@ -23,14 +24,6 @@ export const runtimeStorePlaceholder: RuntimeStore = {
|
|||||||
blink: false,
|
blink: false,
|
||||||
blackout: false,
|
blackout: false,
|
||||||
},
|
},
|
||||||
public: {
|
|
||||||
text: '',
|
|
||||||
visible: false,
|
|
||||||
},
|
|
||||||
lower: {
|
|
||||||
text: '',
|
|
||||||
visible: false,
|
|
||||||
},
|
|
||||||
external: {
|
external: {
|
||||||
text: '',
|
text: '',
|
||||||
visible: false,
|
visible: false,
|
||||||
@@ -45,16 +38,21 @@ export const runtimeStorePlaceholder: RuntimeStore = {
|
|||||||
actualStart: null,
|
actualStart: null,
|
||||||
expectedEnd: null,
|
expectedEnd: null,
|
||||||
},
|
},
|
||||||
|
currentBlock: {
|
||||||
|
block: null,
|
||||||
|
startedAt: null,
|
||||||
|
},
|
||||||
eventNow: null,
|
eventNow: null,
|
||||||
eventNext: null,
|
eventNext: null,
|
||||||
publicEventNow: null,
|
publicEventNow: null,
|
||||||
publicEventNext: null,
|
publicEventNext: null,
|
||||||
timer1: {
|
auxtimer1: {
|
||||||
current: 0,
|
current: 0,
|
||||||
direction: SimpleDirection.CountUp,
|
direction: SimpleDirection.CountUp,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
playback: SimplePlayback.Stop,
|
playback: SimplePlayback.Stop,
|
||||||
},
|
},
|
||||||
|
frozen: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const deepCompare = <T>(a: T, b: T) => isEqual(a, b);
|
const deepCompare = <T>(a: T, b: T) => isEqual(a, b);
|
||||||
|
|||||||
@@ -1,263 +1,4 @@
|
|||||||
import { forgivingStringToMillis, millisToDelayString } from '../dateConfig';
|
import { millisToDelayString } from '../dateConfig';
|
||||||
|
|
||||||
describe('test forgivingStringToMillis()', () => {
|
|
||||||
describe('function handles time with no separators', () => {
|
|
||||||
const testData = [
|
|
||||||
{ value: '', expect: 0 },
|
|
||||||
{ value: '0', expect: 0 },
|
|
||||||
{ value: '-0', expect: 0 },
|
|
||||||
{ value: '1', expect: 60 * 1000 },
|
|
||||||
{ value: '-1', expect: 60 * 1000 },
|
|
||||||
{ value: '0h0m0s', expect: 0 },
|
|
||||||
{ value: '0h0m1s', expect: 1000 },
|
|
||||||
{ value: '0h1m0s', expect: 1000 * 60 },
|
|
||||||
{ value: '1h0m0s', expect: 1000 * 60 * 60 },
|
|
||||||
{ value: '23h0m0s', expect: 1000 * 60 * 60 * 23 },
|
|
||||||
{ value: '12h12m12s', expect: 12 * 1000 + 12 * 60 * 1000 + 12 * 1000 * 60 * 60 },
|
|
||||||
{ value: '12H12M12S', expect: 12 * 1000 + 12 * 60 * 1000 + 12 * 1000 * 60 * 60 },
|
|
||||||
{ value: '2m', expect: 2 * 60 * 1000 },
|
|
||||||
{ value: '1h5s', expect: 1000 * 60 * 60 + 1000 * 5 },
|
|
||||||
{ value: '1h2m', expect: 1000 * 60 * 60 + 1000 * 60 * 2 },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const s of testData) {
|
|
||||||
it(`handles ${s.value} to left`, () => {
|
|
||||||
expect(typeof forgivingStringToMillis(s.value)).toBe('number');
|
|
||||||
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('parses strings correctly', () => {
|
|
||||||
const ts = [
|
|
||||||
{ value: '1.1.1', expect: 60 * 60 * 1000 + 60 * 1000 + 1000 },
|
|
||||||
{ value: '12.1.1', expect: 12 * 60 * 60 * 1000 + 60 * 1000 + 1000 },
|
|
||||||
{ value: '12.55.1', expect: 12 * 60 * 60 * 1000 + 55 * 60 * 1000 + 1000 },
|
|
||||||
{ value: '12.55.40', expect: 12 * 60 * 60 * 1000 + 55 * 60 * 1000 + 40 * 1000 },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const s of ts) {
|
|
||||||
it(`handles ${s.value} to the left`, () => {
|
|
||||||
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('parses time strings', () => {
|
|
||||||
const ts = [
|
|
||||||
{ value: '1h2m3s', expect: 60 * 60 * 1000 + 2 * 60 * 1000 + 3 * 1000 },
|
|
||||||
{ value: '1h3s', expect: 60 * 60 * 1000 + 3 * 1000 },
|
|
||||||
{ value: '1h2m', expect: 60 * 60 * 1000 + 2 * 60 * 1000 },
|
|
||||||
{ value: '10h', expect: 10 * 60 * 60 * 1000 },
|
|
||||||
{ value: '10m', expect: 10 * 60 * 1000 },
|
|
||||||
{ value: '10s', expect: 10 * 1000 },
|
|
||||||
{ value: '120h', expect: 120 * 60 * 60 * 1000 },
|
|
||||||
{ value: '120m', expect: 120 * 60 * 1000 },
|
|
||||||
{ value: '120s', expect: 120 * 1000 },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const s of ts) {
|
|
||||||
it(`handles ${s.value}`, () => {
|
|
||||||
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('handles am/pm', () => {
|
|
||||||
const ampm = [
|
|
||||||
{ value: '9:10:11am', expect: 9 * 60 * 60 * 1000 + 10 * 60 * 1000 + 11 * 1000 },
|
|
||||||
{ value: '9:10:11a', expect: 9 * 60 * 60 * 1000 + 10 * 60 * 1000 + 11 * 1000 },
|
|
||||||
{ value: '9:10:11pm', expect: (12 + 9) * 60 * 60 * 1000 + 10 * 60 * 1000 + 11 * 1000 },
|
|
||||||
{ value: '9:10:11p', expect: (12 + 9) * 60 * 60 * 1000 + 10 * 60 * 1000 + 11 * 1000 },
|
|
||||||
{ value: '9:10am', expect: 9 * 60 * 60 * 1000 + 10 * 60 * 1000 },
|
|
||||||
{ value: '9:10a', expect: 9 * 60 * 60 * 1000 + 10 * 60 * 1000 },
|
|
||||||
{ value: '9:10pm', expect: (12 + 9) * 60 * 60 * 1000 + 10 * 60 * 1000 },
|
|
||||||
{ value: '9:10p', expect: (12 + 9) * 60 * 60 * 1000 + 10 * 60 * 1000 },
|
|
||||||
{ value: '9am', expect: 9 * 60 * 60 * 1000 },
|
|
||||||
{ value: '9a', expect: 9 * 60 * 60 * 1000 },
|
|
||||||
{ value: '9pm', expect: (12 + 9) * 60 * 60 * 1000 },
|
|
||||||
{ value: '9p', expect: (12 + 9) * 60 * 60 * 1000 },
|
|
||||||
{ value: '12am', expect: 0 },
|
|
||||||
{ value: '12pm', expect: 12 * 60 * 60 * 1000 },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const s of ampm) {
|
|
||||||
it(`handles ${s.value}`, () => {
|
|
||||||
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('it infers separators when non existent', () => {
|
|
||||||
const testCases = [
|
|
||||||
{ value: '1', expect: 1000 * 60 }, // 00:01:00
|
|
||||||
{ value: '12', expect: 1000 * 60 * 12 }, // 00:12:00
|
|
||||||
{ value: '123', expect: 1000 * 60 * 23 + 1000 * 60 * 60 }, // 01:23:00
|
|
||||||
{ value: '1234', expect: 1000 * 60 * 34 + 1000 * 60 * 60 * 12 }, // 12:34:00
|
|
||||||
{ value: '12345', expect: 1000 * 60 * 34 + 1000 * 60 * 60 * 12 + 5 * 1000 }, // 12:34:05
|
|
||||||
{ value: '123456', expect: 1000 * 60 * 34 + 1000 * 60 * 60 * 12 + 56 * 1000 }, // 12:34:56
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const s of testCases) {
|
|
||||||
it(`handles basic strings digits: ${s.value}`, () => {
|
|
||||||
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const sixDigits = [
|
|
||||||
{ value: '000000', expect: 0 },
|
|
||||||
{ value: '000001', expect: 1000 },
|
|
||||||
{ value: '000100', expect: 1000 * 60 },
|
|
||||||
{ value: '010000', expect: 1000 * 60 * 60 },
|
|
||||||
{ value: '230000', expect: 1000 * 60 * 60 * 23 },
|
|
||||||
{ value: '121212', expect: 12 * 1000 + 12 * 60 * 1000 + 12 * 1000 * 60 * 60 },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const s of sixDigits) {
|
|
||||||
it(`handles string with 6 digits: ${s.value}`, () => {
|
|
||||||
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const fiveDigits = [
|
|
||||||
{ value: '00000', expect: 0 },
|
|
||||||
{ value: '00001', expect: 1000 }, // 00:00:01
|
|
||||||
{ value: '00010', expect: 1000 * 60 }, // 00:01:00
|
|
||||||
{ value: '00100', expect: 1000 * 60 * 10 }, // 00:10:00
|
|
||||||
{ value: '01000', expect: 1000 * 60 * 60 }, // 01:00:00
|
|
||||||
{ value: '10000', expect: 1000 * 60 * 60 * 10 }, // 10:00:00
|
|
||||||
{ value: '23000', expect: 1000 * 60 * 60 * 23 }, // 23:00:00
|
|
||||||
{ value: '12121', expect: 1000 + 12 * 60 * 1000 + 12 * 1000 * 60 * 60 }, // 12:12:01
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const s of fiveDigits) {
|
|
||||||
it(`handles string with 5 digits: ${s.value}`, () => {
|
|
||||||
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const fourDigits = [
|
|
||||||
{ value: '0000', expect: 0 },
|
|
||||||
{ value: '0001', expect: 1000 * 60 }, // 00:01:00
|
|
||||||
{ value: '0010', expect: 1000 * 60 * 10 }, // 00:10:00
|
|
||||||
{ value: '0100', expect: 1000 * 60 * 60 }, // 01:00:00
|
|
||||||
{ value: '1000', expect: 1000 * 60 * 60 * 10 }, // 10:00:00
|
|
||||||
{ value: '2300', expect: 1000 * 60 * 60 * 23 }, // 23:00:00
|
|
||||||
{ value: '1212', expect: 12 * 60 * 1000 + 12 * 1000 * 60 * 60 }, // 12:12:00
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const s of fourDigits) {
|
|
||||||
it(`handles string with 4 digits: ${s.value}`, () => {
|
|
||||||
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const threeDigits = [
|
|
||||||
{ value: '000', expect: 0 },
|
|
||||||
{ value: '001', expect: 1000 * 60 }, // 00:01:00
|
|
||||||
{ value: '010', expect: 1000 * 60 * 10 }, // 00:10:00
|
|
||||||
{ value: '100', expect: 1000 * 60 * 60 }, // 01:00:00
|
|
||||||
{ value: '230', expect: 2 * 1000 * 60 * 60 + 30 * 1000 * 60 }, // 02:30:00
|
|
||||||
{ value: '121', expect: 21 * 60 * 1000 + 1000 * 60 * 60 }, // 01:21:00
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const s of threeDigits) {
|
|
||||||
it(`handles string with 3 digits: ${s.value}`, () => {
|
|
||||||
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const twoDigits = [
|
|
||||||
{ value: '00', expect: 0 },
|
|
||||||
{ value: '01', expect: 1000 * 60 }, // 00:01:00
|
|
||||||
{ value: '10', expect: 1000 * 60 * 10 }, // 00:10:00
|
|
||||||
{ value: '23', expect: 1000 * 60 * 23 }, // 00:23:00
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const s of twoDigits) {
|
|
||||||
it(`handles string with 2 digits: ${s.value}`, () => {
|
|
||||||
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const singleDigit = [...Array(10).keys()];
|
|
||||||
|
|
||||||
for (const s of singleDigit) {
|
|
||||||
it(`handles string with a single digits ${s}`, () => {
|
|
||||||
expect(forgivingStringToMillis(`${s}`)).toBe(s * 1000 * 60);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('handles overflows', () => {
|
|
||||||
const ts = [
|
|
||||||
// minutes overflow
|
|
||||||
{ value: '2.0.0', expect: 1000 * 60 * 120 },
|
|
||||||
{ value: '99', expect: 1000 * 60 * 99 },
|
|
||||||
{ value: '1.39.0', expect: 1000 * 60 * 99 },
|
|
||||||
// seconds overflow
|
|
||||||
{ value: '0.0.120', expect: 120 * 1000 },
|
|
||||||
{ value: '0.2.0', expect: 120 * 1000 },
|
|
||||||
{ value: '0.0.99', expect: 99 * 1000 },
|
|
||||||
{ value: '0.1.39', expect: 99 * 1000 },
|
|
||||||
// hours overflow
|
|
||||||
{ value: '25.0.0', expect: 1000 * 60 * 60 * 25 },
|
|
||||||
// hours overflow
|
|
||||||
{ value: '50.0.0', expect: 1000 * 60 * 60 * 50 },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const s of ts) {
|
|
||||||
it(`handles ${s.value} to the left`, () => {
|
|
||||||
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('test fillLeft', () => {
|
|
||||||
describe('function handles separators', () => {
|
|
||||||
const testData = [
|
|
||||||
{ value: '1:2:3:10', expect: 3723000 },
|
|
||||||
{ value: '2,10', expect: 2 * 60 * 60 * 1000 + 60 * 10 * 1000 },
|
|
||||||
{ value: '2.10', expect: 2 * 60 * 60 * 1000 + 60 * 10 * 1000 },
|
|
||||||
{ value: '2 10', expect: 2 * 60 * 60 * 1000 + 60 * 10 * 1000 },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const s of testData) {
|
|
||||||
it(`handles ${s.value}`, () => {
|
|
||||||
expect(typeof forgivingStringToMillis(s.value)).toBe('number');
|
|
||||||
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('parses strings correctly', () => {
|
|
||||||
const ts = [
|
|
||||||
{ value: '1.2', expect: 60 * 60 * 1000 + 2 * 60 * 1000 },
|
|
||||||
{ value: '1.70', expect: 60 * 60 * 1000 + 70 * 60 * 1000 },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const s of ts) {
|
|
||||||
it(`handles ${s.value}`, () => {
|
|
||||||
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('handles overflows', () => {
|
|
||||||
const ts = [
|
|
||||||
// minutes overflow
|
|
||||||
{ value: '0.120', expect: 120 * 60 * 1000 },
|
|
||||||
{ value: '0.99', expect: 99 * 60 * 1000 },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const s of ts) {
|
|
||||||
it(`handles ${s.value}`, () => {
|
|
||||||
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('millisToDelayString()', () => {
|
describe('millisToDelayString()', () => {
|
||||||
it('returns null for null values', () => {
|
it('returns null for null values', () => {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ describe('cloneEvent()', () => {
|
|||||||
timeWarning: 120000,
|
timeWarning: 120000,
|
||||||
timeDanger: 60000,
|
timeDanger: 60000,
|
||||||
custom: {
|
custom: {
|
||||||
lighting: { value: '3' },
|
lighting: '3',
|
||||||
} as EventCustomFields,
|
} as EventCustomFields,
|
||||||
} as OntimeEvent;
|
} as OntimeEvent;
|
||||||
|
|
||||||
|
|||||||
@@ -1,163 +1,11 @@
|
|||||||
import { MaybeNumber } from 'ontime-types';
|
import { MaybeNumber } from 'ontime-types';
|
||||||
import { formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
|
import { formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description safe parse string to int
|
* Parses a value in millis to a string which encodes a delay
|
||||||
* @param {string} valueAsString
|
* @param millis
|
||||||
* @return {number}
|
* @param format
|
||||||
*/
|
*/
|
||||||
const parse = (valueAsString: string): number => {
|
|
||||||
const parsed = parseInt(valueAsString, 10);
|
|
||||||
if (isNaN(parsed)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return Math.abs(parsed);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description Utility function to check if a string contain am/pm indicators
|
|
||||||
* @param {string} value
|
|
||||||
*/
|
|
||||||
function checkAmPm(value: string) {
|
|
||||||
let isPM = false;
|
|
||||||
let isAM = false;
|
|
||||||
if (value.toLowerCase().includes('pm')) {
|
|
||||||
isPM = true;
|
|
||||||
value = value.replace(/pm/i, '');
|
|
||||||
} else if (value.toLowerCase().includes('p')) {
|
|
||||||
isPM = true;
|
|
||||||
value = value.replace(/p/i, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
// we need to remove am, but it doesn't actually change anything
|
|
||||||
if (value.toLowerCase().includes('am')) {
|
|
||||||
isAM = true;
|
|
||||||
value = value.replace(/am/i, '');
|
|
||||||
} else if (value.toLowerCase().includes('a')) {
|
|
||||||
isAM = true;
|
|
||||||
value = value.replace(/a/i, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
return { isAM, isPM, value };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description Utility function to check if a string contain h / m / s indicators
|
|
||||||
* @param {string} value
|
|
||||||
*/
|
|
||||||
function checkMatchers(value: string) {
|
|
||||||
const hoursMatch = /(\d+)h/i.exec(value);
|
|
||||||
const hoursMatchValue = hoursMatch ? parse(hoursMatch[1]) : 0;
|
|
||||||
|
|
||||||
const minutesMatch = /(\d+)m/i.exec(value);
|
|
||||||
const minutesMatchValue = minutesMatch ? parse(minutesMatch[1]) : 0;
|
|
||||||
|
|
||||||
const secondsMatch = /(\d+)s/i.exec(value);
|
|
||||||
const secondsMatchValue = secondsMatch ? parse(secondsMatch[1]) : 0;
|
|
||||||
|
|
||||||
if (hoursMatchValue > 0 || minutesMatchValue > 0 || secondsMatchValue > 0) {
|
|
||||||
return (
|
|
||||||
hoursMatchValue * MILLIS_PER_HOUR + minutesMatchValue * MILLIS_PER_MINUTE + secondsMatchValue * MILLIS_PER_SECOND
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return { hoursMatchValue };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description Utility function to infer separators from a whole string
|
|
||||||
* @param {string} value
|
|
||||||
* @param {boolean} isAM
|
|
||||||
* @param {boolean} isPM
|
|
||||||
*/
|
|
||||||
function inferSeparators(value: string, isAM: boolean, isPM: boolean) {
|
|
||||||
const length = value.length;
|
|
||||||
let inferredMillis = 0;
|
|
||||||
let addAM = 0;
|
|
||||||
if (length === 1) {
|
|
||||||
if (isPM || isAM) {
|
|
||||||
inferredMillis = parse(value) * MILLIS_PER_HOUR;
|
|
||||||
if (isAM) {
|
|
||||||
// this ensures we dont add 12 hours in the end
|
|
||||||
addAM = inferredMillis;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
inferredMillis = parse(value) * MILLIS_PER_MINUTE;
|
|
||||||
}
|
|
||||||
} else if (length === 2) {
|
|
||||||
if (isPM || isAM) {
|
|
||||||
inferredMillis = parse(value) * inferredMillis;
|
|
||||||
if (isAM) {
|
|
||||||
// this ensures we dont add 12 hours in the end
|
|
||||||
addAM = 12;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
inferredMillis = parse(value) * MILLIS_PER_MINUTE;
|
|
||||||
}
|
|
||||||
} else if (length === 3) {
|
|
||||||
inferredMillis = parse(value[0]) * MILLIS_PER_HOUR + parse(value.substring(1)) * MILLIS_PER_MINUTE;
|
|
||||||
} else if (length === 4) {
|
|
||||||
inferredMillis = parse(value.substring(0, 2)) * MILLIS_PER_HOUR + parse(value.substring(2)) * MILLIS_PER_MINUTE;
|
|
||||||
} else if (length === 5) {
|
|
||||||
const hours = parse(value.substring(0, 2));
|
|
||||||
const minutes = parse(value.substring(2, 4));
|
|
||||||
const seconds = parse(value.substring(4));
|
|
||||||
inferredMillis = hours * MILLIS_PER_HOUR + minutes * MILLIS_PER_MINUTE + seconds * MILLIS_PER_SECOND;
|
|
||||||
} else if (length >= 6) {
|
|
||||||
const hours = parse(value.substring(0, 2));
|
|
||||||
const minutes = parse(value.substring(2, 4));
|
|
||||||
const seconds = parse(value.substring(4));
|
|
||||||
inferredMillis = hours * MILLIS_PER_HOUR + minutes * MILLIS_PER_MINUTE + seconds * MILLIS_PER_SECOND;
|
|
||||||
}
|
|
||||||
return { inferredMillis, addAM };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description Parses a time string to millis, auto-filling to the left
|
|
||||||
* @param {string} value - time string
|
|
||||||
* @returns {number} - time string in millis
|
|
||||||
*/
|
|
||||||
export const forgivingStringToMillis = (value: string): number => {
|
|
||||||
if (value === '12am') {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { isAM, isPM, value: parsingValue } = checkAmPm(value);
|
|
||||||
const maybeMillisFromMatchers = checkMatchers(parsingValue);
|
|
||||||
if (typeof maybeMillisFromMatchers === 'number') {
|
|
||||||
return maybeMillisFromMatchers;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { hoursMatchValue } = maybeMillisFromMatchers;
|
|
||||||
|
|
||||||
let millis = 0;
|
|
||||||
|
|
||||||
// split string at known separators : , .
|
|
||||||
const separatorRegex = /[\s,:.]+/;
|
|
||||||
const [first, second, third] = parsingValue.split(separatorRegex);
|
|
||||||
|
|
||||||
if (first != null && second != null && third != null) {
|
|
||||||
// if string has three sections, treat as [hours] [minutes] [seconds]
|
|
||||||
millis = parse(first) * MILLIS_PER_HOUR;
|
|
||||||
millis += parse(second) * MILLIS_PER_MINUTE;
|
|
||||||
millis += parse(third) * MILLIS_PER_SECOND;
|
|
||||||
} else if (first != null && second == null && third == null) {
|
|
||||||
// we only have one section, infer separators
|
|
||||||
const { inferredMillis, addAM } = inferSeparators(first, isAM, isPM);
|
|
||||||
millis = inferredMillis;
|
|
||||||
hoursMatchValue = addAM;
|
|
||||||
}
|
|
||||||
if (first != null && second != null && third == null) {
|
|
||||||
millis = parse(first) * MILLIS_PER_HOUR;
|
|
||||||
millis += parse(second) * MILLIS_PER_MINUTE;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add 12 hours if it is PM
|
|
||||||
if (isPM && hoursMatchValue < 12) {
|
|
||||||
millis += 12 * MILLIS_PER_HOUR;
|
|
||||||
}
|
|
||||||
return millis;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function millisToDelayString(millis: MaybeNumber, format: 'compact' | 'expanded' = 'compact'): string {
|
export function millisToDelayString(millis: MaybeNumber, format: 'compact' | 'expanded' = 'compact'): string {
|
||||||
if (millis == null || millis === 0) {
|
if (millis == null || millis === 0) {
|
||||||
return '';
|
return '';
|
||||||
|
|||||||
@@ -3,4 +3,6 @@ export function isMacOS() {
|
|||||||
return userAgent.includes('macintosh') || userAgent.includes('mac os');
|
return userAgent.includes('macintosh') || userAgent.includes('mac os');
|
||||||
}
|
}
|
||||||
|
|
||||||
export const deviceAlt = isMacOS() ? '⌥' : 'Alt';
|
export const deviceAlt = isMacOS() ? 'Option' : 'Alt';
|
||||||
|
|
||||||
|
export const deviceMod = isMacOS() ? 'Cmd' : 'Ctrl';
|
||||||
|
|||||||