mirror of
https://github.com/cpvalente/ontime.git
synced 2026-07-26 10:38:55 +00:00
Compare commits
153 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 20859abda9 | |||
| 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
|
||||
|
||||
+22
-9
@@ -21,7 +21,7 @@ Locally, we would need to run both the React client and the node.js server in de
|
||||
|
||||
From the project root, run the following commands
|
||||
- __Install the project dependencies__ by running `pnpm i`
|
||||
- __Run dev mode__ by running `pnpm turbo dev`
|
||||
- __Run dev mode__ by running `pnpm dev` or `pnpm dev:electron` to get the electron window
|
||||
|
||||
|
||||
### Debugging backend
|
||||
@@ -30,10 +30,10 @@ The previous command will start the development servers for both the client, ser
|
||||
Typically in dev mode we prefer to start these in separate terminals to help with error tracking and debugging.
|
||||
|
||||
We do that by creating two terminals an running
|
||||
- __Run the React UI__ by running `pnpm turbo dev --filter=ontime-ui`
|
||||
- __Run the nodejs server__ by running `pnpm turbo dev --filter=ontime-server`
|
||||
- __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 turbo dev:inspect --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
|
||||
|
||||
@@ -46,7 +46,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 `pnpm turbo 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
|
||||
@@ -66,7 +66,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)
|
||||
@@ -77,13 +77,13 @@ 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 `pnpm turbo run build:electron`
|
||||
- __Create the package__ by running `pnpm turbo run dist-win`, `pnpm turbo run dist-mac` or `pnpm 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 `pnpm turbo run dist-mac:local` command to build a MacOS distribution locally and skip the notary process.
|
||||
Use the `pnpm dist-mac:local` command to build a MacOS distribution locally and skip the notary process.
|
||||
|
||||
## DOCKER
|
||||
|
||||
@@ -99,3 +99,16 @@ Other useful commands
|
||||
|
||||
- __List running processes__ by running `docker ps`
|
||||
- __Kill running process__ by running `docker kill <process-id>`
|
||||
|
||||
## CONTRIBUTION GUIDELINES
|
||||
|
||||
If you want to propose changes to the codebase, please reach out before opening a Pull Request.
|
||||
|
||||
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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "4.0.0-alpha.5",
|
||||
"version": "4.0.0-beta.5",
|
||||
"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>
|
||||
|
||||
+15
-18
@@ -1,23 +1,23 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "4.0.0-alpha.5",
|
||||
"version": "4.0.0-beta.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@base-ui-components/react": "1.0.0-beta.2",
|
||||
"@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",
|
||||
"@fontsource/open-sans": "^5.0.28",
|
||||
"@mantine/hooks": "^8.2.4",
|
||||
"@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.84.1",
|
||||
"@tanstack/react-query-devtools": "^5.84.1",
|
||||
"@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.11.0",
|
||||
"babel-plugin-react-compiler": "19.1.0-rc.2",
|
||||
"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",
|
||||
@@ -27,25 +27,22 @@
|
||||
"react-hook-form": "^7.62.0",
|
||||
"react-icons": "5.5.0",
|
||||
"react-qr-code": "^2.0.18",
|
||||
"react-router": "^7.8.0",
|
||||
"react-router": "^7.8.2",
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"react-virtuoso": "^4.14.0",
|
||||
"web-vitals": "^5.1.0",
|
||||
"zustand": "^5.0.7"
|
||||
"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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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,7 +51,7 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
|
||||
label: view.label,
|
||||
})),
|
||||
...enabledPresets.map((preset) => ({
|
||||
value: preset.search,
|
||||
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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { memo } from 'react';
|
||||
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
|
||||
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 (
|
||||
@@ -62,6 +65,13 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
|
||||
<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} />
|
||||
|
||||
@@ -11,9 +11,11 @@
|
||||
}
|
||||
|
||||
.pin {
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
|
||||
input {
|
||||
font-size: 4rem;
|
||||
|
||||
@@ -62,8 +62,6 @@
|
||||
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'] {
|
||||
@@ -73,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>
|
||||
|
||||
@@ -39,7 +39,14 @@ export default function ParamInput({ paramField }: ParamInputProps) {
|
||||
}
|
||||
|
||||
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') {
|
||||
@@ -74,19 +81,17 @@ export default function ParamInput({ paramField }: ParamInputProps) {
|
||||
|
||||
interface EditFormMultiOptionProps {
|
||||
paramField: ParamField & { type: 'multi-option' };
|
||||
options: string[];
|
||||
}
|
||||
|
||||
function MultiOption({ paramField }: EditFormMultiOptionProps) {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { id, values, defaultValue = [''] } = paramField;
|
||||
|
||||
const optionFromParams = searchParams.getAll(id);
|
||||
const [paramState, setParamState] = useState<string[]>(optionFromParams.length ? optionFromParams : defaultValue);
|
||||
function MultiOption({ paramField, options }: EditFormMultiOptionProps) {
|
||||
const { id, values } = paramField;
|
||||
const [paramState, setParamState] = useState<string[]>(options);
|
||||
|
||||
// synchronise options
|
||||
useEffect(() => {
|
||||
const params = searchParams.getAll(id);
|
||||
setParamState(params.length ? params : defaultValue);
|
||||
}, [searchParams, id, defaultValue]);
|
||||
setParamState(options);
|
||||
}, [options]);
|
||||
|
||||
const toggleValue = (value: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
bottom: 0;
|
||||
|
||||
width: 40rem;
|
||||
max-width: 100vw;
|
||||
height: 100vh;
|
||||
|
||||
display: flex;
|
||||
|
||||
@@ -56,13 +56,18 @@ export function makeCustomFieldSelectOptions(customFields: CustomFields, filterI
|
||||
/**
|
||||
* Creates data for a select element that displays project custom data
|
||||
*/
|
||||
export function makeProjectDataOptions(projectData: ProjectData): SelectOption[] {
|
||||
return projectData.custom.map((entry, index) => {
|
||||
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 };
|
||||
|
||||
@@ -6,7 +6,7 @@ import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { RUNDOWN } from '../api/constants';
|
||||
import { fetchCurrentRundown } from '../api/rundown';
|
||||
import { useSelectedEventId } from '../hooks/useSocket';
|
||||
import { getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
|
||||
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
|
||||
|
||||
import useProjectData from './useProjectData';
|
||||
|
||||
@@ -54,8 +54,8 @@ 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);
|
||||
}
|
||||
@@ -85,8 +85,8 @@ export function useFlatRundownWithMetadata() {
|
||||
/**
|
||||
* 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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,7 +10,9 @@ 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),
|
||||
};
|
||||
|
||||
@@ -162,46 +164,15 @@ export const useProgressData = createSelector((state: RuntimeStore) => ({
|
||||
timeDanger: state.eventNow?.timeDanger ?? null,
|
||||
}));
|
||||
|
||||
export const useRundownOverview = createSelector((state: RuntimeStore) => ({
|
||||
plannedStart: state.rundown.plannedStart,
|
||||
actualStart: state.rundown.actualStart,
|
||||
plannedEnd: state.rundown.plannedEnd,
|
||||
expectedEnd: state.offset.expectedRundownEnd,
|
||||
}));
|
||||
|
||||
export const useRuntimePlaybackOverview = createSelector((state: RuntimeStore) => ({
|
||||
playback: state.timer.playback,
|
||||
clock: state.clock,
|
||||
|
||||
numEvents: state.rundown.numEvents,
|
||||
selectedEventIndex: state.rundown.selectedEventIndex,
|
||||
offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative,
|
||||
|
||||
groupExpectedEnd: state.offset.expectedGroupEnd,
|
||||
}));
|
||||
|
||||
export const useTimelineStatus = createSelector((state: RuntimeStore) => ({
|
||||
clock: state.clock,
|
||||
offset: state.offset.absolute,
|
||||
}));
|
||||
|
||||
export const useExpectedStartData = createSelector((state: RuntimeStore) => ({
|
||||
offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative,
|
||||
mode: state.offset.mode,
|
||||
currentDay: state.eventNow?.dayOffset ?? 0,
|
||||
currentDay: state.rundown.currentDay ?? 0,
|
||||
actualStart: state.rundown.actualStart,
|
||||
plannedStart: state.rundown.plannedStart,
|
||||
clock: state.clock,
|
||||
}));
|
||||
|
||||
export const useCurrentDay = createSelector((state: RuntimeStore) => ({
|
||||
currentDay: state.eventNow?.dayOffset ?? 0,
|
||||
}));
|
||||
|
||||
export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({
|
||||
offset: state.offset.absolute,
|
||||
}));
|
||||
|
||||
export const usePing = createSelector((state: RuntimeStore) => ({
|
||||
ping: state.ping,
|
||||
}));
|
||||
@@ -225,6 +196,45 @@ 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) => ({
|
||||
@@ -266,11 +276,7 @@ export const useStudioTimersSocket = createSelector((state: RuntimeStore) => ({
|
||||
eventNow: state.eventNow,
|
||||
message: state.message,
|
||||
time: state.timer,
|
||||
offset: state.offset,
|
||||
offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative,
|
||||
rundown: state.rundown,
|
||||
}));
|
||||
|
||||
export const useTimelineSocket = createSelector((state: RuntimeStore) => ({
|
||||
clock: state.clock,
|
||||
offset: state.offset.absolute,
|
||||
expectedRundownEnd: state.offset.expectedRundownEnd,
|
||||
}));
|
||||
|
||||
@@ -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,89 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
type Target = 'cuesheet' | 'timer' | 'clock' | 'countdown' | 'backstage' | 'studio';
|
||||
|
||||
interface SelectionState {
|
||||
[key: string]: boolean;
|
||||
}
|
||||
|
||||
interface ColumnPermissions {
|
||||
read: string[];
|
||||
write: string[];
|
||||
}
|
||||
|
||||
interface CuesheetLinksState {
|
||||
target: Target | null;
|
||||
readSelected: SelectionState;
|
||||
writeSelected: SelectionState;
|
||||
setTarget: (target: Target | null) => void;
|
||||
setField: (field: 'read' | 'write', key: string, value: boolean) => void;
|
||||
toggleField: (field: 'read' | 'write', key: string) => void;
|
||||
selectAll: (field: 'read' | 'write', keys: string[]) => void;
|
||||
clearAll: (field: 'read' | 'write', keys: string[]) => void;
|
||||
// Returns arrays of column keys that have read/write permissions if target is 'cuesheet'
|
||||
getSelections: () => ColumnPermissions | null;
|
||||
}
|
||||
|
||||
export const useCuesheetLinksStore = create<CuesheetLinksState>((set, get) => ({
|
||||
target: null,
|
||||
readSelected: {},
|
||||
writeSelected: {},
|
||||
setTarget: (target) => set({ target }),
|
||||
setField: (field, key, value) =>
|
||||
set((state) => ({
|
||||
...(field === 'read'
|
||||
? { readSelected: { ...state.readSelected, [key]: value } }
|
||||
: { writeSelected: { ...state.writeSelected, [key]: value } }),
|
||||
})),
|
||||
toggleField: (field, key) =>
|
||||
set((state) => ({
|
||||
...(field === 'read'
|
||||
? { readSelected: { ...state.readSelected, [key]: !state.readSelected[key] } }
|
||||
: { writeSelected: { ...state.writeSelected, [key]: !state.writeSelected[key] } }),
|
||||
})),
|
||||
selectAll: (field, keys) =>
|
||||
set((_state) => ({
|
||||
...(field === 'read'
|
||||
? {
|
||||
readSelected: keys.reduce((acc, key) => {
|
||||
acc[key] = true;
|
||||
return acc;
|
||||
}, {} as SelectionState),
|
||||
}
|
||||
: {
|
||||
writeSelected: keys.reduce((acc, key) => {
|
||||
acc[key] = true;
|
||||
return acc;
|
||||
}, {} as SelectionState),
|
||||
}),
|
||||
})),
|
||||
clearAll: (field, keys) =>
|
||||
set((_state) => ({
|
||||
...(field === 'read'
|
||||
? {
|
||||
readSelected: keys.reduce((acc, key) => {
|
||||
acc[key] = false;
|
||||
return acc;
|
||||
}, {} as SelectionState),
|
||||
}
|
||||
: {
|
||||
writeSelected: keys.reduce((acc, key) => {
|
||||
acc[key] = false;
|
||||
return acc;
|
||||
}, {} as SelectionState),
|
||||
}),
|
||||
})),
|
||||
getSelections: () => {
|
||||
const state = get();
|
||||
if (state.target !== 'cuesheet') return null;
|
||||
|
||||
return {
|
||||
read: Object.entries(state.readSelected)
|
||||
.filter(([_, selected]) => selected)
|
||||
.map(([key]) => key),
|
||||
write: Object.entries(state.writeSelected)
|
||||
.filter(([_, selected]) => selected)
|
||||
.map(([key]) => key),
|
||||
};
|
||||
},
|
||||
}));
|
||||
@@ -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' }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -173,7 +173,7 @@ describe('initRundownMetadata()', () => {
|
||||
isPast: false,
|
||||
isNextDay: false,
|
||||
totalGap: 10,
|
||||
isLinkedToLoaded: false,
|
||||
isLinkedToLoaded: true,
|
||||
isLoaded: true,
|
||||
groupId: 'group',
|
||||
groupColour: 'red',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -125,6 +125,7 @@ function processEntry(
|
||||
if (entry.id === selectedEventId) {
|
||||
processedData.isLoaded = true;
|
||||
processedData.isPast = false;
|
||||
processedData.isLinkedToLoaded = true;
|
||||
}
|
||||
|
||||
if (isOntimeGroup(entry)) {
|
||||
@@ -158,7 +159,8 @@ function processEntry(
|
||||
* a) find an unlinked event
|
||||
* b) find a countToEnd event
|
||||
*/
|
||||
processedData.isLinkedToLoaded = entry.linkStart && !processedData.previousEvent?.countToEnd;
|
||||
processedData.isLinkedToLoaded =
|
||||
entry.linkStart && !processedData.previousEvent?.countToEnd && processedData.isLinkedToLoaded;
|
||||
}
|
||||
|
||||
if (isNewLatest(entry, processedData.latestEvent)) {
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { MaybeString } from 'ontime-types';
|
||||
|
||||
export default function safeParseNumber(value: MaybeString, defaultValue: number = 0): number {
|
||||
if (!value) return defaultValue;
|
||||
const number = Number(value);
|
||||
if (isNaN(number)) return defaultValue;
|
||||
return number;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
ApiAction,
|
||||
ApiActionTag,
|
||||
Log,
|
||||
MessageTag,
|
||||
RefetchKey,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
|
||||
import { isProduction, websocketUrl } from '../../externals';
|
||||
import {
|
||||
APP_SETTINGS,
|
||||
CLIENT_LIST,
|
||||
CUSTOM_FIELDS,
|
||||
PROJECT_DATA,
|
||||
@@ -177,6 +178,9 @@ export const connectSocket = () => {
|
||||
case RefetchKey.Translation:
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: TRANSLATION });
|
||||
break;
|
||||
case RefetchKey.Settings:
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: APP_SETTINGS });
|
||||
break;
|
||||
default: {
|
||||
target satisfies never;
|
||||
break;
|
||||
@@ -195,7 +199,7 @@ export const connectSocket = () => {
|
||||
};
|
||||
};
|
||||
|
||||
export function sendSocket<T extends MessageTag | ApiAction>(
|
||||
export function sendSocket<T extends MessageTag | ApiActionTag>(
|
||||
tag: T,
|
||||
payload: T extends MessageTag ? Pick<WsPacketToServer & { tag: T }, 'payload'>['payload'] : unknown,
|
||||
): void {
|
||||
|
||||
@@ -12,6 +12,8 @@ import { APP_SETTINGS } from '../api/constants';
|
||||
import { useExpectedStartData } from '../hooks/useSocket';
|
||||
import { ontimeQueryClient } from '../queryClient';
|
||||
|
||||
import { ExtendedEntry } from './rundownMetadata';
|
||||
|
||||
/**
|
||||
* Returns current time in milliseconds from midnight
|
||||
* @returns {number}
|
||||
@@ -154,3 +156,33 @@ export function useTimeUntilExpectedStart(
|
||||
);
|
||||
return expectedStart - clock;
|
||||
}
|
||||
|
||||
export function getExpectedTimesFromExtendedEvent(
|
||||
event: Pick<
|
||||
ExtendedEntry<OntimeEvent>,
|
||||
'timeStart' | 'dayOffset' | 'delay' | 'totalGap' | 'isLinkedToLoaded' | 'countToEnd' | 'duration'
|
||||
> | null,
|
||||
state: ReturnType<typeof useExpectedStartData>,
|
||||
) {
|
||||
if (event === null) return { expectedStart: 0, timeToStart: 0, expectedEnd: 0, plannedEnd: 0 };
|
||||
|
||||
const expectedStart = getExpectedStart(
|
||||
{ timeStart: event.timeStart, delay: event.delay, dayOffset: event.dayOffset },
|
||||
{
|
||||
totalGap: event.totalGap,
|
||||
isLinkedToLoaded: event.isLinkedToLoaded,
|
||||
...state,
|
||||
},
|
||||
);
|
||||
|
||||
const plannedEnd = event.timeStart + event.duration + event.delay;
|
||||
|
||||
return {
|
||||
expectedStart,
|
||||
timeToStart: expectedStart - state.clock,
|
||||
expectedEnd: event.countToEnd
|
||||
? Math.max(expectedStart + event.duration, plannedEnd)
|
||||
: expectedStart + event.duration,
|
||||
plannedEnd,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,8 +33,8 @@ export function validateProjectFile(file: File) {
|
||||
}
|
||||
|
||||
// Limit file size of a project file to around 1MB
|
||||
if (file.size > 1_000_000) {
|
||||
throw new Error('File size limit (1MB) exceeded');
|
||||
if (file.size > 2_000_000) {
|
||||
throw new Error('File size limit (2MB) exceeded');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,8 +56,8 @@ export function validateLogo(file: File) {
|
||||
throw new Error('File is empty');
|
||||
}
|
||||
|
||||
// Limit file size of a project file to around 1MB
|
||||
if (file.size > 1_000_000) {
|
||||
throw new Error('File size limit (1MB) exceeded');
|
||||
// Limit file size of a project file to around 1.5MB
|
||||
if (file.size > 1_500_000) {
|
||||
throw new Error('File size limit (1.5MB) exceeded');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,11 +17,11 @@ export const buyMeACoffeeUrl = 'https://buymeacoffee.com/cpvalente';
|
||||
|
||||
// resolve environment
|
||||
export const appVersion = version;
|
||||
export const isProduction = import.meta.env.MODE === 'production';
|
||||
export const isDev = !isProduction;
|
||||
export const isDocker = import.meta.env.IS_DOCKER; // this env is made available by the vite.config.js define function
|
||||
export const isProduction = import.meta.env.PROD;
|
||||
export const isDev = import.meta.env.DEV;
|
||||
export const currentHostName = window.location.hostname;
|
||||
export const isLocalhost = currentHostName === 'localhost' || currentHostName === '127.0.0.1';
|
||||
export const isDockerImage = Boolean(import.meta.env.VITE_IS_DOCKER);
|
||||
export const isOntimeCloud = currentHostName.includes('cloud.getontime.no');
|
||||
|
||||
// resolve entrypoint URLs
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
/* eslint-disable react/display-name */
|
||||
import { ComponentType, useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router';
|
||||
|
||||
import useUrlPresets from '../common/hooks-query/useUrlPresets';
|
||||
import { getRouteFromPreset } from '../common/utils/urlPresets';
|
||||
|
||||
const withPreset = <P extends object>(Component: ComponentType<P>) => {
|
||||
return (props: Partial<P>) => {
|
||||
const { data } = useUrlPresets();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// navigate if is alias route
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
const destination = getRouteFromPreset(location, data);
|
||||
|
||||
// navigate to this destination if its not null
|
||||
if (destination) {
|
||||
navigate(destination);
|
||||
}
|
||||
}, [data, navigate, location]);
|
||||
|
||||
return <Component {...(props as P)} />;
|
||||
};
|
||||
};
|
||||
|
||||
export default withPreset;
|
||||
@@ -107,16 +107,16 @@ export default function AutomationsList(props: AutomationsListProps) {
|
||||
</IconButton>
|
||||
</Panel.InlineElements>
|
||||
</tr>
|
||||
{deleteError && (
|
||||
<tr>
|
||||
<td colSpan={5}>
|
||||
<Panel.Error>{deleteError}</Panel.Error>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{deleteError && (
|
||||
<tr>
|
||||
<td colSpan={5}>
|
||||
<Panel.Error>{deleteError}</Panel.Error>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
</Panel.Card>
|
||||
|
||||
+6
-2
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
||||
import { CustomFields, Rundown } from 'ontime-types';
|
||||
|
||||
import Button from '../../../../../common/components/buttons/Button';
|
||||
import useRundown from '../../../../../common/hooks-query/useRundown';
|
||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
|
||||
import PreviewSpreadsheet from './preview/PreviewRundown';
|
||||
@@ -17,7 +18,7 @@ interface ImportReviewProps {
|
||||
|
||||
export default function ImportReview(props: ImportReviewProps) {
|
||||
const { rundown, customFields, onFinished, onCancel } = props;
|
||||
|
||||
const { data: currentRundown } = useRundown();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { importRundown } = useGoogleSheet();
|
||||
const resetPreview = useSheetStore((state) => state.resetPreview);
|
||||
@@ -29,9 +30,12 @@ export default function ImportReview(props: ImportReviewProps) {
|
||||
|
||||
const applyImport = async () => {
|
||||
setLoading(true);
|
||||
|
||||
// we need to import on-top of the currently loaded rundown
|
||||
// so the id needs to match
|
||||
await importRundown(
|
||||
{
|
||||
[rundown.id]: rundown,
|
||||
[currentRundown.id]: { ...rundown, id: currentRundown.id, title: currentRundown.title },
|
||||
},
|
||||
customFields,
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { MessageTag } from 'ontime-types';
|
||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||
import { usePing } from '../../../../common/hooks/useSocket';
|
||||
import { sendSocket } from '../../../../common/utils/socket';
|
||||
import { isDockerImage } from '../../../../externals';
|
||||
import { isDocker } from '../../../../externals';
|
||||
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function NetworkLogPanel({ location }: PanelBaseProps) {
|
||||
return (
|
||||
<>
|
||||
<Panel.Header>Network</Panel.Header>
|
||||
{isDockerImage && (
|
||||
{isDocker && (
|
||||
<Panel.Section>
|
||||
<OntimeCloudStats />
|
||||
</Panel.Section>
|
||||
|
||||
+5
-1
@@ -14,4 +14,8 @@
|
||||
.copiable {
|
||||
cursor: text ;
|
||||
user-select: text;
|
||||
}
|
||||
}
|
||||
|
||||
.self {
|
||||
background-color: $blue-1100;
|
||||
}
|
||||
|
||||
+3
-2
@@ -8,6 +8,7 @@ import { RenameClientModal } from '../../../../../common/components/client-modal
|
||||
import Tag from '../../../../../common/components/tag/Tag';
|
||||
import { setClientRemote } from '../../../../../common/hooks/useSocket';
|
||||
import { useClientStore } from '../../../../../common/stores/clientStore';
|
||||
import { cx } from '../../../../../common/utils/styleUtils';
|
||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './ClientControlPanel.module.scss';
|
||||
@@ -71,13 +72,13 @@ export default function ClientList() {
|
||||
const { identify, name, path } = client;
|
||||
const isCurrent = id === key;
|
||||
return (
|
||||
<tr key={key}>
|
||||
<tr key={key} className={cx([isCurrent && style.self])}>
|
||||
<Panel.InlineElements relation='inner' as='td'>
|
||||
{isCurrent && <Tag>SELF</Tag>}
|
||||
{name}
|
||||
</Panel.InlineElements>
|
||||
<td className={style.copiable}>{path}</td>
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<Panel.InlineElements relation='inner' as='td'>
|
||||
<Button
|
||||
size='small'
|
||||
className={`${identify ? style.blink : ''}`}
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
|
||||
import {
|
||||
deleteProject,
|
||||
downloadCSV,
|
||||
downloadProject,
|
||||
duplicateProject,
|
||||
loadProject,
|
||||
@@ -187,10 +186,6 @@ function ActionMenu(props: ActionMenuProps) {
|
||||
await downloadProject(filename);
|
||||
};
|
||||
|
||||
const handleExportCSV = async () => {
|
||||
await downloadCSV(filename);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
render={<IconButton variant='ghosted-white' />}
|
||||
@@ -213,7 +208,6 @@ function ActionMenu(props: ActionMenuProps) {
|
||||
{ type: 'item', icon: IoPencilOutline, label: 'Rename', onClick: handleRename },
|
||||
{ type: 'item', icon: IoCopyOutline, label: 'Duplicate', onClick: handleDuplicate },
|
||||
{ type: 'item', icon: IoDocumentOutline, label: 'Download', onClick: handleDownload },
|
||||
{ type: 'item', icon: IoDocumentOutline, label: 'Export CSV Rundown', onClick: handleExportCSV },
|
||||
{ type: 'divider' },
|
||||
{ type: 'item', icon: IoTrash, label: 'Delete', onClick: () => onDelete(filename), disabled: current },
|
||||
]}
|
||||
|
||||
@@ -27,6 +27,7 @@ export default function ProjectData() {
|
||||
reset,
|
||||
formState: { isSubmitting, isValid, isDirty, errors },
|
||||
setError,
|
||||
clearErrors,
|
||||
watch,
|
||||
control,
|
||||
setValue,
|
||||
@@ -53,6 +54,7 @@ export default function ProjectData() {
|
||||
|
||||
const handleUploadProjectLogo = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
clearErrors('logo');
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
@@ -93,6 +95,7 @@ export default function ProjectData() {
|
||||
|
||||
const onSubmit = async (formData: ProjectData) => {
|
||||
try {
|
||||
clearErrors();
|
||||
await updateProjectData(formData);
|
||||
} catch (error) {
|
||||
const message = maybeAxiosError(error);
|
||||
|
||||
@@ -31,7 +31,7 @@ const staticOptions = [
|
||||
},
|
||||
{
|
||||
id: 'manage',
|
||||
label: 'Project data',
|
||||
label: 'Project settings',
|
||||
secondary: [
|
||||
{ id: 'manage__defaults', label: 'Rundown defaults' },
|
||||
{ id: 'manage__custom', label: 'Custom fields' },
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { use, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { PresetContext } from '../../common/context/PresetContext';
|
||||
|
||||
/** @url https://developer.mozilla.org/en-US/docs/Web/API/WakeLock */
|
||||
export default function KeepAwake() {
|
||||
const { keepAwake } = useKeepAwakeOptions();
|
||||
const [wakeLockSentinel, setWakeLockSentinel] = useState<WakeLockSentinel | null>(null);
|
||||
|
||||
const removeLock = () => {
|
||||
if (wakeLockSentinel) wakeLockSentinel.release().finally(() => setWakeLockSentinel(null));
|
||||
};
|
||||
|
||||
const acquireLock = () => {
|
||||
if (!wakeLockSentinel || wakeLockSentinel.released) {
|
||||
setWakeLockSentinel(null);
|
||||
navigator.wakeLock
|
||||
.request('screen')
|
||||
.then((sentinel) => {
|
||||
setWakeLockSentinel(sentinel);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
if (keepAwake) {
|
||||
acquireLock();
|
||||
document.addEventListener(
|
||||
'visibilitychange',
|
||||
() => {
|
||||
if (wakeLockSentinel !== null && document.visibilityState === 'visible') {
|
||||
acquireLock();
|
||||
}
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
} else {
|
||||
removeLock();
|
||||
}
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
removeLock();
|
||||
};
|
||||
}, [keepAwake]);
|
||||
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const keepAwakeKey = 'keep-awake';
|
||||
|
||||
function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URLSearchParams) {
|
||||
// Helper to get value from either source, prioritizing defaultValues
|
||||
return defaultValues?.has(keepAwakeKey) || searchParams.has(keepAwakeKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook exposes the keep awake options
|
||||
*/
|
||||
export function useKeepAwakeOptions() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const maybePreset = use(PresetContext);
|
||||
|
||||
const keepAwake = useMemo(() => {
|
||||
const defaultValues = maybePreset ? new URLSearchParams(maybePreset.search) : undefined;
|
||||
return getOptionsFromParams(searchParams, defaultValues);
|
||||
}, [maybePreset, searchParams]);
|
||||
|
||||
const toggleKeepAwake = useCallback(() => {
|
||||
setSearchParams((searchParams) => {
|
||||
if (keepAwake) {
|
||||
searchParams.delete(keepAwakeKey);
|
||||
} else {
|
||||
searchParams.set(keepAwakeKey, '1');
|
||||
}
|
||||
return searchParams;
|
||||
});
|
||||
}, [keepAwake]);
|
||||
|
||||
return { keepAwake, toggleKeepAwake };
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { useSelectedEventId } from '../../common/hooks/useSocket';
|
||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
||||
import useProjectData from '../../common/hooks-query/useProjectData';
|
||||
import useRundown from '../../common/hooks-query/useRundown';
|
||||
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
|
||||
import useSettings from '../../common/hooks-query/useSettings';
|
||||
import { cx } from '../../common/utils/styleUtils';
|
||||
import { throttle } from '../../common/utils/throttle';
|
||||
@@ -21,14 +21,14 @@ import OperatorGroup from './operator-group/OperatorGroup';
|
||||
import StatusBar from './status-bar/StatusBar';
|
||||
import { getOperatorOptions, useOperatorOptions } from './operator.options';
|
||||
import type { EditEvent } from './operator.types';
|
||||
import { getEventData, makeOperatorMetadata } from './operator.utils';
|
||||
import { getEventData } from './operator.utils';
|
||||
|
||||
import style from './Operator.module.scss';
|
||||
|
||||
const selectedOffset = 50;
|
||||
|
||||
export default function Operator() {
|
||||
const { data, status } = useRundown();
|
||||
const { data, rundownMetadata, status } = useRundownWithMetadata();
|
||||
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
||||
const { data: projectData, status: projectDataStatus } = useProjectData();
|
||||
|
||||
@@ -49,6 +49,7 @@ export default function Operator() {
|
||||
scrollRef,
|
||||
doFollow: !lockAutoScroll,
|
||||
topOffset: selectedOffset,
|
||||
followTrigger: selectedEventId,
|
||||
});
|
||||
|
||||
useWindowTitle('Operator');
|
||||
@@ -112,7 +113,6 @@ export default function Operator() {
|
||||
}
|
||||
|
||||
const canEdit = shouldEdit && subscribe.length;
|
||||
const { process } = makeOperatorMetadata(selectedEventId);
|
||||
|
||||
return (
|
||||
<div className={style.operatorContainer} data-testid='operator-view'>
|
||||
@@ -129,8 +129,7 @@ export default function Operator() {
|
||||
{data.order.map((entryId) => {
|
||||
const entry = data.entries[entryId];
|
||||
if (isOntimeEvent(entry)) {
|
||||
const { isPast, isSelected, isLinkedToLoaded, totalGap } = process(entry);
|
||||
|
||||
const { isPast, isLinkedToLoaded, isLoaded, totalGap } = rundownMetadata[entryId];
|
||||
// hide past events (if setting) and skipped events
|
||||
if ((hidePast && isPast) || entry.skip) {
|
||||
return null;
|
||||
@@ -157,9 +156,9 @@ export default function Operator() {
|
||||
delay={entry.delay}
|
||||
dayOffset={entry.dayOffset}
|
||||
isLinkedToLoaded={isLinkedToLoaded}
|
||||
isSelected={isSelected}
|
||||
isSelected={isLoaded}
|
||||
isPast={isPast}
|
||||
selectedRef={isSelected ? selectedRef : undefined}
|
||||
selectedRef={isLoaded ? selectedRef : undefined}
|
||||
showStart={showStart}
|
||||
subscribed={subscribedData}
|
||||
totalGap={totalGap}
|
||||
@@ -178,10 +177,10 @@ export default function Operator() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { isPast, isSelected, isLinkedToLoaded, totalGap } = process(nestedEntry);
|
||||
const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[nestedEntryId];
|
||||
|
||||
// hide past events (if setting) and skipped events
|
||||
if (hidePast && isPast) {
|
||||
if ((hidePast && isPast) || nestedEntry.skip) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -206,9 +205,9 @@ export default function Operator() {
|
||||
delay={nestedEntry.delay}
|
||||
dayOffset={nestedEntry.dayOffset}
|
||||
isLinkedToLoaded={isLinkedToLoaded}
|
||||
isSelected={isSelected}
|
||||
isSelected={isLoaded}
|
||||
isPast={isPast}
|
||||
selectedRef={isSelected ? selectedRef : undefined}
|
||||
selectedRef={isLoaded ? selectedRef : undefined}
|
||||
showStart={showStart}
|
||||
subscribed={subscribedData}
|
||||
totalGap={totalGap}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { OntimeEvent } from 'ontime-types';
|
||||
|
||||
import { makeOperatorMetadata } from '../operator.utils';
|
||||
|
||||
describe('makeOperatorMetadata()', () => {
|
||||
it('should track past, selected states, gaps and linking', () => {
|
||||
const event1 = { id: 'event1', gap: 5, linkStart: false } as OntimeEvent;
|
||||
const event2 = { id: 'event2', gap: 10, linkStart: true } as OntimeEvent;
|
||||
const event3 = { id: 'event3', gap: 15, linkStart: true } as OntimeEvent;
|
||||
const { process } = makeOperatorMetadata('event2');
|
||||
|
||||
expect(process(event1)).toEqual({
|
||||
isPast: true,
|
||||
isSelected: false,
|
||||
totalGap: 5,
|
||||
isLinkedToLoaded: false,
|
||||
});
|
||||
expect(process(event2)).toEqual({
|
||||
isPast: false,
|
||||
isSelected: true,
|
||||
totalGap: 15,
|
||||
isLinkedToLoaded: false,
|
||||
});
|
||||
expect(process(event3)).toEqual({
|
||||
isPast: false,
|
||||
isSelected: false,
|
||||
totalGap: 30,
|
||||
isLinkedToLoaded: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle null selectedId', () => {
|
||||
const event1 = { id: 'event1', gap: 5, linkStart: true } as OntimeEvent;
|
||||
const event2 = { id: 'event2', gap: 10, linkStart: false } as OntimeEvent;
|
||||
const event3 = { id: 'event3', gap: 15, linkStart: true } as OntimeEvent;
|
||||
const { process } = makeOperatorMetadata(null);
|
||||
|
||||
expect(process(event1)).toEqual({
|
||||
isPast: false,
|
||||
isSelected: false,
|
||||
totalGap: 5,
|
||||
isLinkedToLoaded: true,
|
||||
});
|
||||
expect(process(event2)).toEqual({
|
||||
isPast: false,
|
||||
isSelected: false,
|
||||
totalGap: 15,
|
||||
isLinkedToLoaded: false,
|
||||
});
|
||||
expect(process(event3)).toEqual({
|
||||
isPast: false,
|
||||
isSelected: false,
|
||||
totalGap: 30,
|
||||
isLinkedToLoaded: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should break linking chain on countToEnd events', () => {
|
||||
const event1 = { id: 'event1', gap: 5, linkStart: true, countToEnd: false } as OntimeEvent;
|
||||
const event2 = { id: 'event2', gap: 10, linkStart: true, countToEnd: true } as OntimeEvent;
|
||||
const event3 = { id: 'event3', gap: 15, linkStart: true, countToEnd: false } as OntimeEvent;
|
||||
const { process } = makeOperatorMetadata(null);
|
||||
|
||||
expect(process(event1)).toEqual({
|
||||
isPast: false,
|
||||
isSelected: false,
|
||||
totalGap: 5,
|
||||
isLinkedToLoaded: true,
|
||||
});
|
||||
expect(process(event2)).toEqual({
|
||||
isPast: false,
|
||||
isSelected: false,
|
||||
totalGap: 15,
|
||||
isLinkedToLoaded: true,
|
||||
});
|
||||
expect(process(event3)).toEqual({
|
||||
isPast: false,
|
||||
isSelected: false,
|
||||
totalGap: 30,
|
||||
isLinkedToLoaded: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -71,7 +71,7 @@ function OperatorEvent({
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className={operatorClasses} ref={selectedRef} onContextMenu={handleLongPress} {...mouseHandlers}>
|
||||
<div className={operatorClasses} data-testid={cue} ref={selectedRef} onContextMenu={handleLongPress} {...mouseHandlers}>
|
||||
<div className={style.binder} style={{ ...cueColours }}>
|
||||
<span className={style.cue}>{cue}</span>
|
||||
</div>
|
||||
@@ -167,5 +167,5 @@ function TimeUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }:
|
||||
const isDue = timeUntil < MILLIS_PER_SECOND;
|
||||
const timeUntilString = isDue ? 'DUE' : `${formatDuration(Math.abs(timeUntil), timeUntil > 2 * MILLIS_PER_MINUTE)}`;
|
||||
|
||||
return <span className={style.timeUntil}>{timeUntilString}</span>;
|
||||
return <span className={style.timeUntil} data-testid='time-until'>{timeUntilString}</span>;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { isStringBoolean } from '../viewers/common/viewUtils';
|
||||
|
||||
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ViewOption[] => {
|
||||
const fieldOptions = makeOptionsFromCustomFields(customFields, [
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'title', label: 'Title' },
|
||||
{ value: 'note', label: 'Note' },
|
||||
]);
|
||||
@@ -38,7 +39,7 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin
|
||||
description: 'Field to be shown in the second line of text',
|
||||
type: 'option',
|
||||
values: fieldOptions,
|
||||
defaultValue: '',
|
||||
defaultValue: 'none',
|
||||
},
|
||||
{
|
||||
id: 'subscribe',
|
||||
|
||||
@@ -1,74 +1,33 @@
|
||||
import { CustomFields, EntryId, MaybeString, OntimeEvent } from 'ontime-types';
|
||||
|
||||
import { getPropertyValue } from '../viewers/common/viewUtils';
|
||||
|
||||
import type { Subscribed } from './operator.types';
|
||||
|
||||
type OperatorMetadata = {
|
||||
isLinkedToLoaded: boolean;
|
||||
isPast: boolean;
|
||||
isSelected: boolean;
|
||||
totalGap: number;
|
||||
};
|
||||
|
||||
export function makeOperatorMetadata(selectedId: EntryId | null) {
|
||||
const hasSelection = Boolean(selectedId);
|
||||
let hasSeenSelected = false;
|
||||
let totalGap = 0;
|
||||
/** if the event can link all the way back to the currently playing event */
|
||||
let isLinkedToLoaded = false;
|
||||
let previousEvent: OntimeEvent | null = null;
|
||||
|
||||
function process(event: OntimeEvent): Readonly<OperatorMetadata> {
|
||||
const isSelected = event.id === selectedId;
|
||||
if (isSelected) {
|
||||
hasSeenSelected = true;
|
||||
}
|
||||
|
||||
// is past if we havent yet seen the selected event
|
||||
const isPast = hasSelection && !hasSeenSelected;
|
||||
totalGap += event.gap;
|
||||
|
||||
if (!isPast && !isSelected) {
|
||||
/**
|
||||
* isLinkToLoaded is a chain value that we maintain until we
|
||||
* a) find an unlinked event
|
||||
* b) find a countToEnd event
|
||||
*/
|
||||
isLinkedToLoaded = event.linkStart && !previousEvent?.countToEnd;
|
||||
}
|
||||
|
||||
previousEvent = event;
|
||||
return { isPast, isSelected, totalGap, isLinkedToLoaded };
|
||||
}
|
||||
|
||||
return { process };
|
||||
}
|
||||
|
||||
export function getEventData(
|
||||
event: OntimeEvent,
|
||||
main: MaybeString,
|
||||
secondary: MaybeString,
|
||||
subscriptions: string[],
|
||||
customFields: CustomFields,
|
||||
) {
|
||||
const mainField = main ? getPropertyValue(event, main) ?? '' : event.title;
|
||||
const secondaryField = getPropertyValue(event, secondary) ?? '';
|
||||
|
||||
// remove subscriptions that are not in customFields
|
||||
const sanitisedSubscriptions = subscriptions.filter((field) => Object.hasOwn(customFields, field));
|
||||
const subscribedData = sanitisedSubscriptions.reduce<Subscribed>((acc, id) => {
|
||||
const field = customFields[id];
|
||||
if (field) {
|
||||
acc.push({
|
||||
id,
|
||||
label: field.label,
|
||||
colour: field.colour,
|
||||
value: event.custom[id],
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return { mainField, secondaryField, subscribedData };
|
||||
}
|
||||
import { CustomFields, MaybeString, OntimeEvent } from 'ontime-types';
|
||||
|
||||
import { getPropertyValue } from '../viewers/common/viewUtils';
|
||||
|
||||
import type { Subscribed } from './operator.types';
|
||||
|
||||
export function getEventData(
|
||||
event: OntimeEvent,
|
||||
main: MaybeString,
|
||||
secondary: MaybeString,
|
||||
subscriptions: string[],
|
||||
customFields: CustomFields,
|
||||
) {
|
||||
const mainField = main ? getPropertyValue(event, main) ?? '' : event.title;
|
||||
const secondaryField = getPropertyValue(event, secondary) ?? '';
|
||||
|
||||
// remove subscriptions that are not in customFields
|
||||
const sanitisedSubscriptions = subscriptions.filter((field) => Object.hasOwn(customFields, field));
|
||||
const subscribedData = sanitisedSubscriptions.reduce<Subscribed>((acc, id) => {
|
||||
const field = customFields[id];
|
||||
if (field) {
|
||||
acc.push({
|
||||
id,
|
||||
label: field.label,
|
||||
colour: field.colour,
|
||||
value: event.custom[id],
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return { mainField, secondaryField, subscribedData };
|
||||
}
|
||||
|
||||
@@ -47,9 +47,10 @@
|
||||
|
||||
.daySpan {
|
||||
&::after {
|
||||
content: '*';
|
||||
content: "+"attr(data-day-offset);
|
||||
vertical-align: super;
|
||||
font-size: 0.75em;
|
||||
font-size: 0.6em;
|
||||
letter-spacing: 0;
|
||||
color: $info-blue;
|
||||
}
|
||||
}
|
||||
@@ -67,3 +68,10 @@
|
||||
font-size: calc(1rem - 2px);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dueTime {
|
||||
text-transform: capitalize;
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0;
|
||||
color: $playback-over;
|
||||
}
|
||||
|
||||
@@ -8,30 +8,33 @@ import {
|
||||
TbFolderPin,
|
||||
TbFolderStar,
|
||||
} from 'react-icons/tb';
|
||||
import { OntimeEvent, OntimeGroup, TimerPhase, TimerType } from 'ontime-types';
|
||||
import { isPlaybackActive, millisToString } from 'ontime-utils';
|
||||
import { OffsetMode, OntimeEvent, OntimeGroup, TimerPhase, TimerType } from 'ontime-types';
|
||||
import { dayInMs, isPlaybackActive, millisToString } from 'ontime-utils';
|
||||
|
||||
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
||||
import {
|
||||
useClock,
|
||||
useCurrentGroupId,
|
||||
useFlagTimerOverView,
|
||||
useGroupTimerOverView,
|
||||
useNextFlag,
|
||||
useRundownOverview,
|
||||
useRuntimePlaybackOverview,
|
||||
useOffsetOverview,
|
||||
useProgressOverview,
|
||||
useStartTimesOverview,
|
||||
useTimer,
|
||||
} from '../../../common/hooks/useSocket';
|
||||
import { useEntry } from '../../../common/hooks-query/useRundown';
|
||||
import { getOffsetState, getOffsetText } from '../../../common/utils/offset';
|
||||
import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
import { calculateEndAndDaySpan, formattedTime } from '../overview.utils';
|
||||
import { calculateEndAndDaySpan, formatDueTime, formattedTime } from '../overview.utils';
|
||||
|
||||
import { OverUnder, TimeColumn } from './TimeLayout';
|
||||
|
||||
import style from './TimeElements.module.scss';
|
||||
|
||||
export function StartTimes() {
|
||||
const { plannedEnd, plannedStart, actualStart, expectedEnd } = useRundownOverview();
|
||||
const { plannedEnd, plannedStart, actualStart, expectedEnd } = useStartTimesOverview();
|
||||
|
||||
const plannedStartText = plannedStart === null ? timerPlaceholder : formatTime(plannedStart);
|
||||
|
||||
@@ -47,7 +50,7 @@ export function StartTimes() {
|
||||
<Tooltip text='Planned start time' render={<TbCalendarPin className={style.icon} />} />
|
||||
<span className={cx([style.time, plannedStart === null && style.muted])}>{plannedStartText}</span>
|
||||
</div>
|
||||
<div className={style.labelledElement}>
|
||||
<div className={style.labelledElement} data-testid='actual-start-time'>
|
||||
<Tooltip text='Actual start time' render={<TbCalendarClock className={style.icon} />} />
|
||||
<span className={cx([style.time, actualStart === null && style.muted])}>{formattedTime(actualStart)}</span>
|
||||
</div>
|
||||
@@ -58,8 +61,8 @@ export function StartTimes() {
|
||||
<Tooltip text='Planned end time' render={<TbCalendarPin className={style.icon} />} />
|
||||
{maybePlannedDaySpan > 0 ? (
|
||||
<Tooltip
|
||||
text={`Event spans over ${maybePlannedDaySpan + 1} days`}
|
||||
render={<span className={cx([style.time, style.daySpan])} />}
|
||||
text={`Rundown spans over ${maybePlannedDaySpan + 1} days`}
|
||||
render={<span className={cx([style.time, style.daySpan])} data-day-offset={maybePlannedDaySpan} />}
|
||||
>
|
||||
{plannedEndText}
|
||||
</Tooltip>
|
||||
@@ -71,8 +74,8 @@ export function StartTimes() {
|
||||
<Tooltip text='Expected end time' render={<TbCalendarStar className={style.icon} />} />
|
||||
{maybeExpectedEnd !== null && maybeExpectedDaySpan > 0 ? (
|
||||
<Tooltip
|
||||
text={`Event spans over ${maybeExpectedDaySpan + 1} days`}
|
||||
render={<span className={cx([style.time, style.daySpan])} />}
|
||||
text={`Rundown spans over ${maybeExpectedDaySpan + 1} days`}
|
||||
render={<span className={cx([style.time, style.daySpan])} data-day-offset={maybeExpectedDaySpan} />}
|
||||
>
|
||||
{formattedTime(maybeExpectedEnd)}
|
||||
</Tooltip>
|
||||
@@ -96,61 +99,110 @@ export function MetadataTimes() {
|
||||
);
|
||||
}
|
||||
|
||||
//TODO: there a some things here we still need to think about, mainly what to do whit the planed group duration in relation to the events
|
||||
function GroupTimes() {
|
||||
const { clock, groupExpectedEnd } = useRuntimePlaybackOverview();
|
||||
const { clock, mode, groupExpectedEnd, actualGroupStart, currentDay, playback } = useGroupTimerOverView();
|
||||
const { currentGroupId } = useCurrentGroupId();
|
||||
const group = useEntry(currentGroupId) as OntimeGroup | null;
|
||||
|
||||
// the group end time dose not encode any day offsets
|
||||
const plannedGroupEnd = group && group.timeStart !== null ? group.timeStart + group.duration - clock : null;
|
||||
const plannedTimeUntilGroupEnd = formattedTime(plannedGroupEnd, 3, TimerType.CountDown);
|
||||
const active = isPlaybackActive(playback);
|
||||
|
||||
// the group end time dose not encode any day offsets so it is calculated with group start time and duration
|
||||
const plannedGroupEnd = (() => {
|
||||
if (!active) return null;
|
||||
if (!group || group.timeStart === null) return null;
|
||||
const normalizedClock = clock + currentDay * dayInMs;
|
||||
|
||||
return mode === OffsetMode.Absolute
|
||||
? group.timeStart + group.duration - normalizedClock
|
||||
: actualGroupStart + group.duration - normalizedClock;
|
||||
})();
|
||||
|
||||
const plannedTimeUntilGroupEnd = formatDueTime(plannedGroupEnd, 3, TimerType.CountDown);
|
||||
|
||||
const expectedGroupEnd = groupExpectedEnd !== null ? groupExpectedEnd - clock : null;
|
||||
const expectedTimeUntilGroupEnd = formattedTime(expectedGroupEnd, 3, TimerType.CountDown);
|
||||
|
||||
const groupTitle = group?.title ?? null;
|
||||
const expectedTimeUntilGroupEnd = formatDueTime(expectedGroupEnd, 3, TimerType.CountDown);
|
||||
|
||||
return (
|
||||
<div className={style.metadataRow}>
|
||||
<span className={groupTitle ? style.labelTitle : style.label}>{`${groupTitle ? groupTitle : 'Group'} `}</span>
|
||||
<span className={group?.title ? style.labelTitle : style.label}>{`${group?.title || 'Group'} `}</span>
|
||||
<div className={style.labelledElement}>
|
||||
<Tooltip text='Time to planned group end' render={<TbFolderPin className={style.icon} />} />
|
||||
<span className={cx([style.time, !group && style.muted])}>{plannedTimeUntilGroupEnd}</span>
|
||||
<span
|
||||
className={cx([
|
||||
style.time,
|
||||
(!group || !active) && style.muted,
|
||||
plannedTimeUntilGroupEnd === 'due' && style.dueTime,
|
||||
])}
|
||||
>
|
||||
{plannedTimeUntilGroupEnd}
|
||||
</span>
|
||||
</div>
|
||||
<div className={style.labelledElement}>
|
||||
<Tooltip text='Time to expected group end' render={<TbFolderStar className={style.icon} />} />
|
||||
<span className={cx([style.time, groupExpectedEnd === null && style.muted])}>{expectedTimeUntilGroupEnd}</span>
|
||||
<span
|
||||
className={cx([
|
||||
style.time,
|
||||
!groupExpectedEnd && style.muted,
|
||||
expectedTimeUntilGroupEnd === 'due' && style.dueTime,
|
||||
])}
|
||||
>
|
||||
{expectedTimeUntilGroupEnd}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FlagTimes() {
|
||||
const { clock } = useClock();
|
||||
const { clock, mode, actualStart, plannedStart, playback, currentDay } = useFlagTimerOverView();
|
||||
const { id, expectedStart } = useNextFlag();
|
||||
const entry = useEntry(id) as OntimeEvent | null;
|
||||
|
||||
const plannedFlagStart = entry ? entry.timeStart - clock : null;
|
||||
const plannedTimeUntilDisplay = formattedTime(plannedFlagStart, 3, TimerType.CountDown);
|
||||
const active = isPlaybackActive(playback);
|
||||
|
||||
const plannedFlagStart = (() => {
|
||||
if (!active) return null;
|
||||
if (!entry) return null;
|
||||
const normalizedTimeStart = entry.timeStart + entry.dayOffset * dayInMs;
|
||||
const normalizedClock = clock + currentDay * dayInMs;
|
||||
return mode === OffsetMode.Absolute
|
||||
? normalizedTimeStart - normalizedClock
|
||||
: normalizedTimeStart + actualStart - plannedStart - normalizedClock;
|
||||
})();
|
||||
|
||||
const plannedTimeUntilDisplay = formatDueTime(plannedFlagStart, 3, TimerType.CountDown);
|
||||
|
||||
const expectedTimeUntil = expectedStart !== null ? expectedStart - clock : null;
|
||||
const expectedTimeUntilDisplay = formattedTime(expectedTimeUntil, 3, TimerType.CountDown);
|
||||
const expectedTimeUntilDisplay = formatDueTime(expectedTimeUntil, 3, TimerType.CountDown);
|
||||
|
||||
const title = entry?.title ?? null;
|
||||
|
||||
return (
|
||||
<div className={style.metadataRow}>
|
||||
<span className={title ? style.labelTitle : style.label}>{`${title ? title : 'Flag'} `}</span>
|
||||
<span className={title ? style.labelTitle : style.label}>{`${title || 'Flag'} `}</span>
|
||||
<div className={style.labelledElement}>
|
||||
<Tooltip text='Time to next flag planned start' render={<TbFlagPin className={style.icon} />} />
|
||||
<span data-testid='flag-plannedStart' className={cx([style.time, !entry && style.muted])}>
|
||||
<span
|
||||
data-testid='flag-plannedStart'
|
||||
className={cx([
|
||||
style.time,
|
||||
(!entry || !active) && style.muted,
|
||||
plannedTimeUntilDisplay === 'due' && style.dueTime,
|
||||
])}
|
||||
>
|
||||
{plannedTimeUntilDisplay}
|
||||
</span>
|
||||
</div>
|
||||
<div className={style.labelledElement}>
|
||||
<Tooltip text='Time to next flag expected start' render={<TbFlagStar className={style.icon} />} />
|
||||
<span data-testid='flag-expectedStart' className={cx([style.time, expectedTimeUntil === null && style.muted])}>
|
||||
<span
|
||||
data-testid='flag-expectedStart'
|
||||
className={cx([
|
||||
style.time,
|
||||
expectedTimeUntil === null && style.muted,
|
||||
expectedTimeUntilDisplay === 'due' && style.dueTime,
|
||||
])}
|
||||
>
|
||||
{expectedTimeUntilDisplay}
|
||||
</span>
|
||||
</div>
|
||||
@@ -159,7 +211,7 @@ function FlagTimes() {
|
||||
}
|
||||
|
||||
export function ProgressOverview() {
|
||||
const { numEvents, selectedEventIndex } = useRuntimePlaybackOverview();
|
||||
const { numEvents, selectedEventIndex } = useProgressOverview();
|
||||
|
||||
const current = selectedEventIndex !== null ? selectedEventIndex + 1 : enDash;
|
||||
const progressText = numEvents ? `${current} of ${numEvents || enDash}` : enDash;
|
||||
@@ -168,7 +220,7 @@ export function ProgressOverview() {
|
||||
}
|
||||
|
||||
export function OffsetOverview() {
|
||||
const { offset, playback } = useRuntimePlaybackOverview();
|
||||
const { offset, playback } = useOffsetOverview();
|
||||
|
||||
const isPlaying = isPlaybackActive(playback);
|
||||
const offsetState = getOffsetState(isPlaying ? offset : null);
|
||||
@@ -179,8 +231,9 @@ export function OffsetOverview() {
|
||||
|
||||
export function ClockOverview({ className }: { className?: string }) {
|
||||
const { clock } = useClock();
|
||||
const formattedClock = formatTime(clock);
|
||||
|
||||
return <TimeColumn label='Time now' value={formattedTime(clock)} className={className} />;
|
||||
return <TimeColumn label='Time now' value={formattedClock} className={className} />;
|
||||
}
|
||||
|
||||
export function TimerOverview({ className }: { className?: string }) {
|
||||
|
||||
@@ -3,6 +3,23 @@ import { dayInMs, millisToString } from 'ontime-utils';
|
||||
|
||||
import { timerPlaceholder, timerPlaceholderMin } from '../../common/utils/styleUtils';
|
||||
|
||||
/**
|
||||
* Composition to stop negative timers from being formatted
|
||||
* They should show a due string instead
|
||||
*
|
||||
* This is used for cases when a negative timer is unwanted
|
||||
* eg: count down to a milestone
|
||||
*/
|
||||
export function formatDueTime(
|
||||
time: MaybeNumber,
|
||||
segments: number = 3,
|
||||
direction?: TimerType.CountDown | TimerType.CountUp,
|
||||
dueString = 'due',
|
||||
): string {
|
||||
if (time !== null && time <= 0) return dueString;
|
||||
return formattedTime(time, segments, direction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encapsulates the logic for formatting time in overview
|
||||
*/
|
||||
|
||||
@@ -82,7 +82,12 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
|
||||
|
||||
const cursorRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
useFollowComponent({ followRef: cursorRef, scrollRef, doFollow: editorMode === AppMode.Run });
|
||||
useFollowComponent({
|
||||
followRef: cursorRef,
|
||||
scrollRef,
|
||||
doFollow: true,
|
||||
followTrigger: editorMode === AppMode.Edit ? cursor : featureData?.selectedEventId,
|
||||
});
|
||||
|
||||
// DND KIT
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 10 } }));
|
||||
@@ -312,7 +317,7 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
|
||||
setMetadata(rundownMetadata);
|
||||
}, [order, entries, rundownMetadata]);
|
||||
|
||||
// in run mode, we follow selection
|
||||
// in run mode, we follow the playback selection and open groups as needed
|
||||
useEffect(() => {
|
||||
if (editorMode !== AppMode.Run || !featureData?.selectedEventId) {
|
||||
return;
|
||||
|
||||
@@ -28,20 +28,22 @@
|
||||
padding-inline: 0;
|
||||
box-shadow: $box-shadow-right;
|
||||
|
||||
flex: 1 2 auto; /* flex-grow: 1, flex-shrink: 2, flex-basis: auto */
|
||||
flex: 1 1 0; /* flex-grow: 1, flex-shrink: 1, flex-basis: 0 */
|
||||
min-width: 38rem;
|
||||
max-width: 60rem;
|
||||
max-width: none;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.side {
|
||||
max-height: 100%;
|
||||
max-width: 45rem;
|
||||
margin: 0.5rem 0;
|
||||
padding: 1rem;
|
||||
padding-right: 0;
|
||||
background-color: $gray-1325;
|
||||
border-radius: 0 8px 8px 0;
|
||||
|
||||
flex: 1 1 auto; /* flex-grow: 1, flex-shrink: 1, flex-basis: auto */
|
||||
max-width: 45rem;
|
||||
flex: 1 1 0; /* flex-grow: 1, flex-shrink: 1, flex-basis: 0 */
|
||||
// width is locked to swatch picker elements
|
||||
min-width: calc(15 * 2rem + 13 * 0.5rem);
|
||||
width: 0;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
.rundownEditor {
|
||||
// width is locked to swatch picker elements
|
||||
width: calc(15 * 2rem + 13 * 0.5rem);
|
||||
min-width: calc(15 * 2rem + 13 * 0.5rem);
|
||||
|
||||
// we dont want a scrollbar when in the modal
|
||||
.content {
|
||||
|
||||
@@ -37,7 +37,6 @@ export default function RundownDelay({ data, hasCursor }: RundownDelayProps) {
|
||||
|
||||
const dragStyle = {
|
||||
zIndex: isDragging ? 2 : 'inherit',
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
@@ -205,7 +205,6 @@ export default function RundownEvent({
|
||||
|
||||
const dragStyle = {
|
||||
zIndex: isDragging ? 2 : 'inherit',
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
@@ -73,9 +73,7 @@ interface EventUntilProps {
|
||||
isLinkedToLoaded: boolean;
|
||||
}
|
||||
|
||||
function EventUntil(props: EventUntilProps) {
|
||||
const { timeStart, delay, dayOffset, totalGap, isLinkedToLoaded } = props;
|
||||
|
||||
function EventUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }: EventUntilProps) {
|
||||
const timeUntil = useTimeUntilExpectedStart({ timeStart, delay, dayOffset }, { totalGap, isLinkedToLoaded });
|
||||
const isDue = timeUntil < MILLIS_PER_SECOND;
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
|
||||
zIndex: isDragging ? 2 : 'inherit',
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
cursor: isOver ? (isValidDrop ? 'grabbing' : 'no-drop') : 'grab',
|
||||
cursor: isOver ? (isValidDrop ? 'grabbing' : 'no-drop') : 'inherit',
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -70,7 +70,6 @@ export default function RundownMilestone({ colour, cue, entryId, hasCursor, titl
|
||||
|
||||
const dragStyle = {
|
||||
zIndex: isDragging ? 2 : 'inherit',
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
@@ -41,9 +41,9 @@ export function canDrop(
|
||||
order?: 'after' | 'before',
|
||||
isTargetCollapsed?: boolean,
|
||||
): boolean {
|
||||
// this would mean inserting a group inside another
|
||||
// inserting before would mean adding a group inside another
|
||||
if (targetType === 'end-group') {
|
||||
return false;
|
||||
return order === 'after';
|
||||
}
|
||||
|
||||
// this means swapping places with another group
|
||||
|
||||
@@ -225,6 +225,7 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
|
||||
<Switch
|
||||
size='large'
|
||||
name='lockNav'
|
||||
data-testid='lockNav'
|
||||
checked={watch('lockNav')}
|
||||
onCheckedChange={(checked) => setValue('lockNav', checked, { shouldDirty: true })}
|
||||
disabled={watch('lockConfig')}
|
||||
@@ -239,6 +240,7 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
|
||||
<Switch
|
||||
size='large'
|
||||
name='lockConfig'
|
||||
data-testid='lockConfig'
|
||||
checked={watch('lockConfig')}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
@@ -254,6 +256,7 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
|
||||
<Switch
|
||||
size='large'
|
||||
name='authenticate'
|
||||
data-testid='authenticate'
|
||||
checked={watch('authenticate')}
|
||||
onCheckedChange={(checked) => setValue('authenticate', checked, { shouldDirty: true })}
|
||||
/>
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function GenerateLinkFormExport({ lockedPath }: GenerateLinkFormE
|
||||
{ value: OntimeView.Timer, label: 'Timer' },
|
||||
{ value: OntimeView.Cuesheet, label: 'Cuesheet' },
|
||||
{ value: OntimeView.Operator, label: 'Operator' },
|
||||
{ value: '', label: 'Companion' },
|
||||
{ value: '<<companion>>', label: 'Companion' },
|
||||
...urlPresetData.map((preset) => ({
|
||||
value: `preset-${preset.alias}`,
|
||||
label: `URL Preset: ${preset.alias}`,
|
||||
|
||||
@@ -63,7 +63,6 @@ body,
|
||||
html {
|
||||
font-size: 15px;
|
||||
font-family: $ontime-font-family;
|
||||
background-color: var(--background-color-override, $ui-black);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export const initializeSentry = () => {
|
||||
/NetworkError/i,
|
||||
/The operation couldn't be completed/i,
|
||||
],
|
||||
denyUrls: [/extensions\//i, /^chrome:\/\//i, /^chrome-extension:\/\//i],
|
||||
denyUrls: [/extensions\//i, /^chrome:\/\//i, /^chrome-extension:\/\//i, /external\//i],
|
||||
beforeSend(event) {
|
||||
// Drop errors that happen during known data-unavailable states
|
||||
const error = event.exception?.values?.[0]?.value;
|
||||
|
||||
@@ -17,7 +17,6 @@ $header-font-size: clamp(24px, 2.5vw, 48px);
|
||||
|
||||
// General styling
|
||||
$accent-color: $red-500; // --accent-color-override
|
||||
$delay-color: $ontime-delay-text;
|
||||
$viewer-label-color: rgba(white, 25%);
|
||||
|
||||
// Main Properties of a viewer
|
||||
@@ -43,4 +42,4 @@ $timer-bold-font-family: 'Arial Black', sans-serif; // --font-family-bold-overri
|
||||
$external-color: rgba(white, 85%); // --external-color-override
|
||||
|
||||
// properties related to the studio clock
|
||||
$studio-breakpoint: 1200px;
|
||||
$studio-breakpoint: 1300px;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TranslationObject } from 'ontime-types';
|
||||
export const langDe: TranslationObject = {
|
||||
'common.expected_finish': 'Erwartetes Ende',
|
||||
'common.minutes': 'min',
|
||||
'common.seconds': 'sek',
|
||||
'common.now': 'Jetzt',
|
||||
'common.next': 'Nächste',
|
||||
'common.scheduled_start': 'Geplanter beginn',
|
||||
@@ -15,6 +16,7 @@ export const langDe: TranslationObject = {
|
||||
'common.no_data': 'Keine Daten',
|
||||
'countdown.ended': 'Veranstaltung endete um',
|
||||
'countdown.running': 'Veranstaltung läuft',
|
||||
'countdown.loaded': 'Veranstaltung geladen',
|
||||
'countdown.select_event': 'Wählen Sie eine Veranstaltung aus, um sie zu verfolgen',
|
||||
'countdown.to_start': 'Zeit bis zum Start',
|
||||
'countdown.waiting': 'Warten auf den Veranstaltungsbeginn',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TranslationObject } from 'ontime-types';
|
||||
export const langEs: TranslationObject = {
|
||||
'common.expected_finish': 'Finalización esperada',
|
||||
'common.minutes': 'min',
|
||||
'common.seconds': 'sec',
|
||||
'common.now': 'Ahora',
|
||||
'common.next': 'Siguiente',
|
||||
'common.scheduled_start': 'Inicio programado',
|
||||
@@ -15,6 +16,7 @@ export const langEs: TranslationObject = {
|
||||
'common.no_data': 'Sin datos',
|
||||
'countdown.ended': 'Evento finalizado a las',
|
||||
'countdown.running': 'Evento en curso',
|
||||
'countdown.loaded': 'Evento está cargado', //TODO: check translation
|
||||
'countdown.select_event': 'Seleccionar un evento para seguir',
|
||||
'countdown.to_start': 'Tiempo para comenzar',
|
||||
'countdown.waiting': 'Esperando el inicio del evento',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TranslationObject } from 'ontime-types';
|
||||
export const langFr: TranslationObject = {
|
||||
'common.expected_finish': 'Fin estimée à',
|
||||
'common.minutes': 'min',
|
||||
'common.seconds': 'sec',
|
||||
'common.now': 'Maintenant',
|
||||
'common.next': 'A suivre',
|
||||
'common.scheduled_start': 'Début prévu',
|
||||
@@ -15,6 +16,7 @@ export const langFr: TranslationObject = {
|
||||
'common.no_data': 'Aucune donnée',
|
||||
'countdown.ended': 'Évènement terminé à',
|
||||
'countdown.running': 'Évènement en cours',
|
||||
'countdown.loaded': 'Évènement chargé',
|
||||
'countdown.select_event': 'Sélectionnez un évènement à suivre',
|
||||
'countdown.to_start': 'Évènement commence dans',
|
||||
'countdown.waiting': 'En attente du début de l’évènement',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TranslationObject } from 'ontime-types';
|
||||
export const langIt: TranslationObject = {
|
||||
'common.expected_finish': 'Fine Prevista',
|
||||
'common.minutes': 'min',
|
||||
'common.seconds': 'sec',
|
||||
'common.now': 'Adesso',
|
||||
'common.next': 'Prossimo',
|
||||
'common.scheduled_start': 'Inizio programmato',
|
||||
@@ -15,6 +16,7 @@ export const langIt: TranslationObject = {
|
||||
'common.no_data': 'Nessun dato disponibile',
|
||||
'countdown.ended': 'Evento finito alle',
|
||||
'countdown.running': 'Evento in corso',
|
||||
'countdown.loaded': 'Evento caricato', //TODO: check translation
|
||||
'countdown.select_event': 'Seleziona un evento da seguire',
|
||||
'countdown.to_start': 'Tempo alla partenza',
|
||||
'countdown.waiting': "In attesa dell'inizio dell'evento",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TranslationObject } from 'ontime-types';
|
||||
export const langPt: TranslationObject = {
|
||||
'common.expected_finish': 'Término esperado',
|
||||
'common.minutes': 'min',
|
||||
'common.seconds': 'sec',
|
||||
'common.now': 'Agora',
|
||||
'common.next': 'Próximo',
|
||||
'common.scheduled_start': 'Início programado',
|
||||
@@ -15,6 +16,7 @@ export const langPt: TranslationObject = {
|
||||
'common.no_data': 'Sem dados',
|
||||
'countdown.ended': 'Evento encerrado às',
|
||||
'countdown.running': 'Evento em andamento',
|
||||
'countdown.loaded': 'Evento carregado', //TODO: check translation
|
||||
'countdown.select_event': 'Selecione um evento para acompanhar',
|
||||
'countdown.to_start': 'Tempo para iniciar',
|
||||
'countdown.waiting': 'Aguardando o início do evento',
|
||||
|
||||
@@ -10,15 +10,19 @@ import {
|
||||
makeProjectDataOptions,
|
||||
} from '../../common/components/view-params-editor/viewParams.utils';
|
||||
import { PresetContext } from '../../common/context/PresetContext';
|
||||
import { scheduleOptions } from '../common/schedule/schedule.options';
|
||||
import { getScheduleOptions } from '../common/schedule/schedule.options';
|
||||
|
||||
export const getBackstageOptions = (
|
||||
timeFormat: string,
|
||||
customFields: CustomFields,
|
||||
projectData: ProjectData,
|
||||
): ViewOption[] => {
|
||||
const secondaryOptions = makeOptionsFromCustomFields(customFields, [{ value: 'note', label: 'Note' }]);
|
||||
const projectDataOptions = makeProjectDataOptions(projectData);
|
||||
const customFieldOptions = makeOptionsFromCustomFields(customFields, []);
|
||||
const secondaryOptions = makeOptionsFromCustomFields(customFields, [
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'note', label: 'Note' },
|
||||
]);
|
||||
const projectDataOptions = makeProjectDataOptions(projectData, [{ value: 'none', label: 'None' }]);
|
||||
|
||||
return [
|
||||
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
|
||||
@@ -32,11 +36,11 @@ export const getBackstageOptions = (
|
||||
description: 'Select the data source for auxiliary text shown in now and next cards',
|
||||
type: 'option',
|
||||
values: secondaryOptions,
|
||||
defaultValue: '',
|
||||
defaultValue: 'none',
|
||||
},
|
||||
],
|
||||
},
|
||||
scheduleOptions,
|
||||
getScheduleOptions(customFieldOptions),
|
||||
{
|
||||
title: OptionTitle.ElementVisibility,
|
||||
collapsible: true,
|
||||
@@ -47,7 +51,7 @@ export const getBackstageOptions = (
|
||||
description: 'Select a project data source to show in the view',
|
||||
type: 'option',
|
||||
values: projectDataOptions,
|
||||
defaultValue: '',
|
||||
defaultValue: 'none',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
|
||||
import { getScheduledTimes } from './schedule.utils';
|
||||
import { useSchedule } from './ScheduleContext';
|
||||
import ScheduleItem from './ScheduleItem';
|
||||
|
||||
@@ -20,17 +19,21 @@ export default function Schedule({ className }: ScheduleProps) {
|
||||
return (
|
||||
<ul className={cx(['schedule', className])} ref={containerRef}>
|
||||
{events.map((event) => {
|
||||
const { timeStart, timeEnd, delay } = getScheduledTimes(event);
|
||||
|
||||
return (
|
||||
<ScheduleItem
|
||||
key={event.id}
|
||||
timeStart={timeStart}
|
||||
timeEnd={timeEnd}
|
||||
title={event.title}
|
||||
timeStart={event.timeStart}
|
||||
dayOffset={event.dayOffset}
|
||||
delay={event.delay}
|
||||
totalGap={event.totalGap}
|
||||
isLinkedToLoaded={event.isLinkedToLoaded}
|
||||
countToEnd={event.countToEnd}
|
||||
duration={event.duration}
|
||||
colour={event.colour}
|
||||
skip={event.skip}
|
||||
delay={delay}
|
||||
title={event.title}
|
||||
timeEnd={event.timeEnd}
|
||||
cue={event.cue}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
import {
|
||||
createContext,
|
||||
PropsWithChildren,
|
||||
RefObject,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { isOntimeEvent, OntimeEntry, OntimeEvent } from 'ontime-types';
|
||||
import { createContext, PropsWithChildren, RefObject, use, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { EntryId, isOntimeEvent, OntimeEntry, OntimeEvent } from 'ontime-types';
|
||||
|
||||
import { usePartialRundown } from '../../../common/hooks-query/useRundown';
|
||||
import { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||
|
||||
import { useScheduleOptions } from './schedule.options';
|
||||
|
||||
interface ScheduleContextState {
|
||||
events: OntimeEvent[];
|
||||
events: ExtendedEntry<OntimeEvent>[];
|
||||
selectedEventId: string | null;
|
||||
numPages: number;
|
||||
visiblePage: number;
|
||||
@@ -25,13 +17,18 @@ interface ScheduleContextState {
|
||||
const ScheduleContext = createContext<ScheduleContextState | undefined>(undefined);
|
||||
|
||||
interface ScheduleProviderProps {
|
||||
selectedEventId: string | null;
|
||||
selectedEventId: EntryId | null;
|
||||
}
|
||||
|
||||
export const ScheduleProvider = ({ children, selectedEventId }: PropsWithChildren<ScheduleProviderProps>) => {
|
||||
const { cycleInterval, stopCycle } = useScheduleOptions();
|
||||
const { data: events } = usePartialRundown((event: OntimeEntry) => {
|
||||
return isOntimeEvent(event);
|
||||
const { cycleInterval, stopCycle, filter } = useScheduleOptions();
|
||||
const { data: events } = usePartialRundown((entry: ExtendedEntry<OntimeEntry>) => {
|
||||
if (filter) {
|
||||
// custom keys are prepended with custom-
|
||||
const customKey = filter.startsWith('custom-') ? filter.slice('custom-'.length) : filter;
|
||||
return isOntimeEvent(entry) && Boolean(entry.custom[customKey]);
|
||||
}
|
||||
return isOntimeEvent(entry);
|
||||
});
|
||||
|
||||
const [firstIndex, setFirstIndex] = useState(-1);
|
||||
@@ -135,9 +132,9 @@ export const ScheduleProvider = ({ children, selectedEventId }: PropsWithChildre
|
||||
selectedEventIndex = 0;
|
||||
|
||||
return (
|
||||
<ScheduleContext.Provider
|
||||
<ScheduleContext
|
||||
value={{
|
||||
events: viewEvents as OntimeEvent[],
|
||||
events: viewEvents as ExtendedEntry<OntimeEvent>[],
|
||||
selectedEventId,
|
||||
numPages,
|
||||
visiblePage,
|
||||
@@ -145,12 +142,12 @@ export const ScheduleProvider = ({ children, selectedEventId }: PropsWithChildre
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ScheduleContext.Provider>
|
||||
</ScheduleContext>
|
||||
);
|
||||
};
|
||||
|
||||
export const useSchedule = () => {
|
||||
const context = useContext(ScheduleContext);
|
||||
const context = use(ScheduleContext);
|
||||
if (!context) {
|
||||
throw new Error('useSchedule() can only be used inside a ScheduleContext');
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useRuntimeOffset } from '../../../common/hooks/useSocket';
|
||||
import { OntimeEvent } from 'ontime-types';
|
||||
|
||||
import { useExpectedStartData } from '../../../common/hooks/useSocket';
|
||||
import { getOffsetState } from '../../../common/utils/offset';
|
||||
import { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
import { formatTime, getExpectedTimesFromExtendedEvent } from '../../../common/utils/time';
|
||||
import SuperscriptTime from '../../../features/viewers/common/superscript-time/SuperscriptTime';
|
||||
|
||||
import { useScheduleOptions } from './schedule.options';
|
||||
@@ -13,116 +16,150 @@ const formatOptions = {
|
||||
format24: 'HH:mm',
|
||||
};
|
||||
|
||||
interface ScheduleItemProps {
|
||||
timeStart: number;
|
||||
timeEnd: number;
|
||||
title: string;
|
||||
colour?: string;
|
||||
skip?: boolean;
|
||||
delay: number;
|
||||
}
|
||||
type ScheduleItemProps = Pick<
|
||||
ExtendedEntry<OntimeEvent>,
|
||||
| 'timeStart'
|
||||
| 'dayOffset'
|
||||
| 'delay'
|
||||
| 'totalGap'
|
||||
| 'isLinkedToLoaded'
|
||||
| 'countToEnd'
|
||||
| 'duration'
|
||||
| 'colour'
|
||||
| 'skip'
|
||||
| 'title'
|
||||
| 'timeEnd'
|
||||
| 'cue'
|
||||
>;
|
||||
|
||||
export default function ScheduleItem(props: ScheduleItemProps) {
|
||||
const { timeStart, timeEnd, title, colour, skip, delay } = props;
|
||||
export default function ScheduleItem({
|
||||
timeStart,
|
||||
dayOffset,
|
||||
delay,
|
||||
totalGap,
|
||||
isLinkedToLoaded,
|
||||
countToEnd,
|
||||
colour,
|
||||
duration,
|
||||
skip,
|
||||
title,
|
||||
timeEnd,
|
||||
cue,
|
||||
}: ScheduleItemProps) {
|
||||
const { showExpected } = useScheduleOptions();
|
||||
|
||||
if (showExpected) {
|
||||
return (
|
||||
<ExpectedScheduleItem
|
||||
timeStart={timeStart}
|
||||
timeEnd={timeEnd}
|
||||
title={title}
|
||||
colour={colour}
|
||||
skip={skip}
|
||||
delay={delay}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (delay > 0) {
|
||||
return (
|
||||
<DelayedScheduleItem
|
||||
timeStart={timeStart}
|
||||
timeEnd={timeEnd}
|
||||
title={title}
|
||||
colour={colour}
|
||||
skip={skip}
|
||||
delay={delay}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const start = formatTime(timeStart, formatOptions);
|
||||
const end = formatTime(timeEnd, formatOptions);
|
||||
return (
|
||||
<li className={cx(['entry', skip && 'entry--skip'])}>
|
||||
<li className={cx(['entry', skip && 'entry--skip'])} data-testid={cue}>
|
||||
<div className='entry-times'>
|
||||
<span className='entry-colour' style={{ backgroundColor: colour }} />
|
||||
<SuperscriptTime time={start} />
|
||||
→
|
||||
<SuperscriptTime time={end} />
|
||||
{showExpected ? (
|
||||
<ExpectedScheduleItem
|
||||
timeStart={timeStart}
|
||||
dayOffset={dayOffset}
|
||||
delay={delay}
|
||||
totalGap={totalGap}
|
||||
isLinkedToLoaded={isLinkedToLoaded}
|
||||
countToEnd={countToEnd}
|
||||
duration={duration}
|
||||
colour={colour}
|
||||
/>
|
||||
) : delay > 0 ? (
|
||||
<DelayedScheduleItem timeStart={timeStart} delay={delay} colour={colour} timeEnd={timeEnd} />
|
||||
) : (
|
||||
<PlannedScheduleItem timeStart={timeStart} timeEnd={timeEnd} colour={colour} />
|
||||
)}
|
||||
</div>
|
||||
<div className='entry-title'>{title}</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function DelayedScheduleItem(props: ScheduleItemProps) {
|
||||
const { timeStart, timeEnd, title, colour, skip, delay } = props;
|
||||
function PlannedScheduleItem({
|
||||
timeStart,
|
||||
timeEnd,
|
||||
colour,
|
||||
}: Pick<ScheduleItemProps, 'timeStart' | 'timeEnd' | 'colour'>) {
|
||||
const start = formatTime(timeStart, formatOptions);
|
||||
const end = formatTime(timeEnd, formatOptions);
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className='entry-colour' style={{ backgroundColor: colour }} />
|
||||
<SuperscriptTime time={start} />
|
||||
→
|
||||
<SuperscriptTime time={end} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DelayedScheduleItem({
|
||||
timeStart,
|
||||
timeEnd,
|
||||
colour,
|
||||
delay,
|
||||
}: Pick<ScheduleItemProps, 'timeStart' | 'timeEnd' | 'colour' | 'delay'>) {
|
||||
const start = formatTime(timeStart, formatOptions);
|
||||
const end = formatTime(timeEnd, formatOptions);
|
||||
const delayedStart = formatTime(timeStart + delay, formatOptions);
|
||||
const delayedEnd = formatTime(timeEnd + delay, formatOptions);
|
||||
|
||||
return (
|
||||
<li className={cx(['entry', skip && 'entry--skip'])}>
|
||||
<div className='entry-times'>
|
||||
<span className='entry-times--delayed'>
|
||||
<span className='entry-colour' style={{ backgroundColor: colour }} />
|
||||
<SuperscriptTime time={start} />
|
||||
→
|
||||
<SuperscriptTime time={end} />
|
||||
</span>
|
||||
<span className='entry-times--delay'>
|
||||
<SuperscriptTime time={delayedStart} />
|
||||
→
|
||||
<SuperscriptTime time={delayedEnd} />
|
||||
</span>
|
||||
</div>
|
||||
<div className='entry-title'>{title}</div>
|
||||
</li>
|
||||
<>
|
||||
<span className='entry-times--delayed'>
|
||||
<span className='entry-colour' style={{ backgroundColor: colour }} />
|
||||
<SuperscriptTime time={start} />
|
||||
→
|
||||
<SuperscriptTime time={end} />
|
||||
</span>
|
||||
<span className='entry-times--delay'>
|
||||
<SuperscriptTime time={delayedStart} />
|
||||
→
|
||||
<SuperscriptTime time={delayedEnd} />
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpectedScheduleItem(props: ScheduleItemProps) {
|
||||
const { timeStart, timeEnd, title, colour, skip, delay } = props;
|
||||
function ExpectedScheduleItem({
|
||||
timeStart,
|
||||
dayOffset,
|
||||
delay,
|
||||
totalGap,
|
||||
isLinkedToLoaded,
|
||||
countToEnd,
|
||||
colour,
|
||||
duration,
|
||||
}: Omit<ScheduleItemProps, 'timeEnd' | 'cue' | 'skip' | 'title'>) {
|
||||
const expectedStartData = useExpectedStartData();
|
||||
const { expectedStart, expectedEnd, plannedEnd } = getExpectedTimesFromExtendedEvent(
|
||||
{
|
||||
timeStart,
|
||||
dayOffset,
|
||||
delay,
|
||||
totalGap,
|
||||
isLinkedToLoaded,
|
||||
countToEnd,
|
||||
duration,
|
||||
},
|
||||
expectedStartData,
|
||||
);
|
||||
|
||||
return (
|
||||
<li className={cx(['entry', skip && 'entry--skip'])}>
|
||||
<div className='entry-times'>
|
||||
<span className='entry-colour' style={{ backgroundColor: colour }} />
|
||||
<ExpectedTime time={timeStart} delay={delay} />
|
||||
→
|
||||
<ExpectedTime time={timeEnd} delay={delay} />
|
||||
</div>
|
||||
<div className='entry-title'>{title}</div>
|
||||
</li>
|
||||
<>
|
||||
<span className='entry-colour' style={{ backgroundColor: colour }} />
|
||||
<ExpectedTime expectedTime={expectedStart} plannedTime={timeStart} />
|
||||
→
|
||||
<ExpectedTime expectedTime={expectedEnd} plannedTime={plannedEnd} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExpectedTimeProps {
|
||||
time: number;
|
||||
delay: number;
|
||||
expectedTime: number;
|
||||
plannedTime: number;
|
||||
}
|
||||
|
||||
function ExpectedTime(props: ExpectedTimeProps) {
|
||||
const { time, delay } = props;
|
||||
const { offset } = useRuntimeOffset();
|
||||
|
||||
const expectedOffset = offset - delay;
|
||||
const expectedTime = formatTime(time - offset, formatOptions);
|
||||
const expectedState = getOffsetState(expectedOffset);
|
||||
|
||||
return <SuperscriptTime className={`entry-times--${expectedState}`} time={expectedTime} />;
|
||||
function ExpectedTime({ expectedTime, plannedTime }: ExpectedTimeProps) {
|
||||
const timeDisplay = formatTime(expectedTime);
|
||||
const expectedState = getOffsetState(expectedTime - plannedTime);
|
||||
return <SuperscriptTime className={`entry-times--${expectedState}`} time={timeDisplay} />;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { SelectOption } from '../../../common/components/select/Select';
|
||||
import { OptionTitle } from '../../../common/components/view-params-editor/constants';
|
||||
import { ViewOption } from '../../../common/components/view-params-editor/viewParams.types';
|
||||
import type { ViewOption } from '../../../common/components/view-params-editor/viewParams.types';
|
||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||
|
||||
export const scheduleOptions: ViewOption = {
|
||||
export const getScheduleOptions = (customFieldOptions: SelectOption[]): ViewOption => ({
|
||||
title: OptionTitle.Schedule,
|
||||
collapsible: true,
|
||||
options: [
|
||||
{
|
||||
id: 'filter',
|
||||
title: 'Filter',
|
||||
description: 'Hide events without data in the selected custom field',
|
||||
type: 'option',
|
||||
values: customFieldOptions,
|
||||
defaultValue: 'None',
|
||||
},
|
||||
{
|
||||
id: 'stopCycle',
|
||||
title: 'Stop cycling through event pages',
|
||||
@@ -31,9 +40,10 @@ export const scheduleOptions: ViewOption = {
|
||||
defaultValue: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
type ScheduleOptions = {
|
||||
filter: string | null;
|
||||
cycleInterval: number;
|
||||
stopCycle: boolean;
|
||||
showExpected: boolean;
|
||||
@@ -41,6 +51,7 @@ type ScheduleOptions = {
|
||||
|
||||
function getScheduleOptionsFromParams(searchParams: URLSearchParams): ScheduleOptions {
|
||||
return {
|
||||
filter: searchParams.get('filter'),
|
||||
cycleInterval: Number(searchParams.get('cycleInterval')) || 10,
|
||||
stopCycle: isStringBoolean(searchParams.get('stopCycle')),
|
||||
showExpected: isStringBoolean(searchParams.get('showExpected')),
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { OntimeEvent } from 'ontime-types';
|
||||
|
||||
/**
|
||||
* Gather rules for how to present scheduled times
|
||||
*/
|
||||
export function getScheduledTimes(event: OntimeEvent) {
|
||||
return {
|
||||
timeStart: event.timeStart,
|
||||
timeEnd: event.timeEnd,
|
||||
delay: event.skip ? 0 : event.delay,
|
||||
};
|
||||
}
|
||||
@@ -118,7 +118,11 @@ $item-height: 3.5rem;
|
||||
}
|
||||
|
||||
.sub--live {
|
||||
background-color: $green-700;
|
||||
background-color: $active-green;
|
||||
}
|
||||
|
||||
.sub--armed {
|
||||
background-color: $gray-1325;
|
||||
}
|
||||
|
||||
.sub__binder {
|
||||
@@ -137,15 +141,19 @@ $item-height: 3.5rem;
|
||||
}
|
||||
|
||||
.sub__schedule--delayed {
|
||||
color: $delay-color;
|
||||
color: $ontime-delay-text;
|
||||
}
|
||||
|
||||
.sub__schedule--strike {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.sub__schedule--over {
|
||||
color: $playback-over
|
||||
color: $playback-over;
|
||||
}
|
||||
|
||||
.sub__schedule--under {
|
||||
color: $playback-under
|
||||
color: $playback-under;
|
||||
}
|
||||
|
||||
.sub__title {
|
||||
|
||||
@@ -9,6 +9,7 @@ import ViewLogo from '../../common/components/view-logo/ViewLogo';
|
||||
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useClock } from '../../common/hooks/useSocket';
|
||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
||||
import { formatTime, getDefaultFormat } from '../../common/utils/time';
|
||||
import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
|
||||
import { useTranslation } from '../../translation/TranslationProvider';
|
||||
@@ -39,14 +40,14 @@ export default function CountdownLoader() {
|
||||
return <Countdown {...data} />;
|
||||
}
|
||||
|
||||
function Countdown({ customFields, events, projectData, isMirrored, settings }: CountdownData) {
|
||||
function Countdown({ customFields, rundownData, projectData, isMirrored, settings }: CountdownData) {
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const { subscriptions } = useCountdownOptions();
|
||||
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
|
||||
// gather rundown data
|
||||
const playableEvents = events.filter((event) => isOntimeEvent(event) && isPlayableEvent(event));
|
||||
const playableEvents = rundownData.filter((entry) => isOntimeEvent(entry) && isPlayableEvent(entry));
|
||||
|
||||
// gather presentation data
|
||||
const hasEvents = playableEvents.length > 0;
|
||||
@@ -85,7 +86,7 @@ function Countdown({ customFields, events, projectData, isMirrored, settings }:
|
||||
}
|
||||
|
||||
interface CountdownContentsProps {
|
||||
playableEvents: OntimeEvent[];
|
||||
playableEvents: ExtendedEntry<OntimeEvent>[];
|
||||
subscriptions: EntryId[];
|
||||
goToEditMode: () => void;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { IoArrowBack, IoClose, IoSaveOutline } from 'react-icons/io5';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { EntryId, OntimeEvent } from 'ontime-types';
|
||||
import { EntryId, PlayableEvent } from 'ontime-types';
|
||||
|
||||
import Button from '../../common/components/buttons/Button';
|
||||
import { cx } from '../../common/utils/styleUtils';
|
||||
@@ -12,7 +12,7 @@ import { makeSubscriptionsUrl } from './countdown.utils';
|
||||
import './Countdown.scss';
|
||||
|
||||
interface CountdownSelectProps {
|
||||
events: OntimeEvent[];
|
||||
events: PlayableEvent[];
|
||||
subscriptions: EntryId[];
|
||||
disableEdit: () => void;
|
||||
}
|
||||
@@ -52,7 +52,7 @@ export default function CountdownSelect({ events, subscriptions, disableEdit }:
|
||||
|
||||
return (
|
||||
<div className='list-container'>
|
||||
{events.map((event: OntimeEvent, index: number) => {
|
||||
{events.map((event, index) => {
|
||||
const title = event.title || '{no title}';
|
||||
const isSelected = selectedIds.has(event.id);
|
||||
|
||||
|
||||
@@ -1,32 +1,37 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { IoPencil } from 'react-icons/io5';
|
||||
import { EntryId, OntimeEvent } from 'ontime-types';
|
||||
import { MaybeNumber, OntimeEvent } from 'ontime-types';
|
||||
import { dayInMs } from 'ontime-utils';
|
||||
|
||||
import Button from '../../common/components/buttons/Button';
|
||||
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
|
||||
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
||||
import {
|
||||
useCountdownSocket,
|
||||
useCurrentDay,
|
||||
usePlayback,
|
||||
useRuntimeOffset,
|
||||
useSelectedEventId,
|
||||
} from '../../common/hooks/useSocket';
|
||||
import { useExpectedStartData, usePlayback, useSelectedEventId } from '../../common/hooks/useSocket';
|
||||
import useReport from '../../common/hooks-query/useReport';
|
||||
import { getOffsetState } from '../../common/utils/offset';
|
||||
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
||||
import { cx } from '../../common/utils/styleUtils';
|
||||
import { throttle } from '../../common/utils/throttle';
|
||||
import FollowButton from '../../features/operator/follow-button/FollowButton';
|
||||
import ClockTime from '../../features/viewers/common/clock-time/ClockTime';
|
||||
import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
|
||||
import { getPropertyValue } from '../../features/viewers/common/viewUtils';
|
||||
import { useTranslation } from '../../translation/TranslationProvider';
|
||||
|
||||
import { useCountdownOptions } from './countdown.options';
|
||||
import { getIsLive, getSubscriptionDisplayData, sanitiseTitle, timerProgress } from './countdown.utils';
|
||||
import {
|
||||
CountdownEvent,
|
||||
extendEventData,
|
||||
getIsLive,
|
||||
isOutsideRange,
|
||||
preferredFormat12,
|
||||
preferredFormat24,
|
||||
useSubscriptionDisplayData,
|
||||
} from './countdown.utils';
|
||||
|
||||
import './Countdown.scss';
|
||||
|
||||
interface CountdownSubscriptionsProps {
|
||||
subscribedEvents: OntimeEvent[];
|
||||
subscribedEvents: ExtendedEntry<OntimeEvent>[];
|
||||
goToEditMode: () => void;
|
||||
}
|
||||
|
||||
@@ -36,6 +41,9 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
|
||||
const { selectedEventId } = useSelectedEventId();
|
||||
const showFab = useFadeOutOnInactivity(true);
|
||||
|
||||
const { data: reportData } = useReport();
|
||||
const { offset, currentDay, actualStart, plannedStart, mode } = useExpectedStartData();
|
||||
|
||||
const timeoutId = useRef<NodeJS.Timeout | null>(null);
|
||||
const [lockAutoScroll, setLockAutoScroll] = useState(false);
|
||||
const selectedRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -45,6 +53,7 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
|
||||
scrollRef,
|
||||
doFollow: !lockAutoScroll,
|
||||
topOffset: 0,
|
||||
followTrigger: selectedEventId,
|
||||
});
|
||||
|
||||
// reset scroll if nothing is selected
|
||||
@@ -92,23 +101,20 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
|
||||
{subscribedEvents.map((event) => {
|
||||
const secondaryData = getPropertyValue(event, secondarySource);
|
||||
const isLive = getIsLive(event.id, selectedEventId, playback);
|
||||
|
||||
const isArmed = !isLive && event.id === selectedEventId;
|
||||
const countdownEvent = extendEventData(event, currentDay, actualStart, plannedStart, offset, mode, reportData);
|
||||
const title = event.title.length ? event.title : ' '; // insert utf-8 empty space to avoid the line collapsing
|
||||
return (
|
||||
<div key={event.id} ref={isLive ? selectedRef : undefined} className={cx(['sub', isLive && 'sub--live'])}>
|
||||
<div
|
||||
key={event.id}
|
||||
ref={isLive ? selectedRef : undefined}
|
||||
className={cx(['sub', isLive && 'sub--live', isArmed && 'sub--armed'])}
|
||||
data-testid={event.cue}
|
||||
>
|
||||
<div className='sub__binder' style={{ '--user-color': event.colour }} />
|
||||
<div className={cx(['sub__schedule', event.delay > 0 && 'sub__schedule--delayed'])}>
|
||||
{showExpected ? (
|
||||
<ExpectedSchedule timeStart={event.timeStart} timeEnd={event.timeEnd} delay={event.delay} />
|
||||
) : (
|
||||
<>
|
||||
<ClockTime value={event.timeStart + event.delay} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
|
||||
→
|
||||
<ClockTime value={event.timeEnd + event.delay} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<SubscriptionStatus key={event.id} event={event} selectedEventId={selectedEventId} />
|
||||
<div className={cx(['sub__title', !event.title && 'subdued'])}>{sanitiseTitle(event.title)}</div>
|
||||
<ScheduleTime event={countdownEvent} showExpected={showExpected} />
|
||||
<SubscriptionStatus event={countdownEvent} />
|
||||
<div className={cx(['sub__title', !event.title && 'subdued'])}>{title}</div>
|
||||
{secondaryData && <div className='sub__secondary'>{secondaryData}</div>}
|
||||
</div>
|
||||
);
|
||||
@@ -123,63 +129,82 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
|
||||
);
|
||||
}
|
||||
|
||||
interface ExpectedScheduleProps {
|
||||
timeStart: number;
|
||||
timeEnd: number;
|
||||
delay: number;
|
||||
}
|
||||
function ExpectedSchedule(props: ExpectedScheduleProps) {
|
||||
const { timeStart, timeEnd, delay } = props;
|
||||
type ScheduleTimeProps = {
|
||||
event: CountdownEvent;
|
||||
showExpected: boolean;
|
||||
};
|
||||
//TODO: consider relative mode
|
||||
export function ScheduleTime(props: ScheduleTimeProps) {
|
||||
const { event, showExpected } = props;
|
||||
const { timeStart, duration, delay, expectedStart, countToEnd } = event;
|
||||
|
||||
const { offset } = useRuntimeOffset();
|
||||
const plannedStart = timeStart + delay + event.dayOffset * dayInMs;
|
||||
|
||||
// offset is negative if we are ahead
|
||||
const expectedOffset = offset - delay;
|
||||
const expectedState = getOffsetState(expectedOffset);
|
||||
// only show new exacted value if outside range of the planned value
|
||||
const isExpectedValueShow = showExpected && isOutsideRange(plannedStart, expectedStart);
|
||||
|
||||
const plannedStateClass = isExpectedValueShow ? 'sub__schedule--strike' : delay !== 0 ? 'sub__schedule--delayed' : '';
|
||||
|
||||
const expectedStateClass = `sub__schedule--${getOffsetState(expectedStart - plannedStart)}`;
|
||||
const plannedEnd = plannedStart + duration + delay;
|
||||
const expectedEnd = countToEnd ? Math.max(expectedStart + duration, plannedEnd) : expectedStart + duration;
|
||||
const expectedEndClass = `sub__schedule--${getOffsetState(expectedEnd - plannedEnd)}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='sub__schedule'>
|
||||
<ClockTime
|
||||
value={timeStart - expectedOffset}
|
||||
className={`sub__schedule--${expectedState}`}
|
||||
preferredFormat12='h:mm'
|
||||
preferredFormat24='HH:mm'
|
||||
value={plannedStart}
|
||||
preferredFormat12={preferredFormat12}
|
||||
preferredFormat24={preferredFormat24}
|
||||
className={plannedStateClass}
|
||||
/>
|
||||
→
|
||||
<ClockTime value={timeEnd - expectedOffset} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
|
||||
</>
|
||||
{!isExpectedValueShow && (
|
||||
<>
|
||||
→
|
||||
<ClockTime
|
||||
value={plannedEnd}
|
||||
preferredFormat12={preferredFormat12}
|
||||
preferredFormat24={preferredFormat24}
|
||||
className={plannedStateClass}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{isExpectedValueShow && (
|
||||
<>
|
||||
<ClockTime
|
||||
value={expectedStart}
|
||||
className={expectedStateClass}
|
||||
preferredFormat12={preferredFormat12}
|
||||
preferredFormat24={preferredFormat24}
|
||||
/>
|
||||
→
|
||||
<ClockTime
|
||||
value={expectedEnd}
|
||||
className={expectedEndClass}
|
||||
preferredFormat12={preferredFormat12}
|
||||
preferredFormat24={preferredFormat24}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SubscriptionStatusProps {
|
||||
event: OntimeEvent;
|
||||
selectedEventId: EntryId | null;
|
||||
event: ExtendedEntry<OntimeEvent> & { endedAt: MaybeNumber; expectedStart: number };
|
||||
}
|
||||
|
||||
function SubscriptionStatus({ event, selectedEventId }: SubscriptionStatusProps) {
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const { currentDay } = useCurrentDay();
|
||||
const { offset } = useRuntimeOffset();
|
||||
const { showExpected } = useCountdownOptions();
|
||||
const { playback, current, clock } = useCountdownSocket();
|
||||
|
||||
// TODO: use reporter values as in the event block chip
|
||||
const { status, timer } = getSubscriptionDisplayData(
|
||||
current,
|
||||
playback,
|
||||
clock,
|
||||
event,
|
||||
selectedEventId,
|
||||
offset,
|
||||
currentDay,
|
||||
getLocalizedString('common.minutes'),
|
||||
showExpected,
|
||||
);
|
||||
function SubscriptionStatus({ event }: SubscriptionStatusProps) {
|
||||
const { status, statusDisplay, timeDisplay } = useSubscriptionDisplayData(event);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='sub__status'>{getLocalizedString(timerProgress[status])}</div>
|
||||
<div className='sub__timer'>{timer}</div>
|
||||
<div className='sub__status'>{statusDisplay}</div>
|
||||
{status === 'done' ? (
|
||||
<SuperscriptTime className='sub__timer' time={timeDisplay} />
|
||||
) : (
|
||||
<div className='sub__timer'>{timeDisplay}</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
@use '@/theme/viewerDefs' as *;
|
||||
|
||||
.single-container {
|
||||
height: 100%;
|
||||
margin-top: 5vh;
|
||||
margin-top: 7.5vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $view-element-gap;
|
||||
gap: 5rem;
|
||||
}
|
||||
|
||||
.event__title {
|
||||
@@ -13,13 +12,25 @@
|
||||
padding: $view-card-padding;
|
||||
border-radius: $element-border-radius;
|
||||
font-size: clamp(40px, 4.5vw, 80px);
|
||||
line-height: 1.1em;
|
||||
line-height: 1.1;
|
||||
text-align: center;
|
||||
border-left: 0.25em solid;
|
||||
border-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||
|
||||
.secondary {
|
||||
text-align: left;
|
||||
font-size: $title-font-size;
|
||||
}
|
||||
|
||||
.sub__schedule {
|
||||
color: inherit;
|
||||
font-size: $base-font-size;
|
||||
}
|
||||
}
|
||||
|
||||
.event__status {
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
font-size: clamp(2rem, 3.5vw, 3.5rem);
|
||||
font-size: $header-font-size;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@@ -1,29 +1,57 @@
|
||||
import { IoPencil } from 'react-icons/io5';
|
||||
import { OntimeEvent } from 'ontime-types';
|
||||
import { MaybeNumber, OntimeEvent } from 'ontime-types';
|
||||
import { getExpectedStart } from 'ontime-utils';
|
||||
|
||||
import Button from '../../common/components/buttons/Button';
|
||||
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
|
||||
import { useCountdownSocket, useCurrentDay, useRuntimeOffset, useSelectedEventId } from '../../common/hooks/useSocket';
|
||||
import { useExpectedStartData } from '../../common/hooks/useSocket';
|
||||
import useReport from '../../common/hooks-query/useReport';
|
||||
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
||||
import { cx } from '../../common/utils/styleUtils';
|
||||
import { useTranslation } from '../../translation/TranslationProvider';
|
||||
import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
|
||||
import { getPropertyValue } from '../../features/viewers/common/viewUtils';
|
||||
|
||||
import { useCountdownOptions } from './countdown.options';
|
||||
import { getSubscriptionDisplayData, timerProgress } from './countdown.utils';
|
||||
import { useSubscriptionDisplayData } from './countdown.utils';
|
||||
import { ScheduleTime } from './CountdownSubscriptions';
|
||||
|
||||
import './SingleEventCountdown.scss';
|
||||
|
||||
interface SingleEventCountdownProps {
|
||||
subscribedEvent: OntimeEvent;
|
||||
subscribedEvent: ExtendedEntry<OntimeEvent>;
|
||||
goToEditMode: () => void;
|
||||
}
|
||||
|
||||
export default function SingleEventCountdown({ subscribedEvent, goToEditMode }: SingleEventCountdownProps) {
|
||||
const { secondarySource, showExpected } = useCountdownOptions();
|
||||
const showFab = useFadeOutOnInactivity(true);
|
||||
const { data: reportData } = useReport();
|
||||
|
||||
const { offset, currentDay, actualStart, plannedStart, mode } = useExpectedStartData();
|
||||
const { totalGap, isLinkedToLoaded } = subscribedEvent;
|
||||
const expectedStart = getExpectedStart(subscribedEvent, {
|
||||
currentDay,
|
||||
totalGap,
|
||||
actualStart,
|
||||
plannedStart,
|
||||
isLinkedToLoaded,
|
||||
offset,
|
||||
mode,
|
||||
});
|
||||
|
||||
const { endedAt } = reportData[subscribedEvent.id] ?? { endedAt: null };
|
||||
const countdownEvent = { ...subscribedEvent, expectedStart, endedAt };
|
||||
const title = subscribedEvent.title.length ? subscribedEvent.title : ' '; // insert utf-8 empty space to avoid the line collapsing
|
||||
const secondaryData = getPropertyValue(subscribedEvent, secondarySource);
|
||||
|
||||
return (
|
||||
<div className='single-container' data-testid='countdown-event'>
|
||||
<SubscriptionStatus event={subscribedEvent} />
|
||||
<div className='event__title'>{subscribedEvent.title}</div>
|
||||
<SubscriptionStatus event={countdownEvent} />
|
||||
<div className='event__title' style={{ borderColor: countdownEvent.colour }}>
|
||||
<ScheduleTime event={countdownEvent} showExpected={showExpected} />
|
||||
{title}
|
||||
{secondaryData && <div className='secondary'>{secondaryData}</div>}
|
||||
</div>
|
||||
<div className={cx(['fab-container', !showFab && 'fab-container--hidden'])}>
|
||||
<Button variant='primary' size='xlarge' onClick={goToEditMode}>
|
||||
<IoPencil /> Edit
|
||||
@@ -34,34 +62,20 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }:
|
||||
}
|
||||
|
||||
interface SubscriptionStatusProps {
|
||||
event: OntimeEvent;
|
||||
event: ExtendedEntry<OntimeEvent> & { endedAt: MaybeNumber; expectedStart: number };
|
||||
}
|
||||
|
||||
function SubscriptionStatus({ event }: SubscriptionStatusProps) {
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const { selectedEventId } = useSelectedEventId();
|
||||
const { currentDay } = useCurrentDay();
|
||||
const { offset } = useRuntimeOffset();
|
||||
const { showExpected } = useCountdownOptions();
|
||||
const { playback, current, clock } = useCountdownSocket();
|
||||
|
||||
// TODO: use reporter values as in the event block chip
|
||||
const { status, timer } = getSubscriptionDisplayData(
|
||||
current,
|
||||
playback,
|
||||
clock,
|
||||
event,
|
||||
selectedEventId,
|
||||
offset,
|
||||
currentDay,
|
||||
getLocalizedString('common.minutes'),
|
||||
showExpected,
|
||||
);
|
||||
const { status, statusDisplay, timeDisplay } = useSubscriptionDisplayData(event);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='event__status'>{getLocalizedString(timerProgress[status])}</div>
|
||||
<div className='event__timer'>{timer}</div>
|
||||
<div className='event__status'>{statusDisplay}</div>
|
||||
{status === 'done' ? (
|
||||
<SuperscriptTime className='event__timer' time={timeDisplay} />
|
||||
) : (
|
||||
<div className='event__timer'>{timeDisplay}</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user