mirror of
https://github.com/cpvalente/ontime.git
synced 2026-07-26 10:38:55 +00:00
Compare commits
260 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aa927597f3 | |||
| ef7f0fb54a | |||
| 324320fdc8 | |||
| 4b27d080b6 | |||
| 7918063753 | |||
| e2202c2417 | |||
| 9817c8f8c0 | |||
| 1d8c04d0b4 | |||
| 10762f10bf | |||
| da7c0d4ff2 | |||
| 4a27973de1 | |||
| bdeb087e28 | |||
| 3662cd58f5 | |||
| 62c52bca07 | |||
| 1fc4a06133 | |||
| 147862e647 | |||
| 12bc323e57 | |||
| de8f9ef587 | |||
| 217402050d | |||
| de75d55849 | |||
| c4e6215b29 | |||
| 57315595a5 | |||
| fc7ed6bd20 | |||
| 750927dc8d | |||
| 00d2ae0a7c | |||
| 56c4d68bae | |||
| a02cdf71c0 | |||
| 25030debe4 | |||
| 27243c4432 | |||
| 22559c59d4 | |||
| 700948bfbb | |||
| 960a39fbf4 | |||
| d986f438d9 | |||
| c2012bd9e3 | |||
| 1f0f92371f | |||
| 4b81725e80 | |||
| 846eaf569d | |||
| ccf1ddf2fd | |||
| 81e00993f2 | |||
| e22c7698c3 | |||
| ef053fc2f7 | |||
| 3b6d3a4631 | |||
| 31c3e603ed | |||
| 1dfe5c980d | |||
| 80e4dc0941 | |||
| bc0b83aa14 | |||
| 971c6c66f0 | |||
| 343697dc93 | |||
| 4058231d6b | |||
| c5ff254267 | |||
| ac14cb9624 | |||
| bcabc50ad1 | |||
| cbd7bce106 | |||
| 00aa8a09fa | |||
| d04f958c11 | |||
| 5716fc31cf | |||
| a5d628af0c | |||
| 111a1f95ba | |||
| 4bf74d86b3 | |||
| d2638d64e4 | |||
| 02cddeec4d | |||
| a8fe8b5f1e | |||
| 8d2db7ffcd | |||
| 313590905f | |||
| 7bbdab7dd9 | |||
| 304832ecde | |||
| 0ab4abfcfe | |||
| 1847822f31 | |||
| 4499495212 | |||
| 0035070db8 | |||
| 890a97d071 | |||
| 5441e5b07f | |||
| ab4b94f2d8 | |||
| fd7ccb4a5b | |||
| 2e6bf234d5 | |||
| c934b0ecdd | |||
| cd6d6f24eb | |||
| e632a08557 | |||
| 0057b4a3f1 | |||
| ac8c1e333e | |||
| 321297b685 | |||
| 67e67d6a5d | |||
| 4385e0b9c9 | |||
| 02243242f1 | |||
| 1af42b766d | |||
| 9d6ec3758b | |||
| 9e78ff4d1c | |||
| 2037aef080 | |||
| 48b9cf042f | |||
| 1866e5e78d | |||
| bc1b3800c8 | |||
| a2505c838b | |||
| bffa863fb4 | |||
| d00d7677bc | |||
| 3ae73be562 | |||
| 763080cd4d | |||
| 6ff06b21a8 | |||
| adebde1b43 | |||
| 89922fe394 | |||
| f219ebb8f8 | |||
| eace97ec7a | |||
| ec1df8b958 | |||
| 8d2d8ef979 | |||
| ae15f3cdc5 | |||
| 3c41b40c5f | |||
| 3b38756dda | |||
| 5a4494ba85 | |||
| 02ad852dd4 | |||
| 7ce1097826 | |||
| 7ee0de5341 | |||
| 0db249871b | |||
| 87df656470 | |||
| 54f07c4330 | |||
| ae061cb69a | |||
| ba529def34 | |||
| 210a85c463 | |||
| 3ad3ad088f | |||
| 617da68ab5 | |||
| efceb930e5 | |||
| 836fa52757 | |||
| 902edf4a85 | |||
| 0e770655dc | |||
| db922a67e2 | |||
| 048a9c96ea | |||
| ed93cf313a | |||
| 97a0e17192 | |||
| 022a58e142 | |||
| e0149c1cbf | |||
| 4665f9c2f7 | |||
| 9ab6b30aea | |||
| e5d2457717 | |||
| 486d89ecf4 | |||
| 94b1f63c7c | |||
| 0299463f62 | |||
| f7ccb6bb51 | |||
| 6c6f5c2c0f | |||
| 1695b4dc68 | |||
| 1ed08df198 | |||
| c300fc18e1 | |||
| bea7e2bb2b | |||
| ad7f658f15 | |||
| f23adfa45c | |||
| 20ee391525 | |||
| 78184b23b7 | |||
| e204f671be | |||
| 8c90f5043f | |||
| 7a322406da | |||
| 307f0ffd34 | |||
| 5f8a5c4995 | |||
| 3747dbc1a8 | |||
| 20859abda9 | |||
| 9ebb3decdb | |||
| 35347e8d63 | |||
| e37d2ce50f | |||
| 49e923026e | |||
| f1a8db9649 | |||
| dddeefd544 | |||
| b4aaa8966f | |||
| ad9db4edd3 | |||
| fef9ed4fcc | |||
| 9ec47eda6e | |||
| 32edf3404c | |||
| bcf80f9d77 | |||
| df137402cf | |||
| daddb7f7b7 | |||
| 82e8b9d66a | |||
| 765d5d97cf | |||
| ed4d3ba265 | |||
| a358ec0f17 | |||
| 524721002a | |||
| 9ab54edbf5 | |||
| 8a7f8c8ebc | |||
| 3ce539af2a | |||
| 3158311aa1 | |||
| 6b4d96dddd | |||
| dc3a32d2bc | |||
| bda01c886e | |||
| 93916653be | |||
| 40f8976350 | |||
| 78e905a735 | |||
| 126ff61738 | |||
| 1e63238cac | |||
| 6ffe34a642 | |||
| 69f276765b | |||
| 75554071a7 | |||
| a1fab10bfd | |||
| 5bc286145d | |||
| 44508ce8b4 | |||
| 90105a199a | |||
| 9618bec847 | |||
| 66a4e27fdd | |||
| ef906f43d7 | |||
| 93b2a1aece | |||
| 050629d281 | |||
| abe9012f20 | |||
| 1025347c08 | |||
| 43988ea12b | |||
| 722e045b20 | |||
| ad6804019b | |||
| cd40a6e55e | |||
| 08d9e24871 | |||
| 0649678dca | |||
| a00ec2d02a | |||
| 54a74ccc2a | |||
| 6f3ab274bd | |||
| 90870ecfb6 | |||
| b640e0e181 | |||
| c41fe824cf | |||
| 62c8319d70 | |||
| b1d23467a2 | |||
| 9a62daf047 | |||
| b9ab1c6fd7 | |||
| c1054711b0 | |||
| a4d4f29a37 | |||
| 2793aadea0 | |||
| e7cfb7d9d9 | |||
| 256a851c9b | |||
| 4ab9c81cb8 | |||
| 2f13d6c89e | |||
| f1f7bad25e | |||
| a8b52a48f7 | |||
| cd0999b2ab | |||
| b4c60f3f04 | |||
| 5cefad3666 | |||
| 7a6ecd8c34 | |||
| ba96ecfd91 | |||
| 7bed3757f2 | |||
| e2e755b1d2 | |||
| a77d23109d | |||
| 0eb3b8d382 | |||
| 0021185288 | |||
| 94c72ff4f6 | |||
| e4c08dc9b2 | |||
| ec74af0d62 | |||
| b6d72dd082 | |||
| 31c311daf0 | |||
| e9b3cc6090 | |||
| 543b04a097 | |||
| d39b85b6e6 | |||
| bbe107bb2b | |||
| c616240db1 | |||
| 166be66ce3 | |||
| 4180d0a337 | |||
| 78108e316c | |||
| 3bb8b70915 | |||
| 1c4f13a0ed | |||
| 3ca0abad53 | |||
| 030c8f897f | |||
| 68175cfa3b | |||
| fd8f757851 | |||
| 876d111c61 | |||
| 730cb95c04 | |||
| 3c388d4fb5 | |||
| 2e23718d73 | |||
| 89ea8c470b | |||
| 178640bfc4 | |||
| 4ed38340e0 | |||
| 69eb9a5eff | |||
| 351425127a | |||
| 95f2ba37cc |
+1
-1
@@ -16,7 +16,7 @@
|
||||
|
||||
# Ignore build folders
|
||||
node_modules
|
||||
dist
|
||||
**/node_modules
|
||||
|
||||
# Ignore default volumes created by running docker compose up
|
||||
ontime-db
|
||||
|
||||
+2
-1
@@ -1 +1,2 @@
|
||||
"ONTIME_VERSION.js"
|
||||
"ONTIME_VERSION.js"
|
||||
dist/
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 271 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 270 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 272 KiB |
@@ -14,19 +14,19 @@ jobs:
|
||||
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: 22
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Get pnpm store directory
|
||||
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
version: 10
|
||||
path: ${{ env.PNPM_STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -38,16 +38,20 @@ jobs:
|
||||
|
||||
- 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: Setup pnpm auth config
|
||||
run: pnpm config set '//registry.npmjs.org/:_authToken' "${NODE_AUTH_TOKEN}"
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
|
||||
|
||||
- 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
|
||||
|
||||
@@ -6,43 +6,63 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
publish_docker:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CI: ''
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Docker Login
|
||||
uses: docker/login-action@v2.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Docker Setup Buildx
|
||||
uses: docker/setup-buildx-action@v2.5.0
|
||||
- name: Get pnpm store directory
|
||||
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- 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: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.PNPM_STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- 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
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build project packages
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
run: pnpm 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
|
||||
# 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
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Ontime build
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: [ "*" ]
|
||||
tags: ['*']
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -11,15 +11,20 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Get pnpm store directory
|
||||
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
version: 10
|
||||
path: ${{ env.PNPM_STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -30,7 +35,7 @@ jobs:
|
||||
run: pnpm build
|
||||
|
||||
- name: Electron - Build app
|
||||
env:
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLEID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLEIDPASS }}
|
||||
APPLE_TEAM_ID: ${{ secrets.TEAMID }}
|
||||
@@ -53,15 +58,9 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -86,15 +85,9 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -0,0 +1,46 @@
|
||||
name: Ontime Resolver build
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build_resolver:
|
||||
permissions:
|
||||
id-token: write
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Get pnpm store directory
|
||||
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.PNPM_STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build project packages
|
||||
run: pnpm build:resolver
|
||||
|
||||
- name: Setup pnpm auth config
|
||||
run: pnpm config set '//registry.npmjs.org/:_authToken' "${NODE_AUTH_TOKEN}"
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
|
||||
|
||||
- name: Publish to NPM
|
||||
run: pnpm publish --access public --no-git-checks
|
||||
env:
|
||||
NPM_CONFIG_PROVENANCE: true
|
||||
working-directory: ./apps/resolver
|
||||
+41
-46
@@ -14,55 +14,35 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Get pnpm store directory
|
||||
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
version: 10
|
||||
path: ${{ env.PNPM_STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- 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 code quality
|
||||
- name: Run linter
|
||||
run: pnpm lint
|
||||
working-directory: ./packages/types
|
||||
|
||||
- name: Run TypeScript checks
|
||||
run: pnpm typecheck
|
||||
|
||||
# We choose to run tests separately
|
||||
- name: React - Run unit tests
|
||||
- name: 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
|
||||
run: pnpm test
|
||||
|
||||
e2e-test:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -70,21 +50,36 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Get pnpm store directory
|
||||
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
version: 10
|
||||
path: ${{ env.PNPM_STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build client
|
||||
run: pnpm build:local
|
||||
- name: Get installed Playwright version
|
||||
run: echo "PLAYWRIGHT_VERSION=$(pnpm ls @playwright/test --parseable | cut -s -d '@' -f3 | cut -d '/' -f1)" >> $GITHUB_ENV
|
||||
- name: Cache playwright binaries
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-playwright-
|
||||
- run: npx playwright install --with-deps
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install --with-deps
|
||||
|
||||
@@ -34,6 +34,10 @@ e2e/tests/fixtures/tmp/*
|
||||
build/
|
||||
dist/
|
||||
|
||||
# bundled assets
|
||||
translations.json
|
||||
override.css
|
||||
|
||||
# working stuff
|
||||
**/TODO.md
|
||||
|
||||
|
||||
+6
-2
@@ -5,6 +5,10 @@ node_modules
|
||||
|
||||
playwright-report
|
||||
|
||||
|
||||
pnpm-lock.yaml
|
||||
**/*.toml
|
||||
**/*.yml
|
||||
**/*.json
|
||||
**/*.json
|
||||
!tsconfig.common.json
|
||||
!turbo.json
|
||||
!package.json
|
||||
|
||||
+24
-19
@@ -20,19 +20,21 @@ development.
|
||||
Locally, we would need to run both the React client and the node.js server in development mode
|
||||
|
||||
From the project root, run the following commands
|
||||
|
||||
- __Install the project dependencies__ by running `pnpm i`
|
||||
- __Run dev mode__ by running `turbo dev`
|
||||
- __Create a local build__ by running `pnpm build`, this will populate local dependencies
|
||||
- __Run dev mode__ by running `pnpm dev` or `pnpm dev:electron` to get the electron window
|
||||
|
||||
|
||||
### Debugging backend
|
||||
|
||||
To debug backend code in Node.js:
|
||||
The previous command will start the development servers for both the client, server and electron applications.
|
||||
Typically in dev mode we prefer to start these in separate terminals to help with error tracking and debugging.
|
||||
|
||||
- Open two separate terminals and navigate to the `apps/client` and `apps/server` directories.
|
||||
- In each terminal, run the command `pnpm dev` to start the development servers for both the client and server
|
||||
applications.
|
||||
- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by
|
||||
running `pnpm dev:inspect`.
|
||||
We do that by creating two terminals an running
|
||||
- __Run the React UI__ by running `pnpm dev --filter=ontime-ui`
|
||||
- __Run the nodejs server__ by running `pnpm dev --filter=ontime-server`
|
||||
|
||||
- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by running `pnpm dev:inspect --filter=ontime-server`.
|
||||
|
||||
## TESTING
|
||||
|
||||
@@ -45,7 +47,7 @@ Generally we have 2 types of tests.
|
||||
|
||||
Unit tests are contained in mostly all the apps and packages (client, server and utils)
|
||||
|
||||
You can run unit tests by running `turbo run test:pipeline` from the project root.
|
||||
You can run unit tests by running `pnpm test:pipeline` from the project root.
|
||||
This will run all tests and close test runner.
|
||||
|
||||
Alternatively you can navigate to an app or project and run `pnpm test` to run those tests in watch mode
|
||||
@@ -65,7 +67,7 @@ start the webserver with `pnpm dev:server`
|
||||
|
||||
Some other useful commands
|
||||
|
||||
- `pnpm e2e --ui` open playwright UI
|
||||
- `pnpm e2e:ui` open playwright UI
|
||||
- `pnpm e2e --headed` run tests with a visible browser window
|
||||
|
||||
## CREATE AN INSTALLABLE FILE (Windows | MacOS | Linux)
|
||||
@@ -76,14 +78,11 @@ You can generate a distribution for your OS by running the following steps.
|
||||
From the project root, run the following commands
|
||||
|
||||
- __Install the project dependencies__ by running `pnpm i`
|
||||
- __Build the UI and server__ by running `turbo run build:electron`
|
||||
- __Create the package__ by running `turbo run dist-win`, `turbo run dist-mac` or `turbo run dist-linux`
|
||||
- __Build the UI and server__ by running `pnpm build`
|
||||
- __Create the package__ by running `pnpm dist-win`, `pnpm dist-mac` or `pnpm dist-linux`
|
||||
|
||||
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 run dist-mac:local` command to build a MacOS distribution locally.
|
||||
|
||||
## DOCKER
|
||||
|
||||
Ontime provides a docker-compose file to aid with building and running docker images.
|
||||
@@ -99,9 +98,15 @@ Other useful commands
|
||||
- __List running processes__ by running `docker ps`
|
||||
- __Kill running process__ by running `docker kill <process-id>`
|
||||
|
||||
## General Info
|
||||
## CONTRIBUTION GUIDELINES
|
||||
|
||||
# APP Building
|
||||
If you want to propose changes to the codebase, please reach out before opening a Pull Request.
|
||||
|
||||
We build the app from app.js for almost all applications. The output file will still be named index.cjs. This is because of Electron.
|
||||
Building the app from index.ts only applies for applications that don't use electron. index.ts will take over the initialization of the server and UI when electron isn't present.
|
||||
For new PRs, please follow the following checklist:
|
||||
* [ ] You have updated and ran unit locally and they are passing. Unit tests are generally created for all utility functions and business logic
|
||||
* [ ] You have ran code formatting and linting in all your changes
|
||||
* [ ] The branch is clean and the commits are meaningfully separated and contain descriptive messages
|
||||
* [ ] The PR body contains description and motivation for the changes
|
||||
|
||||
After this checklist is complete, you can request a review from one of the maintainers to get feedback and approval on the changes. \
|
||||
We will review as soon as possible
|
||||
|
||||
+30
-37
@@ -1,37 +1,30 @@
|
||||
FROM node:22-bullseye AS builder
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN npm install -g pnpm@10.11.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:22-alpine
|
||||
|
||||
# Set environment variables
|
||||
# Environment Variable to signal that we are running production
|
||||
ENV NODE_ENV=docker
|
||||
# Ontime Data path
|
||||
ENV ONTIME_DATA=/data/
|
||||
|
||||
WORKDIR /app/
|
||||
|
||||
# Prepare UI
|
||||
COPY --from=builder /app/apps/client/build ./client/
|
||||
|
||||
# Prepare Backend
|
||||
COPY --from=builder /app/apps/server/dist/ ./server/
|
||||
COPY --from=builder /app/apps/server/src/external/ ./external/
|
||||
COPY --from=builder /app/apps/server/src/user/ ./user/
|
||||
COPY --from=builder /app/apps/server/src/html/ ./html/
|
||||
|
||||
# Export default ports
|
||||
EXPOSE 4001/tcp 8888/udp 9999/udp
|
||||
|
||||
CMD ["node", "server/docker.cjs"]
|
||||
|
||||
# Build and run commands
|
||||
# !!! Note that this command needs pre-build versions of the UI and server apps
|
||||
# docker buildx build . -t getontime/ontime
|
||||
# docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/data/ getontime/ontime
|
||||
ARG NODE_VERSION=22.15.1
|
||||
FROM node:${NODE_VERSION}-alpine
|
||||
|
||||
# Set environment variables
|
||||
# Environment Variable to signal that we are running production
|
||||
ENV NODE_ENV=docker
|
||||
# Ontime Data path
|
||||
ENV ONTIME_DATA=/data/
|
||||
|
||||
RUN mkdir /app
|
||||
WORKDIR /app/
|
||||
|
||||
# Prepare UI
|
||||
COPY apps/client/build/ ./client/
|
||||
|
||||
# Prepare Backend
|
||||
COPY apps/server/dist/ ./server/
|
||||
COPY apps/server/src/external/ ./external/
|
||||
COPY apps/server/src/user/ ./user/
|
||||
COPY apps/server/src/html/ ./html/
|
||||
|
||||
# Export default ports
|
||||
EXPOSE 4001/tcp 8888/udp 9999/udp
|
||||
|
||||
CMD ["node", "server/docker.cjs"]
|
||||
|
||||
# Build and run commands
|
||||
# pnpm build:docker
|
||||
# docker buildx build . -t getontime/ontime
|
||||
# docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/data/ getontime/ontime
|
||||
|
||||
@@ -43,12 +43,12 @@ Ontime is made by entertainment and broadcast engineers and used by
|
||||
- Houses of worship
|
||||
|
||||
## Main features
|
||||
- [x] **Multiplatform**: Available for Windows / MacOS, Linux. You can also self host with the docker image
|
||||
- [x] **In any device**: Ontime is available in the local network to any device with a browser, eg: tablets, mobile phones, laptops, signage, media servers...
|
||||
- [x] **Made for teams**: Ontime caters to different roles in your production team: directors, operators, backstage and front of house signage...
|
||||
- [x] **Delay workflows**: Manage and communicate runtime delays in real-time to your team
|
||||
- [x] **Automatable**: Ontime can be fully or partially controlled by an operator, or run standalone with the system clock
|
||||
- [x] **Focus on integrations**: Use one of the APIs provided (OSC, HTTP, Websocket) or the available [Companion module](https://bitfocus.io/connections/getontime-ontime) to integrate into your workflow (vMix, disguise, Qlab, OBS)
|
||||
- **Multiplatform**: Available as a Cloud service and for Windows, macOS, Linux, or self-hosted via Docker.
|
||||
- **In any device**: Ontime is available to any device with a browser, eg: tablets, mobile phones, laptops, signage, media servers...
|
||||
- **Team Collaboration**: Dedicated views for directors, operators, backstage, and signage.
|
||||
- **Real-Time Updates**: Manage and communicate runtime delays effortlessly.
|
||||
- **Automatable**: Ontime can be fully or partially controlled by an operator, or run standalone with the system clock
|
||||
- **Flexible Integrations**: Use one of the APIs provided (OSC, HTTP, Websocket) or the available [Companion module](https://bitfocus.io/connections/getontime-ontime) to integrate into your workflow (vMix, disguise, Qlab, OBS)
|
||||
|
||||
... and a lot more ...
|
||||
|
||||
@@ -79,75 +79,29 @@ Ontime is made by video engineers and entertainment technicians.
|
||||
|
||||
## Using Ontime
|
||||
|
||||
Ontime can be started by downloading the latest release for your platform. \
|
||||
Alternatively you can also use the docker image, available at [Docker Hub](https://hub.docker.com/r/getontime/ontime)
|
||||
### Getting started
|
||||
|
||||
Once installed and running, any device in the network has access to Ontime.
|
||||
The easiest way to start with Ontime is by leveraging our [Cloud service](https://getontime.no). \
|
||||
This will give you immediate access to running instances of Ontime which are available to share with anyone with an internet connection.
|
||||
|
||||
Ontime provides different screens which allow for different types of interactions with the data. These are called
|
||||
views. \
|
||||
Each view in Ontime focuses on empowering a specific role or achieving a particular task.
|
||||
Alternatively, you can run Ontime locally for free by downloading the latest release for your platform or using the docker image, available at [Docker Hub](https://hub.docker.com/r/getontime/ontime)
|
||||
|
||||
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 backstage views
|
||||
-------------------------------------------------------------
|
||||
IP.ADDRESS:4001/timer > Presenter / Stage timer view
|
||||
IP.ADDRESS:4001/backstage > Stage Manager / Backstage view
|
||||
IP.ADDRESS:4001/countdown > Countdown to anything
|
||||
IP.ADDRESS:4001/studio > Studio Clock
|
||||
IP.ADDRESS:4001/timeline > Timeline
|
||||
```
|
||||
|
||||
```
|
||||
For production views
|
||||
-------------------------------------------------------------
|
||||
IP.ADDRESS:4001/editor > the control interface, same as the app
|
||||
IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
|
||||
IP.ADDRESS:4001/op > automated views for operators
|
||||
```
|
||||
Once installed and running, any device that shares the same network as Ontime will have access to Ontime.
|
||||
|
||||
More information is available [in our docs](https://docs.getontime.no)
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Continued development
|
||||
## Continued development
|
||||
|
||||
Ontime is under active development. We continue adding and improving features in collaboration with users.
|
||||
|
||||
Have an idea? Reach out via [email](mail@getontime.no)
|
||||
or [open an issue](https://github.com/cpvalente/ontime/issues/new)
|
||||
|
||||
### Issues
|
||||
## Issues
|
||||
|
||||
We use Github's issue tracking for bug reporting and feature requests. \
|
||||
Found a bug? [Open an issue](https://github.com/cpvalente/ontime/issues/new).
|
||||
|
||||
#### Unsigned App
|
||||
|
||||
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.`
|
||||
|
||||
or in Linux
|
||||
|
||||
`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.
|
||||
|
||||
- In Windows: click `more` -> `Run Anyway`
|
||||
- In Linux: right-click the AppImage file: `Properties` -> `Permissions` -> `Allow Executing File as a Program`
|
||||
|
||||
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)
|
||||
|
||||
## Contributing
|
||||
|
||||
Looking to contribute? All types of help are appreciated, from coding to testing and feature specification.
|
||||
@@ -159,6 +113,7 @@ Information about the project setup can be found in the [development documentati
|
||||
## Links
|
||||
- [Ontime website](https://getontime.no)
|
||||
- [Documentation](https://docs.getontime.no)
|
||||
- [Video tutorials](https://www.youtube.com/@ontimeapp)
|
||||
- [Ontime discord server](https://discord.com/invite/eje3CSUEXm)
|
||||
|
||||
## License
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "4.0.0-alpha.0",
|
||||
"version": "4.0.0",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -13,12 +13,6 @@
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<title>ontime</title>
|
||||
<style>
|
||||
body,
|
||||
html {
|
||||
background-color: #101010 !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
|
||||
+25
-28
@@ -1,51 +1,48 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "4.0.0-alpha.0",
|
||||
"version": "4.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@base-ui-components/react": "1.0.0-beta.1",
|
||||
"@base-ui-components/react": "1.0.0-beta.4",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/is-prop-valid": "^1.3.1",
|
||||
"@fontsource/open-sans": "^5.0.28",
|
||||
"@mantine/hooks": "^8.1.2",
|
||||
"@sentry/react": "^8.43.0",
|
||||
"@fontsource/open-sans": "^5.2.6",
|
||||
"@mantine/hooks": "^8.2.8",
|
||||
"@sentry/react": "^10.2.0",
|
||||
"@table-nav/react": "^0.0.7",
|
||||
"@tanstack/react-query": "^5.62.7",
|
||||
"@tanstack/react-query-devtools": "^5.62.7",
|
||||
"@tanstack/react-query": "^5.85.9",
|
||||
"@tanstack/react-query-devtools": "^5.85.9",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"autosize": "^6.0.1",
|
||||
"axios": "^1.9.0",
|
||||
"babel-plugin-react-compiler": "19.1.0-rc.2",
|
||||
"csv-stringify": "^6.4.5",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "^19.1.0",
|
||||
"axios": "^1.12.2",
|
||||
"babel-plugin-react-compiler": "19.1.0-rc.3",
|
||||
"csv-stringify": "^6.6.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^19.1.1",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-fast-compare": "^3.2.2",
|
||||
"react-hook-form": "^7.53.1",
|
||||
"react-icons": "5.4.0",
|
||||
"react-qr-code": "^2.0.12",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"react-hook-form": "^7.62.0",
|
||||
"react-icons": "5.5.0",
|
||||
"react-qr-code": "^2.0.18",
|
||||
"react-router": "^7.8.2",
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"web-vitals": "^3.1.1",
|
||||
"zustand": "^5.0.3"
|
||||
"react-virtuoso": "^4.14.0",
|
||||
"web-vitals": "^5.1.0",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"scripts": {
|
||||
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
|
||||
"postinstall": "pnpm addversion",
|
||||
"dev": "cross-env BROWSER=none vite",
|
||||
"build": "vite build",
|
||||
"build:local": "cross-env NODE_ENV=local vite build",
|
||||
"build:electron": "cross-env NODE_ENV=local vite build",
|
||||
"build:docker": "cross-env VITE_IS_DOCKER=true vite build",
|
||||
"build:localdocker": "cross-env NODE_ENV=local vite build",
|
||||
"dev:electron": "pnpm dev",
|
||||
"lint": "eslint . --quiet",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "vite build",
|
||||
"test": "vitest",
|
||||
"test:pipeline": "vitest run",
|
||||
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build",
|
||||
"analyse": "npx vite-bundle-visualizer"
|
||||
},
|
||||
"browserslist": {
|
||||
@@ -64,8 +61,8 @@
|
||||
"@sentry/vite-plugin": "^2.16.1",
|
||||
"@tanstack/eslint-plugin-query": "^5.8.4",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@typescript-eslint/eslint-plugin": "catalog:",
|
||||
"@typescript-eslint/parser": "catalog:",
|
||||
"@vitejs/plugin-react": "4.5.1",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { BrowserRouter } from 'react-router';
|
||||
import { Tooltip } from '@base-ui-components/react/tooltip';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
@@ -8,6 +8,7 @@ import IdentifyOverlay from './common/components/identify-overlay/IdentifyOverla
|
||||
import { AppContextProvider } from './common/context/AppContext';
|
||||
import { ontimeQueryClient } from './common/queryClient';
|
||||
import { connectSocket } from './common/utils/socket';
|
||||
import KeepAwake from './features/keep-awake/KeepAwake';
|
||||
import { TranslationProvider } from './translation/TranslationProvider';
|
||||
import AppRouter from './AppRouter';
|
||||
import { baseURI } from './externals';
|
||||
@@ -24,6 +25,7 @@ function App() {
|
||||
<ErrorBoundary>
|
||||
<TranslationProvider>
|
||||
<IdentifyOverlay />
|
||||
<KeepAwake />
|
||||
<AppRouter />
|
||||
</TranslationProvider>
|
||||
</ErrorBoundary>
|
||||
|
||||
+157
-53
@@ -1,38 +1,34 @@
|
||||
import React from 'react';
|
||||
import { Navigate, Route } from 'react-router-dom';
|
||||
import { ComponentType, lazy, Suspense, useEffect, useMemo } from 'react';
|
||||
import { Navigate, Route, useLocation, useNavigate, useParams } from 'react-router';
|
||||
import { OntimeView, OntimeViewPresettable, URLPreset } from 'ontime-types';
|
||||
|
||||
import ViewNavigationMenu from './common/components/navigation-menu/ViewNavigationMenu';
|
||||
import { PresetContext } from './common/context/PresetContext';
|
||||
import { useClientPath } from './common/hooks/useClientPath';
|
||||
import useUrlPresets from './common/hooks-query/useUrlPresets';
|
||||
import { getRouteFromPreset } from './common/utils/urlPresets';
|
||||
import Log from './features/log/Log';
|
||||
import withPreset from './features/PresetWrapper';
|
||||
import withData from './features/viewers/ViewWrapper';
|
||||
import Loader from './views/common/loader/Loader';
|
||||
import NotFound from './views/common/not-found/NotFound';
|
||||
import ViewLoader from './views/ViewLoader';
|
||||
import { getIsNavigationLocked, sessionScope } from './externals';
|
||||
import { initializeSentry } from './sentry.config';
|
||||
|
||||
const Editor = React.lazy(() => import('./views/editor/ProtectedEditor'));
|
||||
const Cuesheet = React.lazy(() => import('./views/cuesheet/ProtectedCuesheet'));
|
||||
const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
|
||||
const Timer = lazy(() => import('./views/timer/Timer'));
|
||||
const Countdown = lazy(() => import('./views/countdown/Countdown'));
|
||||
const Backstage = lazy(() => import('./views/backstage/Backstage'));
|
||||
const StudioClock = lazy(() => import('./views/studio/Studio'));
|
||||
const Timeline = lazy(() => import('./views/timeline/TimelinePage'));
|
||||
const ProjectInfo = lazy(() => import('./views/project-info/ProjectInfo'));
|
||||
|
||||
const TimerView = React.lazy(() => import('./views/timer/Timer'));
|
||||
const Countdown = React.lazy(() => import('./views/countdown/Countdown'));
|
||||
const Editor = lazy(() => import('./views/editor/ProtectedEditor'));
|
||||
const Cuesheet = lazy(() => import('./views/cuesheet/ProtectedCuesheet'));
|
||||
const Operator = lazy(() => import('./features/operator/OperatorExport'));
|
||||
|
||||
const Backstage = React.lazy(() => import('./views/backstage/Backstage'));
|
||||
const Timeline = React.lazy(() => import('./views/timeline/TimelinePage'));
|
||||
const StudioClock = React.lazy(() => import('./views/studio/Studio'));
|
||||
const ProjectInfo = React.lazy(() => import('./views/project-info/ProjectInfo'));
|
||||
|
||||
const STimer = withPreset(withData(TimerView));
|
||||
const SCountdown = withPreset(withData(Countdown));
|
||||
const SBackstage = withPreset(withData(Backstage));
|
||||
const SProjectInfo = withPreset(ProjectInfo); // NOTE: ProjectInfo does not use the viewWrapper since it has no options
|
||||
const SStudio = withPreset(withData(StudioClock));
|
||||
const STimeline = withPreset(withData(Timeline));
|
||||
const PCuesheet = withPreset(Cuesheet);
|
||||
const POperator = withPreset(Operator);
|
||||
|
||||
const EditorFeatureWrapper = React.lazy(() => import('./features/EditorFeatureWrapper'));
|
||||
const RundownPanel = React.lazy(() => import('./features/rundown/RundownExport'));
|
||||
const TimerControl = React.lazy(() => import('./features/control/playback/TimerControlExport'));
|
||||
const MessageControl = React.lazy(() => import('./features/control/message/MessageControlExport'));
|
||||
const EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper'));
|
||||
const RundownPanel = lazy(() => import('./features/rundown/RundownExport'));
|
||||
const TimerControl = lazy(() => import('./features/control/playback/TimerControlExport'));
|
||||
const MessageControl = lazy(() => import('./features/control/message/MessageControlExport'));
|
||||
|
||||
// Initialize Sentry with our configuration
|
||||
const SentryRouter = initializeSentry();
|
||||
@@ -42,73 +38,78 @@ export default function AppRouter() {
|
||||
useClientPath();
|
||||
|
||||
return (
|
||||
<React.Suspense fallback={null}>
|
||||
<Suspense fallback={<Loader />}>
|
||||
<SentryRouter>
|
||||
<Route path='/' element={<Navigate to='/timer' />} />
|
||||
<Route
|
||||
path='/timer'
|
||||
path='timer'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<STimer />
|
||||
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
|
||||
<Timer />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/countdown'
|
||||
path='countdown'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<SCountdown />
|
||||
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
|
||||
<Countdown />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/backstage'
|
||||
path='backstage'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<SBackstage />
|
||||
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
|
||||
<Backstage />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/studio'
|
||||
path='studio'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<SStudio />
|
||||
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
|
||||
<StudioClock />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/timeline'
|
||||
path='timeline'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<STimeline />
|
||||
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
|
||||
<Timeline />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/info'
|
||||
path='info'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<SProjectInfo />
|
||||
<ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />
|
||||
<ProjectInfo />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
|
||||
{/*/!* Protected Routes *!/*/}
|
||||
<Route path='/editor' element={<Editor />} />
|
||||
<Route path='/cuesheet' element={<PCuesheet />} />
|
||||
<Route path='editor' element={<Editor />} />
|
||||
<Route path='cuesheet' element={<Cuesheet />} />
|
||||
<Route
|
||||
path='/op'
|
||||
path='op'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<POperator />
|
||||
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
|
||||
<Operator />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
|
||||
{/*/!* Protected Routes - Elements *!/*/}
|
||||
<Route
|
||||
path='/rundown'
|
||||
path='rundown'
|
||||
element={
|
||||
<EditorFeatureWrapper>
|
||||
<RundownPanel />
|
||||
@@ -116,7 +117,7 @@ export default function AppRouter() {
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/timercontrol'
|
||||
path='timercontrol'
|
||||
element={
|
||||
<EditorFeatureWrapper>
|
||||
<TimerControl />
|
||||
@@ -124,7 +125,7 @@ export default function AppRouter() {
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/messagecontrol'
|
||||
path='messagecontrol'
|
||||
element={
|
||||
<EditorFeatureWrapper>
|
||||
<MessageControl />
|
||||
@@ -132,16 +133,119 @@ export default function AppRouter() {
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/log'
|
||||
path='log'
|
||||
element={
|
||||
<EditorFeatureWrapper>
|
||||
<Log />
|
||||
</EditorFeatureWrapper>
|
||||
}
|
||||
/>
|
||||
{/*/!* Send to default if nothing found *!/*/}
|
||||
<Route path='*' element={<STimer />} />
|
||||
{/**
|
||||
* If the views are prefixed with the "preset" path, we are in a locked preset
|
||||
* Locked presets do not expose their parameters
|
||||
*/}
|
||||
<Route path='preset/:alias' element={<PresetView />} />
|
||||
|
||||
{/**
|
||||
* If we havent matched any views or presets, we may be in an unlocked preset
|
||||
* Unlocked presets are unwrapped to expose their target and parameters
|
||||
*/}
|
||||
<Route path='*' element={<RedirectPreset />} />
|
||||
</SentryRouter>
|
||||
</React.Suspense>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
const PresetViewMap: Record<OntimeViewPresettable, ComponentType> = {
|
||||
[OntimeView.Cuesheet]: Cuesheet,
|
||||
[OntimeView.Operator]: Operator,
|
||||
[OntimeView.Timer]: Timer,
|
||||
[OntimeView.Backstage]: Backstage,
|
||||
[OntimeView.Timeline]: Timeline,
|
||||
[OntimeView.StudioClock]: StudioClock,
|
||||
[OntimeView.Countdown]: Countdown,
|
||||
[OntimeView.ProjectInfo]: ProjectInfo,
|
||||
};
|
||||
|
||||
/**
|
||||
* This view will mask a configured canonical route
|
||||
* and inject the preset search parameters to context
|
||||
* User are not able to configure the parameters locked presets
|
||||
*/
|
||||
function PresetView() {
|
||||
const { data, status } = useUrlPresets();
|
||||
const { alias } = useParams();
|
||||
|
||||
const preset: URLPreset | undefined = useMemo(() => {
|
||||
if (status === 'pending' || !alias) return;
|
||||
return data.find((p) => p.alias === alias && p.enabled);
|
||||
}, [data, status, alias]);
|
||||
|
||||
if (status === 'pending') {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
/**
|
||||
* We need to check the session scope to determine if the user can navigate
|
||||
* If the user has a global scope, they can navigate freely
|
||||
* Otherwise, they are locked to the preset view
|
||||
*/
|
||||
const showNav = sessionScope === 'rw';
|
||||
|
||||
/**
|
||||
* If we are in a preset path but cannot find a preset, we will need to show a not found page
|
||||
* This can happen if the preset was deleted or disabled
|
||||
*/
|
||||
if (!preset) {
|
||||
return (
|
||||
<>
|
||||
<ViewNavigationMenu isNavigationLocked={!showNav} suppressSettings />
|
||||
<NotFound />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Locked presets do not allow configuration changes
|
||||
* Whether the user can navigate is determined by the locked param
|
||||
*
|
||||
* We inject the preset to the context value for the view to consume
|
||||
*/
|
||||
const Component = PresetViewMap[preset.target as OntimeViewPresettable];
|
||||
return (
|
||||
<PresetContext value={preset}>
|
||||
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSettings />
|
||||
{Component ? <Component /> : <NotFound />}
|
||||
</PresetContext>
|
||||
);
|
||||
}
|
||||
|
||||
function RedirectPreset() {
|
||||
const { data, status } = useUrlPresets();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// checks if we are in a preset path and resolves a destination URL
|
||||
const destination = useMemo(() => {
|
||||
if (status === 'pending') return null;
|
||||
return getRouteFromPreset(location, data);
|
||||
}, [data, location, status]);
|
||||
|
||||
// if we have a destination, we will navigate to it
|
||||
useEffect(() => {
|
||||
if (destination) {
|
||||
navigate(`/${destination}`, { replace: true });
|
||||
}
|
||||
}, [destination, navigate]);
|
||||
|
||||
if (status === 'pending') {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSettings />
|
||||
<NotFound />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import axios from 'axios';
|
||||
import { TranslationObject } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
import { ontimeQueryClient } from '../../common/queryClient';
|
||||
|
||||
import { apiEntryUrl, customTranslationsURL, TRANSLATION } from './constants';
|
||||
|
||||
const assetsPath = `${apiEntryUrl}/assets`;
|
||||
|
||||
@@ -28,3 +31,21 @@ export async function restoreCSSContents(): Promise<string> {
|
||||
const res = await axios.post(`${assetsPath}/css/restore`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to get user translation
|
||||
*/
|
||||
export async function getUserTranslation(): Promise<TranslationObject> {
|
||||
const res = await axios.get(customTranslationsURL);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to post user translation
|
||||
*/
|
||||
export async function postUserTranslation(translation: TranslationObject): Promise<void> {
|
||||
await axios.post(`${assetsPath}/translations`, {
|
||||
translation,
|
||||
});
|
||||
await ontimeQueryClient.invalidateQueries({ queryKey: TRANSLATION });
|
||||
}
|
||||
|
||||
@@ -15,12 +15,15 @@ export const URL_PRESETS = ['urlpresets'];
|
||||
export const VIEW_SETTINGS = ['viewSettings'];
|
||||
export const CLIENT_LIST = ['clientList'];
|
||||
export const REPORT = ['report'];
|
||||
export const TRANSLATION = ['translation'];
|
||||
|
||||
// API URLs
|
||||
export const apiEntryUrl = `${serverURL}/data`;
|
||||
|
||||
const userAssetsPath = 'user';
|
||||
const cssOverridePath = 'styles/override.css';
|
||||
const customTranslationsPath = 'translations/translations.json';
|
||||
|
||||
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
|
||||
export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`;
|
||||
export const customTranslationsURL = `${serverURL}/${userAssetsPath}/${customTranslationsPath}`;
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
|
||||
|
||||
import { makeTable } from '../../views/cuesheet/cuesheet.utils';
|
||||
import { aggregateRundowns, makeCSVFromArrayOfArrays } from '../utils/csv';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
import { createBlob, downloadBlob } from './utils';
|
||||
|
||||
@@ -33,27 +30,6 @@ export async function downloadProject(fileName: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request download of the current rundown as a CSV file
|
||||
* @param fileName
|
||||
*/
|
||||
export async function downloadCSV(fileName: string = 'rundown') {
|
||||
try {
|
||||
const { data, name } = await fileDownload(fileName);
|
||||
const { project, rundowns, customFields } = data;
|
||||
|
||||
const flatRundowns = aggregateRundowns(rundowns);
|
||||
const sheetData = makeTable(project, flatRundowns, customFields);
|
||||
|
||||
const fileContent = makeCSVFromArrayOfArrays(sheetData);
|
||||
|
||||
const blob = createBlob(fileContent, 'text/csv;charset=utf-8;');
|
||||
downloadBlob(blob, `${name}.csv`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to upload project file
|
||||
*/
|
||||
|
||||
@@ -3,6 +3,7 @@ import { CustomFields, Rundown } from 'ontime-types';
|
||||
import { ImportMap } from 'ontime-utils';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
import { downloadBlob } from './utils';
|
||||
|
||||
const excelPath = `${apiEntryUrl}/excel`;
|
||||
|
||||
@@ -39,3 +40,18 @@ export async function importRundownPreview(options: ImportMap): Promise<PreviewS
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a xlsx representation of the rundown from the server
|
||||
*/
|
||||
export async function downloadAsExcel(rundownId: string, fileName?: string) {
|
||||
try {
|
||||
const response = await axios.get(`${excelPath}/${rundownId}/export`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
downloadBlob(response.data, `${fileName ?? 'Ontime_rundown'}.xlsx`);
|
||||
} catch (error) {
|
||||
console.error('Error downloading file:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import {
|
||||
EntryId,
|
||||
MessageResponse,
|
||||
OntimeEntry,
|
||||
OntimeEvent,
|
||||
ProjectRundownsList,
|
||||
Rundown,
|
||||
TransientEventPayload,
|
||||
} from 'ontime-types';
|
||||
import { EntryId, OntimeEntry, OntimeEvent, ProjectRundownsList, Rundown, TransientEventPayload } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
const rundownPath = `${apiEntryUrl}/rundown`;
|
||||
const rundownPath = `${apiEntryUrl}/rundowns`;
|
||||
|
||||
// #region operations on project rundowns =========================
|
||||
|
||||
/**
|
||||
* HTTP request to fetch a list of existing rundowns
|
||||
@@ -22,37 +16,64 @@ export async function fetchProjectRundownList(): Promise<ProjectRundownsList> {
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to fetch all events
|
||||
* HTTP request to fetch all entries in the currently loaded rundown
|
||||
*/
|
||||
export async function fetchCurrentRundown(): Promise<Rundown> {
|
||||
const res = await axios.get(`${rundownPath}/current`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to switch the currently loaded rundown
|
||||
*/
|
||||
export async function loadRundown(id: string): Promise<AxiosResponse<ProjectRundownsList>> {
|
||||
return axios.post(`${rundownPath}/${id}/load`);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to create a new rundown
|
||||
*/
|
||||
export async function createRundown(title: string): Promise<AxiosResponse<ProjectRundownsList>> {
|
||||
return axios.post(rundownPath, { title });
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to delete a rundown
|
||||
*/
|
||||
export async function deleteRundown(id: string): Promise<AxiosResponse<ProjectRundownsList>> {
|
||||
return axios.delete(`${rundownPath}/${id}`);
|
||||
}
|
||||
|
||||
// #endregion operations on project rundowns ======================
|
||||
// #region operations on rundown entries ==========================
|
||||
|
||||
/**
|
||||
* HTTP request to post new entry
|
||||
*/
|
||||
export async function postAddEntry(data: TransientEventPayload): Promise<AxiosResponse<OntimeEntry>> {
|
||||
return axios.post(rundownPath, data);
|
||||
export async function postAddEntry(
|
||||
rundownId: string,
|
||||
data: TransientEventPayload,
|
||||
): Promise<AxiosResponse<OntimeEntry>> {
|
||||
return axios.post(`${rundownPath}/${rundownId}/entry`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to edit an entry
|
||||
*/
|
||||
export async function putEditEntry(data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> {
|
||||
return axios.put(rundownPath, data);
|
||||
export async function putEditEntry(rundownId: string, data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> {
|
||||
return axios.put(`${rundownPath}/${rundownId}/entry`, data);
|
||||
}
|
||||
|
||||
type BatchEditEntry = {
|
||||
export type BatchEditEntry = {
|
||||
data: Partial<OntimeEvent>;
|
||||
ids: string[];
|
||||
ids: EntryId[];
|
||||
};
|
||||
|
||||
/**
|
||||
* HTTP request to edit multiple events
|
||||
*/
|
||||
export async function putBatchEditEvents(data: BatchEditEntry): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.put(`${rundownPath}/batch`, data);
|
||||
export async function putBatchEditEvents(rundownId: string, data: BatchEditEntry): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.put(`${rundownPath}/${rundownId}/batch`, data);
|
||||
}
|
||||
|
||||
export type ReorderEntry = {
|
||||
@@ -64,60 +85,57 @@ export type ReorderEntry = {
|
||||
/**
|
||||
* HTTP request to reorder an entry
|
||||
*/
|
||||
export async function patchReorderEntry(data: ReorderEntry): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.patch(`${rundownPath}/reorder`, data);
|
||||
export async function patchReorderEntry(rundownId: string, data: ReorderEntry): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.patch(`${rundownPath}/${rundownId}/reorder`, data);
|
||||
}
|
||||
|
||||
export type SwapEntry = {
|
||||
from: string;
|
||||
to: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* HTTP request to swap two events
|
||||
*/
|
||||
export async function requestEventSwap(data: SwapEntry): Promise<AxiosResponse<MessageResponse>> {
|
||||
return axios.patch(`${rundownPath}/swap`, data);
|
||||
export async function requestEventSwap(rundownId: string, from: EntryId, to: EntryId): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.patch(`${rundownPath}/${rundownId}/swap`, { from, to });
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to request application of delay
|
||||
*/
|
||||
export async function requestApplyDelay(delayId: EntryId): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.patch(`${rundownPath}/applydelay/${delayId}`);
|
||||
export async function requestApplyDelay(rundownId: string, delayId: EntryId): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.patch(`${rundownPath}/${rundownId}/applydelay/${delayId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request for cloning an entry
|
||||
*/
|
||||
export async function postCloneEntry(entryId: EntryId): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.post(`${rundownPath}/clone/${entryId}`);
|
||||
export async function postCloneEntry(rundownId: string, entryId: EntryId): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request for dissolving of a block
|
||||
* HTTP request for grouping a list of entries into a group
|
||||
*/
|
||||
export async function requestUngroup(blockId: EntryId): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.post(`${rundownPath}/ungroup/${blockId}`);
|
||||
export async function requestGroupEntries(rundownId: string, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.post(`${rundownPath}/${rundownId}/group`, { ids: entryIds });
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request for grouping a list of entries into a block
|
||||
* HTTP request for dissolving of a group
|
||||
*/
|
||||
export async function requestGroupEntries(entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.post(`${rundownPath}/group`, { ids: entryIds });
|
||||
export async function requestUngroup(rundownId: string, groupId: EntryId): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.post(`${rundownPath}/${rundownId}/ungroup/${groupId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to delete entries
|
||||
* HTTP request to delete entries of a given rundown
|
||||
*/
|
||||
export async function deleteEntries(entryIds: EntryId[]): Promise<AxiosResponse<MessageResponse>> {
|
||||
return axios.delete(rundownPath, { data: { ids: entryIds } });
|
||||
export async function deleteEntries(rundownId: string, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.delete(`${rundownPath}/${rundownId}/entries`, { data: { ids: entryIds } });
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to delete all events
|
||||
* HTTP request to delete all entries of a given rundown
|
||||
*/
|
||||
export async function requestDeleteAll(): Promise<AxiosResponse<MessageResponse>> {
|
||||
return axios.delete(`${rundownPath}/all`);
|
||||
export async function requestDeleteAll(rundownId: string): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.delete(`${rundownPath}/${rundownId}/all`);
|
||||
}
|
||||
|
||||
// #endregion operations on rundown entries =======================
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios from 'axios';
|
||||
import { GetInfo } from 'ontime-types';
|
||||
import { GetInfo, LinkOptions } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
@@ -16,12 +16,7 @@ export async function getInfo(): Promise<GetInfo> {
|
||||
/**
|
||||
* HTTP request to get a pre-authenticated URL
|
||||
*/
|
||||
export async function generateUrl(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
lock: boolean,
|
||||
authenticate: boolean,
|
||||
): Promise<string> {
|
||||
const res = await axios.post(`${sessionPath}/url`, { baseUrl, path, lock, authenticate });
|
||||
export async function generateUrl(options: LinkOptions & { baseUrl: string; path: string }): Promise<string> {
|
||||
const res = await axios.post(`${sessionPath}/url`, options);
|
||||
return res.data.url;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { apiEntryUrl } from './constants';
|
||||
const urlPresetsPath = `${apiEntryUrl}/url-presets`;
|
||||
|
||||
/**
|
||||
* HTTP request to retrieve aliases
|
||||
* HTTP request to retrieve all presets
|
||||
*/
|
||||
export async function getUrlPresets(): Promise<URLPreset[]> {
|
||||
const res = await axios.get(urlPresetsPath);
|
||||
@@ -14,8 +14,22 @@ export async function getUrlPresets(): Promise<URLPreset[]> {
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to mutate aliases
|
||||
* HTTP request to add a preset
|
||||
*/
|
||||
export async function postUrlPresets(data: URLPreset[]): Promise<URLPreset[]> {
|
||||
return axios.post(urlPresetsPath, data);
|
||||
export async function postUrlPreset(data: URLPreset): Promise<URLPreset[]> {
|
||||
return (await axios.post(urlPresetsPath, data)).data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to edit a preset
|
||||
*/
|
||||
export async function putUrlPreset(alias: string, data: URLPreset): Promise<URLPreset[]> {
|
||||
return (await axios.put(`${urlPresetsPath}/${alias}`, data)).data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to delete a preset
|
||||
*/
|
||||
export async function deleteUrlPreset(alias: string): Promise<URLPreset[]> {
|
||||
return (await axios.delete(`${urlPresetsPath}/${alias}`)).data;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,20 @@ export function maybeAxiosError(error: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility unwrap a an instance of Error
|
||||
*/
|
||||
export function unwrapError(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
} else {
|
||||
if (typeof error !== 'string') {
|
||||
return JSON.stringify(error);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility unwraps a potential axios error and sends to logger
|
||||
* @param prepend
|
||||
|
||||
@@ -33,7 +33,13 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
|
||||
if (newPath === '/' || newPath === currentPath) {
|
||||
return;
|
||||
}
|
||||
setRedirect({ target: id, redirect: newPath });
|
||||
|
||||
if (newPath.startsWith('preset-')) {
|
||||
setRedirect({ target: id, redirect: newPath.slice(7) });
|
||||
} else {
|
||||
setRedirect({ target: id, redirect: newPath });
|
||||
}
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -45,8 +51,8 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
|
||||
label: view.label,
|
||||
})),
|
||||
...enabledPresets.map((preset) => ({
|
||||
value: preset.pathAndParams,
|
||||
label: `Preset: ${preset.alias}`,
|
||||
value: `preset-${preset.alias}`,
|
||||
label: `URL Preset: ${preset.alias}`,
|
||||
})),
|
||||
];
|
||||
|
||||
@@ -61,32 +67,11 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
|
||||
<>
|
||||
<Info>
|
||||
Remotely redirect the client to a different URL. <br />
|
||||
Either by selecting a URL Preset or entering a custom path.
|
||||
Either by entering a custom path or selecting a URL Preset.
|
||||
<br />
|
||||
<br />
|
||||
<AppLink search='settings=sharing__presets'>Manage URL Presets</AppLink>
|
||||
</Info>
|
||||
<div>
|
||||
<span className={style.label}>Select View or URL Preset</span>
|
||||
<div className={style.textEntry}>
|
||||
<Select
|
||||
fluid
|
||||
options={viewOptions}
|
||||
defaultValue={viewOptions[0].value}
|
||||
onValueChange={(value) => setSelected(value)}
|
||||
disabled={enabledPresets.length === 0}
|
||||
/>
|
||||
<Button
|
||||
variant='primary'
|
||||
aria-label='Redirect to preset'
|
||||
className={style.redirect}
|
||||
disabled={enabledPresets.length === 0 || selected === '/'}
|
||||
onClick={() => handleRedirect(selected)}
|
||||
>
|
||||
Redirect <IoArrowForward />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.inlineEntry}>
|
||||
<span className={style.label}>Enter custom path</span>
|
||||
<label className={style.textEntry}>
|
||||
@@ -100,9 +85,34 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
|
||||
className={style.redirect}
|
||||
onClick={() => handleRedirect(path)}
|
||||
>
|
||||
Redirect <IoArrowForward />
|
||||
Redirect
|
||||
<IoArrowForward />
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<span className={style.label}>Select View or URL Preset</span>
|
||||
<div className={style.inlineEntry}>
|
||||
<label className={style.textEntry}>
|
||||
{origin}
|
||||
<Select
|
||||
fluid
|
||||
options={viewOptions}
|
||||
defaultValue={viewOptions[0].value}
|
||||
onValueChange={(value) => setSelected(value)}
|
||||
disabled={enabledPresets.length === 0}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
variant='primary'
|
||||
aria-label='Redirect to preset'
|
||||
className={style.redirect}
|
||||
disabled={enabledPresets.length === 0 || selected === '/'}
|
||||
onClick={() => handleRedirect(selected)}
|
||||
>
|
||||
Redirect <IoArrowForward />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { PropsWithChildren, useState } from 'react';
|
||||
import { IoCheckmark } from 'react-icons/io5';
|
||||
import { IoCopy } from 'react-icons/io5';
|
||||
import { PropsWithChildren, useRef, useState } from 'react';
|
||||
import { IoCheckmark, IoCopy } from 'react-icons/io5';
|
||||
|
||||
import copyToClipboard from '../../utils/copyToClipboard';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
@@ -24,32 +23,29 @@ export default function CopyTag({
|
||||
onClick,
|
||||
}: PropsWithChildren<CopyTagProps>) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const handleClick = () => {
|
||||
copyToClipboard(copyValue);
|
||||
setCopied(true);
|
||||
|
||||
// reset copied state
|
||||
setTimeout(() => setCopied(false), 1000);
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
timeoutRef.current = setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={style.copytag}>
|
||||
{onClick !== undefined ? (
|
||||
<Button className={style.action} size={size} tabIndex={-1} onClick={onClick} disabled={disabled}>
|
||||
<Button className={style.action} size={size} onClick={onClick} disabled={disabled}>
|
||||
{children}
|
||||
</Button>
|
||||
) : (
|
||||
<div className={cx([style.label, style[size]])}>{children}</div>
|
||||
)}
|
||||
<IconButton
|
||||
className={style.copy}
|
||||
variant='primary'
|
||||
size={size}
|
||||
tabIndex={-1}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
<IconButton className={style.copy} variant='primary' size={size} onClick={handleClick} disabled={disabled}>
|
||||
{copied ? <IoCheckmark /> : <IoCopy />}
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
.delaySymbol {
|
||||
svg {
|
||||
display: inline;
|
||||
vertical-align: middle;
|
||||
font-size: 1.5rem;
|
||||
color: $ontime-delay;
|
||||
margin: 0 auto;
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
border-radius: 3px;
|
||||
box-shadow: $box-shadow-l1;
|
||||
border: 1px solid $gray-1100;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
|
||||
@@ -49,15 +49,14 @@ class ErrorBoundary extends React.Component {
|
||||
return (
|
||||
<div className={style.errorContainer} data-testid='error-container'>
|
||||
<div>
|
||||
<p className={style.error}>:/</p>
|
||||
<p>Something went wrong</p>
|
||||
<div
|
||||
role='button'
|
||||
<p className={style.error}>: /</p>
|
||||
<p>Something went wrong.</p>
|
||||
<a
|
||||
className={style.report}
|
||||
onClick={() => Sentry.showReportDialog({ eventId: this.state.eventId })}
|
||||
href={`mailto:mail@getontime.no?subject=Error%20Report&body=${encodeURIComponent(this.reportContent)}`}
|
||||
>
|
||||
Report error
|
||||
</div>
|
||||
</a>
|
||||
<div
|
||||
role='button'
|
||||
className={style.report}
|
||||
|
||||
@@ -3,24 +3,21 @@
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
background-color: #121212;
|
||||
color: white;
|
||||
background-color: $ui-black;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: $error-red;
|
||||
font-weight: 600;
|
||||
}
|
||||
.error {
|
||||
color: $error-red;
|
||||
}
|
||||
|
||||
.report {
|
||||
text-decoration: underline $error-red;
|
||||
cursor: pointer;
|
||||
}
|
||||
.report {
|
||||
color: $blue-500;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
cursor: pointer;
|
||||
|
||||
.report:hover {
|
||||
color: $error-red;
|
||||
}
|
||||
|
||||
.report:active {
|
||||
color: white;
|
||||
&:hover {
|
||||
color: $ontime-color;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MouseEvent } from 'react';
|
||||
import { IoBan } from 'react-icons/io5';
|
||||
|
||||
import { cx } from '../../../utils/styleUtils';
|
||||
@@ -10,12 +11,13 @@ interface SwatchProps {
|
||||
isSelected?: boolean;
|
||||
}
|
||||
|
||||
export default function Swatch(props: SwatchProps) {
|
||||
const { color, isSelected, onClick } = props;
|
||||
|
||||
const handleClick = () => {
|
||||
export default function Swatch({ color, isSelected, onClick }: SwatchProps) {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
onClick?.(color);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const classes = cx([style.swatch, isSelected && style.selected, onClick && style.selectable]);
|
||||
|
||||
if (!color) {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
.list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 99px;
|
||||
border: 2px solid $gray-1200;
|
||||
color: $ui-white;
|
||||
|
||||
@@ -31,11 +31,7 @@
|
||||
}
|
||||
|
||||
&[data-checked] {
|
||||
background-color: $gray-1200;
|
||||
|
||||
&:hover {
|
||||
border-color: $gray-1000;
|
||||
}
|
||||
background-color: $gray-700;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
@@ -57,6 +53,6 @@
|
||||
border-radius: 100%;
|
||||
width: 0.5em;
|
||||
height: 0.5em;
|
||||
background-color: $blue-500;
|
||||
background-color: $ui-white;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function DelayInput(props: DelayInputProps) {
|
||||
const [value, setValue] = useState<string>('');
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
// avoid wrong submit on cancel
|
||||
let ignoreChange = false;
|
||||
const ignoreChangeRef = useRef(false);
|
||||
|
||||
// set internal value on duration change
|
||||
useEffect(() => {
|
||||
@@ -35,8 +35,8 @@ export default function DelayInput(props: DelayInputProps) {
|
||||
* @param {string} newValue string to be parsed
|
||||
*/
|
||||
const validateAndSubmit = (newValue: string) => {
|
||||
if (ignoreChange) {
|
||||
ignoreChange = false;
|
||||
if (ignoreChangeRef.current) {
|
||||
ignoreChangeRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ export default function DelayInput(props: DelayInputProps) {
|
||||
} else if (event.key === 'Tab') {
|
||||
validateAndSubmit((event.target as HTMLInputElement).value);
|
||||
} else if (event.key === 'Escape') {
|
||||
ignoreChange = true;
|
||||
ignoreChangeRef.current = true;
|
||||
setValue(millisToString(duration));
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
|
||||
@@ -10,13 +10,19 @@
|
||||
padding-inline: 0.5em;
|
||||
outline: none;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
&::placeholder {
|
||||
color: $gray-500;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled):not(:read-only) {
|
||||
background-color: $gray-1100;
|
||||
}
|
||||
|
||||
&:focus:not(:read-only) {
|
||||
background-color: $gray-1000;
|
||||
border: 1px solid $blue-500;
|
||||
outline: 2px solid $blue-500;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
@@ -24,9 +30,8 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: $gray-500;
|
||||
letter-spacing: 0;
|
||||
&:read-only::placeholder {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,9 +9,13 @@
|
||||
border-radius: $component-border-radius-md;
|
||||
border: 1px solid transparent;
|
||||
|
||||
padding-inline: 0.5em;
|
||||
outline: none;
|
||||
|
||||
&::placeholder {
|
||||
color: $gray-500;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: $gray-1100;
|
||||
}
|
||||
@@ -26,21 +30,21 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: $gray-500;
|
||||
letter-spacing: 0;
|
||||
&:read-only::placeholder {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.subtle {
|
||||
background-color: $gray-1200;
|
||||
padding-top: 0.5em;
|
||||
padding-inline: 0.5em;
|
||||
padding-top: 0.25em;
|
||||
}
|
||||
|
||||
.ghosted {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
padding-top: 0.5em;
|
||||
padding-top: 0.25em;
|
||||
}
|
||||
|
||||
.fluid {
|
||||
|
||||
@@ -9,7 +9,7 @@ import style from './TimeInput.module.scss';
|
||||
interface NullableTimeInputProps<T extends string> {
|
||||
id?: T;
|
||||
name: T;
|
||||
submitHandler: (field: T, value: string) => void;
|
||||
submitHandler: (field: T, value: number) => void;
|
||||
time?: number | null;
|
||||
emptyDisplay: string;
|
||||
placeholder?: string;
|
||||
@@ -71,7 +71,7 @@ export default function NullableTimeInput<T extends string>({
|
||||
return false;
|
||||
}
|
||||
|
||||
submitHandler(name, newValue);
|
||||
submitHandler(name, valueInMillis);
|
||||
return true;
|
||||
},
|
||||
[name, submitHandler, time],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type PropsWithChildren } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import style from './AppLink.module.scss';
|
||||
|
||||
@@ -11,8 +11,7 @@ interface AppLinkProps {
|
||||
* Component used to navigate to an editor link inside the same window
|
||||
* Handles the path to respect Ontime Clouds base URL
|
||||
*/
|
||||
export default function AppLink(props: PropsWithChildren<AppLinkProps>) {
|
||||
const { search, children } = props;
|
||||
export default function AppLink({ search, children }: PropsWithChildren<AppLinkProps>) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleClick = () => navigate({ search });
|
||||
|
||||
@@ -12,9 +12,7 @@ interface ExternalLinkProps {
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
export default function ExternalLink(props: ExternalLinkProps) {
|
||||
const { href, inline, children } = props;
|
||||
|
||||
export default function ExternalLink({ href, inline, children }: ExternalLinkProps) {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
openLink(href);
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
transform: translateX(-50%);
|
||||
|
||||
padding-inline: 1rem;
|
||||
min-width: min(680px, 90vw);
|
||||
min-width: min(880px, 90vw);
|
||||
min-height: min(200px, 10vh);
|
||||
max-width: min(800px, 90vw);
|
||||
max-width: min(1200px, 90vw);
|
||||
|
||||
background-color: $gray-1250;
|
||||
color: $ui-white;
|
||||
|
||||
@@ -64,3 +64,8 @@
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin-left: auto;
|
||||
color: $white-20;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { memo } from 'react';
|
||||
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { LuCoffee } from 'react-icons/lu';
|
||||
import { useLocation } from 'react-router';
|
||||
import { Dialog } from '@base-ui-components/react/dialog';
|
||||
import { useDisclosure, useFullscreen } from '@mantine/hooks';
|
||||
|
||||
import { isLocalhost } from '../../../externals';
|
||||
import { useKeepAwakeOptions } from '../../../features/keep-awake/KeepAwake';
|
||||
import { navigatorConstants } from '../../../viewerConfig';
|
||||
import { useClientStore } from '../../stores/clientStore';
|
||||
import { useViewOptionsStore } from '../../stores/viewOptions';
|
||||
@@ -31,6 +33,7 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
|
||||
const [isRenameOpen, handlers] = useDisclosure(false);
|
||||
const { fullscreen, toggle } = useFullscreen();
|
||||
const { mirror, toggleMirror } = useViewOptionsStore();
|
||||
const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions();
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
@@ -57,10 +60,18 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
|
||||
Toggle Fullscreen
|
||||
{fullscreen ? <IoContract /> : <IoExpand />}
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem active={mirror} onClick={toggleMirror}>
|
||||
<NavigationMenuItem active={mirror} onClick={() => toggleMirror()}>
|
||||
Flip Screen
|
||||
<IoSwapVertical />
|
||||
{mirror && <span className={style.note}>Active</span>}
|
||||
</NavigationMenuItem>
|
||||
{window.isSecureContext && (
|
||||
<NavigationMenuItem active={keepAwake} onClick={toggleKeepAwake}>
|
||||
Keep Awake
|
||||
<LuCoffee />
|
||||
{keepAwake && <span className={style.note}>Active</span>}
|
||||
</NavigationMenuItem>
|
||||
)}
|
||||
<NavigationMenuItem onClick={handlers.open}>Rename Client</NavigationMenuItem>
|
||||
|
||||
<hr className={style.separator} />
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
import { memo } from 'react';
|
||||
import { useDisclosure, useHotkeys } from '@mantine/hooks';
|
||||
|
||||
import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store';
|
||||
|
||||
import FloatingNavigation from './floating-navigation/FloatingNavigation';
|
||||
import ViewLockedIcon from './view-locked-icon/ViewLockedIcon';
|
||||
import NavigationMenu from './NavigationMenu';
|
||||
import useViewEditor from './useViewEditor';
|
||||
|
||||
interface ViewNavigationMenuProps {
|
||||
isLockable?: boolean;
|
||||
/** prevent navigation */
|
||||
isNavigationLocked?: boolean;
|
||||
/** prevent showing settings */
|
||||
suppressSettings?: boolean;
|
||||
}
|
||||
|
||||
export default memo(ViewNavigationMenu);
|
||||
function ViewNavigationMenu({ isLockable, suppressSettings }: ViewNavigationMenuProps) {
|
||||
function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) {
|
||||
const [isMenuOpen, menuHandler] = useDisclosure();
|
||||
const { showEditFormDrawer, isViewLocked } = useViewEditor({ isLockable });
|
||||
const { open: showEditFormDrawer } = useViewParamsEditorStore();
|
||||
|
||||
useHotkeys([
|
||||
[
|
||||
'Space',
|
||||
() => {
|
||||
if (isViewLocked) return;
|
||||
if (isNavigationLocked) return;
|
||||
menuHandler.toggle();
|
||||
},
|
||||
{ preventDefault: true },
|
||||
@@ -28,24 +31,24 @@ function ViewNavigationMenu({ isLockable, suppressSettings }: ViewNavigationMenu
|
||||
[
|
||||
'mod + ,',
|
||||
() => {
|
||||
if (isViewLocked || suppressSettings) return;
|
||||
if (suppressSettings) return;
|
||||
showEditFormDrawer();
|
||||
},
|
||||
{ preventDefault: true },
|
||||
],
|
||||
]);
|
||||
|
||||
if (isViewLocked) {
|
||||
if (isNavigationLocked && suppressSettings) {
|
||||
return <ViewLockedIcon />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FloatingNavigation
|
||||
toggleMenu={menuHandler.toggle}
|
||||
toggleSettings={suppressSettings ? undefined : () => showEditFormDrawer()}
|
||||
toggleMenu={isNavigationLocked ? undefined : menuHandler.toggle}
|
||||
toggleSettings={suppressSettings ? undefined : showEditFormDrawer}
|
||||
/>
|
||||
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
|
||||
{!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { IoArrowUp } from 'react-icons/io5';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { useElectronEvent } from '../../../hooks/useElectronEvent';
|
||||
import { handleLinks } from '../../../utils/linkUtils';
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { IoLockClosedOutline } from 'react-icons/io5';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { useIsSmallDevice } from '../../../hooks/useIsSmallDevice';
|
||||
import NavigationMenuItem from '../navigation-menu-item/NavigationMenuItem';
|
||||
|
||||
+2
-1
@@ -14,7 +14,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 1rem;
|
||||
padding: 0.5em;
|
||||
padding-top: 0.75rem;
|
||||
padding-left: 0.5em;
|
||||
|
||||
position: fixed;
|
||||
left: 0;
|
||||
|
||||
+13
-12
@@ -1,5 +1,4 @@
|
||||
import { IoApps } from 'react-icons/io5';
|
||||
import { IoSettingsOutline } from 'react-icons/io5';
|
||||
import { IoApps, IoSettingsOutline } from 'react-icons/io5';
|
||||
|
||||
import { useFadeOutOnInactivity } from '../../../hooks/useFadeOutOnInactivity';
|
||||
import { cx } from '../../../utils/styleUtils';
|
||||
@@ -8,7 +7,7 @@ import IconButton from '../../buttons/IconButton';
|
||||
import style from './FloatingNavigation.module.scss';
|
||||
|
||||
interface FloatingNavigationProps {
|
||||
toggleMenu: () => void;
|
||||
toggleMenu?: () => void;
|
||||
toggleSettings?: () => void;
|
||||
}
|
||||
|
||||
@@ -20,15 +19,17 @@ export default function FloatingNavigation({ toggleMenu, toggleSettings }: Float
|
||||
id='fadeable-navigation'
|
||||
className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])}
|
||||
>
|
||||
<IconButton
|
||||
variant='subtle-white'
|
||||
size='xlarge'
|
||||
onClick={toggleMenu}
|
||||
aria-label='toggle menu'
|
||||
data-testid='navigation__toggle-menu'
|
||||
>
|
||||
<IoApps />
|
||||
</IconButton>
|
||||
{toggleMenu && (
|
||||
<IconButton
|
||||
variant='subtle-white'
|
||||
size='xlarge'
|
||||
onClick={toggleMenu}
|
||||
aria-label='toggle menu'
|
||||
data-testid='navigation__toggle-menu'
|
||||
>
|
||||
<IoApps />
|
||||
</IconButton>
|
||||
)}
|
||||
{toggleSettings && (
|
||||
<IconButton
|
||||
variant='subtle-white'
|
||||
|
||||
+6
-1
@@ -5,12 +5,17 @@
|
||||
}
|
||||
|
||||
.interfaces {
|
||||
padding: 0.5rem 1rem;
|
||||
padding: 0.5rem 0.5rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.interfaceCopy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.goIcon {
|
||||
@include rotate-fourty-five;
|
||||
margin-left: 0.25rem;
|
||||
|
||||
+4
-2
@@ -30,8 +30,10 @@ export default function OtherAddresses({ currentLocation }: OtherAddressesProps)
|
||||
const address = linkToOtherHost(nif.address, currentLocation);
|
||||
|
||||
return (
|
||||
<CopyTag key={nif.name} copyValue={address} onClick={() => openLink(address)}>
|
||||
{nif.address} <IoArrowUp className={style.goIcon} />
|
||||
<CopyTag key={nif.name} copyValue={address} onClick={() => openLink(address)} size='small'>
|
||||
<span className={style.interfaceCopy}>
|
||||
{nif.address} <IoArrowUp className={style.goIcon} />
|
||||
</span>
|
||||
</CopyTag>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||
import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store';
|
||||
|
||||
interface EditorVisibilityOptions {
|
||||
isLockable?: boolean;
|
||||
}
|
||||
|
||||
export default function useViewEditor({ isLockable }: EditorVisibilityOptions) {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { open: showEditFormDrawer } = useViewParamsEditorStore();
|
||||
|
||||
const isViewLocked = useMemo(() => {
|
||||
if (!isLockable) {
|
||||
return false;
|
||||
}
|
||||
return isStringBoolean(searchParams.get('locked'));
|
||||
}, [isLockable, searchParams]);
|
||||
|
||||
return { showEditFormDrawer, isViewLocked };
|
||||
}
|
||||
@@ -11,9 +11,11 @@
|
||||
}
|
||||
|
||||
.pin {
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
|
||||
input {
|
||||
font-size: 4rem;
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
.radioGroup {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
color: $gray-900;
|
||||
font-size: calc(1rem - 2px);
|
||||
color: $ui-white;
|
||||
|
||||
&[data-disabled] {
|
||||
.item {
|
||||
opacity: $opacity-disabled;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.horizontal {
|
||||
@@ -32,8 +38,8 @@
|
||||
.radio {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
width: 16px; // use absolute units to avoid subpixel rendering issues
|
||||
height: 16px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 100%;
|
||||
@@ -46,10 +52,7 @@
|
||||
}
|
||||
|
||||
&[data-checked] {
|
||||
background-color: $ui-white;
|
||||
&:hover {
|
||||
border-color: $gray-1000;
|
||||
}
|
||||
background-color: $blue-700;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
@@ -69,8 +72,9 @@
|
||||
&::before {
|
||||
content: '';
|
||||
border-radius: 100%;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
background-color: $gray-1200;
|
||||
width: 6px; // half of the radio size
|
||||
height: 6px;
|
||||
aspect-ratio: 1;
|
||||
background-color: $ui-white;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
border: 1px solid transparent;
|
||||
|
||||
padding-inline: 0.5rem;
|
||||
font-size: calc(1rem - 2px);
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover:not([data-disabled]) {
|
||||
@@ -57,14 +56,12 @@
|
||||
}
|
||||
|
||||
.popup {
|
||||
font-size: calc(1rem - 2px);
|
||||
font-size: 1rem;
|
||||
background-color: $gray-1200;
|
||||
box-sizing: border-box;
|
||||
padding: 2px;
|
||||
border-radius: $component-border-radius-md;
|
||||
color: $ui-white;
|
||||
overflow-y: auto;
|
||||
max-height: 20rem;
|
||||
border: 1px solid $gray-1000;
|
||||
|
||||
&[data-side='start'] {
|
||||
@@ -74,6 +71,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
.list {
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
padding-block: 0.25rem;
|
||||
overflow-y: auto;
|
||||
max-height: 20rem;
|
||||
max-height: var(--available-height);
|
||||
scroll-padding-block: 1.5rem;
|
||||
}
|
||||
|
||||
.item {
|
||||
box-sizing: border-box;
|
||||
outline: 0;
|
||||
|
||||
@@ -31,14 +31,17 @@ export default function Select<T>({ options, fluid, size = 'medium', ...selectRo
|
||||
<BaseSelect.Positioner side='bottom' align='start'>
|
||||
<BaseSelect.ScrollUpArrow className={styles.scrollArrow} />
|
||||
<BaseSelect.Popup className={styles.popup}>
|
||||
{options.map(({ disabled, label, value }) => (
|
||||
<BaseSelect.Item key={String(value)} className={styles.item} value={value} disabled={disabled}>
|
||||
<BaseSelect.ItemIndicator className={styles.itemIndicator}>
|
||||
<IoCheckmark className={styles.itemIndicatorIcon} />
|
||||
</BaseSelect.ItemIndicator>
|
||||
<BaseSelect.ItemText className={styles.itemLabel}>{label}</BaseSelect.ItemText>
|
||||
</BaseSelect.Item>
|
||||
))}
|
||||
<BaseSelect.Arrow />
|
||||
<BaseSelect.List className={styles.list}>
|
||||
{options.map(({ disabled, label, value }) => (
|
||||
<BaseSelect.Item key={String(value)} className={styles.item} value={value} disabled={disabled}>
|
||||
<BaseSelect.ItemIndicator className={styles.itemIndicator}>
|
||||
<IoCheckmark className={styles.itemIndicatorIcon} />
|
||||
</BaseSelect.ItemIndicator>
|
||||
<BaseSelect.ItemText className={styles.itemLabel}>{label}</BaseSelect.ItemText>
|
||||
</BaseSelect.Item>
|
||||
))}
|
||||
</BaseSelect.List>
|
||||
</BaseSelect.Popup>
|
||||
<BaseSelect.ScrollDownArrow className={styles.scrollArrow} />
|
||||
</BaseSelect.Positioner>
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
}
|
||||
|
||||
.text {
|
||||
display: block;
|
||||
margin-inline: auto;;
|
||||
font-weight: 600;
|
||||
font-size: 2em;
|
||||
max-width: 600px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,14 +7,13 @@ import style from './Empty.module.scss';
|
||||
|
||||
interface EmptyProps {
|
||||
text?: string;
|
||||
style?: CSSProperties;
|
||||
injectedStyles?: CSSProperties;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Empty(props: EmptyProps) {
|
||||
const { text, className, ...rest } = props;
|
||||
export default function Empty({ text, className, injectedStyles }: EmptyProps) {
|
||||
return (
|
||||
<div className={cx([style.emptyContainer, className])} {...rest}>
|
||||
<div className={cx([style.emptyContainer, className])} style={injectedStyles}>
|
||||
<EmptyImage className={style.empty} />
|
||||
{text && <span className={style.text}>{text}</span>}
|
||||
</div>
|
||||
|
||||
@@ -6,15 +6,13 @@ import style from './EmptyPage.module.scss';
|
||||
|
||||
interface EmptyPageProps {
|
||||
text?: string;
|
||||
style?: CSSProperties;
|
||||
injectedStyles?: CSSProperties;
|
||||
}
|
||||
|
||||
export default function EmptyPage(props: EmptyPageProps) {
|
||||
const { text, ...rest } = props;
|
||||
|
||||
export default function EmptyPage({ text, injectedStyles }: EmptyPageProps) {
|
||||
return (
|
||||
<div className={style.page}>
|
||||
<Empty text={text} {...rest} />
|
||||
<Empty text={text} injectedStyles={injectedStyles} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
.emptyContainer {
|
||||
width: 100%;
|
||||
color: $white-10;
|
||||
|
||||
.emptyCell {
|
||||
margin-inline: auto;
|
||||
text-align: center;
|
||||
padding-top: 10vh;
|
||||
}
|
||||
|
||||
.empty {
|
||||
width: 100%;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-weight: 600;
|
||||
font-size: 2em;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import EmptyImage from '../../../assets/images/empty.svg?react';
|
||||
|
||||
import style from './EmptyTableBody.module.scss';
|
||||
|
||||
interface EmptyTableBodyProps {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export default function EmptyTableBody({ text }: EmptyTableBodyProps) {
|
||||
return (
|
||||
<tbody className={style.emptyContainer}>
|
||||
<tr>
|
||||
<td colSpan={99} className={style.emptyCell}>
|
||||
<EmptyImage className={style.empty} />
|
||||
{text && <span className={style.text}>{text}</span>}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,11 @@
|
||||
outline: 2px solid $blue-500;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&[data-disabled] {
|
||||
opacity: $opacity-disabled;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.medium {
|
||||
@@ -41,7 +46,7 @@
|
||||
}
|
||||
|
||||
.thumb {
|
||||
aspect-ratio: 1 / 1;
|
||||
aspect-ratio: 1;
|
||||
height: 100%;
|
||||
border-radius: 99px;
|
||||
background-color: $ui-white;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import SwatchPicker from '../input/colour-input/SwatchPicker';
|
||||
|
||||
@@ -16,10 +16,13 @@ const ensureHex = (value: string) => {
|
||||
return value;
|
||||
};
|
||||
|
||||
export default function InlineColourPicker(props: InlineColourPickerProps) {
|
||||
const { name, value } = props;
|
||||
export default function InlineColourPicker({ name, value }: InlineColourPickerProps) {
|
||||
const [colour, setColour] = useState(() => ensureHex(value));
|
||||
|
||||
useEffect(() => {
|
||||
setColour(ensureHex(value));
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className={style.inline}>
|
||||
<SwatchPicker color={colour} onChange={setColour} alwaysDisplayColor />
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { ComponentProps, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||
import Checkbox from '../checkbox/Checkbox';
|
||||
import Input from '../input/input/Input';
|
||||
import Select from '../select/Select';
|
||||
import Select, { SelectOption } from '../select/Select';
|
||||
import Switch from '../switch/Switch';
|
||||
|
||||
import InlineColourPicker from './InlineColourPicker';
|
||||
@@ -35,15 +35,22 @@ export default function ParamInput({ paramField }: ParamInputProps) {
|
||||
return <span className={style.empty}>No options available</span>;
|
||||
}
|
||||
|
||||
return <Select size='large' name={id} defaultValue={defaultOptionValue} options={paramField.values} />;
|
||||
return <ControlledSelect id={id} initialValue={defaultOptionValue} options={paramField.values} />;
|
||||
}
|
||||
|
||||
if (type === 'multi-option') {
|
||||
return <MultiOption paramField={paramField} />;
|
||||
const optionFromParams = searchParams.getAll(id);
|
||||
|
||||
return (
|
||||
<MultiOption
|
||||
paramField={paramField}
|
||||
options={optionFromParams.length ? optionFromParams : paramField.defaultValue ?? ['']}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'boolean') {
|
||||
return <ControlledSwitch id={id} initialValue={isStringBoolean(searchParams.get(id)) || defaultValue} />;
|
||||
return <ControlledSwitch id={id} initialValue={isStringBoolean(searchParams.get(id)) ?? defaultValue} />;
|
||||
}
|
||||
|
||||
if (type === 'number') {
|
||||
@@ -63,27 +70,28 @@ export default function ParamInput({ paramField }: ParamInputProps) {
|
||||
}
|
||||
|
||||
if (type === 'colour') {
|
||||
const currentvalue = `#${searchParams.get(id) ?? defaultValue}`;
|
||||
|
||||
return <InlineColourPicker name={id} value={currentvalue} />;
|
||||
return <InlineColourPicker name={id} value={searchParams.get(id) ?? defaultValue} />;
|
||||
}
|
||||
|
||||
const defaultStringValue = searchParams.get(id) ?? defaultValue;
|
||||
const defaultStringValue = searchParams.get(id) ?? defaultValue ?? '';
|
||||
const { placeholder } = paramField;
|
||||
|
||||
return <Input height='large' name={id} defaultValue={defaultStringValue} placeholder={placeholder} />;
|
||||
return <ControlledInput id={id} initialValue={defaultStringValue} placeholder={placeholder} />;
|
||||
}
|
||||
|
||||
interface EditFormMultiOptionProps {
|
||||
paramField: ParamField & { type: 'multi-option' };
|
||||
options: string[];
|
||||
}
|
||||
|
||||
function MultiOption({ paramField }: EditFormMultiOptionProps) {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { id, values, defaultValue = [''] } = paramField;
|
||||
function MultiOption({ paramField, options }: EditFormMultiOptionProps) {
|
||||
const { id, values } = paramField;
|
||||
const [paramState, setParamState] = useState<string[]>(options);
|
||||
|
||||
const optionFromParams = searchParams.getAll(id);
|
||||
const [paramState, setParamState] = useState<string[]>(optionFromParams || defaultValue);
|
||||
// synchronise options
|
||||
useEffect(() => {
|
||||
setParamState(options);
|
||||
}, [options]);
|
||||
|
||||
const toggleValue = (value: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
@@ -129,5 +137,52 @@ interface ControlledSwitchProps {
|
||||
}
|
||||
function ControlledSwitch({ id, initialValue }: ControlledSwitchProps) {
|
||||
const [checked, setChecked] = useState(initialValue);
|
||||
|
||||
// synchronise checked state
|
||||
useEffect(() => {
|
||||
setChecked(initialValue);
|
||||
}, [initialValue]);
|
||||
|
||||
return <Switch size='large' name={id} checked={checked} onCheckedChange={setChecked} />;
|
||||
}
|
||||
|
||||
interface ControlledSelectProps {
|
||||
id: string;
|
||||
initialValue?: string;
|
||||
options: SelectOption[];
|
||||
}
|
||||
function ControlledSelect({ id, initialValue, options }: ControlledSelectProps) {
|
||||
const [selected, setSelected] = useState(initialValue);
|
||||
|
||||
// synchronise selected state
|
||||
useEffect(() => {
|
||||
setSelected(initialValue);
|
||||
}, [initialValue]);
|
||||
|
||||
return (
|
||||
<Select size='large' name={id} options={options} value={selected} onValueChange={(value) => setSelected(value)} />
|
||||
);
|
||||
}
|
||||
|
||||
interface ControlledInputProps<T extends number | string> extends ComponentProps<typeof Input> {
|
||||
id: string;
|
||||
initialValue: T;
|
||||
}
|
||||
function ControlledInput<T extends number | string>({ id, initialValue, ...inputProps }: ControlledInputProps<T>) {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
|
||||
// synchronise selected state
|
||||
useEffect(() => {
|
||||
setValue(initialValue);
|
||||
}, [initialValue]);
|
||||
|
||||
return (
|
||||
<Input
|
||||
height='large'
|
||||
name={id}
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value as T)}
|
||||
{...inputProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,9 @@
|
||||
bottom: 0;
|
||||
|
||||
width: 40rem;
|
||||
max-width: 100vw;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { FormEvent, memo } from 'react';
|
||||
import { IoClose } from 'react-icons/io5';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { Dialog } from '@base-ui-components/react/dialog';
|
||||
import { OntimeView } from 'ontime-types';
|
||||
|
||||
import useViewSettings from '../../hooks-query/useViewSettings';
|
||||
import Button from '../buttons/Button';
|
||||
@@ -11,17 +12,18 @@ import Info from '../info/Info';
|
||||
import { ViewOption } from './viewParams.types';
|
||||
import { getURLSearchParamsFromObj } from './viewParams.utils';
|
||||
import { useViewParamsEditorStore } from './viewParamsEditor.store';
|
||||
import { ViewParamsPresets } from './ViewParamsPresets';
|
||||
import ViewParamsSection from './ViewParamsSection';
|
||||
|
||||
import style from './ViewParamsEditor.module.scss';
|
||||
|
||||
interface EditFormDrawerProps {
|
||||
target: OntimeView;
|
||||
viewOptions: ViewOption[];
|
||||
}
|
||||
|
||||
export default memo(ViewParamsEditor);
|
||||
|
||||
function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
||||
function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
|
||||
const [_, setSearchParams] = useSearchParams();
|
||||
const { data: viewSettings } = useViewSettings();
|
||||
const { isOpen, close } = useViewParamsEditorStore();
|
||||
@@ -56,7 +58,7 @@ function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
||||
<Dialog.Popup className={style.drawer}>
|
||||
<div className={style.header}>
|
||||
<Dialog.Title>Customise</Dialog.Title>
|
||||
<IconButton variant='subtle-white' size='large' onClick={handleClose}>
|
||||
<IconButton variant='subtle-white' size='large' data-testid='close-view-params' onClick={handleClose}>
|
||||
<IoClose />
|
||||
</IconButton>
|
||||
</div>
|
||||
@@ -64,6 +66,7 @@ function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
||||
{viewSettings.overrideStyles && (
|
||||
<Info className={style.info}>This view style is being modified by a custom CSS file.</Info>
|
||||
)}
|
||||
<ViewParamsPresets target={target} />
|
||||
<form id='edit-params-form' onSubmit={onParamsFormSubmit} className={style.sectionList}>
|
||||
{viewOptions.map((section) => (
|
||||
<ViewParamsSection
|
||||
@@ -79,8 +82,14 @@ function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
||||
<Button variant='subtle' size='large' onClick={resetParams} type='reset'>
|
||||
Reset to default
|
||||
</Button>
|
||||
<Button variant='primary' size='large' form='edit-params-form' type='submit'>
|
||||
Save
|
||||
<Button
|
||||
variant='primary'
|
||||
size='large'
|
||||
form='edit-params-form'
|
||||
type='submit'
|
||||
data-testid='apply-view-params'
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Popup>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
.presetSection {
|
||||
background-color: $gray-1350;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 1rem 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
max-height: 10rem;
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.preset {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
&.active {
|
||||
color: $blue-500;
|
||||
}
|
||||
}
|
||||
|
||||
.presetActions {
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { OntimeView, URLPreset } from 'ontime-types';
|
||||
|
||||
import { useViewUrlPresets } from '../../hooks-query/useUrlPresets';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
import Button from '../buttons/Button';
|
||||
|
||||
import style from './ViewParamsPresets.module.scss';
|
||||
|
||||
/**
|
||||
* Shows a list of presets for the current view
|
||||
*/
|
||||
export function ViewParamsPresets({ target }: { target: OntimeView }) {
|
||||
const { viewPresets } = useViewUrlPresets(target);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const handleRecall = (preset: URLPreset) => {
|
||||
const newSearch = new URLSearchParams(preset.search);
|
||||
newSearch.set('alias', preset.alias);
|
||||
setSearchParams(newSearch);
|
||||
};
|
||||
|
||||
if (viewPresets.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={style.presetSection}>
|
||||
{viewPresets.map((preset) => {
|
||||
const active = searchParams.get('alias') === preset.alias;
|
||||
return (
|
||||
<div key={preset.alias} className={cx([style.preset, active && style.active])}>
|
||||
<div>{preset.alias}</div>
|
||||
<Button
|
||||
variant={active ? 'ghosted' : 'subtle-white'}
|
||||
onClick={() => handleRecall(preset)}
|
||||
disabled={active}
|
||||
className={style.presetActions}
|
||||
>
|
||||
{active ? 'Applied' : 'Apply'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -36,11 +36,15 @@
|
||||
}
|
||||
|
||||
.closed {
|
||||
transition: rotate $transition-time-feedback;
|
||||
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
|
||||
rotate: 0deg;
|
||||
}
|
||||
|
||||
.open {
|
||||
transition: rotate $transition-time-feedback;
|
||||
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
|
||||
rotate: 180deg;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { memo } from 'react';
|
||||
import { IoChevronDown } from 'react-icons/io5';
|
||||
import { useLocalStorage } from '@mantine/hooks';
|
||||
|
||||
@@ -16,8 +15,7 @@ interface ViewParamsSectionProps {
|
||||
options: ParamField[];
|
||||
}
|
||||
|
||||
export default memo(ViewParamsSection);
|
||||
function ViewParamsSection({ title, collapsible, options }: ViewParamsSectionProps) {
|
||||
export default function ViewParamsSection({ title, collapsible, options }: ViewParamsSectionProps) {
|
||||
const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false });
|
||||
|
||||
const handleCollapse = () => {
|
||||
@@ -49,15 +47,11 @@ interface SectionContentsProps {
|
||||
}
|
||||
|
||||
function SectionContents({ options, collapsed }: SectionContentsProps) {
|
||||
if (collapsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{options.map((option) => {
|
||||
return (
|
||||
<label key={option.title} className={style.label}>
|
||||
<label key={option.title} className={cx([style.label, collapsed && style.hidden])}>
|
||||
<span className={style.title}>{option.title}</span>
|
||||
<span className={style.description}>{option.description}</span>
|
||||
<ParamInput paramField={option} />
|
||||
|
||||
-1
@@ -232,7 +232,6 @@ describe('getURLSearchParamsFromObj()', () => {
|
||||
bool2: 'on',
|
||||
};
|
||||
const result = getURLSearchParamsFromObj(params, mockOptionsWithBooleans);
|
||||
console.log('Result:', result.toString());
|
||||
expect(result.get('bool1')).toBe('false');
|
||||
expect(result.get('bool2')).toBe('true');
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ export type MultiselectOption = { value: string; label: string; colour: string }
|
||||
type MultiOptionsField = {
|
||||
type: 'multi-option';
|
||||
values: MultiselectOption[];
|
||||
defaultValue?: string;
|
||||
defaultValue?: string[];
|
||||
};
|
||||
|
||||
type StringField = { type: 'string'; defaultValue?: string; placeholder?: string };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CustomFields } from 'ontime-types';
|
||||
import type { CustomFields, ProjectData } from 'ontime-types';
|
||||
|
||||
import type { SelectOption } from '../select/Select';
|
||||
|
||||
@@ -53,6 +53,23 @@ export function makeCustomFieldSelectOptions(customFields: CustomFields, filterI
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates data for a select element that displays project custom data
|
||||
*/
|
||||
export function makeProjectDataOptions(
|
||||
projectData: ProjectData,
|
||||
additionalOptions: SelectOption[] = [],
|
||||
): SelectOption[] {
|
||||
const generatedOptions = projectData.custom.map((entry, index) => {
|
||||
return {
|
||||
value: `${index}-${entry.title}`,
|
||||
label: entry.title,
|
||||
};
|
||||
});
|
||||
|
||||
return [...additionalOptions, ...generatedOptions];
|
||||
}
|
||||
|
||||
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createContext } from 'react';
|
||||
import { URLPreset } from 'ontime-types';
|
||||
|
||||
export const PresetContext = createContext<URLPreset | undefined>(undefined);
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { langEn } from 'ontime-types';
|
||||
|
||||
import { getUserTranslation } from '../../common/api/assets';
|
||||
import { TRANSLATION } from '../../common/api/constants';
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
|
||||
export function useCustomTranslation() {
|
||||
const { data, status, refetch } = useQuery({
|
||||
queryKey: TRANSLATION,
|
||||
queryFn: getUserTranslation,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
});
|
||||
return { data: data ?? langEn, status, refetch };
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { PROJECT_DATA } from '../api/constants';
|
||||
import { getProjectData } from '../api/project';
|
||||
import { getProjectData, postProjectData } from '../api/project';
|
||||
import { projectDataPlaceholder } from '../models/ProjectData';
|
||||
|
||||
export default function useProjectData() {
|
||||
@@ -10,11 +10,25 @@ export default function useProjectData() {
|
||||
queryKey: PROJECT_DATA,
|
||||
queryFn: getProjectData,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
return { data: data ?? projectDataPlaceholder, status, isFetching, isError, refetch };
|
||||
}
|
||||
|
||||
export function useUpdateProjectData() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const updateFn = useMutation({
|
||||
mutationFn: postProjectData,
|
||||
onSuccess: (newProjectData) => {
|
||||
queryClient.setQueryData(PROJECT_DATA, newProjectData);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
updateProjectData: updateFn.mutateAsync,
|
||||
isMutating: updateFn.isPending,
|
||||
isMutatingError: updateFn.isError,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { ProjectRundownsList } from 'ontime-types';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { PROJECT_RUNDOWNS } from '../api/constants';
|
||||
import { fetchProjectRundownList } from '../api/rundown';
|
||||
import { createRundown, deleteRundown, fetchProjectRundownList, loadRundown } from '../api/rundown';
|
||||
|
||||
/**
|
||||
* Project rundowns
|
||||
@@ -13,10 +13,43 @@ export function useProjectRundowns() {
|
||||
queryKey: PROJECT_RUNDOWNS,
|
||||
queryFn: fetchProjectRundownList,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
networkMode: 'always',
|
||||
});
|
||||
return { data: data ?? { loaded: '', rundowns: [] }, status, isError, refetch, isFetching };
|
||||
}
|
||||
|
||||
export function useMutateProjectRundowns() {
|
||||
const ontimeQueryClient = useQueryClient();
|
||||
|
||||
const { mutateAsync: create } = useMutation({
|
||||
mutationFn: createRundown,
|
||||
onMutate: () => {
|
||||
ontimeQueryClient.cancelQueries({ queryKey: PROJECT_RUNDOWNS });
|
||||
},
|
||||
onSuccess: (response) => {
|
||||
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: remove } = useMutation({
|
||||
mutationFn: deleteRundown,
|
||||
onMutate: () => {
|
||||
ontimeQueryClient.cancelQueries({ queryKey: PROJECT_RUNDOWNS });
|
||||
},
|
||||
onSuccess: (response) => {
|
||||
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: load } = useMutation({
|
||||
mutationFn: loadRundown,
|
||||
onMutate: () => {
|
||||
ontimeQueryClient.cancelQueries({ queryKey: PROJECT_RUNDOWNS });
|
||||
},
|
||||
onSuccess: (response) => {
|
||||
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
|
||||
},
|
||||
});
|
||||
|
||||
return { create, remove, load };
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { EntryId, OntimeEntry, Rundown } from 'ontime-types';
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { RUNDOWN } from '../api/constants';
|
||||
import { fetchCurrentRundown } from '../api/rundown';
|
||||
import { useSelectedEventId } from '../hooks/useSocket';
|
||||
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
|
||||
|
||||
import useProjectData from './useProjectData';
|
||||
|
||||
@@ -26,14 +28,18 @@ export default function useRundown() {
|
||||
queryKey: RUNDOWN,
|
||||
queryFn: fetchCurrentRundown,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
networkMode: 'always',
|
||||
});
|
||||
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
|
||||
}
|
||||
|
||||
export function useRundownWithMetadata() {
|
||||
const { data, status } = useRundown();
|
||||
const { selectedEventId } = useSelectedEventId();
|
||||
const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]);
|
||||
return { data, status, rundownMetadata };
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides access to a flat rundown
|
||||
* built from the order and rundown fields
|
||||
@@ -48,14 +54,15 @@ export function useFlatRundown() {
|
||||
|
||||
// update data whenever the revision changes
|
||||
useEffect(() => {
|
||||
if (data.revision !== -1 && data.revision !== prevRevision) {
|
||||
const flatRundown = data.flatOrder.map((id) => data.entries[id]);
|
||||
if (data.revision !== -1 || data.revision !== prevRevision) {
|
||||
const flatRundown = data.order.map((id) => data.entries[id]);
|
||||
setFlatRundown(flatRundown);
|
||||
setPrevRevision(data.revision);
|
||||
}
|
||||
}, [data.entries, data.flatOrder, data.revision, prevRevision]);
|
||||
|
||||
// TODO: should we have a project id field?
|
||||
// TODO(v4): cleanup as part of load multiple rundowns
|
||||
// invalidate current version if project changes
|
||||
useEffect(() => {
|
||||
if (projectData?.title !== loadedProject.current) {
|
||||
@@ -64,14 +71,22 @@ export function useFlatRundown() {
|
||||
}
|
||||
}, [projectData]);
|
||||
|
||||
return { data: flatRundown, status };
|
||||
return { data: flatRundown, rundownId: data.id, status };
|
||||
}
|
||||
|
||||
export function useFlatRundownWithMetadata() {
|
||||
const { data, status } = useRundown();
|
||||
const { selectedEventId } = useSelectedEventId();
|
||||
|
||||
const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]);
|
||||
return { data: rundownWithMetadata, status };
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides access to a partial rundown based on a filter callback
|
||||
*/
|
||||
export function usePartialRundown(cb: (event: OntimeEntry) => boolean) {
|
||||
const { data, status } = useFlatRundown();
|
||||
export function usePartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boolean) {
|
||||
const { data, status } = useFlatRundownWithMetadata();
|
||||
const filteredData = useMemo(() => {
|
||||
return data.filter(cb);
|
||||
}, [data, cb]);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { unobfuscate } from 'ontime-utils';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { APP_SETTINGS } from '../api/constants';
|
||||
import { getSettings } from '../api/settings';
|
||||
import { ontimePlaceholderSettings } from '../models/OntimeSettings';
|
||||
@@ -11,10 +10,6 @@ export default function useSettings() {
|
||||
queryKey: APP_SETTINGS,
|
||||
queryFn: getSettings,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
networkMode: 'always',
|
||||
select: (data) => {
|
||||
const unobfuscated = { ...data };
|
||||
if (data.editorKey) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { OntimeView, URLPreset } from 'ontime-types';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { URL_PRESETS } from '../api/constants';
|
||||
import { getUrlPresets } from '../api/urlPresets';
|
||||
import { deleteUrlPreset, getUrlPresets, postUrlPreset, putUrlPreset } from '../api/urlPresets';
|
||||
|
||||
interface FetchProps {
|
||||
skip?: boolean;
|
||||
@@ -13,12 +15,50 @@ export default function useUrlPresets({ skip = false }: FetchProps = {}) {
|
||||
queryKey: URL_PRESETS,
|
||||
queryFn: getUrlPresets,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
networkMode: 'always',
|
||||
enabled: !skip,
|
||||
});
|
||||
|
||||
return { data: data ?? [], status, isError, refetch };
|
||||
}
|
||||
|
||||
export function useViewUrlPresets(view: OntimeView) {
|
||||
const { data } = useUrlPresets();
|
||||
|
||||
const viewPresets = useMemo(() => data.filter((preset) => preset.target === view), [data, view]);
|
||||
|
||||
return { viewPresets };
|
||||
}
|
||||
|
||||
export function useUpdateUrlPreset() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const addFn = useMutation({
|
||||
mutationFn: postUrlPreset,
|
||||
onSuccess: (newPresets) => {
|
||||
queryClient.setQueryData(URL_PRESETS, newPresets);
|
||||
},
|
||||
});
|
||||
|
||||
const updateFn = useMutation({
|
||||
mutationFn: ({ alias, data }: { alias: string; data: URLPreset }) => putUrlPreset(alias, data),
|
||||
onSuccess: (newPresets) => {
|
||||
queryClient.setQueryData(URL_PRESETS, newPresets);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteFn = useMutation({
|
||||
mutationFn: deleteUrlPreset,
|
||||
onSuccess: (newPresets) => {
|
||||
queryClient.setQueryData(URL_PRESETS, newPresets);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
addPreset: addFn.mutateAsync,
|
||||
updatePreset: (alias: string, data: URLPreset) => updateFn.mutateAsync({ alias, data }),
|
||||
deletePreset: deleteFn.mutateAsync,
|
||||
isMutating: addFn.isPending || updateFn.isPending || deleteFn.isPending,
|
||||
isMutationError: addFn.isError || updateFn.isError || deleteFn.isError,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { VIEW_SETTINGS } from '../api/constants';
|
||||
import { viewsSettingsPlaceholder } from '../models/ViewSettings.type';
|
||||
|
||||
export default function useViewSettings() {
|
||||
const { data, isPending } = useQuery({
|
||||
const { data, status } = useQuery({
|
||||
queryKey: VIEW_SETTINGS,
|
||||
queryFn: getViewSettings,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
@@ -24,5 +24,5 @@ export default function useViewSettings() {
|
||||
},
|
||||
});
|
||||
|
||||
return { data: data ?? viewsSettingsPlaceholder, mutateAsync, isPending };
|
||||
return { data: data ?? viewsSettingsPlaceholder, status, mutateAsync };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useLocation, useNavigate } from 'react-router';
|
||||
import { MessageTag } from 'ontime-types';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { addDialog } from '../stores/dialogStore';
|
||||
|
||||
|
||||
@@ -2,13 +2,13 @@ import { useCallback } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
EntryId,
|
||||
isOntimeBlock,
|
||||
isOntimeEvent,
|
||||
isOntimeGroup,
|
||||
MaybeString,
|
||||
OntimeBlock,
|
||||
OntimeEntry,
|
||||
OntimeEvent,
|
||||
Rundown,
|
||||
SupportedEntry,
|
||||
TimeField,
|
||||
TimeStrategy,
|
||||
TransientEventPayload,
|
||||
@@ -30,13 +30,12 @@ import {
|
||||
requestEventSwap,
|
||||
requestGroupEntries,
|
||||
requestUngroup,
|
||||
SwapEntry,
|
||||
} from '../api/rundown';
|
||||
import { logAxiosError } from '../api/utils';
|
||||
import { useEditorSettings } from '../stores/editorSettings';
|
||||
|
||||
export type EventOptions = Partial<{
|
||||
// options of any new entries (event / delay / block)
|
||||
// options of any new entries (event / delay / group)
|
||||
after: MaybeString;
|
||||
before: MaybeString;
|
||||
// options of entries of type OntimeEvent
|
||||
@@ -59,15 +58,25 @@ export const useEntryActions = () => {
|
||||
defaultEndAction,
|
||||
} = useEditorSettings();
|
||||
|
||||
/**
|
||||
* Returns the currently loaded rundown
|
||||
*/
|
||||
const getCurrentRundownData = useCallback(() => {
|
||||
return queryClient.getQueryData<Rundown>(RUNDOWN);
|
||||
}, [queryClient]);
|
||||
|
||||
/**
|
||||
* Looks for an entry with a given ID in the currently loaded rundown
|
||||
*/
|
||||
const getEntryById = useCallback(
|
||||
(eventId: string): OntimeEntry | undefined => {
|
||||
const cachedRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
|
||||
(eventId: EntryId): OntimeEntry | undefined => {
|
||||
const cachedRundown = getCurrentRundownData();
|
||||
if (!cachedRundown?.entries) {
|
||||
return;
|
||||
}
|
||||
return cachedRundown.entries[eventId];
|
||||
},
|
||||
[queryClient],
|
||||
[getCurrentRundownData],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -75,8 +84,8 @@ export const useEntryActions = () => {
|
||||
* @private
|
||||
*/
|
||||
const { mutateAsync: addEntryMutation } = useMutation({
|
||||
// TODO(v4): optimistic create entry
|
||||
mutationFn: postAddEntry,
|
||||
mutationFn: ([rundownId, entry]: Parameters<typeof postAddEntry>) => postAddEntry(rundownId, entry),
|
||||
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
|
||||
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
|
||||
});
|
||||
|
||||
@@ -85,29 +94,27 @@ export const useEntryActions = () => {
|
||||
*/
|
||||
const addEntry = useCallback(
|
||||
async (entry: Partial<OntimeEntry>, options?: EventOptions) => {
|
||||
const rundownData = getCurrentRundownData();
|
||||
const rundownId = rundownData?.id;
|
||||
|
||||
if (!rundownId) {
|
||||
throw new Error('Rundown not initialised');
|
||||
}
|
||||
|
||||
const newEntry: TransientEventPayload = { ...entry, id: generateId() };
|
||||
|
||||
// ************* CHECK OPTIONS specific to events
|
||||
if (isOntimeEvent(newEntry)) {
|
||||
// merge creation time options with event settings
|
||||
const applicationOptions = {
|
||||
after: options?.after,
|
||||
before: options?.before,
|
||||
lastEventId: options?.lastEventId,
|
||||
linkPrevious: options?.linkPrevious ?? linkPrevious,
|
||||
};
|
||||
|
||||
if (applicationOptions?.lastEventId) {
|
||||
if (options?.lastEventId) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
|
||||
const rundownData = queryClient.getQueryData<Rundown>(RUNDOWN)!;
|
||||
const previousEvent = rundownData.entries[applicationOptions.lastEventId];
|
||||
const previousEvent = rundownData.entries[options?.lastEventId];
|
||||
if (isOntimeEvent(previousEvent)) {
|
||||
newEntry.timeStart = previousEvent.timeEnd;
|
||||
}
|
||||
}
|
||||
|
||||
// Override event with options from editor settings
|
||||
newEntry.linkStart = applicationOptions.linkPrevious;
|
||||
newEntry.linkStart = options?.linkPrevious ?? linkPrevious;
|
||||
|
||||
if (newEntry.duration === undefined && newEntry.timeEnd === undefined) {
|
||||
newEntry.duration = parseUserTime(defaultDuration);
|
||||
@@ -143,21 +150,21 @@ export const useEntryActions = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
await addEntryMutation(newEntry);
|
||||
await addEntryMutation([rundownId, newEntry]);
|
||||
} catch (error) {
|
||||
logAxiosError('Failed adding event', error);
|
||||
}
|
||||
},
|
||||
[
|
||||
addEntryMutation,
|
||||
defaultDangerTime,
|
||||
defaultDuration,
|
||||
defaultEndAction,
|
||||
defaultTimerType,
|
||||
defaultTimeStrategy,
|
||||
defaultWarnTime,
|
||||
getCurrentRundownData,
|
||||
linkPrevious,
|
||||
queryClient,
|
||||
defaultDuration,
|
||||
defaultDangerTime,
|
||||
defaultWarnTime,
|
||||
defaultTimerType,
|
||||
defaultEndAction,
|
||||
defaultTimeStrategy,
|
||||
addEntryMutation,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -166,22 +173,28 @@ export const useEntryActions = () => {
|
||||
* @private
|
||||
*/
|
||||
const { mutateAsync: cloneEntryMutation } = useMutation({
|
||||
mutationFn: postCloneEntry,
|
||||
mutationFn: ([rundownId, entryId]: Parameters<typeof postCloneEntry>) => postCloneEntry(rundownId, entryId),
|
||||
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
|
||||
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
|
||||
});
|
||||
|
||||
/**
|
||||
* Clone a selection
|
||||
* Clone an entry
|
||||
*/
|
||||
const clone = useCallback(
|
||||
async (entryId: EntryId) => {
|
||||
try {
|
||||
await cloneEntryMutation(entryId);
|
||||
const rundownId = getCurrentRundownData()?.id;
|
||||
if (!rundownId) {
|
||||
throw new Error('Rundown not initialised');
|
||||
}
|
||||
|
||||
await cloneEntryMutation([rundownId, entryId]);
|
||||
} catch (error) {
|
||||
logAxiosError('Error cloning entry', error);
|
||||
}
|
||||
},
|
||||
[cloneEntryMutation],
|
||||
[cloneEntryMutation, getCurrentRundownData],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -189,9 +202,9 @@ export const useEntryActions = () => {
|
||||
* @private
|
||||
*/
|
||||
const { mutateAsync: updateEntryMutation } = useMutation({
|
||||
mutationFn: putEditEntry,
|
||||
mutationFn: ([rundownId, newEvent]: Parameters<typeof putEditEntry>) => putEditEntry(rundownId, newEvent),
|
||||
// we optimistically update here
|
||||
onMutate: async (newEvent) => {
|
||||
onMutate: async ([_rundownId, newEvent]) => {
|
||||
// cancel ongoing queries
|
||||
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
||||
|
||||
@@ -219,7 +232,9 @@ export const useEntryActions = () => {
|
||||
},
|
||||
// Mutation fails, rollback undoes optimist update
|
||||
onError: (_error, _newEvent, context) => {
|
||||
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
|
||||
if (context?.previousData) {
|
||||
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
|
||||
}
|
||||
},
|
||||
// Mutation finished, failed or successful
|
||||
// Fetch anyway, just to be sure
|
||||
@@ -234,19 +249,17 @@ export const useEntryActions = () => {
|
||||
const updateEntry = useCallback(
|
||||
async (entry: Partial<OntimeEntry>) => {
|
||||
try {
|
||||
await updateEntryMutation(entry);
|
||||
const rundownId = getCurrentRundownData()?.id;
|
||||
if (!rundownId) {
|
||||
throw new Error('Rundown not initialised');
|
||||
}
|
||||
|
||||
await updateEntryMutation([rundownId, entry]);
|
||||
} catch (error) {
|
||||
logAxiosError('Error updating event', error);
|
||||
}
|
||||
},
|
||||
[updateEntryMutation],
|
||||
);
|
||||
|
||||
const updateCustomField = useCallback(
|
||||
async (entryId: EntryId, field: string, value: string) => {
|
||||
updateEntry({ id: entryId, custom: { [field]: value } });
|
||||
},
|
||||
[updateEntry],
|
||||
[getCurrentRundownData, updateEntryMutation],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -258,6 +271,11 @@ export const useEntryActions = () => {
|
||||
*/
|
||||
const updateTimer = useCallback(
|
||||
async (eventId: EntryId, field: TimeField, value: string, lockOnUpdate?: boolean) => {
|
||||
const rundownId = getCurrentRundownData()?.id;
|
||||
if (!rundownId) {
|
||||
throw new Error('Rundown not initialised');
|
||||
}
|
||||
|
||||
// an empty value with no lock has no domain validity
|
||||
if (!lockOnUpdate && value === '') {
|
||||
return;
|
||||
@@ -287,7 +305,7 @@ export const useEntryActions = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
await updateEntryMutation(newEvent);
|
||||
await updateEntryMutation([rundownId, newEvent]);
|
||||
} catch (error) {
|
||||
logAxiosError('Error updating event', error);
|
||||
}
|
||||
@@ -339,7 +357,7 @@ export const useEntryActions = () => {
|
||||
return previousEnd;
|
||||
}
|
||||
},
|
||||
[updateEntryMutation, queryClient],
|
||||
[getCurrentRundownData, updateEntryMutation, queryClient],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -347,8 +365,8 @@ export const useEntryActions = () => {
|
||||
* @private
|
||||
*/
|
||||
const { mutateAsync: batchUpdateEventsMutation } = useMutation({
|
||||
mutationFn: putBatchEditEvents,
|
||||
onMutate: async ({ ids, data }) => {
|
||||
mutationFn: ([rundownId, data]: Parameters<typeof putBatchEditEvents>) => putBatchEditEvents(rundownId, data),
|
||||
onMutate: async ([_rundownId, data]) => {
|
||||
// cancel ongoing queries
|
||||
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
||||
|
||||
@@ -356,7 +374,7 @@ export const useEntryActions = () => {
|
||||
const previousRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
|
||||
|
||||
if (previousRundown) {
|
||||
const eventIds = new Set(ids);
|
||||
const eventIds = new Set(data.ids);
|
||||
const newRundown = { ...previousRundown.entries };
|
||||
|
||||
eventIds.forEach((eventId) => {
|
||||
@@ -403,14 +421,19 @@ export const useEntryActions = () => {
|
||||
});
|
||||
|
||||
const batchUpdateEvents = useCallback(
|
||||
async (data: Partial<OntimeEvent>, eventIds: string[]) => {
|
||||
async (data: Partial<OntimeEvent>, eventIds: EntryId[]) => {
|
||||
try {
|
||||
await batchUpdateEventsMutation({ ids: eventIds, data });
|
||||
const rundownId = getCurrentRundownData()?.id;
|
||||
if (!rundownId) {
|
||||
throw new Error('Rundown not initialised');
|
||||
}
|
||||
|
||||
await batchUpdateEventsMutation([rundownId, { data, ids: eventIds }]);
|
||||
} catch (error) {
|
||||
logAxiosError('Error updating events', error);
|
||||
}
|
||||
},
|
||||
[batchUpdateEventsMutation],
|
||||
[batchUpdateEventsMutation, getCurrentRundownData],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -418,9 +441,9 @@ export const useEntryActions = () => {
|
||||
* @private
|
||||
*/
|
||||
const { mutateAsync: deleteEntryMutation } = useMutation({
|
||||
mutationFn: deleteEntries,
|
||||
mutationFn: ([rundownId, entryIds]: Parameters<typeof deleteEntries>) => deleteEntries(rundownId, entryIds),
|
||||
// we optimistically update here
|
||||
onMutate: async (entryIds: EntryId[]) => {
|
||||
onMutate: async ([_rundownId, entryIds]) => {
|
||||
// cancel ongoing queries
|
||||
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
||||
|
||||
@@ -462,12 +485,17 @@ export const useEntryActions = () => {
|
||||
const deleteEntry = useCallback(
|
||||
async (entryIds: EntryId[]) => {
|
||||
try {
|
||||
await deleteEntryMutation(entryIds);
|
||||
const rundownId = getCurrentRundownData()?.id;
|
||||
if (!rundownId) {
|
||||
throw new Error('Rundown not initialised');
|
||||
}
|
||||
|
||||
await deleteEntryMutation([rundownId, entryIds]);
|
||||
} catch (error) {
|
||||
logAxiosError('Error deleting event', error);
|
||||
}
|
||||
},
|
||||
[deleteEntryMutation],
|
||||
[deleteEntryMutation, getCurrentRundownData],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -475,7 +503,7 @@ export const useEntryActions = () => {
|
||||
* @private
|
||||
*/
|
||||
const { mutateAsync: deleteAllEntriesMutation } = useMutation({
|
||||
mutationFn: requestDeleteAll,
|
||||
mutationFn: ([rundownId]: Parameters<typeof requestDeleteAll>) => requestDeleteAll(rundownId),
|
||||
// we optimistically update here
|
||||
onMutate: async () => {
|
||||
// cancel ongoing queries
|
||||
@@ -514,18 +542,24 @@ export const useEntryActions = () => {
|
||||
*/
|
||||
const deleteAllEntries = useCallback(async () => {
|
||||
try {
|
||||
await deleteAllEntriesMutation();
|
||||
const rundownId = getCurrentRundownData()?.id;
|
||||
if (!rundownId) {
|
||||
throw new Error('Rundown not initialised');
|
||||
}
|
||||
|
||||
await deleteAllEntriesMutation([rundownId]);
|
||||
} catch (error) {
|
||||
logAxiosError('Error deleting events', error);
|
||||
}
|
||||
}, [deleteAllEntriesMutation]);
|
||||
}, [deleteAllEntriesMutation, getCurrentRundownData]);
|
||||
|
||||
/**
|
||||
* Calls mutation to apply a delay
|
||||
* @private
|
||||
*/
|
||||
const { mutateAsync: applyDelayMutation } = useMutation({
|
||||
mutationFn: requestApplyDelay,
|
||||
mutationFn: ([rundownId, delayId]: Parameters<typeof requestApplyDelay>) => requestApplyDelay(rundownId, delayId),
|
||||
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
|
||||
onSuccess: (response) => {
|
||||
if (!response.data) return;
|
||||
|
||||
@@ -551,20 +585,26 @@ export const useEntryActions = () => {
|
||||
const applyDelay = useCallback(
|
||||
async (delayEventId: EntryId) => {
|
||||
try {
|
||||
await applyDelayMutation(delayEventId);
|
||||
const rundownId = getCurrentRundownData()?.id;
|
||||
if (!rundownId) {
|
||||
throw new Error('Rundown not initialised');
|
||||
}
|
||||
|
||||
await applyDelayMutation([rundownId, delayEventId]);
|
||||
} catch (error) {
|
||||
logAxiosError('Error applying delay', error);
|
||||
}
|
||||
},
|
||||
[applyDelayMutation],
|
||||
[applyDelayMutation, getCurrentRundownData],
|
||||
);
|
||||
|
||||
/**
|
||||
* Calls mutation to dissolve a block
|
||||
* Calls mutation to dissolve a group
|
||||
* @private
|
||||
*/
|
||||
const { mutateAsync: ungroupMutation } = useMutation({
|
||||
mutationFn: requestUngroup,
|
||||
mutationFn: ([rundownId, groupId]: Parameters<typeof requestUngroup>) => requestUngroup(rundownId, groupId),
|
||||
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
|
||||
onSuccess: (response) => {
|
||||
if (!response.data) return;
|
||||
|
||||
@@ -582,25 +622,32 @@ export const useEntryActions = () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* Deletes a block and moves its events to the top level
|
||||
* Deletes a group and moves its events to the top level
|
||||
*/
|
||||
const ungroup = useCallback(
|
||||
async (blockId: EntryId) => {
|
||||
async (groupId: EntryId) => {
|
||||
try {
|
||||
await ungroupMutation(blockId);
|
||||
const rundownId = getCurrentRundownData()?.id;
|
||||
if (!rundownId) {
|
||||
throw new Error('Rundown not initialised');
|
||||
}
|
||||
|
||||
await ungroupMutation([rundownId, groupId]);
|
||||
} catch (error) {
|
||||
logAxiosError('Error dissolving group', error);
|
||||
}
|
||||
},
|
||||
[ungroupMutation],
|
||||
[getCurrentRundownData, ungroupMutation],
|
||||
);
|
||||
|
||||
/**
|
||||
* Calls mutation to create a block with a selection
|
||||
* Calls mutation to create a group with a selection
|
||||
* @private
|
||||
*/
|
||||
const { mutateAsync: groupEntriesMutation } = useMutation({
|
||||
mutationFn: requestGroupEntries,
|
||||
mutationFn: ([rundownId, entryIds]: Parameters<typeof requestGroupEntries>) =>
|
||||
requestGroupEntries(rundownId, entryIds),
|
||||
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
|
||||
onSuccess: (response) => {
|
||||
if (!response.data) return;
|
||||
|
||||
@@ -618,26 +665,31 @@ export const useEntryActions = () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a block with a selection
|
||||
* Create a group with a selection
|
||||
*/
|
||||
const groupEntries = useCallback(
|
||||
async (entryIds: EntryId[]) => {
|
||||
if (entryIds.length === 0) return;
|
||||
|
||||
try {
|
||||
const rundownData = getCurrentRundownData();
|
||||
const rundownId = rundownData?.id;
|
||||
if (!rundownId) {
|
||||
throw new Error('Rundown not initialised');
|
||||
}
|
||||
|
||||
if (entryIds.length === 1) {
|
||||
await groupEntriesMutation(entryIds);
|
||||
await groupEntriesMutation([rundownId, entryIds]);
|
||||
} else {
|
||||
const rundown = queryClient.getQueryData<Rundown>(RUNDOWN);
|
||||
if (!rundown) return;
|
||||
const orderedIds = orderEntries(entryIds, rundown.flatOrder);
|
||||
await groupEntriesMutation(orderedIds);
|
||||
// the user selection may be out of order
|
||||
const orderedIds = orderEntries(entryIds, rundownData.flatOrder);
|
||||
await groupEntriesMutation([rundownId, orderedIds]);
|
||||
}
|
||||
} catch (error) {
|
||||
logAxiosError('Error grouping entries', error);
|
||||
}
|
||||
},
|
||||
[groupEntriesMutation, queryClient],
|
||||
[getCurrentRundownData, groupEntriesMutation],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -645,9 +697,8 @@ export const useEntryActions = () => {
|
||||
* @private
|
||||
*/
|
||||
const { mutateAsync: reorderEntryMutation } = useMutation({
|
||||
mutationFn: patchReorderEntry,
|
||||
// Mutation finished, failed or successful
|
||||
// Fetch anyway, just to be sure
|
||||
mutationFn: ([rundownId, data]: Parameters<typeof patchReorderEntry>) => patchReorderEntry(rundownId, data),
|
||||
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||
},
|
||||
@@ -658,34 +709,36 @@ export const useEntryActions = () => {
|
||||
*/
|
||||
const move = useCallback(
|
||||
async (entryId: EntryId, direction: 'up' | 'down') => {
|
||||
const rundown = queryClient.getQueryData<Rundown>(RUNDOWN);
|
||||
if (!rundown) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { destinationId, order } =
|
||||
direction === 'up'
|
||||
? moveUp(entryId, rundown.flatOrder, rundown.entries)
|
||||
: moveDown(entryId, rundown.flatOrder, rundown.entries);
|
||||
|
||||
if (!destinationId) {
|
||||
return; // noop
|
||||
}
|
||||
|
||||
try {
|
||||
const rundownData = getCurrentRundownData();
|
||||
const rundownId = rundownData?.id;
|
||||
if (!rundownId) {
|
||||
throw new Error('Rundown not initialised');
|
||||
}
|
||||
|
||||
const { destinationId, order } =
|
||||
direction === 'up'
|
||||
? moveUp(entryId, rundownData.flatOrder, rundownData.entries)
|
||||
: moveDown(entryId, rundownData.flatOrder, rundownData.entries);
|
||||
|
||||
if (!destinationId) {
|
||||
return; // noop
|
||||
}
|
||||
|
||||
const reorderObject: ReorderEntry = {
|
||||
entryId,
|
||||
destinationId,
|
||||
order,
|
||||
};
|
||||
await reorderEntryMutation(reorderObject);
|
||||
await reorderEntryMutation([rundownId, reorderObject]);
|
||||
// the rundown needs to know whether we moved into a group
|
||||
return rundownData.entries[destinationId]?.type === SupportedEntry.Group ? destinationId : undefined;
|
||||
} catch (error) {
|
||||
logAxiosError('Error re-ordering event', error);
|
||||
}
|
||||
// the rundown needs to know whether we moved into a block
|
||||
return rundown.entries[destinationId]?.type === 'block' ? destinationId : undefined;
|
||||
return undefined;
|
||||
},
|
||||
[queryClient, reorderEntryMutation],
|
||||
[getCurrentRundownData, reorderEntryMutation],
|
||||
);
|
||||
/**
|
||||
* Reorders a given entry
|
||||
@@ -693,17 +746,25 @@ export const useEntryActions = () => {
|
||||
const reorderEntry = useCallback(
|
||||
async (entryId: EntryId, destinationId: EntryId, order: 'before' | 'after' | 'insert') => {
|
||||
try {
|
||||
const reorderObject: ReorderEntry = {
|
||||
entryId,
|
||||
destinationId,
|
||||
order,
|
||||
};
|
||||
await reorderEntryMutation(reorderObject);
|
||||
const rundownId = getCurrentRundownData()?.id;
|
||||
if (!rundownId) {
|
||||
throw new Error('Rundown not initialised');
|
||||
}
|
||||
|
||||
await reorderEntryMutation([
|
||||
rundownId,
|
||||
{
|
||||
entryId,
|
||||
destinationId,
|
||||
order,
|
||||
},
|
||||
]);
|
||||
} catch (error) {
|
||||
logAxiosError('Error re-ordering event', error);
|
||||
throw error; // rethrow to handle in the component
|
||||
}
|
||||
},
|
||||
[reorderEntryMutation],
|
||||
[getCurrentRundownData, reorderEntryMutation],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -711,9 +772,9 @@ export const useEntryActions = () => {
|
||||
* @private
|
||||
*/
|
||||
const { mutateAsync: swapEventsMutation } = useMutation({
|
||||
mutationFn: requestEventSwap,
|
||||
mutationFn: ([rundownId, from, to]: Parameters<typeof requestEventSwap>) => requestEventSwap(rundownId, from, to),
|
||||
// we optimistically update here
|
||||
onMutate: async ({ from, to }) => {
|
||||
onMutate: async ([_rundownId, from, to]) => {
|
||||
// cancel ongoing queries
|
||||
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
||||
|
||||
@@ -762,14 +823,19 @@ export const useEntryActions = () => {
|
||||
* Swaps the schedule of two events
|
||||
*/
|
||||
const swapEvents = useCallback(
|
||||
async ({ from, to }: SwapEntry) => {
|
||||
async (from: EntryId, to: EntryId) => {
|
||||
try {
|
||||
await swapEventsMutation({ from, to });
|
||||
const rundownId = getCurrentRundownData()?.id;
|
||||
if (!rundownId) {
|
||||
throw new Error('Rundown not initialised');
|
||||
}
|
||||
|
||||
await swapEventsMutation([rundownId, from, to]);
|
||||
} catch (error) {
|
||||
logAxiosError('Error re-ordering event', error);
|
||||
}
|
||||
},
|
||||
[swapEventsMutation],
|
||||
[getCurrentRundownData, swapEventsMutation],
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -787,7 +853,6 @@ export const useEntryActions = () => {
|
||||
swapEvents,
|
||||
updateEntry,
|
||||
updateTimer,
|
||||
updateCustomField,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -805,14 +870,12 @@ function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) {
|
||||
}
|
||||
|
||||
function deleteEntry(entry: OntimeEntry) {
|
||||
if (isOntimeBlock(entry) || !entry.parent) {
|
||||
if (isOntimeGroup(entry) || !entry.parent) {
|
||||
order = order.filter((id) => id !== entry.id);
|
||||
} else {
|
||||
const parent = entries[entry.parent];
|
||||
if ('parent' in entries) {
|
||||
(parent as OntimeBlock).entries = (parent as OntimeBlock).entries.filter(
|
||||
(parentEntry) => parentEntry !== entry.id,
|
||||
);
|
||||
if (parent && isOntimeGroup(parent)) {
|
||||
parent.entries = parent.entries.filter((parentEntry) => parentEntry !== entry.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@ export const useFadeOutOnInactivity = (initialState = false) => {
|
||||
|
||||
const throttledShowMenu = throttle(setShowMenuTrue, 1000);
|
||||
|
||||
// we call the function on mount, to make sure the menu is hidden
|
||||
throttledShowMenu();
|
||||
|
||||
document.addEventListener('mousemove', throttledShowMenu);
|
||||
document.addEventListener('keydown', throttledShowMenu);
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { RefObject, useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { useSelectedEventId } from './useSocket';
|
||||
import { RefObject, useCallback, useEffect } from 'react';
|
||||
import { MaybeString } from 'ontime-types';
|
||||
|
||||
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
|
||||
componentRef: RefObject<ComponentRef>,
|
||||
@@ -18,37 +17,26 @@ function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends H
|
||||
scrollRef.current.scrollTo({ top, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function snapToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
|
||||
componentRef: RefObject<ComponentRef>,
|
||||
scrollRef: RefObject<ScrollRef>,
|
||||
topOffset: number,
|
||||
) {
|
||||
if (!componentRef.current || !scrollRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const componentRect = componentRef.current.getBoundingClientRect();
|
||||
const scrollRect = scrollRef.current.getBoundingClientRect();
|
||||
const top = componentRect.top - scrollRect.top + scrollRef.current.scrollTop - topOffset;
|
||||
|
||||
// maintain current x scroll position
|
||||
scrollRef.current.scrollTo(scrollRef.current.scrollLeft, top);
|
||||
}
|
||||
|
||||
interface UseFollowComponentProps {
|
||||
followRef: RefObject<HTMLElement | null>;
|
||||
scrollRef: RefObject<HTMLElement | null>;
|
||||
doFollow: boolean;
|
||||
topOffset?: number;
|
||||
setScrollFlag?: (newValue: boolean) => void;
|
||||
followTrigger?: MaybeString; // this would be an entry id or null
|
||||
}
|
||||
|
||||
export default function useFollowComponent(props: UseFollowComponentProps) {
|
||||
const { followRef, scrollRef, doFollow, topOffset = 100, setScrollFlag } = props;
|
||||
|
||||
// when cursor moves, view should follow
|
||||
export default function useFollowComponent({
|
||||
followRef,
|
||||
scrollRef,
|
||||
doFollow,
|
||||
topOffset = 100,
|
||||
setScrollFlag,
|
||||
followTrigger,
|
||||
}: UseFollowComponentProps) {
|
||||
// when trigger moves, view should follow
|
||||
useEffect(() => {
|
||||
if (!doFollow) {
|
||||
if (!doFollow || !followTrigger) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -60,16 +48,14 @@ export default function useFollowComponent(props: UseFollowComponentProps) {
|
||||
setScrollFlag?.(false);
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line -- the prompt seems incorrect
|
||||
}, [followRef?.current, scrollRef?.current]);
|
||||
}, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset]);
|
||||
|
||||
const scrollToRefComponent = useCallback(
|
||||
(componentRef = followRef, containerRef = scrollRef, offset = topOffset) => {
|
||||
if (componentRef.current && containerRef.current) {
|
||||
if (componentRef && containerRef) {
|
||||
// @ts-expect-error -- we know this are not null
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
scrollToComponent(componentRef!, scrollRef!, offset);
|
||||
scrollToComponent(componentRef!, containerRef!, offset);
|
||||
}
|
||||
},
|
||||
[followRef, scrollRef, topOffset],
|
||||
@@ -77,32 +63,3 @@ export default function useFollowComponent(props: UseFollowComponentProps) {
|
||||
|
||||
return scrollToRefComponent;
|
||||
}
|
||||
|
||||
export function useFollowSelected(doFollow: boolean, topOffset = 100) {
|
||||
const selectedEvenId = useSelectedEventId();
|
||||
|
||||
const selectedRef = useRef<HTMLTableRowElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!doFollow) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedEvenId && selectedRef.current && scrollRef.current) {
|
||||
// Use requestAnimationFrame to ensure the component is fully loaded
|
||||
window.requestAnimationFrame(() => {
|
||||
snapToComponent(
|
||||
{ current: selectedRef.current } as RefObject<HTMLElement>,
|
||||
{ current: scrollRef.current } as RefObject<HTMLElement>,
|
||||
topOffset,
|
||||
);
|
||||
});
|
||||
}
|
||||
}, [doFollow, selectedEvenId, topOffset]);
|
||||
|
||||
return {
|
||||
selectedRef,
|
||||
scrollRef,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { OffsetMode, RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types';
|
||||
import { OffsetMode, RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage, TimerType } from 'ontime-types';
|
||||
|
||||
import { useRuntimeStore } from '../stores/runtime';
|
||||
import { sendSocket } from '../utils/socket';
|
||||
@@ -10,14 +10,15 @@ const createSelector =
|
||||
|
||||
export const setClientRemote = {
|
||||
setIdentify: (payload: { target: string; identify: boolean }) => sendSocket('client', payload),
|
||||
setRedirect: (payload: { target: string; redirect: string }) => sendSocket('client', payload),
|
||||
setRedirect: (payload: { target: string; redirect: string }) => {
|
||||
sendSocket('client', payload);
|
||||
},
|
||||
setClientName: (payload: { target: string; rename: string }) => sendSocket('client', payload),
|
||||
};
|
||||
|
||||
export const useRundownEditor = createSelector((state: RuntimeStore) => ({
|
||||
playback: state.timer.playback,
|
||||
selectedEventId: state.eventNow?.id ?? null,
|
||||
selectedBlockId: state.blockNow?.id ?? null,
|
||||
nextEventId: state.eventNext?.id ?? null,
|
||||
}));
|
||||
|
||||
@@ -59,8 +60,8 @@ export const setMessage = {
|
||||
|
||||
export const usePlaybackControl = createSelector((state: RuntimeStore) => ({
|
||||
playback: state.timer.playback,
|
||||
selectedEventIndex: state.runtime.selectedEventIndex,
|
||||
numEvents: state.runtime.numEvents,
|
||||
selectedEventIndex: state.rundown.selectedEventIndex,
|
||||
numEvents: state.rundown.numEvents,
|
||||
timerPhase: state.timer.phase,
|
||||
}));
|
||||
|
||||
@@ -131,8 +132,8 @@ export const useSelectedEventId = createSelector((state: RuntimeStore) => ({
|
||||
selectedEventId: state.eventNow?.id ?? null,
|
||||
}));
|
||||
|
||||
export const useCurrentBlockId = createSelector((state: RuntimeStore) => ({
|
||||
currentBlockId: state.blockNow?.id ?? null,
|
||||
export const useCurrentGroupId = createSelector((state: RuntimeStore) => ({
|
||||
currentGroupId: state.groupNow?.id ?? null,
|
||||
}));
|
||||
|
||||
export const setEventPlayback = {
|
||||
@@ -151,7 +152,8 @@ export const useClock = createSelector((state: RuntimeStore) => ({
|
||||
}));
|
||||
|
||||
export const useNextFlag = createSelector((state: RuntimeStore) => ({
|
||||
nextFlag: state.nextFlag,
|
||||
id: state.eventFlag?.id ?? null,
|
||||
expectedStart: state.offset.expectedFlagStart,
|
||||
}));
|
||||
|
||||
/** Used by the progress bar components */
|
||||
@@ -162,45 +164,13 @@ export const useProgressData = createSelector((state: RuntimeStore) => ({
|
||||
timeDanger: state.eventNow?.timeDanger ?? null,
|
||||
}));
|
||||
|
||||
export const useRuntimeOverview = createSelector((state: RuntimeStore) => ({
|
||||
plannedStart: state.runtime.plannedStart,
|
||||
actualStart: state.runtime.actualStart,
|
||||
plannedEnd: state.runtime.plannedEnd,
|
||||
expectedEnd: state.runtime.expectedEnd,
|
||||
}));
|
||||
|
||||
export const useRuntimePlaybackOverview = createSelector((state: RuntimeStore) => ({
|
||||
playback: state.timer.playback,
|
||||
export const useExpectedStartData = createSelector((state: RuntimeStore) => ({
|
||||
offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative,
|
||||
mode: state.offset.mode,
|
||||
currentDay: state.rundown.currentDay ?? 0,
|
||||
actualStart: state.rundown.actualStart,
|
||||
plannedStart: state.rundown.plannedStart,
|
||||
clock: state.clock,
|
||||
|
||||
numEvents: state.runtime.numEvents,
|
||||
selectedEventIndex: state.runtime.selectedEventIndex,
|
||||
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
|
||||
|
||||
blockStartedAt: state.blockNow?.startedAt ?? null,
|
||||
blockExpectedEnd: state.blockNow?.expectedEnd ?? null,
|
||||
}));
|
||||
|
||||
export const useTimelineStatus = createSelector((state: RuntimeStore) => ({
|
||||
clock: state.clock,
|
||||
offset: state.runtime.offset,
|
||||
}));
|
||||
|
||||
export const useTimeUntilData = createSelector((state: RuntimeStore) => ({
|
||||
clock: state.clock,
|
||||
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
|
||||
offsetMode: state.runtime.offsetMode,
|
||||
currentDay: state.eventNow?.dayOffset ?? 0,
|
||||
actualStart: state.runtime.actualStart,
|
||||
plannedStart: state.runtime.plannedStart,
|
||||
}));
|
||||
|
||||
export const useCurrentDay = createSelector((state: RuntimeStore) => ({
|
||||
currentDay: state.eventNow?.dayOffset ?? 0,
|
||||
}));
|
||||
|
||||
export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({
|
||||
offset: state.runtime.offset,
|
||||
}));
|
||||
|
||||
export const usePing = createSelector((state: RuntimeStore) => ({
|
||||
@@ -213,7 +183,7 @@ export const useIsOnline = createSelector((state: RuntimeStore) => ({
|
||||
}));
|
||||
|
||||
export const useOffsetMode = createSelector((state: RuntimeStore) => ({
|
||||
offsetMode: state.runtime.offsetMode,
|
||||
offsetMode: state.offset.mode,
|
||||
}));
|
||||
|
||||
export const setOffsetMode = (payload: OffsetMode) => sendSocket('offsetmode', payload);
|
||||
@@ -225,3 +195,88 @@ export const usePlayback = () => {
|
||||
|
||||
return useRuntimeStore(featureSelector);
|
||||
};
|
||||
|
||||
/* ======================= Overview data subscriptions ======================= */
|
||||
|
||||
export const useStartTimesOverview = createSelector((state: RuntimeStore) => ({
|
||||
plannedStart: state.rundown.plannedStart,
|
||||
actualStart: state.rundown.actualStart,
|
||||
plannedEnd: state.rundown.plannedEnd,
|
||||
expectedEnd: state.offset.expectedRundownEnd,
|
||||
}));
|
||||
|
||||
export const useProgressOverview = createSelector((state: RuntimeStore) => ({
|
||||
numEvents: state.rundown.numEvents,
|
||||
selectedEventIndex: state.rundown.selectedEventIndex,
|
||||
}));
|
||||
|
||||
export const useOffsetOverview = createSelector((state: RuntimeStore) => ({
|
||||
offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative,
|
||||
playback: state.timer.playback,
|
||||
}));
|
||||
|
||||
export const useGroupTimerOverView = createSelector((state: RuntimeStore) => ({
|
||||
clock: state.clock,
|
||||
mode: state.offset.mode,
|
||||
groupExpectedEnd: state.offset.expectedGroupEnd,
|
||||
// we can force these numbers to 0 fo this use case to avoid null checks
|
||||
actualGroupStart: state.rundown.actualGroupStart ?? 0,
|
||||
currentDay: state.eventNow?.dayOffset ?? 0,
|
||||
playback: state.timer.playback,
|
||||
}));
|
||||
|
||||
export const useFlagTimerOverView = createSelector((state: RuntimeStore) => ({
|
||||
clock: state.clock,
|
||||
mode: state.offset.mode,
|
||||
// we can force these numbers to 0 fo this use case to avoid null checks
|
||||
actualStart: state.rundown.actualStart ?? 0,
|
||||
plannedStart: state.rundown.plannedStart ?? 0,
|
||||
currentDay: state.eventNow?.dayOffset ?? 0,
|
||||
playback: state.timer.playback,
|
||||
}));
|
||||
|
||||
/* ======================= View specific subscriptions ======================= */
|
||||
|
||||
export const useTimerSocket = createSelector((state: RuntimeStore) => ({
|
||||
eventNext: state.eventNext,
|
||||
eventNow: state.eventNow,
|
||||
message: state.message,
|
||||
time: state.timer,
|
||||
clock: state.clock,
|
||||
timerTypeNow: state.eventNow?.timerType ?? TimerType.CountDown,
|
||||
countToEndNow: state.eventNow?.countToEnd ?? false,
|
||||
auxTimer: {
|
||||
aux1: state.auxtimer1.current,
|
||||
aux2: state.auxtimer2.current,
|
||||
aux3: state.auxtimer3.current,
|
||||
},
|
||||
}));
|
||||
|
||||
export const useCountdownSocket = createSelector((state: RuntimeStore) => ({
|
||||
playback: state.timer.playback,
|
||||
current: state.timer.current,
|
||||
clock: state.clock,
|
||||
}));
|
||||
|
||||
export const useBackstageSocket = createSelector((state: RuntimeStore) => ({
|
||||
eventNext: state.eventNext,
|
||||
eventNow: state.eventNow,
|
||||
rundown: state.rundown,
|
||||
selectedEventId: state.eventNow?.id ?? null,
|
||||
time: state.timer,
|
||||
}));
|
||||
|
||||
export const useStudioClockSocket = createSelector((state: RuntimeStore) => ({
|
||||
clock: state.clock,
|
||||
playback: state.timer.playback,
|
||||
}));
|
||||
|
||||
export const useStudioTimersSocket = createSelector((state: RuntimeStore) => ({
|
||||
eventNext: state.eventNext,
|
||||
eventNow: state.eventNow,
|
||||
message: state.message,
|
||||
time: state.timer,
|
||||
offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative,
|
||||
rundown: state.rundown,
|
||||
expectedRundownEnd: state.offset.expectedRundownEnd,
|
||||
}));
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { MaybeNumber, Playback, TimerPhase, TimerType } from 'ontime-types';
|
||||
|
||||
// first set extends TimerState
|
||||
export type ViewExtendedTimer = {
|
||||
addedTime: number;
|
||||
current: MaybeNumber;
|
||||
duration: MaybeNumber;
|
||||
elapsed: MaybeNumber;
|
||||
expectedFinish: MaybeNumber;
|
||||
finishedAt: MaybeNumber;
|
||||
phase: TimerPhase;
|
||||
playback: Playback;
|
||||
secondaryTimer: MaybeNumber;
|
||||
startedAt: MaybeNumber;
|
||||
|
||||
clock: number;
|
||||
timerType: TimerType;
|
||||
countToEnd: boolean;
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export type Size = 'xs' | 'sm' | 'md' | 'lg';
|
||||
@@ -1,19 +0,0 @@
|
||||
export type OverridableOptions = {
|
||||
keyColour?: string;
|
||||
textColour?: string;
|
||||
textBackground?: string;
|
||||
font?: string;
|
||||
size?: number;
|
||||
justifyContent?: 'start' | 'center' | 'end';
|
||||
alignItems?: 'start' | 'center' | 'end';
|
||||
left?: string;
|
||||
top?: string;
|
||||
hideNav?: boolean;
|
||||
hideOvertime?: boolean;
|
||||
hideMessagesOverlay?: boolean;
|
||||
hideEndMessage?: boolean;
|
||||
language?: string;
|
||||
showProgressBar?: boolean;
|
||||
hideTimerSeconds?: boolean;
|
||||
removeLeadingZeros?: boolean;
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
import { ClientList } from 'ontime-types';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { makeStageKey } from '../utils/localStorage';
|
||||
|
||||
interface ClientStore {
|
||||
name?: string;
|
||||
setName: (newValue: string) => void;
|
||||
@@ -15,7 +17,7 @@ interface ClientStore {
|
||||
setClients: (clients: ClientList) => void;
|
||||
}
|
||||
|
||||
const clientNameKey = 'ontime-client-name';
|
||||
const clientNameKey = makeStageKey('client-name');
|
||||
|
||||
function persistNameInStorage(newValue: string) {
|
||||
localStorage.setItem(clientNameKey, newValue);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { create } from 'zustand';
|
||||
import { booleanFromLocalStorage } from '../utils/localStorage';
|
||||
|
||||
enum LocalEventKeys {
|
||||
Mirror = 'ontime-view-mirror',
|
||||
Mirror = 'view-mirror',
|
||||
}
|
||||
|
||||
type ViewOptionsStore = {
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { OntimeEntry, ProjectRundowns, Rundown } from 'ontime-types';
|
||||
|
||||
import { aggregateRundowns, makeCSVFromArrayOfArrays } from '../csv';
|
||||
import { makeCSVFromArrayOfArrays } from '../csv';
|
||||
|
||||
describe('makeCSVFromArrayOfArrays()', () => {
|
||||
it('joins an array of arrays with commas and newlines', () => {
|
||||
@@ -13,34 +11,3 @@ after newline,after comma
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregateRundowns()', () => {
|
||||
it('flattens an object of rundowns into a single array', () => {
|
||||
const rundowns = {
|
||||
first: {
|
||||
id: '',
|
||||
title: '',
|
||||
revision: 0,
|
||||
order: ['1', '2'],
|
||||
flatOrder: ['1', '2'],
|
||||
entries: {
|
||||
'1': { id: '1' } as OntimeEntry,
|
||||
'2': { id: '2' } as OntimeEntry,
|
||||
},
|
||||
},
|
||||
second: {
|
||||
id: '',
|
||||
title: '',
|
||||
revision: 0,
|
||||
order: ['3', '4'],
|
||||
flatOrder: ['3', '4'],
|
||||
entries: {
|
||||
'3': { id: '3' } as OntimeEntry,
|
||||
'4': { id: '4' } as OntimeEntry,
|
||||
},
|
||||
} as Rundown,
|
||||
} as ProjectRundowns;
|
||||
|
||||
expect(aggregateRundowns(rundowns)).toStrictEqual([{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import { OntimeDelay, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types';
|
||||
|
||||
import { initRundownMetadata } from '../rundownMetadata';
|
||||
|
||||
describe('initRundownMetadata()', () => {
|
||||
it('processes nested rundown data', () => {
|
||||
const selectedEventId = '12';
|
||||
const demoEvents = {
|
||||
'1': {
|
||||
id: '1',
|
||||
type: SupportedEntry.Event,
|
||||
parent: null,
|
||||
timeStart: 0,
|
||||
timeEnd: 1,
|
||||
duration: 1,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
skip: false,
|
||||
linkStart: false,
|
||||
} as OntimeEvent,
|
||||
group: {
|
||||
id: 'group',
|
||||
type: SupportedEntry.Group,
|
||||
entries: ['11', 'delay', '12', '13'],
|
||||
colour: 'red',
|
||||
} as OntimeGroup,
|
||||
'11': {
|
||||
id: '11',
|
||||
type: SupportedEntry.Event,
|
||||
parent: 'group',
|
||||
timeStart: 10,
|
||||
timeEnd: 11,
|
||||
duration: 1,
|
||||
dayOffset: 0,
|
||||
gap: 10,
|
||||
skip: false,
|
||||
linkStart: false,
|
||||
} as OntimeEvent,
|
||||
delay: {
|
||||
id: 'delay',
|
||||
type: SupportedEntry.Delay,
|
||||
parent: 'group',
|
||||
duration: 0,
|
||||
} as OntimeDelay,
|
||||
'12': {
|
||||
id: '12',
|
||||
type: SupportedEntry.Event,
|
||||
parent: 'group',
|
||||
timeStart: 11,
|
||||
timeEnd: 12,
|
||||
duration: 1,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
skip: false,
|
||||
linkStart: true,
|
||||
} as OntimeEvent,
|
||||
'13': {
|
||||
id: '13',
|
||||
type: SupportedEntry.Event,
|
||||
parent: 'group',
|
||||
timeStart: 12,
|
||||
timeEnd: 13,
|
||||
duration: 1,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
skip: false,
|
||||
linkStart: true,
|
||||
} as OntimeEvent,
|
||||
'2': {
|
||||
id: '2',
|
||||
type: SupportedEntry.Event,
|
||||
parent: null,
|
||||
timeStart: 20,
|
||||
timeEnd: 21,
|
||||
duration: 1,
|
||||
dayOffset: 0,
|
||||
gap: 7,
|
||||
skip: false,
|
||||
linkStart: false,
|
||||
} as OntimeEvent,
|
||||
};
|
||||
|
||||
const { metadata, process } = initRundownMetadata(selectedEventId);
|
||||
|
||||
expect(metadata).toStrictEqual({
|
||||
previousEvent: null,
|
||||
latestEvent: null,
|
||||
previousEntryId: null,
|
||||
thisId: null,
|
||||
eventIndex: 0,
|
||||
isPast: true,
|
||||
isNextDay: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: null,
|
||||
groupColour: undefined,
|
||||
groupEntries: undefined,
|
||||
isFirstAfterGroup: false,
|
||||
});
|
||||
|
||||
expect(process(demoEvents['1'])).toStrictEqual({
|
||||
previousEvent: null,
|
||||
latestEvent: demoEvents['1'],
|
||||
previousEntryId: null,
|
||||
thisId: demoEvents['1'].id,
|
||||
eventIndex: 1, // UI indexes are 1 based
|
||||
isPast: true,
|
||||
isNextDay: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: null,
|
||||
groupColour: undefined,
|
||||
groupEntries: undefined,
|
||||
isFirstAfterGroup: false,
|
||||
});
|
||||
|
||||
expect(process(demoEvents['group'])).toMatchObject({
|
||||
previousEvent: demoEvents['1'],
|
||||
latestEvent: demoEvents['1'],
|
||||
previousEntryId: demoEvents['1'].id,
|
||||
thisId: demoEvents['group'].id,
|
||||
eventIndex: 1,
|
||||
isPast: true,
|
||||
isNextDay: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: 'group',
|
||||
groupColour: 'red',
|
||||
isFirstAfterGroup: false,
|
||||
});
|
||||
|
||||
expect(process(demoEvents['11'])).toMatchObject({
|
||||
previousEvent: demoEvents['1'],
|
||||
latestEvent: demoEvents['11'],
|
||||
previousEntryId: demoEvents['group'].id,
|
||||
thisId: demoEvents['11'].id,
|
||||
eventIndex: 2,
|
||||
isPast: true,
|
||||
isNextDay: false,
|
||||
totalGap: 10,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: 'group',
|
||||
groupColour: 'red',
|
||||
isFirstAfterGroup: false,
|
||||
});
|
||||
|
||||
expect(process(demoEvents['delay'])).toMatchObject({
|
||||
previousEvent: demoEvents['11'],
|
||||
latestEvent: demoEvents['11'],
|
||||
previousEntryId: demoEvents['11'].id,
|
||||
thisId: demoEvents['delay'].id,
|
||||
eventIndex: 2,
|
||||
isPast: true,
|
||||
isNextDay: false,
|
||||
totalGap: 10,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: 'group',
|
||||
groupColour: 'red',
|
||||
isFirstAfterGroup: false,
|
||||
});
|
||||
|
||||
expect(process(demoEvents['12'])).toMatchObject({
|
||||
previousEvent: demoEvents['11'],
|
||||
latestEvent: demoEvents['12'],
|
||||
previousEntryId: demoEvents['delay'].id,
|
||||
thisId: demoEvents['12'].id,
|
||||
eventIndex: 3,
|
||||
isPast: false,
|
||||
isNextDay: false,
|
||||
totalGap: 10,
|
||||
isLinkedToLoaded: true,
|
||||
isLoaded: true,
|
||||
groupId: 'group',
|
||||
groupColour: 'red',
|
||||
isFirstAfterGroup: false,
|
||||
});
|
||||
|
||||
expect(process(demoEvents['13'])).toMatchObject({
|
||||
previousEvent: demoEvents['12'],
|
||||
latestEvent: demoEvents['13'],
|
||||
previousEntryId: demoEvents['12'].id,
|
||||
thisId: demoEvents['13'].id,
|
||||
eventIndex: 4,
|
||||
isPast: false,
|
||||
isNextDay: false,
|
||||
totalGap: 10,
|
||||
isLinkedToLoaded: true,
|
||||
isLoaded: false,
|
||||
groupId: 'group',
|
||||
groupColour: 'red',
|
||||
isFirstAfterGroup: false,
|
||||
});
|
||||
|
||||
expect(process(demoEvents['2'])).toMatchObject({
|
||||
previousEvent: demoEvents['13'],
|
||||
latestEvent: demoEvents['2'],
|
||||
previousEntryId: demoEvents['13'].id,
|
||||
thisId: demoEvents['2'].id,
|
||||
eventIndex: 5,
|
||||
isPast: false,
|
||||
isNextDay: false,
|
||||
totalGap: 17,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: null,
|
||||
groupColour: undefined,
|
||||
isFirstAfterGroup: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('populates previousEntries in groups', () => {
|
||||
const rundownStartsWithGroup = {
|
||||
group: {
|
||||
id: 'group',
|
||||
type: SupportedEntry.Group,
|
||||
colour: 'red',
|
||||
entries: ['1', '2'],
|
||||
} as OntimeGroup,
|
||||
'1': {
|
||||
id: '1',
|
||||
type: SupportedEntry.Event,
|
||||
parent: 'group',
|
||||
timeStart: 1,
|
||||
timeEnd: 2,
|
||||
duration: 1,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
skip: false,
|
||||
linkStart: false,
|
||||
} as OntimeEvent,
|
||||
'2': {
|
||||
id: '2',
|
||||
type: SupportedEntry.Event,
|
||||
parent: 'group',
|
||||
timeStart: 2,
|
||||
timeEnd: 3,
|
||||
duration: 1,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
skip: false,
|
||||
linkStart: false,
|
||||
} as OntimeEvent,
|
||||
};
|
||||
const { process } = initRundownMetadata(null);
|
||||
|
||||
expect(process(rundownStartsWithGroup.group)).toStrictEqual({
|
||||
previousEvent: null,
|
||||
latestEvent: null,
|
||||
previousEntryId: null,
|
||||
thisId: rundownStartsWithGroup.group.id,
|
||||
eventIndex: 0,
|
||||
isPast: false,
|
||||
isNextDay: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: rundownStartsWithGroup.group.id,
|
||||
groupColour: 'red',
|
||||
groupEntries: 2,
|
||||
isFirstAfterGroup: false,
|
||||
});
|
||||
|
||||
expect(process(rundownStartsWithGroup['1'])).toStrictEqual({
|
||||
previousEvent: null,
|
||||
latestEvent: rundownStartsWithGroup['1'],
|
||||
previousEntryId: rundownStartsWithGroup.group.id,
|
||||
thisId: rundownStartsWithGroup['1'].id,
|
||||
eventIndex: 1,
|
||||
isPast: false,
|
||||
isNextDay: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: rundownStartsWithGroup.group.id,
|
||||
groupColour: 'red',
|
||||
groupEntries: 2,
|
||||
isFirstAfterGroup: false,
|
||||
});
|
||||
|
||||
expect(process(rundownStartsWithGroup['2'])).toStrictEqual({
|
||||
previousEvent: rundownStartsWithGroup['1'],
|
||||
latestEvent: rundownStartsWithGroup['2'],
|
||||
previousEntryId: rundownStartsWithGroup['1'].id,
|
||||
thisId: rundownStartsWithGroup['2'].id,
|
||||
eventIndex: 2,
|
||||
isPast: false,
|
||||
isNextDay: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: rundownStartsWithGroup.group.id,
|
||||
groupColour: 'red',
|
||||
groupEntries: 2,
|
||||
isFirstAfterGroup: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,14 @@
|
||||
import { resolvePath } from 'react-router-dom';
|
||||
import { Path, resolvePath } from 'react-router';
|
||||
import { OntimeView, URLPreset } from 'ontime-types';
|
||||
|
||||
import { arePathsEquivalent, generatePathFromPreset, getRouteFromPreset, validateUrlPresetPath } from '../urlPresets';
|
||||
import {
|
||||
arePathsEquivalent,
|
||||
generatePathFromPreset,
|
||||
generateUrlPresetOptions,
|
||||
getCurrentPath,
|
||||
getRouteFromPreset,
|
||||
validateUrlPresetPath,
|
||||
} from '../urlPresets';
|
||||
|
||||
describe('validateUrlPresetPaths()', () => {
|
||||
test.each([
|
||||
@@ -23,11 +31,13 @@ describe('validateUrlPresetPaths()', () => {
|
||||
});
|
||||
|
||||
describe('getRouteFromPreset()', () => {
|
||||
const presets = [
|
||||
const presets: URLPreset[] = [
|
||||
{
|
||||
enabled: true,
|
||||
alias: 'demopage',
|
||||
pathAndParams: '/timer?user=guest',
|
||||
target: OntimeView.Timer,
|
||||
search: 'user=guest',
|
||||
options: {},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -63,28 +73,28 @@ describe('getRouteFromPreset()', () => {
|
||||
|
||||
describe('handle url sharing edge cases', () => {
|
||||
it('finds the correct preset when the url contains extra arguments', () => {
|
||||
const location = resolvePath('/demopage?locked=true&token=123');
|
||||
const location = resolvePath('/demopage?n=1&token=123');
|
||||
expect(getRouteFromPreset(location, presets)?.startsWith('timer?user=guest&alias=demopage')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('appends the feature params to the alias', () => {
|
||||
const location = resolvePath('/demopage?locked=true&token=123');
|
||||
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&locked=true&token=123');
|
||||
const location = resolvePath('/demopage?n=1&token=123');
|
||||
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&n=1&token=123');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generatePathFromPreset()', () => {
|
||||
test.each([
|
||||
['timer?user=guest', 'demopage', 'timer?user=guest&alias=demopage'],
|
||||
['timer?user=admin', 'demopage', 'timer?user=admin&alias=demopage'],
|
||||
])('generates a path from a preset: %s', (path, alias, expected) => {
|
||||
expect(generatePathFromPreset(path, alias, null, null)).toEqual(expected);
|
||||
['timer', 'user=guest', 'demopage', false, 'timer?user=guest&alias=demopage'],
|
||||
['timer', 'user=admin', 'demopage', false, 'timer?user=admin&alias=demopage'],
|
||||
])('generates a path from a preset: %s', (target, search, alias, locked, expected) => {
|
||||
expect(generatePathFromPreset(target, search, alias, locked, null)).toEqual(expected);
|
||||
});
|
||||
|
||||
test('appends the feature params to the alias', () => {
|
||||
expect(generatePathFromPreset('timer?user=guest', 'demopage', 'true', '123')).toBe(
|
||||
'timer?user=guest&alias=demopage&locked=true&token=123',
|
||||
expect(generatePathFromPreset('timer', 'user=guest', 'demopage', true, '123')).toBe(
|
||||
'timer?user=guest&alias=demopage&n=1&token=123',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -101,8 +111,84 @@ describe('arePathsEquivalent()', () => {
|
||||
expect(arePathsEquivalent('timer?test=a', 'timer?test=a')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('checks whether we are in a locked preset', () => {
|
||||
expect(arePathsEquivalent('preset/minimal', 'preset/minimal?test=b')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('considers edge cases for the url sharing feature', () => {
|
||||
expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=b')).toBeFalsy();
|
||||
expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=a')).toBeTruthy();
|
||||
expect(arePathsEquivalent('timer?test=a&n=1=token=123', 'timer?test=b')).toBeFalsy();
|
||||
expect(arePathsEquivalent('timer?test=a&n=1=token=123', 'timer?test=a')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateUrlPresetOptions', () => {
|
||||
it.each([
|
||||
[
|
||||
'cloud URL without protocol',
|
||||
'test',
|
||||
'www.getontime.no/timer?param1=value1¶m2=value2',
|
||||
{
|
||||
alias: 'test',
|
||||
target: 'timer',
|
||||
search: 'param1=value1¶m2=value2',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
'cloud URL',
|
||||
'test',
|
||||
'https://cloud.getontime.no/timer?param1=value1¶m2=value2',
|
||||
{
|
||||
alias: 'test',
|
||||
target: 'timer',
|
||||
search: 'param1=value1¶m2=value2',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
'local URL',
|
||||
'test',
|
||||
'http://localhost:4001/timer?param1=value1¶m2=value2',
|
||||
{
|
||||
alias: 'test',
|
||||
target: 'timer',
|
||||
search: 'param1=value1¶m2=value2',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
'IP-based URL',
|
||||
'test',
|
||||
'http://192.168.0.1:4001/timer?param1=value1¶m2=value2',
|
||||
{
|
||||
alias: 'test',
|
||||
target: 'timer',
|
||||
search: 'param1=value1¶m2=value2',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
])('should generate URL preset options for %s', (_description, alias, url, expected) => {
|
||||
expect(generateUrlPresetOptions(alias, url)).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('throws on invalid URL', () => {
|
||||
expect(() => generateUrlPresetOptions('test', 'invalid-url')).toThrow();
|
||||
});
|
||||
|
||||
it('throws on on invalid route', () => {
|
||||
expect(() => generateUrlPresetOptions('test', 'www.getontime.no/somethingelse/')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCurrentPath()', () => {
|
||||
test.each([
|
||||
[resolvePath('http://localhost:4001/timer'), 'timer'],
|
||||
[resolvePath('http://192.168.0.1:654321/minimal'), 'minimal'],
|
||||
[resolvePath('https://user-hosted.io/cuesheet'), 'cuesheet'],
|
||||
[resolvePath('https://cloud.getontime.no/team-hash/op'), 'op'],
|
||||
[resolvePath('https://cloud.getontime.no/team-hash/backstage/?params-with-slash=true'), 'backstage'],
|
||||
[resolvePath('https://cloud.getontime.no/team-hash/timeline?params-are-ignored=true'), 'timeline'],
|
||||
])('resolves the current: %s', (location, expected) => {
|
||||
expect(getCurrentPath(location as Path)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { stringify } from 'csv-stringify/browser/esm/sync';
|
||||
import { OntimeEntry, ProjectRundowns } from 'ontime-types';
|
||||
|
||||
/**
|
||||
* Converts an array of arrays to a CSV file
|
||||
@@ -7,25 +6,3 @@ import { OntimeEntry, ProjectRundowns } from 'ontime-types';
|
||||
export function makeCSVFromArrayOfArrays(arrayOfArrays: string[][]): string {
|
||||
return stringify(arrayOfArrays);
|
||||
}
|
||||
|
||||
/**
|
||||
* Receives an object of rundowns, and flattens them into a single, linear rundown
|
||||
* Used for CSV export
|
||||
*/
|
||||
export function aggregateRundowns(rundowns: ProjectRundowns): OntimeEntry[] {
|
||||
const rundownKeys = Object.keys(rundowns);
|
||||
if (rundownKeys.length === 0) return [];
|
||||
const flatRundown: OntimeEntry[] = [];
|
||||
|
||||
for (const key of rundownKeys) {
|
||||
const { order, entries } = rundowns[key];
|
||||
|
||||
for (let i = 0; i < order.length; i++) {
|
||||
const entryId = order[i];
|
||||
const entry = entries[entryId];
|
||||
|
||||
flatRundown.push(entry);
|
||||
}
|
||||
}
|
||||
return flatRundown;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { baseURI } from '../../externals';
|
||||
|
||||
export function booleanFromLocalStorage(key: string, fallback: boolean): boolean {
|
||||
const valueInStorage = localStorage.getItem(key);
|
||||
if (valueInStorage) {
|
||||
@@ -7,3 +9,8 @@ export function booleanFromLocalStorage(key: string, fallback: boolean): boolean
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function makeStageKey(key: string) {
|
||||
if (baseURI) return `${baseURI}-${key}`;
|
||||
return key;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,8 @@ export function getOffsetText(offset: MaybeNumber): string {
|
||||
}
|
||||
|
||||
export function getOffsetState(offset: MaybeNumber): 'over' | 'under' | 'muted' | null {
|
||||
if (offset === null) return null;
|
||||
if (offset === 0) return 'muted';
|
||||
return offset < 0 ? 'over' : 'under';
|
||||
if (offset === null) return 'muted';
|
||||
if (offset === 0) return null;
|
||||
// a positive value means that we are in over time aka behind schedule
|
||||
return offset > 0 ? 'over' : 'under';
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user