Compare commits

...

140 Commits

Author SHA1 Message Date
arc-alex 71317f5e40 bump to v3.4.2 2024-08-19 17:26:15 +02:00
Alex Christoffer Rasmussen cc14445252 swap shouldThrottle check
Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2024-08-19 17:24:51 +02:00
arc-alex 31316a6661 remove async from parseProperty 2024-08-19 17:24:42 +02:00
arc-alex a8bc4eb098 bump to version 3.4.1 2024-07-29 12:52:58 +02:00
arc-alex 807698aee1 restore rundownGetAll 2024-07-29 12:51:44 +02:00
Carlos Valente c5e821df35 bump version to 3.4.0 2024-07-16 19:21:56 +02:00
jwetzell 0fd71c9a00 align docker-compose example with docker image layout 2024-07-16 19:20:05 +02:00
jwetzell 53b453abc8 change docker data path from /external to /data 2024-07-16 19:20:05 +02:00
Carlos Valente fe0665506e chore: update typescript 2024-07-16 13:18:20 +02:00
Carlos Valente 9bd600c4b1 chore: add watch mode in dev 2024-07-16 13:18:20 +02:00
jwetzell bbd4254637 Merge pull request #1138 from cpvalente/chore/bump-version
bump version to 3.4.0-beta.3
2024-07-15 08:23:16 -05:00
jwetzell 2603c777ef bump version to 3.4.0-beta.3 2024-07-15 07:23:00 -05:00
jwetzell 8942a4ba4f Merge pull request #1137 from cpvalente/fix/buildx-platforms
drop arm/v6 image
2024-07-15 07:20:32 -05:00
jwetzell f97be749dc drop arm/v6 image 2024-07-15 07:18:06 -05:00
Carlos Valente 5836d5ffc7 bump version to 3.4.0-beta.2 2024-07-15 10:28:58 +02:00
jwetzell 91f21fdeb1 Merge pull request #1134 from cpvalente/fix/docker-building
switch Dockerfile build stage to non-alpine image
2024-07-14 13:18:24 -05:00
jwetzell 844eec132b use npm to install specific pnpm version 2024-07-13 06:36:22 -05:00
jwetzell adc4b3f8bf switch Dockerfile build stage to non-alpine image 2024-07-12 21:12:57 -05:00
Carlos Valente fb09631db9 bump version to 3.4.0-beta 2024-07-11 14:44:02 +02:00
Carlos Valente 19febb98a5 bump version to 3.4.0.beta 2024-07-11 14:33:18 +02:00
Carlos Valente 6bfa11dd8d fix: format clock as timer 2024-07-11 13:31:25 +02:00
Carlos Valente 4758bc84b5 chore: upgrade node dependencies 2024-07-11 13:31:05 +02:00
Carlos Valente 917b182e39 feat: granular endpoints for rundown 2024-07-11 11:03:07 +02:00
Carlos Valente e6c3322321 feat: add list of changes to refetch 2024-07-11 11:03:07 +02:00
Carlos Valente 00d34eec9b refactor: emit load event on reload 2024-07-11 11:02:49 +02:00
Carlos Valente 84792bc00d refactor: initialise data 2024-07-11 11:02:22 +02:00
Carlos Valente dae32e89a3 refactor: patch project 2024-07-11 11:02:22 +02:00
Carlos Valente 367997fd16 refactor: simplify file checks 2024-07-11 11:02:22 +02:00
Carlos Valente 751c3329f0 refactor: load project 2024-07-11 11:02:22 +02:00
Carlos Valente 9c5e403b18 refactor: add directory for crash logs 2024-07-11 11:02:22 +02:00
Carlos Valente c944e77bc1 refactor: last project may not exist 2024-07-11 11:02:22 +02:00
Carlos Valente d94c1d4252 refactor: remove unnecessary check 2024-07-11 11:02:22 +02:00
Carlos Valente 9f395cb16d refactor: safe get info 2024-07-11 11:02:22 +02:00
Carlos Valente 46195fc043 refactor: rename project 2024-07-11 11:02:22 +02:00
Carlos Valente c9ef8d55c1 refactor: duplicate project 2024-07-11 11:02:22 +02:00
Carlos Valente ad0e821cc0 refactor: delete project 2024-07-11 11:02:22 +02:00
Carlos Valente 21454947e0 refactor: create project 2024-07-11 11:02:22 +02:00
Carlos Valente ad69c0ff80 refactor: project service boundaries 2024-07-11 11:02:22 +02:00
Carlos Valente 20d9df2501 refactor: escalate errors from parser 2024-07-11 11:02:22 +02:00
Carlos Valente de9af5aaa2 refactor: minor code cleanups 2024-07-11 11:02:22 +02:00
Carlos Valente 61280b06b7 refactor: simplify logic dynamic menus 2024-07-10 23:01:47 +02:00
Carlos Valente ce4a48cd8d refactor: extract options per view 2024-07-10 23:01:47 +02:00
Carlos Valente 2877475a35 refactor: allow reset selection by refreshing 2024-07-10 23:01:47 +02:00
Carlos Valente 73e23ee323 feat: projected times in countdown 2024-07-10 23:01:47 +02:00
Carlos Valente 938c5fbb4c refactor: offset cannot be null 2024-07-10 23:01:47 +02:00
Carlos Valente e90e94828d style: add npm badge 2024-07-09 09:30:01 +02:00
jwetzell 1f0401632e have electron dev script wait for local server instance 2024-07-03 22:31:20 +02:00
jwetzell 5c3989c9b6 switch workflows to release trigger (#1115)
* switch docker workflow to release trigger

* switch cli publish workflow to release trigger
2024-07-03 22:30:56 +02:00
Carlos Valente 6ad26f06c7 style: rename log feature 2024-07-02 16:56:23 +02:00
Carlos Valente a63fa68d39 bump version to 3.3.3 2024-06-23 23:02:14 +02:00
Carlos Valente 20503de6fd refactor: minor code cleanups 2024-06-23 22:07:34 +02:00
Carlos Valente 0335da9786 refactor: improve errors to user 2024-06-23 22:07:34 +02:00
Carlos Valente 9f1a64f53c chore: upgrade dependencies 2024-06-23 22:07:34 +02:00
Alex Christoffer Rasmussen 975356f4f4 events with 0 duration dose not contribute to next day calculation (#1099) 2024-06-23 22:06:44 +02:00
Carlos Valente 1b448835cd fix: duplicate validation 2024-06-23 16:17:03 +02:00
Carlos Valente cbf3d566b9 fix: prevent calling service before init 2024-06-23 11:50:49 +02:00
Carlos Valente 6c20cb5e99 refactor: remove size limits in title 2024-06-22 13:48:19 +02:00
Carlos Valente cfc3aab893 style: add background to highlight 2024-06-21 14:45:45 +02:00
Alex Christoffer Rasmussen b7c48f38a9 hotfix-build (#1091)
* skip  "alwaysStrict": true

* bump to 3.3.2

* remove all new ts check to be sure
2024-06-17 11:44:44 +02:00
Carlos Valente dcb6bfa62d bump version to 3.3.1 2024-06-17 08:21:01 +02:00
Carlos Valente 0abc4de3cd fix: column reordering in cuesheet 2024-06-17 08:14:12 +02:00
Carlos Valente f750ed3cdc style: match table header size 2024-06-17 08:14:12 +02:00
Carlos Valente f026c1c1e3 chore: run linter on imports 2024-06-17 08:10:14 +02:00
Piotr Górski ce176b01a2 Added Polish Translation (#1077) 2024-06-16 14:46:11 +02:00
Carlos Valente 337dd7892a chore: fix typo in donation text 2024-06-16 14:45:16 +02:00
Carlos Valente b7daf2926a refactor: improve typing 2024-06-16 09:07:01 +02:00
jwetzell 81a07099ae Merge pull request #1084 from cpvalente/fix/npm-publish-with-provenance
add permission for provenance publishing
2024-06-15 14:59:28 -05:00
jwetzell 8f88ca5053 add permission for provenance publishing
https://docs.npmjs.com/generating-provenance-statements
2024-06-15 14:59:16 -05:00
jwetzell 82a885dc44 Merge pull request #1082 from cpvalente/fix/publish-action
add registry url for publishing via CI
2024-06-15 13:33:58 -05:00
jwetzell c4be814de5 add registry url for publishing via CI 2024-06-15 13:33:08 -05:00
Carlos Valente 9f3186e249 bump version to 3.3.0 2024-06-15 17:30:58 +02:00
jwetzell 5159554697 Merge pull request #1080 from cpvalente/fix/npm-publish-action
disable git checks for pnpm publish workflow
2024-06-15 08:30:30 -05:00
jwetzell cf690aff08 disable git checks for pnpm publish workflow 2024-06-15 08:25:43 -05:00
Carlos Valente 48c4e5f429 bump version to 3.3.0 2024-06-15 15:10:24 +02:00
Carlos Valente ad0da6def4 fix: persistence of custom fields (#1078) 2024-06-15 15:07:20 +02:00
Alex Christoffer Rasmussen a15eb0db4c Client remote control (#930)
---------

Co-authored-by: kellhogs <fabianpos99@gmail.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>
Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2024-06-15 12:26:41 +02:00
jwetzell 70e8fbf327 add warning and danger transitions as timer life cycle events (#1071)
* setup detection of TImerPhase changes

* add integration event for transition to warning

* add integration event for transition to danger

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2024-06-15 09:54:29 +02:00
jwetzell 1044dfac01 Merge pull request #1051 from cpvalente/feat/ontime-headless
add a headless version of ontime
2024-06-13 20:11:00 -05:00
jwetzell b5e5c113e2 update lock file 2024-06-13 20:06:11 -05:00
Carlos Valente cbcff5e7ab docs: initial npm documentation 2024-06-13 20:04:08 -05:00
jwetzell 83cb2f82f5 add SIGHUP and SIGTERM to cli shutdown 2024-06-13 20:01:25 -05:00
jwetzell 837fd53dfd remove pkg bundling of cli 2024-06-13 20:01:04 -05:00
jwetzell 4962288a53 update package name for organization scoping 2024-06-13 19:59:47 -05:00
jwetzell 75a35e8537 add access flag to publish to organization 2024-06-13 19:59:47 -05:00
jwetzell 6e839fcefb rename bin to ontime 2024-06-13 19:59:47 -05:00
jwetzell 052a8e4ccb switch pkg to only build linux x64 and arm64 binaries 2024-06-13 19:59:47 -05:00
jwetzell 969c2bad14 add step to publish to NPM as well 2024-06-13 19:59:47 -05:00
jwetzell 9717222491 remove dev script from cli package.json 2024-06-13 19:59:47 -05:00
jwetzell 081c0bdac4 add necessary files to package.json for publishing 2024-06-13 19:59:47 -05:00
jwetzell 8b99e91883 add github workflow to build cli 2024-06-13 19:59:47 -05:00
jwetzell 4af2e688d1 add basic cli app for bundling a headless ontime 2024-06-13 19:59:47 -05:00
Luke Stein cf8c8d4b2e bump version to 3.2.0 2024-06-13 22:46:32 +02:00
Alex Christoffer Rasmussen 9e63261b35 None colour from API (#1063) 2024-06-12 21:31:08 +02:00
Alex Christoffer Rasmussen b1838a5e9a display multiple custom fields in operator view (#1001)
---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2024-06-12 14:25:26 +02:00
Alex Christoffer Rasmussen 9d7b5568e4 use port 3000 in dev mode (#1065) 2024-06-11 13:37:10 +02:00
Alex Christoffer Rasmussen 657aed1244 Fix coerce enum (#1064) 2024-06-11 13:03:34 +02:00
Carlos Valente 13c49f3652 chore: improve dev scripts 2024-06-10 18:26:18 +02:00
Carlos Valente 86b4ddadb5 chore: prepare log levels 2024-06-10 18:26:18 +02:00
Carlos Valente e9f806b88a style: organise console levels and semantics 2024-06-10 18:26:18 +02:00
Carlos Valente 4f672670ed chore: document aux functions 2024-06-10 18:26:18 +02:00
Carlos Valente 8742b790f3 refactor: escalate severe to electron 2024-06-10 18:26:18 +02:00
Carlos Valente d41cd78f2e refactor: add severe log level 2024-06-10 18:26:18 +02:00
Carlos Valente 715600a514 refactor: improve console visibility 2024-06-10 18:26:18 +02:00
Carlos Valente b99cf5f255 refactor: improve error states in spreadsheet flow 2024-06-09 22:04:41 +02:00
Carlos Valente 9a9a0eb7cf refactor: use timer phase while waiting 2024-06-08 13:47:26 +02:00
Carlos Valente 4bf2422ebc refactor: show next when waiting to roll 2024-06-08 13:47:26 +02:00
Michael Foster add769fe1e fix for MultiPartProgressBar styling (#1022) 2024-06-07 21:08:17 +02:00
Carlos Valente 3af4c64ff3 chore: upgrade lint dependencies 2024-06-07 20:15:43 +02:00
Alex Christoffer Rasmussen f405e65c55 Custom for timer title (#1055) 2024-06-07 15:24:29 +02:00
Alex Christoffer Rasmussen 0e32b3a3af Fix: link events in sheet when there is nothing to link (#1053)
* create failing tests

* fix missing optional chaining
2024-06-06 22:38:52 +02:00
Alex Christoffer Rasmussen c2fa115946 Validate project filename (#1046) 2024-06-06 22:37:41 +02:00
jwetzell edb48a2fc7 consider custom fields when loading operator main field (#1049) 2024-06-06 21:57:50 +02:00
Alex Christoffer Rasmussen 6f7846bbf7 fix and test only space osc payload (#1052) 2024-06-06 11:19:12 +02:00
Alex Christoffer Rasmussen 48ebd31c54 bump version to 3.1.1 (#1045) 2024-06-04 15:57:52 +02:00
Carlos Valente 6d1a911ab8 refactor: rename negative to overtime 2024-06-04 15:51:00 +02:00
Carlos Valente ee1753ef29 fix: account for pending timers 2024-06-04 15:51:00 +02:00
Carlos Valente 147e5f5a27 chore: remove unused dependencies 2024-06-03 22:38:57 +02:00
Carlos Valente a02b3faacc chore: bump version to 3.1.0 2024-06-03 22:38:57 +02:00
Alex Christoffer Rasmussen a3b21072eb keep the index row as a colour chip (#1039) 2024-06-03 22:38:22 +02:00
Carlos Valente 2076080c5f refactor: supress modifiers in count-up timers 2024-06-03 22:36:05 +02:00
Alex Christoffer Rasmussen 8b3abe9d61 fix: insert above shortcut (#1020)
refactor: unify setCursor and useEventSelection
2024-06-03 21:34:00 +02:00
Carlos Valente c2039866b4 refactor: fix missed first update (#1031) 2024-06-03 20:27:51 +02:00
Carlos Valente 73e4a15718 style: add label to auxiliary timer 2024-06-03 20:26:25 +02:00
asharonbaltazar c4359af2a0 feat:toggle index column (#1037)
* feat:add `showIndexColumn` boolean property to `CuesheetSettings`

* feat:use `showIndexColumn` to hide column index th/tr

* feat:pass `showIndexColumn` to components

* fix:update column headers naming
2024-06-03 14:21:11 -04:00
Carlos Valente f75f45b19a refactor: simplify client logic on timer phases 2024-06-03 20:18:19 +02:00
Carlos Valente fd2eee32aa refactor: declare timer phase 2024-06-03 20:18:19 +02:00
Carlos Valente a53d354b16 refactor: continue roll 2024-06-03 20:17:41 +02:00
Alex Christoffer Rasmussen ec55236eb2 fix: osc new project (#1032) 2024-06-02 18:01:36 +02:00
Alex Christoffer Rasmussen 3895b37572 Allow time change from api (#1009)
Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2024-06-02 15:21:09 +02:00
Julien Recurt d8626ff324 Add option to display the full time (HH:mm:ss) in the timer view (#988)
* Add option to display the full time (HH:mm:ss) in the timer view

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2024-06-01 13:39:15 +02:00
arc-alex 6c3870c15b catch getWorksheetNames 2024-05-31 22:17:17 +02:00
Carlos Valente 4fadb23b07 refactor: timer overrides do not affect minimal 2024-05-31 22:17:03 +02:00
Carlos Valente 199ed1617f fix: menu link to operator 2024-05-31 18:57:45 +02:00
Carlos Valente 2d618e0657 refactor: remove double import 2024-05-30 22:41:50 +02:00
Carlos Valente f928c1dc95 refactor: avoid unnecessary computer property 2024-05-30 22:41:50 +02:00
jwetzell 8d526c7d31 Merge pull request #1019 from cpvalente/cleanup-server-setup
remove redundant path evaluation
2024-05-30 07:57:23 -05:00
Carlos Valente 686c6108bf refactor: improve payload type for OSC (#954)
---------

Co-authored-by: Joel Wetzell <jwetzell@yahoo.com>
Co-authored-by: arc-alex <ac@omnivox.dk>
2024-05-30 13:02:22 +02:00
Carlos Valente ea3d33ca93 fix: correct key on shortcuts 2024-05-30 12:15:57 +02:00
jwetzell 6bf94ff645 remove redundant path evaluation 2024-05-29 16:25:15 -05:00
Carlos Valente 930cd71ffd chore: upgrade electron 2024-05-29 08:33:44 +02:00
234 changed files with 13069 additions and 8210 deletions
+6 -6
View File
@@ -14,12 +14,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 18.18.2 node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 8 version: 9
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -56,12 +56,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 18.18.2 node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 8 version: 9
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -89,12 +89,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 18.18.2 node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 8 version: 9
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
+53
View File
@@ -0,0 +1,53 @@
name: Ontime CLI build
on:
release:
types: [published]
workflow_dispatch:
jobs:
build_cli:
permissions:
id-token: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 20
registry-url: 'https://registry.npmjs.org'
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 9
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm build
- name: Copy server
run: mkdir -p apps/cli/server && cp apps/server/dist/index.cjs apps/cli/server/index.cjs
- name: Copy client
run: cp -R apps/client/build apps/cli/client
- name: Copy external
run: cp -R apps/server/src/external apps/cli/external
- name: Publish to NPM
run: pnpm publish --access public --no-git-checks
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_CONFIG_PROVENANCE: true
working-directory: ./apps/cli
+6 -8
View File
@@ -1,8 +1,8 @@
name: Docker Image CI Ontime name: Docker Image CI Ontime
on: on:
push: release:
tags: [ "*" ] types: [published]
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@@ -14,8 +14,6 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Setup env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
- name: Docker Login - name: Docker Login
uses: docker/login-action@v2.1.0 uses: docker/login-action@v2.1.0
@@ -32,10 +30,10 @@ jobs:
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6 platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry # Push is a shorthand for --output=type=registry
push: true push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ env.RELEASE_VERSION }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
- name: Build and push pre-release - name: Build and push pre-release
if: github.event.release.prerelease == true if: github.event.release.prerelease == true
@@ -43,8 +41,8 @@ jobs:
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6 platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry # Push is a shorthand for --output=type=registry
push: true push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ env.RELEASE_VERSION }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
+4 -4
View File
@@ -17,12 +17,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 18.18.2 node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 8 version: 9
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -73,12 +73,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 18.18.2 node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 8 version: 9
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
+1 -1
View File
@@ -1 +1 @@
v18.18.2 v20.15.1
+5 -2
View File
@@ -9,8 +9,8 @@ Ontime consists of 3 distinct parts
The steps below will assume you have locally installed the necessary dependencies. The steps below will assume you have locally installed the necessary dependencies.
Other dependencies will be installed as part of the setup Other dependencies will be installed as part of the setup
- __node__ (~18.18.2) - __node__ (~20)
- __pnpm__ (>=8) - __pnpm__ (~9)
- __docker__ (only necessary to run and build docker images) - __docker__ (only necessary to run and build docker images)
## LOCAL DEVELOPMENT ## LOCAL DEVELOPMENT
@@ -81,6 +81,9 @@ From the project root, run the following commands
The build distribution assets will be at `.apps/electron/dist` The build distribution assets will be at `.apps/electron/dist`
Note: The MacOS build will only work in CI, locally it will fail due to notarisation issues.
Use the `turbo dist-mac:local` command to build a MacOS distribution locally.
## DOCKER ## DOCKER
Ontime provides a docker-compose file to aid with building and running docker images. Ontime provides a docker-compose file to aid with building and running docker images.
+4 -4
View File
@@ -1,19 +1,19 @@
FROM node:18.18-alpine AS builder FROM node:20-bullseye AS builder
ENV PNPM_HOME="/pnpm" ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH" ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable RUN npm install -g pnpm@9.5.0
COPY . /app COPY . /app
WORKDIR /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 --filter=ontime-utils install --config.dedupe-peer-dependents=false --frozen-lockfile
RUN pnpm --filter=ontime-ui --filter=ontime-server run build:docker RUN pnpm --filter=ontime-ui --filter=ontime-server run build:docker
FROM node:18.18-alpine FROM node:20-alpine
# Set environment variables # Set environment variables
# Environment Variable to signal that we are running production # Environment Variable to signal that we are running production
ENV NODE_ENV=docker ENV NODE_ENV=docker
# Ontime Data path # Ontime Data path
ENV ONTIME_DATA=/external/ ENV ONTIME_DATA=/data/
WORKDIR /app/ WORKDIR /app/
+1
View File
@@ -1,6 +1,7 @@
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-green.svg)](https://www.gnu.org/licenses/gpl-3.0) [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-green.svg)](https://www.gnu.org/licenses/gpl-3.0)
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/cpvalente/ontime/total) ![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/cpvalente/ontime/total)
![Docker Pulls](https://img.shields.io/docker/pulls/getontime/ontime) ![Docker Pulls](https://img.shields.io/docker/pulls/getontime/ontime)
![NPM Downloads](https://img.shields.io/npm/dy/%40getontime%2Fcli)
[![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/cpvalente) [![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/cpvalente)
[![](https://img.shields.io/static/v1?label=Buy%20me%20a%20coffee&message=%E2%9D%A4&logo=buymeacoffee&color=%23fe8e86)](https://www.buymeacoffee.com/cpvalente) [![](https://img.shields.io/static/v1?label=Buy%20me%20a%20coffee&message=%E2%9D%A4&logo=buymeacoffee&color=%23fe8e86)](https://www.buymeacoffee.com/cpvalente)
+16
View File
@@ -0,0 +1,16 @@
{
"parserOptions": {
"sourceType": "module"
},
"env": {
"browser": true,
"node": true
},
"extends": [
"eslint:recommended"
],
"plugins": [],
"rules": {
"@typescript-eslint/no-var-requires": "off"
}
}
+3
View File
@@ -0,0 +1,3 @@
server
external
client
+9
View File
@@ -0,0 +1,9 @@
{
"endOfLine": "lf",
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"jsxSingleQuote": true,
"printWidth": 120
}
+44
View File
@@ -0,0 +1,44 @@
# Ontime CLI
![Ontime](https://github.com/cpvalente/ontime/blob/9e63261b350e5b7b61c7ffb17c9e75a6c5a8c711/.github/aux-images/editor.png)
> Ontime is an application for managing rundown and event timers.
Congratulations! You got this far into Ontime's rabbit hole and want to manage your installation.
The CLI tool is a fast and lightweight way of installing Ontime and is perfect for self-hosting and headless installs.
## Getting started
### Prerequisites
You will need to have installed the correct version of node.js
Please check [the app engines declaration](https://github.com/cpvalente/ontime/blob/master/package.json) for the correct version.
### Running Ontime
To get Ontime running, all you need to do is install it in your system using your package manager of choice:
Install globally in your system
```bash
npm install -g @getontime/cli
```
... and run using the installed script
```bash
ontime
```
Or install and run ontime (the installation here is temporary for the duration of the session)
```bash
npx install @getontime/cli
```
## Links
- [Ontime's repository](https://github.com/cpvalente/ontime)
- [Ontime's documentation](https://docs.getontime.no/)
- [Ontime's website](https://getontime.no/)
## Sponsoring
You can help the development of this project or say thank you with a one time donation. \
See the [terms of donations](https://github.com/cpvalente/ontime/blob/master/SPONSOR.md)
[![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/cpvalente)
[![](https://img.shields.io/static/v1?label=Buy%20me%20a%20coffee&message=%E2%9D%A4&logo=buymeacoffee&color=%23fe8e86)](https://www.buymeacoffee.com/cpvalente)
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env node
// NOTE: for now the following needs to be in place: ./server/index.cjs, ./client, ./external
const ontimeServer = require('./server/index.cjs');
const { initAssets, startServer, startIntegrations, shutdown } = ontimeServer;
async function startOntime() {
await initAssets();
await startServer();
await startIntegrations();
}
startOntime();
process.on(['SIGHUP', 'SIGINT', 'SIGTERM'], () => {
shutdown();
});
+27
View File
@@ -0,0 +1,27 @@
{
"name": "@getontime/cli",
"version": "3.4.2",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
"keywords": [
"lighdev",
"ontime",
"timer"
],
"license": "AGPL-3.0-only",
"main": "main.js",
"bin": {
"ontime": "main.js"
},
"files": [
"client",
"external",
"server"
],
"devDependencies": {
"eslint": "^8.53.0",
"eslint-config-prettier": "^9.0.0",
"prettier": "^3.0.3"
}
}
+9 -9
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "3.0.4", "version": "3.4.2",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
@@ -70,13 +70,13 @@
"@types/react": "^18.0.26", "@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10", "@types/react-dom": "^18.0.10",
"@types/testing-library__jest-dom": "^5.14.5", "@types/testing-library__jest-dom": "^5.14.5",
"@typescript-eslint/eslint-plugin": "^6.10.0", "@typescript-eslint/eslint-plugin": "^v7.16.1",
"@typescript-eslint/parser": "^6.10.0", "@typescript-eslint/parser": "^7.16.1",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.53.0", "eslint": "^8.56.0",
"eslint-config-prettier": "^9.0.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-jest": "^27.6.0", "eslint-plugin-jest": "^28.6.0",
"eslint-plugin-prettier": "^5.0.1", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-react": "^7.32.0", "eslint-plugin-react": "^7.32.0",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-simple-import-sort": "^8.0.0", "eslint-plugin-simple-import-sort": "^8.0.0",
@@ -84,9 +84,9 @@
"jsdom": "^21.1.0", "jsdom": "^21.1.0",
"ontime-types": "workspace:*", "ontime-types": "workspace:*",
"ontime-utils": "workspace:*", "ontime-utils": "workspace:*",
"prettier": "^3.0.3", "prettier": "^3.3.1",
"sass": "^1.57.1", "sass": "^1.57.1",
"typescript": "^5.4.3", "typescript": "^5.5.3",
"vite": "^5.2.11", "vite": "^5.2.11",
"vite-plugin-compression2": "^0.12.0", "vite-plugin-compression2": "^0.12.0",
"vite-plugin-svgr": "^4.2.0", "vite-plugin-svgr": "^4.2.0",
+6 -3
View File
@@ -4,16 +4,15 @@ import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import ErrorBoundary from './common/components/error-boundary/ErrorBoundary'; import ErrorBoundary from './common/components/error-boundary/ErrorBoundary';
import IdentifyOverlay from './common/components/identify-overlay/IdentifyOverlay';
import { AppContextProvider } from './common/context/AppContext'; import { AppContextProvider } from './common/context/AppContext';
import { ontimeQueryClient } from './common/queryClient'; import { ontimeQueryClient } from './common/queryClient';
import { socketClientName } from './common/stores/connectionName';
import { connectSocket } from './common/utils/socket'; import { connectSocket } from './common/utils/socket';
import theme from './theme/theme'; import theme from './theme/theme';
import { TranslationProvider } from './translation/TranslationProvider'; import { TranslationProvider } from './translation/TranslationProvider';
import AppRouter from './AppRouter'; import AppRouter from './AppRouter';
const preferredClientName = socketClientName.getState().name; connectSocket();
connectSocket(preferredClientName);
function App() { function App() {
return ( return (
@@ -24,11 +23,15 @@ function App() {
<div className='App'> <div className='App'>
<ErrorBoundary> <ErrorBoundary>
<TranslationProvider> <TranslationProvider>
<IdentifyOverlay />
<AppRouter /> <AppRouter />
</TranslationProvider> </TranslationProvider>
</ErrorBoundary> </ErrorBoundary>
<ReactQueryDevtools initialIsOpen={false} /> <ReactQueryDevtools initialIsOpen={false} />
</div> </div>
<ErrorBoundary>
<div id='identify-portal' />
</ErrorBoundary>
</BrowserRouter> </BrowserRouter>
</AppContextProvider> </AppContextProvider>
</QueryClientProvider> </QueryClientProvider>
+4
View File
@@ -1,6 +1,7 @@
import { lazy, Suspense } from 'react'; import { lazy, Suspense } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom'; import { Navigate, Route, Routes } from 'react-router-dom';
import { useClientPath } from './common/hooks/useClientPath';
import Log from './features/log/Log'; import Log from './features/log/Log';
import withPreset from './features/PresetWrapper'; import withPreset from './features/PresetWrapper';
import withData from './features/viewers/ViewWrapper'; import withData from './features/viewers/ViewWrapper';
@@ -34,6 +35,9 @@ const TimerControl = lazy(() => import('./features/control/playback/TimerControl
const MessageControl = lazy(() => import('./features/control/message/MessageControlExport')); const MessageControl = lazy(() => import('./features/control/message/MessageControlExport'));
export default function AppRouter() { export default function AppRouter() {
// handle client path changes
useClientPath();
return ( return (
<Suspense fallback={null}> <Suspense fallback={null}>
<Routes> <Routes>
+1
View File
@@ -11,6 +11,7 @@ export const RUNTIME = ['runtimeStore'];
export const SHEET_STATE = ['sheetState']; export const SHEET_STATE = ['sheetState'];
export const URL_PRESETS = ['urlpresets']; export const URL_PRESETS = ['urlpresets'];
export const VIEW_SETTINGS = ['viewSettings']; export const VIEW_SETTINGS = ['viewSettings'];
export const CLIENT_LIST = ['clientList'];
// resolve location // resolve location
const location = window.location; const location = window.location;
+2 -2
View File
@@ -11,8 +11,8 @@ const dbPath = `${apiEntryUrl}/db`;
/** /**
* HTTP request to the current DB * HTTP request to the current DB
*/ */
async function getDb(fileName?: string): Promise<AxiosResponse<DatabaseModel>> { async function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> {
return axios.post(`${dbPath}/download/`, { fileName }); return axios.post(`${dbPath}/download/`, { filename });
} }
/** /**
@@ -0,0 +1,6 @@
.buttonSection {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $element-spacing;
margin-top: -$element-spacing;
}
@@ -0,0 +1,69 @@
import { useState } from 'react';
import {
Button,
Input,
InputGroup,
InputLeftAddon,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
} from '@chakra-ui/react';
import { setClientRemote } from '../../hooks/useSocket';
import style from './ClientModal.module.scss';
interface RedirectClientModalProps {
id: string;
name?: string;
path?: string;
isOpen: boolean;
onClose: () => void;
}
export function RedirectClientModal(props: RedirectClientModalProps) {
const { id, isOpen, name = '', path: currentPath = '', onClose } = props;
const [path, setPath] = useState(currentPath);
const { setRedirect } = setClientRemote;
const handleRedirect = () => {
if (path !== currentPath && path !== '') {
setRedirect({ target: id, redirect: path });
}
onClose();
};
const host = `${window.location.origin}/`;
const canSubmit = path !== currentPath && path !== '';
return (
<Modal isOpen={isOpen} onClose={onClose} variant='ontime'>
<ModalOverlay />
<ModalContent>
<ModalHeader>Redirect: {name}</ModalHeader>
<ModalCloseButton />
<ModalBody>
<InputGroup variant='ontime-filled' size='md'>
<InputLeftAddon>{host}</InputLeftAddon>
<Input placeholder='minimal?key=0000ffff' value={path} onChange={(event) => setPath(event.target.value)} />
</InputGroup>
</ModalBody>
<ModalFooter>
<div className={style.buttonSection}>
<Button size='md' variant='ontime-subtle' onClick={onClose}>
Cancel
</Button>
<Button size='md' variant='ontime-filled' onClick={handleRedirect} isDisabled={!canSubmit}>
Submit
</Button>
</div>
</ModalFooter>
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,68 @@
import { useState } from 'react';
import {
Button,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
} from '@chakra-ui/react';
import { setClientRemote } from '../../hooks/useSocket';
import style from './ClientModal.module.scss';
interface RenameClientModalProps {
id: string;
name?: string;
isOpen: boolean;
onClose: () => void;
}
export function RenameClientModal(props: RenameClientModalProps) {
const { id, name: currentName = '', isOpen, onClose } = props;
const [name, setName] = useState(currentName);
const { setClientName } = setClientRemote;
const handleRename = () => {
if (name !== currentName && name !== '') {
setClientName({ target: id, rename: name });
}
onClose();
};
const canSubmit = name !== currentName && name !== '';
return (
<Modal isOpen={isOpen} onClose={onClose} variant='ontime'>
<ModalOverlay />
<ModalContent>
<ModalHeader>Rename: {currentName}</ModalHeader>
<ModalCloseButton />
<ModalBody>
<Input
variant='ontime-filled'
size='md'
placeholder='new name'
value={name}
onChange={(event) => setName(event.target.value)}
/>
</ModalBody>
<ModalFooter>
<div className={style.buttonSection}>
<Button size='md' variant='ontime-subtle' onClick={onClose}>
Cancel
</Button>
<Button size='md' variant='ontime-filled' onClick={handleRename} isDisabled={!canSubmit}>
Submit
</Button>
</div>
</ModalFooter>
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,34 @@
.overlay {
position: absolute;
top: 0;
left: 0;
margin: 0;
width: 100vw;
height: 100vh;
padding: 0;
box-sizing: border-box;
border: 10px solid $ui-white;
background-color: $ui-black;
color: $ui-white;
display: grid;
place-content: center;
text-align: center;
z-index: 100;
cursor: pointer;
}
.name {
font-weight: 600;
margin-top: -10vh;
line-height: 1em;
font-size: 12vw;
text-transform: uppercase;
}
.message {
color: $label-gray;
line-height: 2em;
font-size: 3vw;
}
@@ -0,0 +1,64 @@
import { useCallback, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { MILLIS_PER_MINUTE } from 'ontime-utils';
import { setClientRemote } from '../../hooks/useSocket';
import { useClientStore } from '../../stores/clientStore';
import style from './IdentifyOverlay.module.scss';
export default function IdentifyOverlay() {
const clients = useClientStore((store) => store.clients);
const id = useClientStore((store) => store.id);
const showOverlay = clients[id]?.identify;
if (!showOverlay) {
return null;
}
const portalRoot = document.getElementById('identify-portal');
if (!portalRoot) {
return null;
}
return createPortal(<Overlay />, portalRoot);
}
function Overlay() {
const clients = useClientStore((store) => store.clients);
const id = useClientStore((store) => store.id);
const name = useClientStore((store) => store.name);
const timerRef = useRef<NodeJS.Timeout | null>(null);
const { setIdentify } = setClientRemote;
const showOverlay = clients[id]?.identify;
const handleClose = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
setIdentify({ target: id, identify: false });
}, [id, setIdentify]);
// start a timer that will close the overlay after some time
useEffect(() => {
if (showOverlay) {
timerRef.current = setTimeout(handleClose, MILLIS_PER_MINUTE);
}
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
};
}, [showOverlay, id, setIdentify, handleClose]);
console.log('here2');
return (
<div className={style.overlay} data-testid='identify-overlay' onClick={handleClose}>
<div className={style.name}>{name}</div>
<div className={style.message}>Click to close</div>
</div>
);
}
@@ -7,10 +7,6 @@ $progress-bar-br: 3px;
position: relative; position: relative;
width: 100%; width: 100%;
height: $progress-bar-size; height: $progress-bar-size;
border-radius: $progress-bar-br;
background-color: var(--timer-progress-bg-override, $viewer-card-bg-color);
display: flex;
overflow: hidden;
&--hidden { &--hidden {
display: none; display: none;
@@ -18,31 +14,42 @@ $progress-bar-br: 3px;
} }
} }
.multiprogress-bar__bg {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: var(--progress-bar-br, $progress-bar-br);
display: flex;
flex-direction: row;
justify-content: flex-end;
}
.multiprogress-bar__indicator { .multiprogress-bar__indicator {
position: absolute; position: absolute;
height: inherit; inset: 0;
background-color: black; margin: -1px;
margin-left: 0;
display: flex;
flex-direction: row;
justify-content: flex-end;
}
.multiprogress-bar__indicator-bar {
background-color: var(--background-color-override, $ui-black);
opacity: 0.8; opacity: 0.8;
transition: 1s linear; transition: 1s linear;
transition-property: width; transition-property: width;
right: 0;
.multiprogress-bar--ignore-css-override & {
background-color: $ui-black;
}
} }
.multiprogress-bar__bg-normal { .multiprogress-bar__bg-normal {
position: absolute; flex: 1;
height: inherit;
right: 0;
width: 100%;
}
.multiprogress-bar__bg-warning {
position: absolute;
height: inherit;
right: 0;
} }
.multiprogress-bar__bg-danger { .multiprogress-bar__bg-danger {
position: absolute; flex-shrink: 0;
height: inherit; }
right: 0;
}
@@ -13,31 +13,51 @@ interface MultiPartProgressBar {
danger?: MaybeNumber; danger?: MaybeNumber;
dangerColor: string; dangerColor: string;
hidden?: boolean; hidden?: boolean;
ignoreCssOverride?: boolean;
className?: string; className?: string;
} }
export default function MultiPartProgressBar(props: MultiPartProgressBar) { export default function MultiPartProgressBar(props: MultiPartProgressBar) {
const { now, complete, normalColor, warning, warningColor, danger, dangerColor, hidden, className = '' } = props; const {
now,
complete,
normalColor,
warning,
warningColor,
danger,
dangerColor,
hidden,
ignoreCssOverride,
className = '',
} = props;
const percentRemaining = complete === 0 ? 0 : 100 - clamp(100 - (Math.max(now ?? 0, 0) * 100) / complete, 0, 100); const percentRemaining = complete === 0 ? 0 : 100 - clamp(100 - (Math.max(now ?? 0, 0) * 100) / complete, 0, 100);
const dangerWidth = danger ? clamp((danger / complete) * 100, 0, 100) : 0; const dangerWidth = danger ? clamp((danger / complete) * 100, 0, 100) : 0;
const warningWidth = warning ? clamp((warning / complete) * 100, 0, 100) : 0; const warningWidth = warning ? clamp((warning / complete) * 100 - dangerWidth, 0, 100) : 0;
return ( return (
<div className={`multiprogress-bar ${hidden ? 'multiprogress-bar--hidden' : ''} ${className}`}> <div
className={`multiprogress-bar ${hidden ? 'multiprogress-bar--hidden' : ''} ${
ignoreCssOverride ? 'multiprogress-bar--ignore-css-override' : ''
} ${className}`}
>
{now !== null && ( {now !== null && (
<> <>
<div className='multiprogress-bar__bg-normal' style={{ backgroundColor: normalColor }} /> <div className='multiprogress-bar__bg'>
<div <div className='multiprogress-bar__bg-normal' style={{ backgroundColor: normalColor }} />
className='multiprogress-bar__bg-warning' <div
style={{ width: `${warningWidth}%`, backgroundColor: warningColor }} className='multiprogress-bar__bg-warning'
/> style={{ width: `${warningWidth}%`, backgroundColor: warningColor }}
<div />
className='multiprogress-bar__bg-danger' <div
style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }} className='multiprogress-bar__bg-danger'
/> style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }}
<div className='multiprogress-bar__indicator' style={{ width: `${percentRemaining}%` }} /> />
</div>
<div className='multiprogress-bar__indicator'>
<div className='multiprogress-bar__indicator-bar' style={{ width: `${percentRemaining}%` }} />
</div>
</> </>
)} )}
</div> </div>
@@ -19,10 +19,10 @@ import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { navigatorConstants } from '../../../viewerConfig'; import { navigatorConstants } from '../../../viewerConfig';
import useClickOutside from '../../hooks/useClickOutside'; import useClickOutside from '../../hooks/useClickOutside';
import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions'; import { useViewOptionsStore } from '../../stores/viewOptions';
import { isKeyEnter } from '../../utils/keyEvent'; import { isKeyEnter } from '../../utils/keyEvent';
import { RenameClientModal } from '../client-modal/RenameClientModal';
import RenameClientModal from './rename-client-modal/RenameClientModal';
import style from './NavigationMenu.module.scss'; import style from './NavigationMenu.module.scss';
@@ -34,8 +34,10 @@ interface NavigationMenuProps {
function NavigationMenu(props: NavigationMenuProps) { function NavigationMenu(props: NavigationMenuProps) {
const { isOpen, onClose } = props; const { isOpen, onClose } = props;
const { isOpen: isRenameOpen, onOpen: onRenameOpen, onClose: onRenameClose } = useDisclosure(); const id = useClientStore((store) => store.id);
const name = useClientStore((store) => store.name);
const { isOpen: isOpenRename, onOpen: onRenameOpen, onClose: onCloseRename } = useDisclosure();
const { fullscreen, toggle } = useFullscreen(); const { fullscreen, toggle } = useFullscreen();
const { toggleMirror } = useViewOptionsStore(); const { toggleMirror } = useViewOptionsStore();
@@ -45,7 +47,7 @@ function NavigationMenu(props: NavigationMenuProps) {
return createPortal( return createPortal(
<div id='navigation-menu-portal' ref={menuRef}> <div id='navigation-menu-portal' ref={menuRef}>
<RenameClientModal isOpen={isRenameOpen} onClose={onRenameClose} /> <RenameClientModal id={id} name={name} isOpen={isOpenRename} onClose={onCloseRename} />
<Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'> <Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
<DrawerOverlay /> <DrawerOverlay />
<DrawerContent> <DrawerContent>
@@ -1,71 +0,0 @@
import { useEffect, useState } from 'react';
import {
Button,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
} from '@chakra-ui/react';
import { setClientName } from '../../../hooks/useSocket';
import { useSocketClientName } from '../../../stores/connectionName';
interface RenameClientModalProps {
isOpen: boolean;
onClose: () => void;
}
export default function RenameClientModal({ isOpen, onClose }: RenameClientModalProps) {
const { name: clientName, persistName } = useSocketClientName();
const [newName, setNewName] = useState(clientName);
useEffect(() => {
setNewName(clientName);
}, [isOpen, clientName]);
const handleRename = async () => {
if (newName) {
setClientName(newName);
persistName(newName);
onClose();
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
size='sm'
closeOnOverlayClick={false}
motionPreset='slideInBottom'
scrollBehavior='inside'
preserveScrollBarGap
variant='ontime'
>
<ModalOverlay />
<ModalContent>
<ModalHeader>Rename client</ModalHeader>
<ModalCloseButton />
<ModalBody>
<Input
placeholder='Connection must have a name'
defaultValue={newName}
onChange={(e) => setNewName(e.target.value)}
variant='ontime-filled'
/>
<Button
isDisabled={newName === clientName || !newName}
onClick={handleRename}
width='100%'
variant='ontime-filled'
>
Save
</Button>
</ModalBody>
</ModalContent>
</Modal>
);
}
@@ -1,25 +1,37 @@
@use '../../../theme/viewerDefs' as *; @use '../../../theme/viewerDefs' as *;
.title-card { .title-card {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.25rem;
} }
.inline { .title-card__title,
display: flex; .title-card__secondary {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.title-card__title { .title-card__title {
font-weight: 600; font-weight: 600;
font-size: clamp(32px, 3.5vw, 50px); font-size: clamp(1.5rem, 3vw, 3rem);
color: var(--color-override, $viewer-color); color: var(--color-override, $viewer-color);
line-height: 1.1em; line-height: 1.2em;
}
.title-card__secondary {
font-size: clamp(1rem, 2vw, 2.25rem);
color: var(--secondary-color-override, $viewer-secondary-color);
line-height: 1.2em;
} }
.title-card__label { .title-card__label {
position: absolute;
right: 1rem;
top: 0.5rem;
font-size: clamp(1rem, 1.5vw, 1.5rem); font-size: clamp(1rem, 1.5vw, 1.5rem);
font-weight: 400;
color: var(--secondary-color-override, $viewer-secondary-color); color: var(--secondary-color-override, $viewer-secondary-color);
margin-left: auto; margin-left: auto;
text-transform: uppercase; text-transform: uppercase;
@@ -28,13 +40,3 @@
color: var(--accent-color-override, $accent-color); color: var(--accent-color-override, $accent-color);
} }
} }
.title-card__secondary {
font-size: clamp(1.5rem, 2vw, 2.25rem);
color: var(--secondary-color-override, $viewer-secondary-color);
line-height: 1.1em;
&::after {
content: '\200b';
}
}
@@ -1,28 +1,32 @@
import { ForwardedRef, forwardRef } from 'react';
import { useTranslation } from '../../../translation/TranslationProvider'; import { useTranslation } from '../../../translation/TranslationProvider';
import './TitleCard.scss'; import './TitleCard.scss';
interface TitleCardProps { interface TitleCardProps {
label: 'now' | 'next';
title: string; title: string;
label?: 'now' | 'next';
secondary?: string; secondary?: string;
className?: string;
} }
export default function TitleCard(props: TitleCardProps) { const TitleCard = forwardRef((props: TitleCardProps, ref: ForwardedRef<HTMLDivElement>) => {
const { label, title, secondary } = props; const { label, title, secondary, className = '' } = props;
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
const accent = label === 'now'; const accent = label === 'now';
return ( return (
<div className='title-card'> <div className={`title-card ${className}`} ref={ref}>
<div className='inline'> <span className='title-card__title'>{title}</span>
<span className='title-card__title'>{title}</span> <span className={accent ? 'title-card__label title-card__label--accent' : 'title-card__label'}>
<span className={accent ? 'title-card__label title-card__label--accent' : 'title-card__label'}> {label && getLocalizedString(`common.${label}`)}
{getLocalizedString(`common.${label}`)} </span>
</span>
</div>
<div className='title-card__secondary'>{secondary}</div> <div className='title-card__secondary'>{secondary}</div>
</div> </div>
); );
} });
TitleCard.displayName = 'TitleCard';
export default TitleCard;
@@ -1,5 +1,18 @@
import { useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { Input, InputGroup, InputLeftElement, Select, Switch } from '@chakra-ui/react'; import {
Button,
Input,
InputGroup,
InputLeftElement,
Menu,
MenuButton,
MenuItemOption,
MenuList,
MenuOptionGroup,
Select,
Switch,
} from '@chakra-ui/react';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils'; import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
@@ -14,6 +27,10 @@ export default function ParamInput(props: EditFormInputProps) {
const { paramField } = props; const { paramField } = props;
const { id, type, defaultValue } = paramField; const { id, type, defaultValue } = paramField;
if (type === 'persist') {
return null;
}
if (type === 'option') { if (type === 'option') {
const optionFromParams = searchParams.get(id); const optionFromParams = searchParams.get(id);
const defaultOptionValue = optionFromParams || defaultValue; const defaultOptionValue = optionFromParams || defaultValue;
@@ -34,6 +51,10 @@ export default function ParamInput(props: EditFormInputProps) {
); );
} }
if (type === 'multi-option') {
return <MultiOption paramField={paramField} />;
}
if (type === 'boolean') { if (type === 'boolean') {
const defaultCheckedValue = isStringBoolean(searchParams.get(id)) || defaultValue; const defaultCheckedValue = isStringBoolean(searchParams.get(id)) || defaultValue;
@@ -70,3 +91,47 @@ export default function ParamInput(props: EditFormInputProps) {
</InputGroup> </InputGroup>
); );
} }
interface EditFormMultiOptionProps {
paramField: ParamField & { type: 'multi-option' };
}
function MultiOption(props: EditFormMultiOptionProps) {
const [searchParams] = useSearchParams();
const { paramField } = props;
const { id, defaultValue } = paramField;
const optionFromParams = (searchParams.get(id) ?? '').toLocaleLowerCase();
const defaultOptionValue = optionFromParams || defaultValue?.toLocaleLowerCase() || '';
const [paramState, setParamState] = useState<string>(defaultOptionValue);
return (
<>
<input name={id} hidden readOnly value={paramState} />
<Menu isLazy closeOnSelect={false} variant='ontime-on-dark'>
<MenuButton as={Button} variant='ontime-subtle-white' position='relative' width='fit-content' fontWeight={400}>
{paramField.title}
</MenuButton>
<MenuList>
<MenuOptionGroup
type='checkbox'
value={paramState.split('_')}
onChange={(value) => {
setParamState(typeof value === 'object' ? value.filter((v) => v !== '').join('_') : value);
}}
>
{Object.values(paramField.values).map((option) => {
const { value, label } = option;
return (
<MenuItemOption value={value} key={value}>
{label}
</MenuItemOption>
);
})}
</MenuOptionGroup>
</MenuList>
</Menu>
</>
);
}
@@ -13,14 +13,24 @@
color: $label-gray; color: $label-gray;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.25rem gap: 0.25rem;
} }
.columnSection { .section {
color: $ui-white;
font-size: 1rem;
&:not(:first-child) {
margin-top: 2rem;
}
}
.fieldSet {
display: flex; display: flex;
padding: $section-spacing 0; padding: $section-spacing 0;
flex-direction: column; flex-direction: column;
gap: $element-spacing; gap: $element-spacing;
margin-left: 0.5rem;
} }
.title { .title {
@@ -13,7 +13,7 @@ import {
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import ParamInput from './ParamInput'; import ParamInput from './ParamInput';
import { ParamField } from './types'; import { isSection, ViewOption } from './types';
import style from './ViewParamsEditor.module.scss'; import style from './ViewParamsEditor.module.scss';
@@ -22,25 +22,36 @@ type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
/** /**
* Makes a new URLSearchParams object from the given params object * Makes a new URLSearchParams object from the given params object
*/ */
const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ParamField[]) => { const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ViewOption[]) => {
const defaultValues = paramFields.reduce<Record<string, string>>((acc, { id, defaultValue }) => { const newSearchParams = new URLSearchParams();
acc[id] = String(defaultValue);
return acc;
}, {});
return Object.entries(paramsObj).reduce((newSearchParams, [id, value]) => { // Convert paramFields to an object that contains default values
const defaultValues: Record<string, string> = {};
paramFields.forEach((option) => {
if (!isSection(option)) {
defaultValues[option.id] = String(option.defaultValue);
}
// extract persisted values
if ('type' in option && option.type === 'persist') {
newSearchParams.set(option.id, option.value);
}
});
// compare which values are different from the default values
Object.entries(paramsObj).forEach(([id, value]) => {
if (typeof value === 'string' && value.length && defaultValues[id] !== value) { if (typeof value === 'string' && value.length && defaultValues[id] !== value) {
newSearchParams.set(id, value); newSearchParams.set(id, value);
} }
return newSearchParams; });
}, new URLSearchParams()); return newSearchParams;
}; };
interface EditFormDrawerProps { interface EditFormDrawerProps {
paramFields: ParamField[]; viewOptions: ViewOption[];
} }
export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) { export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { isOpen, onClose, onOpen } = useDisclosure(); const { isOpen, onClose, onOpen } = useDisclosure();
@@ -68,7 +79,7 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
formEvent.preventDefault(); formEvent.preventDefault();
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget)); const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, paramFields); const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
setSearchParams(newSearchParams); setSearchParams(newSearchParams);
onClose(); onClose();
@@ -85,15 +96,29 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
<DrawerBody> <DrawerBody>
<form id='edit-params-form' onSubmit={onParamsFormSubmit}> <form id='edit-params-form' onSubmit={onParamsFormSubmit}>
{paramFields.map((field) => ( {viewOptions.map((option) => {
<div key={field.title} className={style.columnSection}> if (isSection(option)) {
<label className={style.label}> return (
<span className={style.title}>{field.title}</span> <div key={option.section} className={style.section}>
<span className={style.description}>{field.description}</span> {option.section}
<ParamInput key={field.title} paramField={field} /> </div>
</label> );
</div> }
))}
if (option.type === 'persist') {
return null;
}
return (
<div key={option.title} className={style.fieldSet}>
<label className={style.label}>
<span className={style.title}>{option.title}</span>
<span className={style.description}>{option.description}</span>
<ParamInput key={option.title} paramField={option} />
</label>
</div>
);
})}
</form> </form>
</DrawerBody> </DrawerBody>
@@ -1,17 +1,18 @@
import { CustomFields } from 'ontime-types'; import { CustomFields } from 'ontime-types';
import { capitaliseFirstLetter } from '../../../features/viewers/common/viewUtils'; import type { ParamField } from './types';
import { ParamField } from './types'; export const makeOptionsFromCustomFields = (
customFields: CustomFields,
const makeOptionsFromCustomFields = (customFields: CustomFields, additionalOptions?: Record<string, string>) => { additionalOptions: Record<string, string> = {},
const customFieldOptions = Object.keys(customFields).reduce((acc, key) => { ) => {
return { ...acc, [`custom-${key}`]: `Custom: ${capitaliseFirstLetter(key)}` }; return Object.entries(customFields).reduce((options, [key, value]) => {
}, additionalOptions ?? {}); options[`custom-${key}`] = `Custom: ${value.label}`;
return customFieldOptions; return options;
}, additionalOptions);
}; };
const getTimeOption = (timeFormat: string): ParamField => { export const getTimeOption = (timeFormat: string): ParamField => {
const placeholder = `${timeFormat} (default)`; const placeholder = `${timeFormat} (default)`;
return { return {
id: 'timeformat', id: 'timeformat',
@@ -22,7 +23,7 @@ const getTimeOption = (timeFormat: string): ParamField => {
}; };
}; };
const hideTimerSeconds: ParamField = { export const hideTimerSeconds: ParamField = {
id: 'hideTimerSeconds', id: 'hideTimerSeconds',
title: 'Hide seconds in timer', title: 'Hide seconds in timer',
description: 'Whether to hide seconds in the running timer', description: 'Whether to hide seconds in the running timer',
@@ -30,474 +31,10 @@ const hideTimerSeconds: ParamField = {
defaultValue: false, defaultValue: false,
}; };
export const getClockOptions = (timeFormat: string): ParamField[] => [ export const showLeadingZeros: ParamField = {
getTimeOption(timeFormat), id: 'showLeadingZeros',
{ title: 'Show leading zeros in timer',
id: 'key', description: 'Whether to show leading zeros in the running timer',
title: 'Key Colour', type: 'boolean',
description: 'Background colour in hexadecimal', defaultValue: false,
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'text',
title: 'Text Colour',
description: 'Text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: 'fffff (default)',
},
{
id: 'textbg',
title: 'Text Background',
description: 'Colour of text background in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'font',
title: 'Font',
description: 'Font family, will use the fonts available in the system',
type: 'string',
placeholder: 'Arial Black (default)',
},
{
id: 'size',
title: 'Text Size',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
type: 'number',
placeholder: '1 (default)',
},
{
id: 'alignx',
title: 'Align Horizontal',
description: 'Moves the horizontally in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsetx',
title: 'Offset Horizontal',
description: 'Offsets the timer horizontal position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
{
id: 'aligny',
title: 'Align Vertical',
description: 'Moves the vertically in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsety',
title: 'Offset Vertical',
description: 'Offsets the timer vertical position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
];
export const getTimerOptions = (timeFormat: string, customFields: CustomFields): ParamField[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
return [
getTimeOption(timeFormat),
hideTimerSeconds,
{
id: 'hideClock',
title: 'Hide Time Now',
description: 'Hides the Time Now field',
type: 'boolean',
defaultValue: false,
},
{
id: 'secondary-src',
title: 'Secondary text',
description: 'Select the data source for the secondary text',
type: 'option',
values: secondaryOptions,
defaultValue: '',
},
{
id: 'hideCards',
title: 'Hide Cards',
description: 'Hides the Now and Next cards',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideProgress',
title: 'Hide progress bar',
description: 'Hides the progress bar',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideMessage',
title: 'Hide Presenter Message',
description: 'Prevents the screen from displaying messages from the presenter',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideExternal',
title: 'Hide External',
description: 'Prevents the screen from displaying the external field',
type: 'boolean',
defaultValue: false,
},
];
}; };
export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
hideTimerSeconds,
{
id: 'key',
title: 'Key Colour',
description: 'Background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'text',
title: 'Text Colour',
description: 'Text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: 'fffff (default)',
},
{
id: 'textbg',
title: 'Text Background',
description: 'Colour of text background in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'font',
title: 'Font',
description: 'Font family, will use the fonts available in the system',
type: 'string',
placeholder: 'Arial Black (default)',
},
{
id: 'size',
title: 'Text Size',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
type: 'number',
placeholder: '1 (default)',
},
{
id: 'alignx',
title: 'Align Horizontal',
description: 'Moves the horizontally in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsetx',
title: 'Offset Horizontal',
description: 'Offsets the timer horizontal position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
{
id: 'aligny',
title: 'Align Vertical',
description: 'Moves the vertically in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsety',
title: 'Offset Vertical',
description: 'Offsets the timer vertical position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
{
id: 'hideovertime',
title: 'Hide Overtime',
description: 'Whether to suppress overtime styles (red borders and red text)',
type: 'boolean',
defaultValue: false,
},
{
id: 'hidemessages',
title: 'Hide Message Overlay',
description: 'Whether to hide the overlay from showing timer screen messages',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideendmessage',
title: 'Hide End Message',
description: 'Whether to hide end message and continue showing the clock if timer is in overtime',
type: 'boolean',
defaultValue: false,
},
];
export const getLowerThirdOptions = (customFields: CustomFields): ParamField[] => {
const topSourceOptions = makeOptionsFromCustomFields(customFields, {
title: 'Title',
});
const bottomSourceOptions = makeOptionsFromCustomFields(customFields, {
title: 'Title',
none: 'None',
});
return [
{
id: 'trigger',
title: 'Animation Trigger',
description: '',
type: 'option',
values: {
event: 'Event Load',
manual: 'Manual',
},
defaultValue: 'manual',
},
{
id: 'top-src',
title: 'Top Text',
description: '',
type: 'option',
values: topSourceOptions,
defaultValue: 'title',
},
{
id: 'bottom-src',
title: 'Bottom Text',
description: 'Select the data source for the bottom element',
type: 'option',
values: bottomSourceOptions,
defaultValue: 'none',
},
{
id: 'top-colour',
title: 'Top Text Colour',
description: 'Top text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '0000ff (default)',
},
{
id: 'bottom-colour',
title: 'Bottom Text Colour',
description: 'Bottom text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '0000ff (default)',
},
{
id: 'top-bg',
title: 'Top Background Colour',
description: 'Top text background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'bottom-bg',
title: 'Bottom Background Colour',
description: 'Bottom text background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'top-size',
title: 'Top Text Size',
description: 'Font size of the top text',
type: 'string',
placeholder: '65px',
},
{
id: 'bottom-size',
title: 'Bottom Text Size',
description: 'Font size of the bottom text',
type: 'string',
placeholder: '64px',
},
{
id: 'width',
title: 'Minimum Width',
description: 'Minimum Width of the element',
type: 'number',
prefix: '%',
placeholder: '45 (default)',
},
{
id: 'transition',
title: 'Transition',
description: 'Transition in time in seconds (default 3)',
type: 'number',
placeholder: '3 (default)',
},
{
id: 'delay',
title: 'Delay',
description: 'Delay between transition in and out in seconds (default 3)',
type: 'number',
placeholder: '3 (default)',
},
{
id: 'key',
title: 'Key Colour',
description: 'Colour of the background',
prefix: '#',
type: 'string',
placeholder: 'ffffffff (default)',
},
{
id: 'line-colour',
title: 'Line Colour',
description: 'Colour of the line',
prefix: '#',
type: 'string',
placeholder: 'ff0000ff (default)',
},
];
};
export const getBackstageOptions = (timeFormat: string, customFields: CustomFields): ParamField[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
return [
getTimeOption(timeFormat),
{
id: 'hidePast',
title: 'Hide past events',
description: 'Scheduler will only show upcoming events',
type: 'boolean',
defaultValue: false,
},
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
description: 'Schedule will not auto-cycle through events',
type: 'boolean',
defaultValue: false,
},
{
id: 'eventsPerPage',
title: 'Events per page',
description: 'Sets the number of events on the page, can cause overflow',
type: 'number',
placeholder: '8 (default)',
},
{
id: 'secondary-src',
title: 'Event secondary text',
description: 'Select the data source for auxiliary text shown in now and next cards',
type: 'option',
values: secondaryOptions,
defaultValue: '',
},
];
};
export const getPublicOptions = (timeFormat: string, customFields: CustomFields): ParamField[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields);
return [
getTimeOption(timeFormat),
{
id: 'hidePast',
title: 'Hide past events',
description: 'Scheduler will only show upcoming events',
type: 'boolean',
defaultValue: false,
},
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
description: 'Schedule will not auto-cycle through events',
type: 'boolean',
defaultValue: false,
},
{
id: 'eventsPerPage',
title: 'Events per page',
description: 'Sets the number of events on the page, can cause overflow',
type: 'number',
placeholder: '8 (default)',
},
{
id: 'secondary-src',
title: 'Event secondary text',
description: 'Select the data source for auxiliary text shown in now and next cards',
type: 'option',
values: secondaryOptions,
defaultValue: '',
},
];
};
export const getStudioClockOptions = (timeFormat: string): ParamField[] => [
getTimeOption(timeFormat),
hideTimerSeconds,
];
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ParamField[] => {
const fieldOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
const customFieldSelect = Object.keys(customFields).reduce((acc, key) => {
return { ...acc, [key]: `Custom: ${capitaliseFirstLetter(key)}` };
}, {});
return [
getTimeOption(timeFormat),
{
id: 'hidepast',
title: 'Hide Past Events',
description: 'Whether to events that have passed',
type: 'boolean',
defaultValue: false,
},
{
id: 'main',
title: 'Main data field',
description: 'Field to be shown in the first line of text',
type: 'option',
values: fieldOptions,
defaultValue: 'title',
},
{
id: 'secondary',
title: 'Secondary data field',
description: 'Field to be shown in the second line of text',
type: 'option',
values: fieldOptions,
defaultValue: '',
},
{
id: 'subscribe',
title: 'Highlight Field',
description: 'Choose a custom field to highlight',
type: 'option',
values: customFieldSelect,
defaultValue: '',
},
{
id: 'shouldEdit',
title: 'Edit custom field',
description: 'Allows editing an events selected custom field by long pressing.',
type: 'boolean',
defaultValue: false,
},
];
};
export const getCountdownOptions = (timeFormat: string): ParamField[] => [getTimeOption(timeFormat), hideTimerSeconds];
@@ -1,3 +1,7 @@
type ParamSection = {
section: string;
};
type BaseField = { type BaseField = {
id: string; id: string;
title: string; title: string;
@@ -9,8 +13,25 @@ type OptionsField = {
values: Record<string, string>; values: Record<string, string>;
defaultValue?: string; defaultValue?: string;
}; };
type MultiOptionsField = {
type: 'multi-option';
values: Record<string, { value: string; label: string; colour: string }>;
defaultValue?: string;
};
type StringField = { type: 'string'; defaultValue?: string; prefix?: string; placeholder?: string }; type StringField = { type: 'string'; defaultValue?: string; prefix?: string; placeholder?: string };
type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string }; type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string };
type BooleanField = { type: 'boolean'; defaultValue: boolean }; type BooleanField = { type: 'boolean'; defaultValue: boolean };
type PersistedField = { type: 'persist'; defaultValue?: string; value: string };
export type ParamField = BaseField & (StringField | BooleanField | NumberField | OptionsField); export type ParamField = BaseField &
(StringField | BooleanField | NumberField | OptionsField | MultiOptionsField | PersistedField);
export type ViewOption = ParamSection | ParamField;
/**
* Type assertion utility checks whether an entry is a section separator
*/
export function isSection(entry: ViewOption): entry is ParamSection {
return 'section' in entry;
}
@@ -0,0 +1,34 @@
import { useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useClientStore } from '../stores/clientStore';
import { socketSendJson } from '../utils/socket';
export const useClientPath = () => {
const navigate = useNavigate();
const { pathname, search } = useLocation();
const redirect = useClientStore((store) => store.redirect);
const setRedirect = useClientStore((store) => store.setRedirect);
// notify of client path changes
useEffect(() => {
//remove leading '/' from path
const fullPath = (pathname.startsWith('/') ? pathname.slice(1) : pathname) + search;
socketSendJson('set-client-path', fullPath);
}, [pathname, search]);
// navigate to new path when received from server
useEffect(() => {
if (redirect === '') {
return;
}
// clear redirect
setRedirect('');
// navigate if there is a path change
if (redirect !== pathname + search) {
navigate(redirect);
}
}, [navigate, pathname, redirect, search, setRedirect]);
};
@@ -19,7 +19,7 @@ export default function useLongPress(callback: () => void, options: LongPressOpt
const { threshold = 400, onStart, onFinish, onCancel } = options; const { threshold = 400, onStart, onFinish, onCancel } = options;
const isLongPressActive = useRef(false); const isLongPressActive = useRef(false);
const isPressed = useRef(false); const isPressed = useRef(false);
const timerId = useRef<NodeJS.Timer>(); const timerId = useRef<NodeJS.Timeout>();
return useMemo(() => { return useMemo(() => {
const start = (event: SyntheticEvent) => { const start = (event: SyntheticEvent) => {
@@ -3,6 +3,12 @@ import { RuntimeStore, SimpleDirection, SimplePlayback } from 'ontime-types';
import { useRuntimeStore } from '../stores/runtime'; import { useRuntimeStore } from '../stores/runtime';
import { socketSendJson } from '../utils/socket'; import { socketSendJson } from '../utils/socket';
export const setClientRemote = {
setIdentify: (payload: { target: string; identify: boolean }) => socketSendJson('client', payload),
setRedirect: (payload: { target: string; redirect: string }) => socketSendJson('client', payload),
setClientName: (payload: { target: string; rename: string }) => socketSendJson('client', payload),
};
export const useRundownEditor = () => { export const useRundownEditor = () => {
const featureSelector = (state: RuntimeStore) => ({ const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback, playback: state.timer.playback,
@@ -1,4 +1,4 @@
import { MaybeNumber, Playback, TimerType } from 'ontime-types'; import { MaybeNumber, Playback, TimerPhase, TimerType } from 'ontime-types';
// first set extends TimerState // first set extends TimerState
export type ViewExtendedTimer = { export type ViewExtendedTimer = {
@@ -8,12 +8,11 @@ export type ViewExtendedTimer = {
elapsed: MaybeNumber; elapsed: MaybeNumber;
expectedFinish: MaybeNumber; expectedFinish: MaybeNumber;
finishedAt: MaybeNumber; finishedAt: MaybeNumber;
phase: TimerPhase;
playback: Playback; playback: Playback;
secondaryTimer: MaybeNumber; secondaryTimer: MaybeNumber;
startedAt: MaybeNumber; startedAt: MaybeNumber;
clock: number; clock: number;
timeDanger: MaybeNumber;
timeWarning: MaybeNumber;
timerType: TimerType; timerType: TimerType;
}; };
@@ -19,13 +19,10 @@ function persistModeToSession(mode: AppMode) {
type AppModeStore = { type AppModeStore = {
mode: AppMode; mode: AppMode;
setMode: (mode: AppMode) => void; setMode: (mode: AppMode) => void;
cursor: string | null;
setCursor: (cursor: string | null) => void;
}; };
export const useAppMode = create<AppModeStore>()((set) => ({ export const useAppMode = create<AppModeStore>()((set) => ({
mode: getModeFromSession(), mode: getModeFromSession(),
cursor: null,
setMode: (mode: AppMode) => { setMode: (mode: AppMode) => {
persistModeToSession(mode); persistModeToSession(mode);
@@ -33,5 +30,4 @@ export const useAppMode = create<AppModeStore>()((set) => ({
return { mode }; return { mode };
}); });
}, },
setCursor: (cursor: string | null) => set({ cursor }),
})); }));
@@ -0,0 +1,82 @@
import { ClientList } from 'ontime-types';
import { create } from 'zustand';
interface ClientStore {
name?: string;
setName: (newValue: string) => void;
id: string;
setId: (newValue: string) => void;
redirect: string;
setRedirect: (newValue: string) => void;
clients: ClientList;
setClients: (clients: ClientList) => void;
}
const clientNameKey = 'ontime-client-name';
function persistNameInStorage(newValue: string) {
localStorage.setItem(clientNameKey, newValue);
}
export const useClientStore = create<ClientStore>((set) => ({
name: localStorage.getItem(clientNameKey) ?? undefined,
setName: (name: string) =>
set(() => {
persistNameInStorage(name);
return { name };
}),
id: '',
setId: (id) => set({ id }),
redirect: '',
setRedirect: (redirect: string) => set({ redirect }),
clients: {},
setClients: (clients: ClientList) => set({ clients }),
}));
/**
* Allows getting client name (outside react)
*/
export function getClientName(): string | undefined {
return useClientStore.getState().name;
}
/**
* Allows updating current client name (outside react)
*/
export function setClientName(name: string): void {
useClientStore.getState().setName(name);
}
/**
* Allows updating current client name (outside react)
*/
export function setClientId(id: string): void {
useClientStore.getState().setId(id);
}
/**
* Allows getting client name (outside react)
*/
export function getClientId(): string | undefined {
return useClientStore.getState().id;
}
/**
* Allows updating redirect (outside react)
*/
export function setClientRedirect(path: string): void {
useClientStore.getState().setRedirect(path);
}
/**
* Allows setting clients (outside react)
*/
export function setClients(clients: ClientList): void {
return useClientStore.getState().setClients(clients);
}
@@ -1,26 +0,0 @@
import { useStore } from 'zustand';
import { createStore } from 'zustand/vanilla';
interface SocketClientNameState {
name?: string;
setName: (newValue: string) => void;
persistName: (newValue: string) => void;
}
const clientNameKey = 'ontime-client-name';
function persistKeyToStorage(newValue: string) {
localStorage.setItem(clientNameKey, newValue);
}
export const socketClientName = createStore<SocketClientNameState>((set) => ({
name: localStorage.getItem(clientNameKey) ?? undefined,
setName: (newValue: string) => set(() => ({ name: newValue })),
persistName: (newValue: string) =>
set(() => {
persistKeyToStorage(newValue);
return { name: newValue };
}),
}));
export const useSocketClientName = () => useStore(socketClientName);
+2 -1
View File
@@ -1,5 +1,5 @@
import isEqual from 'react-fast-compare'; import isEqual from 'react-fast-compare';
import { Playback, RuntimeStore, SimpleDirection, SimplePlayback } from 'ontime-types'; import { Playback, RuntimeStore, SimpleDirection, SimplePlayback, TimerPhase } from 'ontime-types';
import { createWithEqualityFn, useStoreWithEqualityFn } from 'zustand/traditional'; import { createWithEqualityFn, useStoreWithEqualityFn } from 'zustand/traditional';
export const runtimeStorePlaceholder: RuntimeStore = { export const runtimeStorePlaceholder: RuntimeStore = {
@@ -11,6 +11,7 @@ export const runtimeStorePlaceholder: RuntimeStore = {
elapsed: null, elapsed: null,
expectedFinish: null, expectedFinish: null,
finishedAt: null, finishedAt: null,
phase: TimerPhase.None,
playback: Playback.Stop, playback: Playback.Stop,
secondaryTimer: null, secondaryTimer: null,
startedAt: null, startedAt: null,
+2 -2
View File
@@ -3,6 +3,6 @@ export function isMacOS() {
return userAgent.includes('macintosh') || userAgent.includes('mac os'); return userAgent.includes('macintosh') || userAgent.includes('mac os');
} }
export const deviceAlt = isMacOS() ? '' : 'Alt'; export const deviceAlt = isMacOS() ? 'Option' : 'Alt';
export const deviceMod = isMacOS() ? '' : 'Ctrl'; export const deviceMod = isMacOS() ? 'Cmd' : 'Ctrl';
+1
View File
@@ -9,4 +9,5 @@ export const startsWithHttp = /^http:\/\//;
export const startsWithSlash = /^\//; export const startsWithSlash = /^\//;
export const isAlphanumeric = /^[a-z0-9]+$/i; export const isAlphanumeric = /^[a-z0-9]+$/i;
export const isASCII = /^[ -~]+$/; //https://catonmat.net/my-favorite-regex export const isASCII = /^[ -~]+$/; //https://catonmat.net/my-favorite-regex
export const isASCIIorEmpty = /^$|^[ -~]+$/; //https://catonmat.net/my-favorite-regex
export const isNotEmpty = /\S/; export const isNotEmpty = /\S/;
+56 -5
View File
@@ -1,21 +1,31 @@
import { Log, RuntimeStore } from 'ontime-types'; import { Log, RuntimeStore } from 'ontime-types';
import { isProduction, RUNTIME, websocketUrl } from '../api/constants'; import { CLIENT_LIST, isProduction, RUNTIME, websocketUrl } from '../api/constants';
import { ontimeQueryClient } from '../queryClient'; import { ontimeQueryClient } from '../queryClient';
import { socketClientName } from '../stores/connectionName'; import {
getClientId,
getClientName,
setClientId,
setClientName,
setClientRedirect,
setClients,
} from '../stores/clientStore';
import { addLog } from '../stores/logger'; import { addLog } from '../stores/logger';
import { patchRuntime, runtimeStore } from '../stores/runtime'; import { patchRuntime, runtimeStore } from '../stores/runtime';
export let websocket: WebSocket | null = null; export let websocket: WebSocket | null = null;
let reconnectTimeout: NodeJS.Timeout | null = null; let reconnectTimeout: NodeJS.Timeout | null = null;
const reconnectInterval = 1000; const reconnectInterval = 1000;
export let shouldReconnect = true; export let shouldReconnect = true;
export let hasConnected = false; export let hasConnected = false;
export let reconnectAttempts = 0; export let reconnectAttempts = 0;
export const connectSocket = (preferredClientName?: string) => { export const connectSocket = () => {
websocket = new WebSocket(websocketUrl); websocket = new WebSocket(websocketUrl);
const preferredClientName = getClientName();
websocket.onopen = () => { websocket.onopen = () => {
clearTimeout(reconnectTimeout as NodeJS.Timeout); clearTimeout(reconnectTimeout as NodeJS.Timeout);
hasConnected = true; hasConnected = true;
@@ -24,6 +34,9 @@ export const connectSocket = (preferredClientName?: string) => {
if (preferredClientName) { if (preferredClientName) {
socketSendJson('set-client-name', preferredClientName); socketSendJson('set-client-name', preferredClientName);
} }
socketSendJson('set-client-type', 'ontime');
socketSendJson('set-client-path', location.pathname);
}; };
websocket.onclose = () => { websocket.onclose = () => {
@@ -54,10 +67,48 @@ export const connectSocket = (preferredClientName?: string) => {
} }
switch (type) { switch (type) {
case 'client-name': { case 'client-id': {
socketClientName.getState().setName(payload); if (typeof payload === 'string') {
setClientId(payload);
}
break; break;
} }
case 'client-name': {
if (typeof payload === 'string') {
setClientName(payload);
}
break;
}
case 'client-rename': {
if (typeof payload === 'object') {
const id = getClientId();
if (payload.target && payload.target === id) {
setClientName(payload.name);
}
}
break;
}
case 'client-redirect': {
if (typeof payload === 'object') {
const id = getClientId();
if (payload.target && payload.target === id) {
setClientRedirect(payload.path);
}
}
break;
}
case 'client-list': {
setClients(payload);
if (!isProduction) {
ontimeQueryClient.setQueryData(CLIENT_LIST, payload);
}
break;
}
case 'ontime-log': { case 'ontime-log': {
addLog(payload as Log); addLog(payload as Log);
break; break;
+6
View File
@@ -17,4 +17,10 @@ declare global {
} }
} }
declare module 'react' {
interface CSSProperties {
[key: `--${string}`]: string | number;
}
}
export default {}; export default {};
@@ -3,10 +3,11 @@ import { ErrorBoundary } from '@sentry/react';
import { useKeyDown } from '../../common/hooks/useKeyDown'; import { useKeyDown } from '../../common/hooks/useKeyDown';
import AboutPanel from './panel/about-panel/AboutPanel'; import AboutPanel from './panel/about-panel/AboutPanel';
import ClientControlPanel from './panel/client-control-panel/ClientControlPanel';
import FeatureSettingsPanel from './panel/feature-settings-panel/FeatureSettingsPanel'; import FeatureSettingsPanel from './panel/feature-settings-panel/FeatureSettingsPanel';
import GeneralPanel from './panel/general-panel/GeneralPanel'; import GeneralPanel from './panel/general-panel/GeneralPanel';
import IntegrationsPanel from './panel/integrations-panel/IntegrationsPanel'; import IntegrationsPanel from './panel/integrations-panel/IntegrationsPanel';
import LogPanel from './panel/log-panel/LogPanel'; import NetworkLogPanel from './panel/network-panel/NetworkLogPanel';
import ProjectPanel from './panel/project-panel/ProjectPanel'; import ProjectPanel from './panel/project-panel/ProjectPanel';
import ShutdownPanel from './panel/shutdown-panel/ShutdownPanel'; import ShutdownPanel from './panel/shutdown-panel/ShutdownPanel';
import SourcesPanel from './panel/sources-panel/SourcesPanel'; import SourcesPanel from './panel/sources-panel/SourcesPanel';
@@ -30,8 +31,9 @@ export default function AppSettings() {
{panel === 'feature_settings' && <FeatureSettingsPanel location={location} />} {panel === 'feature_settings' && <FeatureSettingsPanel location={location} />}
{panel === 'sources' && <SourcesPanel />} {panel === 'sources' && <SourcesPanel />}
{panel === 'integrations' && <IntegrationsPanel location={location} />} {panel === 'integrations' && <IntegrationsPanel location={location} />}
{panel === 'client_control' && <ClientControlPanel />}
{panel === 'about' && <AboutPanel />} {panel === 'about' && <AboutPanel />}
{panel === 'log' && <LogPanel />} {panel === 'network' && <NetworkLogPanel location={location} />}
{panel === 'shutdown' && <ShutdownPanel />} {panel === 'shutdown' && <ShutdownPanel />}
</PanelContent> </PanelContent>
</ErrorBoundary> </ErrorBoundary>
@@ -73,12 +73,12 @@ $inner-padding: 1rem;
top: 0; top: 0;
z-index: 3; z-index: 3;
box-shadow: 0 1px $white-10; box-shadow: 0 1px $white-10;
background-color: $gray-1350;
} }
th { th {
font-weight: 400; font-weight: 400;
color: $gray-400; color: $gray-400;
background-color: $gray-1350;
white-space: nowrap; white-space: nowrap;
text-transform: capitalize; text-transform: capitalize;
} }
@@ -0,0 +1,24 @@
.fullWidth {
width: 100%;
}
.actionButtons {
display: flex;
gap: $element-spacing;
}
.halfWidth {
width: 50%;
white-space: nowrap;
}
.pathList {
width: 100%;
}
.badgeList {
width: 100%;
* {
margin-right: $element-spacing;
}
}
@@ -0,0 +1,15 @@
import * as Panel from '../PanelUtils';
import ClientList from './ClientList';
export default function ClientControlPanel() {
return (
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Manage clients</Panel.SubHeader>
<Panel.Divider />
<ClientList />
</Panel.Card>
</Panel.Section>
);
}
@@ -0,0 +1,147 @@
import { useState } from 'react';
import { Badge, Button, useDisclosure } from '@chakra-ui/react';
import { RedirectClientModal } from '../../../../common/components/client-modal/RedirectClientModal';
import { RenameClientModal } from '../../../../common/components/client-modal/RenameClientModal';
import { setClientRemote } from '../../../../common/hooks/useSocket';
import { useClientStore } from '../../../../common/stores/clientStore';
import * as Panel from '../PanelUtils';
import style from './ClientControlPanel.module.scss';
export default function ClientList() {
const id = useClientStore((store) => store.id);
const clients = useClientStore((store) => store.clients);
const { isOpen: isOpenRedirect, onOpen: onOpenRedirect, onClose: onCloseRedirect } = useDisclosure();
const { isOpen: isOpenRename, onOpen: onOpenRename, onClose: onCloseRename } = useDisclosure();
const { setIdentify } = setClientRemote;
const [targetId, setTargetId] = useState('');
const openRename = (targetId: string) => {
setTargetId(targetId);
onOpenRename();
};
const openRedirect = (targetId: string) => {
setTargetId(targetId);
onOpenRedirect();
};
const ontimeClients = Object.entries(clients).filter(([_, { type }]) => type === 'ontime');
const otherClients = Object.entries(clients).filter(([_, { type }]) => type !== 'ontime');
const targetClient = clients[targetId];
return (
<>
{isOpenRedirect && (
<RedirectClientModal
id={targetId}
name={targetClient?.name}
path={targetClient?.path}
isOpen={isOpenRedirect}
onClose={onCloseRedirect}
/>
)}
{isOpenRename && (
<RenameClientModal id={targetId} name={targetClient?.name} isOpen={isOpenRename} onClose={onCloseRename} />
)}
<Panel.Section>
<Panel.Title>Ontime Clients ({ontimeClients.length})</Panel.Title>
<Panel.Table>
<thead>
<tr>
<td className={style.fullWidth}>Client Name (Connection ID)</td>
<td className={style.fullWidth}>Path</td>
<td />
</tr>
</thead>
<tbody>
{ontimeClients.map(([key, client]) => {
const { identify, name, path } = client;
const isCurrent = id === key;
return (
<tr key={key}>
<td className={style.badgeList}>
<Badge variant='outline' size='xs'>
{key}
</Badge>
{isCurrent && (
<Badge variant='outline' colorScheme='yellow' size='xs'>
self
</Badge>
)}
{name}
</td>
{isCurrent ? <td /> : <td className={style.pathList}>{path}</td>}
<td className={style.actionButtons}>
<Button
size='xs'
className={`${identify ? style.blink : ''}`}
isDisabled={isCurrent}
variant={identify ? 'ontime-filled' : 'ontime-subtle'}
data-testid={isCurrent ? '' : 'not-self-identify'}
onClick={() => {
setIdentify({ target: key, identify: !identify });
}}
>
Identify
</Button>
<Button
size='xs'
variant='ontime-subtle'
data-testid={isCurrent ? '' : 'not-self-rename'}
onClick={() => openRename(key)}
>
Rename
</Button>
<Button
size='xs'
variant='ontime-subtle'
isDisabled={isCurrent}
data-testid={isCurrent ? '' : 'not-self-redirect'}
onClick={() => openRedirect(key)}
>
Redirect
</Button>
</td>
</tr>
);
})}
</tbody>
</Panel.Table>
</Panel.Section>
<Panel.Divider />
<Panel.Section>
<Panel.Title>Other Clients ({otherClients.length})</Panel.Title>
<Panel.Table>
<thead>
<tr>
<td className={style.halfWidth}>Client Name (Connection ID)</td>
<td className={style.halfWidth}>Client type</td>
</tr>
</thead>
<tbody>
{otherClients.map(([key, client]) => {
const { name, type } = client;
return (
<tr key={key}>
<td className={style.badgeList}>
<Badge variant='outline' size='sx'>
{key}
</Badge>
{name}
</td>
<td>{type}</td>
</tr>
);
})}
</tbody>
</Panel.Table>
</Panel.Section>
</>
);
}
@@ -67,7 +67,7 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
return ( return (
<form onSubmit={handleSubmit(setupSubmit)} className={style.fieldForm}> <form onSubmit={handleSubmit(setupSubmit)} className={style.fieldForm}>
<div className={style.column}> <div className={style.column}>
<Panel.Description>Label</Panel.Description> <Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
{errors.label && <Panel.Error>{errors.label.message}</Panel.Error>} {errors.label && <Panel.Error>{errors.label.message}</Panel.Error>}
<Input <Input
{...register('label', { {...register('label', {
@@ -153,6 +153,7 @@ export default function GeneralPanelForm() {
<option value='pt'>Portuguese</option> <option value='pt'>Portuguese</option>
<option value='es'>Spanish</option> <option value='es'>Spanish</option>
<option value='sv'>Swedish</option> <option value='sv'>Swedish</option>
<option value='pl'>Polish</option>
</Select> </Select>
</Panel.ListItem> </Panel.ListItem>
</Panel.ListGroup> </Panel.ListGroup>
@@ -1,3 +1,4 @@
import { useEffect } from 'react';
import { Controller, useFieldArray, useForm } from 'react-hook-form'; import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { Button, IconButton, Input, Select, Switch } from '@chakra-ui/react'; import { Button, IconButton, Input, Select, Switch } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd'; import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
@@ -8,7 +9,7 @@ import { generateId } from 'ontime-utils';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings'; import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings';
import { isKeyEscape } from '../../../../common/utils/keyEvent'; import { isKeyEscape } from '../../../../common/utils/keyEvent';
import { isASCII, isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex'; import { isASCII, isASCIIorEmpty, isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex';
import * as Panel from '../PanelUtils'; import * as Panel from '../PanelUtils';
import { cycles } from './integrationUtils'; import { cycles } from './integrationUtils';
@@ -40,6 +41,13 @@ export default function OscIntegrations() {
control, control,
}); });
// update form if we get new data from server
useEffect(() => {
if (data) {
reset(data);
}
}, [data, reset]);
const onSubmit = async (values: OSCSettings) => { const onSubmit = async (values: OSCSettings) => {
if (values.portIn === values.portOut) { if (values.portIn === values.portOut) {
setError('portIn', { message: 'OSC IN and OUT Ports cant be the same' }); setError('portIn', { message: 'OSC IN and OUT Ports cant be the same' });
@@ -221,7 +229,7 @@ export default function OscIntegrations() {
<th>Enabled</th> <th>Enabled</th>
<th>Cycle</th> <th>Cycle</th>
<th className={style.halfWidth}>Address</th> <th className={style.halfWidth}>Address</th>
<th className={style.halfWidth}>Payload</th> <th className={style.halfWidth}>Arguments</th>
<th /> <th />
</tr> </tr>
</thead> </thead>
@@ -277,7 +285,7 @@ export default function OscIntegrations() {
{...register(`subscriptions.${index}.payload`, { {...register(`subscriptions.${index}.payload`, {
validate: { validate: {
oscStringIsAscii: (value) => oscStringIsAscii: (value) =>
isASCII.test(value) || 'OSC payloads only allow ASCII characters', isASCIIorEmpty.test(value) || 'OSC arguments only allow ASCII characters',
}, },
})} })}
/> />
@@ -14,4 +14,6 @@ export const cycles: CycleLabel[] = [
{ id: 5, label: 'Every second', value: 'onClock' }, { id: 5, label: 'Every second', value: 'onClock' },
{ id: 5, label: 'On Timer Update', value: 'onUpdate' }, { id: 5, label: 'On Timer Update', value: 'onUpdate' },
{ id: 6, label: 'On Finish', value: 'onFinish' }, { id: 6, label: 'On Finish', value: 'onFinish' },
{ id: 7, label: 'On Warning', value: 'onWarning' },
{ id: 8, label: 'On Danger', value: 'onDanger' },
]; ];
@@ -1,17 +0,0 @@
import * as Panel from '../PanelUtils';
import LogExport from './LogExport';
import InfoNif from './NetworkInterfaces';
export default function LogPanel() {
return (
<>
<Panel.Header>Log</Panel.Header>
<Panel.Section>
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
</Panel.Section>
<InfoNif />
<LogExport />
</>
);
}
@@ -6,7 +6,7 @@ import { handleLinks } from '../../../../common/utils/linkUtils';
import Log from '../../../log/Log'; import Log from '../../../log/Log';
import * as Panel from '../PanelUtils'; import * as Panel from '../PanelUtils';
import style from './LogExport.module.scss'; import style from './NetworkLogExport.module.scss';
export default function LogExport() { export default function LogExport() {
const extract = (event: MouseEvent) => { const extract = (event: MouseEvent) => {
@@ -17,7 +17,7 @@ export default function LogExport() {
<Panel.Section> <Panel.Section>
<Panel.Card> <Panel.Card>
<Panel.SubHeader> <Panel.SubHeader>
Network log Event log
<Button <Button
variant='ontime-subtle' variant='ontime-subtle'
size='sm' size='sm'
@@ -0,0 +1,28 @@
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import { PanelBaseProps } from '../../settingsStore';
import ClientControlPanel from '../client-control-panel/ClientControlPanel';
import * as Panel from '../PanelUtils';
import InfoNif from './NetworkInterfaces';
import LogExport from './NetworkLogExport';
export default function NetworkLogPanel({ location }: PanelBaseProps) {
const clientsRef = useScrollIntoView<HTMLDivElement>('clients', location);
const logRef = useScrollIntoView<HTMLDivElement>('log', location);
return (
<>
<Panel.Header>Network</Panel.Header>
<Panel.Section>
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
</Panel.Section>
<InfoNif />
<div ref={logRef}>
<LogExport />
</div>
<div ref={clientsRef}>
<ClientControlPanel />
</div>
</>
);
}
@@ -50,7 +50,8 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
const handleSubmitCreate = async (values: ProjectCreateFormValues) => { const handleSubmitCreate = async (values: ProjectCreateFormValues) => {
try { try {
setError(null); setError(null);
const filename = values.title?.trim();
const filename = values.title ?? 'untitled';
await createProject({ await createProject({
...values, ...values,
@@ -2,8 +2,6 @@ import { useEffect } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { Button, Input } from '@chakra-ui/react'; import { Button, Input } from '@chakra-ui/react';
import * as Panel from '../PanelUtils';
import style from './ProjectPanel.module.scss'; import style from './ProjectPanel.module.scss';
export type ProjectFormValues = { export type ProjectFormValues = {
@@ -15,10 +13,9 @@ interface ProjectFormProps {
filename: string; filename: string;
onCancel: () => void; onCancel: () => void;
onSubmit: (values: ProjectFormValues) => Promise<void>; onSubmit: (values: ProjectFormValues) => Promise<void>;
submitError: string | null;
} }
export default function ProjectForm({ action, filename, onSubmit, onCancel, submitError }: ProjectFormProps) { export default function ProjectForm({ action, filename, onSubmit, onCancel }: ProjectFormProps) {
const { const {
handleSubmit, handleSubmit,
register, register,
@@ -37,34 +34,31 @@ export default function ProjectForm({ action, filename, onSubmit, onCancel, subm
}, [setFocus]); }, [setFocus]);
return ( return (
<> <form onSubmit={handleSubmit(onSubmit)} className={style.form}>
<form onSubmit={handleSubmit(onSubmit)} className={style.form}> <Input
<Input className={style.formInput}
className={style.formInput} id='filename'
id='filename' size='sm'
type='text'
variant='ontime-filled'
placeholder='Enter new name'
autoComplete='off'
{...register('filename', { required: true })}
/>
<div className={style.actionButtons}>
<Button onClick={onCancel} size='sm' variant='ontime-ghosted' disabled={isSubmitting}>
Cancel
</Button>
<Button
size='sm' size='sm'
type='text'
variant='ontime-filled' variant='ontime-filled'
placeholder='Enter new name' isDisabled={!isDirty || !isValid || isSubmitting}
autoComplete='off' type='submit'
{...register('filename')} className={style.saveButton}
/> >
<div className={style.actionButtons}> {action}
<Button onClick={onCancel} size='sm' variant='ontime-ghosted' disabled={isSubmitting}> </Button>
Cancel </div>
</Button> </form>
<Button
size='sm'
variant='ontime-filled'
isDisabled={!isDirty || !isValid || isSubmitting}
type='submit'
className={style.saveButton}
>
{action}
</Button>
</div>
</form>
{submitError && <Panel.Error>{submitError}</Panel.Error>}
</>
); );
} }
@@ -11,6 +11,7 @@ import {
renameProject, renameProject,
} from '../../../../common/api/db'; } from '../../../../common/api/db';
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils'; import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
import * as Panel from '../PanelUtils';
import ProjectForm, { ProjectFormValues } from './ProjectForm'; import ProjectForm, { ProjectFormValues } from './ProjectForm';
@@ -40,36 +41,53 @@ export default function ProjectListItem({
onToggleEditMode, onToggleEditMode,
}: ProjectListItemProps) { }: ProjectListItemProps) {
const [submitError, setSubmitError] = useState<string | null>(null); const [submitError, setSubmitError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmitRename = async (values: ProjectFormValues) => { const handleSubmitAction = (actionType: 'rename' | 'duplicate') => {
try { return async (values: ProjectFormValues) => {
setLoading(true);
setSubmitError(null); setSubmitError(null);
try {
if (!values.filename) { if (!values.filename) {
setSubmitError('Filename cannot be blank'); setSubmitError('Filename cannot be blank');
return; return;
}
const action = actionType === 'rename' ? renameProject : duplicateProject;
await action(filename, values.filename);
await onRefetch();
onSubmit();
} catch (error) {
setSubmitError(maybeAxiosError(error));
} finally {
setLoading(false);
} }
await renameProject(filename, values.filename); };
};
const handleLoad = async (filename: string) => {
setLoading(true);
setSubmitError(null);
try {
await loadProject(filename);
await onRefetch(); await onRefetch();
onSubmit(); await invalidateAllCaches();
} catch (error) { } catch (error) {
setSubmitError(maybeAxiosError(error)); setSubmitError(maybeAxiosError(error));
} finally {
setLoading(false);
} }
}; };
const handleSubmitDuplicate = async (values: ProjectFormValues) => { const handleDelete = async (filename: string) => {
setLoading(true);
setSubmitError(null);
try { try {
setSubmitError(null); await deleteProject(filename);
if (!values.filename) {
setSubmitError('Filename cannot be blank');
return;
}
await duplicateProject(filename, values.filename);
await onRefetch(); await onRefetch();
onSubmit();
} catch (error) { } catch (error) {
setSubmitError(maybeAxiosError(error)); setSubmitError(maybeAxiosError(error));
} finally {
setLoading(false);
} }
}; };
@@ -86,50 +104,55 @@ export default function ProjectListItem({
const classes = current && !isCurrentlyBeingEdited ? style.current : undefined; const classes = current && !isCurrentlyBeingEdited ? style.current : undefined;
return ( return (
<tr key={filename} className={classes}> <>
{isCurrentlyBeingEdited ? ( {submitError && (
<td colSpan={99}> <tr key='filename-error'>
<ProjectForm <td colSpan={99}>
action={editingMode} <Panel.Error>{submitError}</Panel.Error>
filename={filename} </td>
onSubmit={editingMode === 'duplicate' ? handleSubmitDuplicate : handleSubmitRename} </tr>
onCancel={handleCancel} )}
submitError={submitError} <tr key={filename} className={classes}>
/> {isCurrentlyBeingEdited ? (
</td> <td colSpan={99}>
) : ( <ProjectForm
<> action={editingMode}
<td className={style.containCell}>{filename}</td>
<td>{new Date(updatedAt).toLocaleString()}</td>
<td className={style.actionButton}>
<ActionMenu
current={current}
filename={filename} filename={filename}
onChangeEditMode={handleToggleEditMode} onSubmit={editingMode === 'duplicate' ? handleSubmitAction('duplicate') : handleSubmitAction('rename')}
onRefetch={onRefetch} onCancel={handleCancel}
/> />
</td> </td>
</> ) : (
)} <>
</tr> <td className={style.containCell}>{filename}</td>
<td>{new Date(updatedAt).toLocaleString()}</td>
<td className={style.actionButton}>
<ActionMenu
current={current}
filename={filename}
onChangeEditMode={handleToggleEditMode}
onDelete={handleDelete}
onLoad={handleLoad}
isDisabled={loading}
/>
</td>
</>
)}
</tr>
</>
); );
} }
function ActionMenu({ interface ActionMenuProps {
current,
filename,
onChangeEditMode,
onRefetch,
}: {
current?: boolean; current?: boolean;
filename: string; filename: string;
isDisabled: boolean;
onChangeEditMode: (editMode: EditMode, filename: string) => void; onChangeEditMode: (editMode: EditMode, filename: string) => void;
onRefetch: () => Promise<void>; onDelete: (filename: string) => void;
}) { onLoad: (filename: string) => void;
const handleLoad = async () => { }
await loadProject(filename); function ActionMenu(props: ActionMenuProps) {
await invalidateAllCaches(); const { current, filename, isDisabled, onChangeEditMode, onDelete, onLoad } = props;
};
const handleRename = () => { const handleRename = () => {
onChangeEditMode('rename', filename); onChangeEditMode('rename', filename);
@@ -139,11 +162,6 @@ function ActionMenu({
onChangeEditMode('duplicate', filename); onChangeEditMode('duplicate', filename);
}; };
const handleDelete = async () => {
await deleteProject(filename);
await onRefetch();
};
const handleDownload = async () => { const handleDownload = async () => {
await downloadProject(filename); await downloadProject(filename);
}; };
@@ -161,16 +179,17 @@ function ActionMenu({
color='#e2e2e2' // $gray-200 color='#e2e2e2' // $gray-200
variant='ontime-ghosted' variant='ontime-ghosted'
size='sm' size='sm'
isDisabled={isDisabled}
/> />
<MenuList> <MenuList>
<MenuItem onClick={handleLoad} isDisabled={current}> <MenuItem onClick={() => onLoad(filename)} isDisabled={current}>
Load Load
</MenuItem> </MenuItem>
<MenuItem onClick={handleRename}>Rename</MenuItem> <MenuItem onClick={handleRename}>Rename</MenuItem>
<MenuItem onClick={handleDuplicate}>Duplicate</MenuItem> <MenuItem onClick={handleDuplicate}>Duplicate</MenuItem>
<MenuItem onClick={handleDownload}>Download</MenuItem> <MenuItem onClick={handleDownload}>Download</MenuItem>
<MenuItem onClick={handleExportCSV}>Export CSV Rundown</MenuItem> <MenuItem onClick={handleExportCSV}>Export CSV Rundown</MenuItem>
<MenuItem isDisabled={current} onClick={handleDelete}> <MenuItem isDisabled={current} onClick={() => onDelete(filename)}>
Delete Delete
</MenuItem> </MenuItem>
</MenuList> </MenuList>
@@ -1,5 +1,5 @@
.uploadSection, .uploadSection,
.successSection { .finishSection {
margin-top: 1rem; margin-top: 1rem;
display: flex; display: flex;
padding: 3rem 1rem; padding: 3rem 1rem;
@@ -15,12 +15,18 @@
gap: 2rem; gap: 2rem;
} }
.successSection { .finishSection {
color: $green-500;
font-size: 1.5rem; font-size: 1.5rem;
text-align: center; text-align: center;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
.error {
color: $red-500;
}
.success {
color: $green-500;
}
} }
.buttonRow { .buttonRow {
@@ -76,8 +76,13 @@ export default function SourcesPanel() {
setAuthenticationStatus(result.authenticated); setAuthenticationStatus(result.authenticated);
setSheetId(result.sheetId); setSheetId(result.sheetId);
if (result.authenticated === 'authenticated' && result.sheetId) { if (result.authenticated === 'authenticated' && result.sheetId) {
const names = await getWorksheetNames(result.sheetId); try {
setWorksheets(names); const names = await getWorksheetNames(result.sheetId);
setWorksheets(names);
} catch (error) {
const message = maybeAxiosError(error);
setError(`Error getting worksheets: ${message}`);
}
} }
} }
setImportFlow('gsheet'); setImportFlow('gsheet');
@@ -129,11 +134,20 @@ export default function SourcesPanel() {
await exportRundown(sheetId, importMap); await exportRundown(sheetId, importMap);
}; };
const resetFlow = () => {
setImportFlow('none');
setRundown(null);
setHasFile('none');
setWorksheets(null);
setCustomFields(null);
setError('');
};
const isExcelFlow = importFlow === 'excel'; const isExcelFlow = importFlow === 'excel';
const isGSheetFlow = importFlow === 'gsheet'; const isGSheetFlow = importFlow === 'gsheet';
const isAuthenticated = authenticationStatus === 'authenticated'; const isAuthenticated = authenticationStatus === 'authenticated';
const showInput = importFlow === 'none'; const showInput = importFlow === 'none';
const showSuccess = importFlow === 'finished'; const showCompleted = importFlow === 'finished';
const showAuth = isGSheetFlow && !isAuthenticated; const showAuth = isGSheetFlow && !isAuthenticated;
const showImportMap = (isGSheetFlow && isAuthenticated) || (isExcelFlow && hasFile === 'done'); const showImportMap = (isGSheetFlow && isAuthenticated) || (isExcelFlow && hasFile === 'done');
const showReview = rundown !== null && customFields !== null; const showReview = rundown !== null && customFields !== null;
@@ -184,10 +198,18 @@ export default function SourcesPanel() {
</div> </div>
</> </>
)} )}
{showSuccess && ( {showCompleted && (
<div className={style.successSection}> <div className={style.finishSection}>
<span>Import successful</span> {error ? (
<Button variant='ontime-filled' size='sm' onClick={() => setImportFlow('none')}> <span key='finish__error' className={style.error}>
Import failed
</span>
) : (
<span key='finish__success' className={style.success}>
Import successful
</span>
)}
<Button variant='ontime-filled' size='sm' onClick={resetFlow}>
Return Return
</Button> </Button>
</div> </div>
@@ -195,6 +217,7 @@ export default function SourcesPanel() {
{showAuth && <GSheetSetup onCancel={cancelGSheetFlow} />} {showAuth && <GSheetSetup onCancel={cancelGSheetFlow} />}
{showImportMap && !showReview && ( {showImportMap && !showReview && (
<ImportMapForm <ImportMapForm
hasErrors={Boolean(error)}
isSpreadsheet={isExcelFlow} isSpreadsheet={isExcelFlow}
onCancel={cancelImportMap} onCancel={cancelImportMap}
onSubmitExport={handleSubmitExport} onSubmitExport={handleSubmitExport}
@@ -15,14 +15,15 @@ import { convertToImportMap, getPersistedOptions, NamedImportMap, persistImportM
import style from '../SourcesPanel.module.scss'; import style from '../SourcesPanel.module.scss';
interface ImportMapFormProps { interface ImportMapFormProps {
isSpreadsheet?: boolean; hasErrors: boolean;
isSpreadsheet: boolean;
onCancel: () => void; onCancel: () => void;
onSubmitExport: (importMap: ImportMap) => Promise<void>; onSubmitExport: (importMap: ImportMap) => Promise<void>;
onSubmitImport: (importMap: ImportMap) => Promise<void>; onSubmitImport: (importMap: ImportMap) => Promise<void>;
} }
export default function ImportMapForm(props: ImportMapFormProps) { export default function ImportMapForm(props: ImportMapFormProps) {
const { isSpreadsheet, onCancel, onSubmitExport, onSubmitImport } = props; const { hasErrors, isSpreadsheet, onCancel, onSubmitExport, onSubmitImport } = props;
const namedImportMap = getPersistedOptions(); const namedImportMap = getPersistedOptions();
const { revoke } = useGoogleSheet(); const { revoke } = useGoogleSheet();
const { const {
@@ -78,7 +79,7 @@ export default function ImportMapForm(props: ImportMapFormProps) {
const isLoading = Boolean(loading); const isLoading = Boolean(loading);
const canSubmitSpreadsheet = isSpreadsheet && !isLoading; const canSubmitSpreadsheet = isSpreadsheet && !isLoading;
const canSubmitGSheet = !isLoading; const canSubmitGSheet = !isLoading;
const canSubmit = isValid && (canSubmitSpreadsheet || canSubmitGSheet); const canSubmit = !hasErrors && isValid && (canSubmitSpreadsheet || canSubmitGSheet);
return ( return (
<Panel.Section as='form' id='import-map'> <Panel.Section as='form' id='import-map'>
@@ -50,7 +50,21 @@ export const settingPanels: Readonly<SettingsOption[]> = [
{ id: 'integrations__http', label: 'HTTP settings' }, { id: 'integrations__http', label: 'HTTP settings' },
], ],
}, },
{ id: 'log', label: 'Log', split: true }, {
id: 'network',
label: 'Network',
split: true,
secondary: [
{
id: 'network__log',
label: 'Event log',
},
{
id: 'network__clients',
label: 'Manage clients',
},
],
},
{ {
id: 'about', id: 'about',
label: 'About', label: 'About',
@@ -1,5 +1,12 @@
.extraRow { .label {
display: block;
margin-top: 2rem;
font-size: $inner-section-text-size;
color: $label-gray;
}
.controls {
margin-top: 0.25rem;
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
margin-top: 2rem;
} }
@@ -29,33 +29,36 @@ export function AuxTimer() {
}; };
return ( return (
<div className={style.extraRow}> <label className={style.label}>
<AuxTimerInput /> Auxiliary Timer
<TapButton onClick={toggleDirection} aspect='tight'> <div className={style.controls}>
{direction === SimpleDirection.CountDown && <IoArrowDown data-testid='aux-timer-direction' />} <AuxTimerInput />
{direction === SimpleDirection.CountUp && <IoArrowUp data-testid='aux-timer-direction' />} <TapButton onClick={toggleDirection} aspect='tight'>
</TapButton> {direction === SimpleDirection.CountDown && <IoArrowDown data-testid='aux-timer-direction' />}
{direction === SimpleDirection.CountUp && <IoArrowUp data-testid='aux-timer-direction' />}
</TapButton>
<TapButton <TapButton
onClick={start} onClick={start}
theme={Playback.Play} theme={Playback.Play}
active={playback === SimplePlayback.Start} active={playback === SimplePlayback.Start}
disabled={!userCan.start} disabled={!userCan.start}
> >
<IoPlay data-testid='aux-timer-start' /> <IoPlay data-testid='aux-timer-start' />
</TapButton> </TapButton>
<TapButton <TapButton
onClick={pause} onClick={pause}
theme={Playback.Pause} theme={Playback.Pause}
active={playback === SimplePlayback.Pause} active={playback === SimplePlayback.Pause}
disabled={!userCan.pause} disabled={!userCan.pause}
> >
<IoPause data-testid='aux-timer-pause' /> <IoPause data-testid='aux-timer-pause' />
</TapButton> </TapButton>
<TapButton onClick={stop} theme={Playback.Stop} disabled={!userCan.stop}> <TapButton onClick={stop} theme={Playback.Stop} disabled={!userCan.stop}>
<IoStop data-testid='aux-timer-stop' /> <IoStop data-testid='aux-timer-stop' />
</TapButton> </TapButton>
</div> </div>
</label>
); );
} }
@@ -1,6 +1,6 @@
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { Tooltip } from '@chakra-ui/react'; import { Tooltip } from '@chakra-ui/react';
import { Playback } from 'ontime-types'; import { Playback, TimerPhase } from 'ontime-types';
import { dayInMs, millisToMinutes, millisToSeconds, millisToString } from 'ontime-utils'; import { dayInMs, millisToMinutes, millisToSeconds, millisToString } from 'ontime-utils';
import { useTimer } from '../../../../common/hooks/useSocket'; import { useTimer } from '../../../../common/hooks/useSocket';
@@ -43,8 +43,8 @@ export default function PlaybackTimer(props: PropsWithChildren<PlaybackTimerProp
const finish = millisToString(expectedFinish); const finish = millisToString(expectedFinish);
const isRolling = playback === Playback.Roll; const isRolling = playback === Playback.Roll;
const isWaiting = timer.secondaryTimer !== null && timer.secondaryTimer > 0 && timer.current === null; const isWaiting = timer.phase === TimerPhase.Pending;
const isOvertime = timer.current !== null && timer.current < 0; const isOvertime = timer.phase === TimerPhase.Overtime;
const hasAddedTime = Boolean(timer.addedTime); const hasAddedTime = Boolean(timer.addedTime);
const rollLabel = isRolling ? 'Roll mode active' : ''; const rollLabel = isRolling ? 'Roll mode active' : '';
@@ -16,12 +16,9 @@ interface TimerDisplayProps {
export default function TimerDisplay(props: TimerDisplayProps) { export default function TimerDisplay(props: TimerDisplayProps) {
const { time } = props; const { time } = props;
if (time == null) { const isNegative = (time ?? 0) < 0;
return <div className={style.timer}>{timerPlaceholder}</div>; const display =
} time == null ? timerPlaceholder : millisToString(time, { fallback: timerPlaceholder }).replace('-', '');
const isNegative = time < 0;
const display = millisToString(Math.abs(time), { fallback: timerPlaceholder });
const classes = cx([style.timer, isNegative ? style.finished : null]); const classes = cx([style.timer, isNegative ? style.finished : null]);
return <div className={classes}>{display}</div>; return <div className={classes}>{display}</div>;
@@ -36,7 +36,6 @@ $table-header-font-size: calc(1rem - 3px);
min-width: 2rem; min-width: 2rem;
text-align: right; text-align: right;
font-weight: 400; font-weight: 400;
position: sticky; position: sticky;
left: 0; left: 0;
z-index: 1; z-index: 1;
@@ -48,11 +47,13 @@ $table-header-font-size: calc(1rem - 3px);
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 10; z-index: 10;
background-color: $gray-1300;
font-size: $table-header-font-size; font-size: $table-header-font-size;
color: $gray-700; color: $gray-700;
font-weight: 400;
th {
background-color: $gray-1300;
}
} }
.eventRow { .eventRow {
+14 -21
View File
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useRef } from 'react'; import { useCallback, useRef } from 'react';
import { useLocalStorage } from '@mantine/hooks';
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'; import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import Color from 'color'; import Color from 'color';
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types'; import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
@@ -13,7 +12,7 @@ import DelayRow from './cuesheet-table-elements/DelayRow';
import EventRow from './cuesheet-table-elements/EventRow'; import EventRow from './cuesheet-table-elements/EventRow';
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings'; import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
import { useCuesheetSettings } from './store/CuesheetSettings'; import { useCuesheetSettings } from './store/CuesheetSettings';
import { initialColumnOrder } from './cuesheetCols'; import useColumnManager from './useColumnManager';
import style from './Cuesheet.module.scss'; import style from './Cuesheet.module.scss';
@@ -25,25 +24,22 @@ interface CuesheetProps {
} }
export default function Cuesheet({ data, columns, handleUpdate, selectedId }: CuesheetProps) { export default function Cuesheet({ data, columns, handleUpdate, selectedId }: CuesheetProps) {
const { followSelected, showSettings, showDelayBlock, showPrevious } = useCuesheetSettings(); const { followSelected, showSettings, showDelayBlock, showPrevious, showIndexColumn } = useCuesheetSettings();
const [columnVisibility, setColumnVisibility] = useLocalStorage({ key: 'table-hidden', defaultValue: {} }); const {
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({ columnVisibility,
key: 'table-order', columnOrder,
defaultValue: initialColumnOrder, columnSizing,
}); resetColumnOrder,
const [columnSizing, setColumnSizing] = useLocalStorage({ key: 'table-sizes', defaultValue: {} }); setColumnVisibility,
saveColumnOrder,
setColumnSizing,
} = useColumnManager(columns);
const selectedRef = useRef<HTMLTableRowElement | null>(null); const selectedRef = useRef<HTMLTableRowElement | null>(null);
const tableContainerRef = useRef<HTMLDivElement | null>(null); const tableContainerRef = useRef<HTMLDivElement | null>(null);
useFollowComponent({ followRef: selectedRef, scrollRef: tableContainerRef, doFollow: followSelected }); useFollowComponent({ followRef: selectedRef, scrollRef: tableContainerRef, doFollow: followSelected });
// keep column order in sync with columns
useEffect(() => {
const order = columns.map((col) => col.id as string);
saveColumnOrder(order);
}, [columns, saveColumnOrder]);
const table = useReactTable({ const table = useReactTable({
data, data,
columns, columns,
@@ -61,10 +57,6 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
}); });
const resetColumnOrder = () => {
saveColumnOrder(initialColumnOrder);
};
const setAllVisible = () => { const setAllVisible = () => {
table.toggleAllColumnsVisible(true); table.toggleAllColumnsVisible(true);
}; };
@@ -112,7 +104,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
)} )}
<div ref={tableContainerRef} className={style.cuesheetContainer}> <div ref={tableContainerRef} className={style.cuesheetContainer}>
<table className={style.cuesheet}> <table className={style.cuesheet}>
<CuesheetHeader headerGroups={headerGroups} saveColumnOrder={reorder} /> <CuesheetHeader headerGroups={headerGroups} saveColumnOrder={reorder} showIndexColumn={showIndexColumn} />
<tbody> <tbody>
{rowModel.rows.map((row) => { {rowModel.rows.map((row) => {
const key = row.original.id; const key = row.original.id;
@@ -165,6 +157,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
selectedRef={isSelected ? selectedRef : undefined} selectedRef={isSelected ? selectedRef : undefined}
skip={row.original.skip} skip={row.original.skip}
colour={row.original.colour} colour={row.original.colour}
showIndexColumn={showIndexColumn}
> >
{row.getVisibleCells().map((cell) => { {row.getVisibleCells().map((cell) => {
return ( return (
@@ -19,6 +19,7 @@ export default function CuesheetProgress() {
danger={timeDanger} danger={timeDanger}
dangerColor={data!.dangerColor} dangerColor={data!.dangerColor}
className={styles.progressOverride} className={styles.progressOverride}
ignoreCssOverride
/> />
); );
} }
@@ -1,4 +1,3 @@
import { Tooltip } from '@chakra-ui/react';
import { import {
closestCorners, closestCorners,
DndContext, DndContext,
@@ -13,7 +12,6 @@ import { flexRender, HeaderGroup } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types'; import { OntimeRundownEntry } from 'ontime-types';
import { getAccessibleColour } from '../../../common/utils/styleUtils'; import { getAccessibleColour } from '../../../common/utils/styleUtils';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { SortableCell } from './SortableCell'; import { SortableCell } from './SortableCell';
@@ -22,10 +20,11 @@ import style from '../Cuesheet.module.scss';
interface CuesheetHeaderProps { interface CuesheetHeaderProps {
headerGroups: HeaderGroup<OntimeRundownEntry>[]; headerGroups: HeaderGroup<OntimeRundownEntry>[];
saveColumnOrder: (fromId: string, toId: string) => void; saveColumnOrder: (fromId: string, toId: string) => void;
showIndexColumn: boolean;
} }
export default function CuesheetHeader(props: CuesheetHeaderProps) { export default function CuesheetHeader(props: CuesheetHeaderProps) {
const { headerGroups, saveColumnOrder } = props; const { headerGroups, saveColumnOrder, showIndexColumn } = props;
const handleOnDragEnd = (event: DragEndEvent) => { const handleOnDragEnd = (event: DragEndEvent) => {
const { delta, active, over } = event; const { delta, active, over } = event;
@@ -61,11 +60,7 @@ export default function CuesheetHeader(props: CuesheetHeaderProps) {
return ( return (
<DndContext key={key} sensors={sensors} collisionDetection={closestCorners} onDragEnd={handleOnDragEnd}> <DndContext key={key} sensors={sensors} collisionDetection={closestCorners} onDragEnd={handleOnDragEnd}>
<tr key={headerGroup.id}> <tr key={headerGroup.id}>
<th className={style.indexColumn}> <th className={style.indexColumn}>{showIndexColumn && '#'}</th>
<Tooltip label='Event Order' openDelay={tooltipDelayFast}>
#
</Tooltip>
</th>
<SortableContext key={key} items={headerGroup.headers} strategy={horizontalListSortingStrategy}> <SortableContext key={key} items={headerGroup.headers} strategy={horizontalListSortingStrategy}>
{headerGroup.headers.map((header) => { {headerGroup.headers.map((header) => {
const width = header.getSize(); const width = header.getSize();
@@ -8,6 +8,7 @@ const pastOpacity = '0.2';
interface EventRowProps { interface EventRowProps {
eventIndex: number; eventIndex: number;
showIndexColumn: boolean;
isPast?: boolean; isPast?: boolean;
selectedRef?: MutableRefObject<HTMLTableRowElement | null>; selectedRef?: MutableRefObject<HTMLTableRowElement | null>;
skip?: boolean; skip?: boolean;
@@ -15,7 +16,7 @@ interface EventRowProps {
} }
function EventRow(props: PropsWithChildren<EventRowProps>) { function EventRow(props: PropsWithChildren<EventRowProps>) {
const { children, eventIndex, isPast, selectedRef, skip, colour } = props; const { children, eventIndex, isPast, selectedRef, skip, colour, showIndexColumn } = props;
const ownRef = useRef<HTMLTableRowElement>(null); const ownRef = useRef<HTMLTableRowElement>(null);
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
@@ -56,7 +57,7 @@ function EventRow(props: PropsWithChildren<EventRowProps>) {
ref={selectedRef ?? ownRef} ref={selectedRef ?? ownRef}
> >
<td className={style.indexColumn} style={{ backgroundColor: bgColour, color: textColour.color }}> <td className={style.indexColumn} style={{ backgroundColor: bgColour, color: textColour.color }}>
{eventIndex} {showIndexColumn && eventIndex}
</td> </td>
{isVisible ? children : null} {isVisible ? children : null}
</tr> </tr>
@@ -24,11 +24,13 @@ function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
const { columns, handleResetResizing, handleResetReordering, handleClearToggles } = props; const { columns, handleResetResizing, handleResetReordering, handleClearToggles } = props;
const { const {
followSelected, followSelected,
showIndexColumn,
toggleFollow, toggleFollow,
showPrevious, showPrevious,
togglePreviousVisibility, togglePreviousVisibility,
showDelayBlock, showDelayBlock,
showDelayedTimes, showDelayedTimes,
toggleIndexColumn,
toggleDelayedTimes, toggleDelayedTimes,
toggleDelayVisibility, toggleDelayVisibility,
} = useCuesheetSettings(); } = useCuesheetSettings();
@@ -63,6 +65,10 @@ function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
<Switch variant='ontime' size='sm' isChecked={showPrevious} onChange={() => togglePreviousVisibility()} /> <Switch variant='ontime' size='sm' isChecked={showPrevious} onChange={() => togglePreviousVisibility()} />
Show past events Show past events
</label> </label>
<label className={style.option}>
<Switch variant='ontime' size='sm' isChecked={showIndexColumn} onChange={() => toggleIndexColumn()} />
Show Event Order
</label>
</div> </div>
<div className={style.sectionTitle}>Delay Flow</div> <div className={style.sectionTitle}>Delay Flow</div>
<div className={style.options}> <div className={style.options}>
@@ -117,5 +117,3 @@ export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<Ontim
...dynamicCustomFields, ...dynamicCustomFields,
]; ];
} }
export const initialColumnOrder: string[] = makeCuesheetColumns({}).map((column) => column.id as string);
@@ -4,6 +4,7 @@ import { booleanFromLocalStorage } from '../../../common/utils/localStorage';
interface CuesheetSettings { interface CuesheetSettings {
showSettings: boolean; showSettings: boolean;
showIndexColumn: boolean;
followSelected: boolean; followSelected: boolean;
showPrevious: boolean; showPrevious: boolean;
showDelayBlock: boolean; showDelayBlock: boolean;
@@ -12,6 +13,7 @@ interface CuesheetSettings {
toggleSettings: (newValue?: boolean) => void; toggleSettings: (newValue?: boolean) => void;
toggleFollow: (newValue?: boolean) => void; toggleFollow: (newValue?: boolean) => void;
togglePreviousVisibility: (newValue?: boolean) => void; togglePreviousVisibility: (newValue?: boolean) => void;
toggleIndexColumn: (newValue?: boolean) => void;
toggleDelayVisibility: (newValue?: boolean) => void; toggleDelayVisibility: (newValue?: boolean) => void;
toggleDelayedTimes: (newValue?: boolean) => void; toggleDelayedTimes: (newValue?: boolean) => void;
} }
@@ -27,11 +29,13 @@ enum CuesheetKeys {
Follow = 'ontime-cuesheet-follow-selected', Follow = 'ontime-cuesheet-follow-selected',
DelayVisibility = 'ontime-cuesheet-show-delay', DelayVisibility = 'ontime-cuesheet-show-delay',
PreviousVisibility = 'ontime-cuesheet-show-previous', PreviousVisibility = 'ontime-cuesheet-show-previous',
ColumnIndex = 'ontime-cuesheet-show-index-column',
DelayedTimes = 'ontime-cuesheet-show-delayed', DelayedTimes = 'ontime-cuesheet-show-delayed',
} }
export const useCuesheetSettings = create<CuesheetSettings>()((set) => ({ export const useCuesheetSettings = create<CuesheetSettings>()((set) => ({
showSettings: false, showSettings: false,
showIndexColumn: booleanFromLocalStorage(CuesheetKeys.ColumnIndex, true),
followSelected: booleanFromLocalStorage(CuesheetKeys.Follow, false), followSelected: booleanFromLocalStorage(CuesheetKeys.Follow, false),
showPrevious: booleanFromLocalStorage(CuesheetKeys.PreviousVisibility, true), showPrevious: booleanFromLocalStorage(CuesheetKeys.PreviousVisibility, true),
showDelayBlock: booleanFromLocalStorage(CuesheetKeys.DelayVisibility, true), showDelayBlock: booleanFromLocalStorage(CuesheetKeys.DelayVisibility, true),
@@ -44,6 +48,12 @@ export const useCuesheetSettings = create<CuesheetSettings>()((set) => ({
localStorage.setItem(CuesheetKeys.Follow, String(followSelected)); localStorage.setItem(CuesheetKeys.Follow, String(followSelected));
return { followSelected }; return { followSelected };
}), }),
toggleIndexColumn: (newValue?: boolean) =>
set((state) => {
const showIndexColumn = toggle(state.showIndexColumn, newValue);
localStorage.setItem(CuesheetKeys.ColumnIndex, String(showIndexColumn));
return { showIndexColumn };
}),
togglePreviousVisibility: (newValue?: boolean) => togglePreviousVisibility: (newValue?: boolean) =>
set((state) => { set((state) => {
const showPrevious = toggle(state.showPrevious, newValue); const showPrevious = toggle(state.showPrevious, newValue);
@@ -0,0 +1,46 @@
import { useCallback, useEffect } from 'react';
import { useLocalStorage } from '@mantine/hooks';
import { ColumnDef } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types';
export default function useColumnManager(columns: ColumnDef<OntimeRundownEntry>[]) {
const [columnVisibility, setColumnVisibility] = useLocalStorage({ key: 'table-hidden', defaultValue: {} });
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({
key: 'table-order',
defaultValue: columns.map((col) => col.id as string),
});
const [columnSizing, setColumnSizing] = useLocalStorage({ key: 'table-sizes', defaultValue: {} });
// if the columns change, we update the dataset
useEffect(() => {
let shouldReplace = false;
const newColumns: string[] = [];
// iterate through columns to see if there are new ids
columns.forEach((column) => {
const columnnId = column.id as string;
if (!shouldReplace && !columnOrder.includes(columnnId)) {
shouldReplace = true;
}
newColumns.push(columnnId);
});
if (shouldReplace) {
saveColumnOrder(newColumns);
}
}, [columnOrder, columns, saveColumnOrder]);
const resetColumnOrder = useCallback(() => {
saveColumnOrder(columns.map((col) => col.id as string));
}, [columns, saveColumnOrder]);
return {
columnVisibility,
columnOrder,
columnSizing,
resetColumnOrder,
setColumnVisibility,
saveColumnOrder,
setColumnSizing,
};
}
+23 -23
View File
@@ -1,10 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { CustomField, CustomFields, isOntimeEvent, OntimeEvent, SupportedEvent } from 'ontime-types'; import { isOntimeEvent, OntimeEvent, SupportedEvent } from 'ontime-types';
import { getFirstEventNormal, getLastEventNormal } from 'ontime-utils'; import { getFirstEventNormal, getLastEventNormal } from 'ontime-utils';
import Empty from '../../common/components/state/Empty'; import Empty from '../../common/components/state/Empty';
import { getOperatorOptions } from '../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import useFollowComponent from '../../common/hooks/useFollowComponent'; import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useOperator } from '../../common/hooks/useSocket'; import { useOperator } from '../../common/hooks/useSocket';
@@ -22,16 +21,15 @@ import FollowButton from './follow-button/FollowButton';
import OperatorBlock from './operator-block/OperatorBlock'; import OperatorBlock from './operator-block/OperatorBlock';
import OperatorEvent from './operator-event/OperatorEvent'; import OperatorEvent from './operator-event/OperatorEvent';
import StatusBar from './status-bar/StatusBar'; import StatusBar from './status-bar/StatusBar';
import { getOperatorOptions } from './operator.options';
import style from './Operator.module.scss'; import style from './Operator.module.scss';
const selectedOffset = 50; const selectedOffset = 50;
export type Subscribed = { id: string; label: string; colour: string; value: string }[];
type TitleFields = Pick<OntimeEvent, 'title'>; type TitleFields = Pick<OntimeEvent, 'title'>;
export type EditEvent = Pick<OntimeEvent, 'id' | 'cue'> & { fieldLabel?: string; fieldValue: string }; export type EditEvent = Pick<OntimeEvent, 'id' | 'cue'> & { subscriptions: Subscribed };
export type PartialEdit = EditEvent & {
field: keyof CustomFields;
};
export default function Operator() { export default function Operator() {
const { data, status } = useRundown(); const { data, status } = useRundown();
@@ -45,7 +43,7 @@ export default function Operator() {
const { data: settings } = useSettings(); const { data: settings } = useSettings();
const [showEditPrompt, setShowEditPrompt] = useState(false); const [showEditPrompt, setShowEditPrompt] = useState(false);
const [editEvent, setEditEvent] = useState<PartialEdit | null>(null); const [editEvent, setEditEvent] = useState<EditEvent | null>(null);
const [lockAutoScroll, setLockAutoScroll] = useState(false); const [lockAutoScroll, setLockAutoScroll] = useState(false);
const selectedRef = useRef<HTMLDivElement | null>(null); const selectedRef = useRef<HTMLDivElement | null>(null);
@@ -102,16 +100,9 @@ export default function Operator() {
debouncedHandleScroll(); debouncedHandleScroll();
}; };
const handleEdit = useCallback( const handleEdit = useCallback((event: EditEvent) => {
(event: EditEvent) => { setEditEvent({ ...event });
const field = searchParams.get('subscribe') as keyof CustomField | null; }, []);
if (field) {
setEditEvent({ ...event, field });
}
},
[searchParams],
);
const missingData = !data || !customFields || !projectData; const missingData = !data || !customFields || !projectData;
const isLoading = status === 'pending' || customFieldStatus === 'pending' || projectDataStatus === 'pending'; const isLoading = status === 'pending' || customFieldStatus === 'pending' || projectDataStatus === 'pending';
@@ -122,8 +113,13 @@ export default function Operator() {
// get fields which the user subscribed to // get fields which the user subscribed to
const shouldEdit = searchParams.get('shouldEdit'); const shouldEdit = searchParams.get('shouldEdit');
const subscribe = searchParams.get('subscribe') as keyof CustomFields;
const canEdit = shouldEdit && subscribe; const subscriptions = (searchParams.get('subscribe') ?? '')
.toLocaleLowerCase()
.split('_')
.filter((value) => Object.hasOwn(customFields, value));
const canEdit = shouldEdit && subscriptions;
const main = searchParams.get('main') as keyof TitleFields | null; const main = searchParams.get('main') as keyof TitleFields | null;
const secondary = searchParams.get('secondary'); const secondary = searchParams.get('secondary');
@@ -138,7 +134,7 @@ export default function Operator() {
return ( return (
<div className={style.operatorContainer}> <div className={style.operatorContainer}>
<ViewParamsEditor paramFields={operatorOptions} /> <ViewParamsEditor viewOptions={operatorOptions} />
{editEvent && <EditModal event={editEvent} onClose={() => setEditEvent(null)} />} {editEvent && <EditModal event={editEvent} onClose={() => setEditEvent(null)} />}
<StatusBar <StatusBar
@@ -171,9 +167,14 @@ export default function Operator() {
return null; return null;
} }
const mainField = main ? entry?.[main] || entry.title : entry.title; const mainField = main ? getPropertyValue(entry, main) ?? '' : entry.title;
const secondaryField = getPropertyValue(entry, secondary) ?? ''; const secondaryField = getPropertyValue(entry, secondary) ?? '';
const subscribedData = entry.custom[subscribe]; const subscribedData = subscriptions
? subscriptions.map((id) => {
const { label, colour } = customFields[id];
return { id, label, colour, value: entry.custom[id] };
})
: null;
return ( return (
<OperatorEvent <OperatorEvent
@@ -189,7 +190,6 @@ export default function Operator() {
delay={entry.delay} delay={entry.delay}
isSelected={isSelected} isSelected={isSelected}
subscribed={subscribedData} subscribed={subscribedData}
subscribeLabel={subscribe}
isPast={isPast} isPast={isPast}
selectedRef={isSelected ? selectedRef : undefined} selectedRef={isSelected ? selectedRef : undefined}
onLongPress={canEdit ? handleEdit : () => undefined} onLongPress={canEdit ? handleEdit : () => undefined}
@@ -1,48 +1,69 @@
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { Button, Textarea } from '@chakra-ui/react'; import { Button, Textarea } from '@chakra-ui/react';
import { OntimeEvent } from 'ontime-types';
import { useEventAction } from '../../../common/hooks/useEventAction'; import { useEventAction } from '../../../common/hooks/useEventAction';
import type { PartialEdit } from '../Operator'; import type { EditEvent } from '../Operator';
import style from './EditModal.module.scss'; import style from './EditModal.module.scss';
interface EditModalProps { interface EditModalProps {
event: PartialEdit; event: EditEvent;
onClose: () => void; onClose: () => void;
} }
export default function EditModal(props: EditModalProps) { export default function EditModal(props: EditModalProps) {
const { event, onClose } = props; const { event, onClose } = props;
const { updateCustomField } = useEventAction(); const { updateEvent } = useEventAction();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLTextAreaElement | null>(null); const inputRef = useRef<HTMLTextAreaElement[]>(new Array<HTMLTextAreaElement>());
const handleSave = async () => { const handleSave = async () => {
if (!inputRef.current) return;
setLoading(true); setLoading(true);
const newValue = inputRef.current?.value;
if (newValue === undefined) { const patchObject: Partial<OntimeEvent> = { id: event.id };
return;
inputRef.current.forEach((element) => {
if (element.dataset.field && element.defaultValue != element.value) {
if (patchObject.custom) {
patchObject.custom[element.dataset.field] = element.value;
} else {
Object.assign(patchObject, { custom: { [element.dataset.field]: element.value } });
}
}
});
if (patchObject.custom) {
await updateEvent(patchObject);
} }
await updateCustomField(event.id, event.field, newValue);
setLoading(false); setLoading(false);
onClose(); onClose();
}; };
const fieldLabel = event?.fieldLabel ?? event.field;
return ( return (
<div className={style.editModal}> <div className={style.editModal}>
<div>{`Editing field ${fieldLabel} in cue ${event.cue}`}</div> <div>{`Editing fields in cue ${event.cue}`}</div>
<Textarea {event.subscriptions.map((field) => {
ref={inputRef} return (
variant='ontime-filled' <div key={field.label}>
placeholder={`Add value for ${fieldLabel} field`} <label>{field.label}</label>
defaultValue={event.fieldValue} <Textarea
isDisabled={loading} ref={(element) => {
resize='none' if (element) inputRef.current.push(element);
/> }}
variant='ontime-filled'
placeholder={`Add value for ${field.label} field`}
defaultValue={field.value}
data-field={field.id}
isDisabled={loading}
resize='none'
/>
</div>
);
})}
<div className={style.buttonRow}> <div className={style.buttonRow}>
<Button variant='ontime-subtle' onClick={onClose} isDisabled={loading}> <Button variant='ontime-subtle' onClick={onClose} isDisabled={loading}>
Cancel Cancel
@@ -20,9 +20,9 @@
grid-template-rows: auto auto auto; grid-template-rows: auto auto auto;
column-gap: 0.5rem; column-gap: 0.5rem;
grid-template-areas: grid-template-areas:
"binder main schedule" 'binder main schedule'
"binder secondary running" 'binder secondary running'
"binder fields fields"; 'binder fields fields';
&.subscribed { &.subscribed {
background-color: $gray-1250; background-color: $gray-1250;
@@ -65,12 +65,15 @@
font-size: 1.5rem; font-size: 1.5rem;
letter-spacing: 0.5px; letter-spacing: 0.5px;
color: $ui-white; color: $ui-white;
@include ellipsis-text;
} }
.secondaryField { .secondaryField {
grid-area: secondary; grid-area: secondary;
font-size: 1.25rem; font-size: 1.25rem;
letter-spacing: 0.5px; letter-spacing: 0.5px;
@include ellipsis-text;
} }
.schedule { .schedule {
@@ -93,16 +96,27 @@
font-weight: 400; font-weight: 400;
color: $ui-black; color: $ui-black;
margin: 0.25rem 0; margin: 0.25rem 0;
display: flex;
flex-wrap: wrap;
.field { .field {
font-weight: 600; font-weight: 600;
padding: 0 0.25rem; padding-inline: 0.25rem;
background-color: var(--operator-highlight-override, $orange-600); }
margin-right: 0.5rem;
.noColour {
outline: 0.15rem solid var(--operator-highlight-override, $ui-white);
outline-offset: -0.15rem;
padding-right: 0.3rem;
color: $ui-white;
} }
.value { .value {
color: $orange-500; padding-inline: 0.25rem;
background-color: $gray-1250;
color: var(--operator-highlight-override, $ui-white);
margin-right: 1rem;
} }
} }
@@ -6,7 +6,7 @@ import { useTimer } from '../../../common/hooks/useSocket';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import ClockTime from '../../viewers/common/clock-time/ClockTime'; import ClockTime from '../../viewers/common/clock-time/ClockTime';
import RunningTime from '../../viewers/common/running-time/RunningTime'; import RunningTime from '../../viewers/common/running-time/RunningTime';
import type { EditEvent } from '../Operator'; import type { EditEvent, Subscribed } from '../Operator';
import style from './OperatorEvent.module.scss'; import style from './OperatorEvent.module.scss';
@@ -21,8 +21,7 @@ interface OperatorEventProps {
duration: number; duration: number;
delay?: number; delay?: number;
isSelected: boolean; isSelected: boolean;
subscribed?: string; subscribed: Subscribed | null;
subscribeLabel: string;
isPast: boolean; isPast: boolean;
selectedRef?: RefObject<HTMLDivElement>; selectedRef?: RefObject<HTMLDivElement>;
onLongPress: (event: EditEvent) => void; onLongPress: (event: EditEvent) => void;
@@ -47,7 +46,6 @@ function OperatorEvent(props: OperatorEventProps) {
delay, delay,
isSelected, isSelected,
subscribed, subscribed,
subscribeLabel: subscribedAlias,
isPast, isPast,
selectedRef, selectedRef,
onLongPress, onLongPress,
@@ -56,7 +54,9 @@ function OperatorEvent(props: OperatorEventProps) {
const handleLongPress = (event?: SyntheticEvent) => { const handleLongPress = (event?: SyntheticEvent) => {
// we dont have an event out of useLongPress // we dont have an event out of useLongPress
event?.preventDefault(); event?.preventDefault();
onLongPress({ id, cue, fieldLabel: subscribedAlias, fieldValue: subscribed ?? '' }); if (subscribed) {
onLongPress({ id, cue, subscriptions: subscribed });
}
}; };
const mouseHandlers = useLongPress(handleLongPress, { threshold: 800 }); const mouseHandlers = useLongPress(handleLongPress, { threshold: 800 });
@@ -89,12 +89,22 @@ function OperatorEvent(props: OperatorEventProps) {
</span> </span>
<div className={style.fields}> <div className={style.fields}>
{subscribed && ( {subscribed &&
<> subscribed
<span className={style.field}>{subscribedAlias}</span> .filter((field) => field.value)
<span className={style.value}>{subscribed}</span> .map((field) => {
</> const fieldClasses = cx([style.field, !field.colour ? style.noColour : null]);
)} return (
<div key={field.id}>
<span className={fieldClasses} style={{ backgroundColor: field.colour }}>
{field.label}
</span>
<span className={style.value} style={{ color: field.colour }}>
{field.value}
</span>
</div>
);
})}
</div> </div>
</div> </div>
); );
@@ -0,0 +1,60 @@
import { CustomFields } from 'ontime-types';
import { getTimeOption, makeOptionsFromCustomFields } from '../../common/components/view-params-editor/constants';
import { ViewOption } from '../../common/components/view-params-editor/types';
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ViewOption[] => {
const fieldOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
const customFieldSelect = Object.entries(customFields).reduce<
Record<string, { value: string; label: string; colour: string }>
>((acc, [key, field]) => {
acc[key] = { value: key, label: field.label, colour: field.colour };
return acc;
}, {});
return [
{ section: 'Clock Options' },
getTimeOption(timeFormat),
{ section: 'Data sources' },
{
id: 'main',
title: 'Main data field',
description: 'Field to be shown in the first line of text',
type: 'option',
values: fieldOptions,
defaultValue: 'title',
},
{
id: 'secondary',
title: 'Secondary data field',
description: 'Field to be shown in the second line of text',
type: 'option',
values: fieldOptions,
defaultValue: '',
},
{
id: 'subscribe',
title: 'Highlight Field',
description: 'Choose a custom field to highlight',
type: 'multi-option',
values: customFieldSelect,
},
{ section: 'Element visibility' },
{
id: 'hidepast',
title: 'Hide Past Events',
description: 'Whether to events that have passed',
type: 'boolean',
defaultValue: false,
},
{
id: 'shouldEdit',
title: 'Edit custom field',
description: 'Allows editing an events selected custom field by long pressing.',
type: 'boolean',
defaultValue: false,
},
];
};
@@ -105,5 +105,5 @@
} }
.progressOverride { .progressOverride {
border-radius: 0; --progress-bar-br: 0;
} }
@@ -24,6 +24,7 @@ export default function StatusBarProgress(props: StatusBarProgressProps) {
danger={timeDanger} danger={timeDanger}
dangerColor={viewSettings.dangerColor} dangerColor={viewSettings.dangerColor}
className={styles.progressOverride} className={styles.progressOverride}
ignoreCssOverride
/> />
); );
} }
+55 -40
View File
@@ -14,6 +14,7 @@ import { cloneEvent } from '../../common/utils/eventsManager';
import QuickAddBlock from './quick-add-block/QuickAddBlock'; import QuickAddBlock from './quick-add-block/QuickAddBlock';
import RundownEmpty from './RundownEmpty'; import RundownEmpty from './RundownEmpty';
import { useEventSelection } from './useEventSelection';
import style from './Rundown.module.scss'; import style from './Rundown.module.scss';
@@ -33,7 +34,9 @@ export default function Rundown({ data }: RundownProps) {
const { entryCopyId, setEntryCopyId } = useEntryCopy(); const { entryCopyId, setEntryCopyId } = useEntryCopy();
// cursor // cursor
const { cursor, mode: appMode, setCursor } = useAppMode(); const { mode: appMode } = useAppMode();
const { clearSelectedEvents, setSelectedEvents, cursor } = useEventSelection();
const cursorRef = useRef<HTMLDivElement | null>(null); const cursorRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null); const scrollRef = useRef<HTMLDivElement | null>(null);
useFollowComponent({ followRef: cursorRef, scrollRef, doFollow: appMode === AppMode.Run }); useFollowComponent({ followRef: cursorRef, scrollRef, doFollow: appMode === AppMode.Run });
@@ -44,34 +47,42 @@ export default function Rundown({ data }: RundownProps) {
const deleteAtCursor = useCallback( const deleteAtCursor = useCallback(
(cursor: string | null) => { (cursor: string | null) => {
if (!cursor) return; if (!cursor) return;
const previous = getPreviousNormal(rundown, order, cursor).entry?.id ?? null; const { entry, index } = getPreviousNormal(rundown, order, cursor);
deleteEvent([cursor]); deleteEvent([cursor]);
setCursor(previous); if (entry && index !== null) {
setSelectedEvents({ id: entry.id, selectMode: 'click', index });
}
}, },
[deleteEvent, order, rundown, setCursor], [rundown, order, deleteEvent, setSelectedEvents],
); );
const insertAtCursor = useCallback( const insertCopyAtId = useCallback(
(type: SupportedEvent | 'clone', cursor: string | null, above = false) => { (atId: string | null, copyId: string | null, above = false) => {
const adjustedCursor = above ? getPreviousNormal(rundown, order, cursor ?? '').entry?.id ?? null : cursor; const adjustedCursor = above ? getPreviousNormal(rundown, order, atId ?? '').entry?.id ?? null : atId;
if (copyId === null) {
if (adjustedCursor === null) {
// we cant clone without selection // we cant clone without selection
if (type === 'clone') { return;
return; }
} const cloneEntry = rundown[copyId];
if (cloneEntry?.type === SupportedEvent.Event) {
//if we don't have a cursor add the new event on top
const newEvent = cloneEvent(cloneEntry, adjustedCursor ?? undefined);
addEvent(newEvent);
}
},
[addEvent, order, rundown],
);
const insertAtId = useCallback(
(type: SupportedEvent, id: string | null, above = false) => {
const adjustedCursor = above ? getPreviousNormal(rundown, order, id ?? '').entry?.id ?? null : id;
if (adjustedCursor === null) {
// the only thing to do is adding an event at top // the only thing to do is adding an event at top
addEvent({ type }); addEvent({ type });
return; return;
} }
if (type === 'clone') { if (type === SupportedEvent.Event) {
const cursorEvent = rundown[adjustedCursor];
if (cursorEvent?.type === SupportedEvent.Event) {
const newEvent = cloneEvent(cursorEvent, cursorEvent.id);
addEvent(newEvent);
}
} else if (type === SupportedEvent.Event) {
const newEvent = { const newEvent = {
type: SupportedEvent.Event, type: SupportedEvent.Event,
}; };
@@ -92,23 +103,26 @@ export default function Rundown({ data }: RundownProps) {
if (order.length < 1) { if (order.length < 1) {
return; return;
} }
let newCursor: string | undefined; let newCursor: string | null;
let newIndex: number | null;
if (cursor === null) { if (cursor === null) {
// there is no cursor, we select the first or last depending on direction if it exists // there is no cursor, we select the first or last depending on direction if it exists
newCursor = direction === 'up' ? getLastNormal(rundown, order)?.id : getFirstNormal(rundown, order)?.id; newCursor =
(direction === 'up' ? getLastNormal(rundown, order)?.id : getFirstNormal(rundown, order)?.id) ?? null;
newIndex = direction === 'up' ? order.length : 0;
} else { } else {
// otherwise we select the next or previous // otherwise we select the next or previous
newCursor = const selected =
direction === 'up' direction === 'up' ? getPreviousNormal(rundown, order, cursor) : getNextNormal(rundown, order, cursor);
? getPreviousNormal(rundown, order, cursor).entry?.id newCursor = selected.entry?.id ?? null;
: getNextNormal(rundown, order, cursor).entry?.id; newIndex = selected.index;
} }
if (newCursor) { if (newCursor && newIndex !== null) {
setCursor(newCursor); setSelectedEvents({ id: newCursor, selectMode: 'click', index: newIndex });
} }
}, },
[order, rundown, setCursor], [order, rundown, setSelectedEvents],
); );
const moveEntry = useCallback( const moveEntry = useCallback(
@@ -134,22 +148,22 @@ export default function Rundown({ data }: RundownProps) {
['alt + mod + ArrowDown', () => moveEntry(cursor, 'down'), { preventDefault: true }], ['alt + mod + ArrowDown', () => moveEntry(cursor, 'down'), { preventDefault: true }],
['alt + mod + ArrowUp', () => moveEntry(cursor, 'up'), { preventDefault: true }], ['alt + mod + ArrowUp', () => moveEntry(cursor, 'up'), { preventDefault: true }],
['Escape', () => setCursor(null), { preventDefault: true }], ['Escape', () => clearSelectedEvents(), { preventDefault: true }],
['mod + Backspace', () => deleteAtCursor(cursor), { preventDefault: true }], ['mod + Backspace', () => deleteAtCursor(cursor), { preventDefault: true }],
['alt + E', () => insertAtCursor(SupportedEvent.Event, cursor), { preventDefault: true }], ['alt + E', () => insertAtId(SupportedEvent.Event, cursor), { preventDefault: true }],
['alt + shift + E', () => insertAtCursor(SupportedEvent.Event, cursor, true), { preventDefault: true }], ['alt + shift + E', () => insertAtId(SupportedEvent.Event, cursor, true), { preventDefault: true }],
['alt + B', () => insertAtCursor(SupportedEvent.Block, cursor), { preventDefault: true }], ['alt + B', () => insertAtId(SupportedEvent.Block, cursor), { preventDefault: true }],
['alt + shift + B', () => insertAtCursor(SupportedEvent.Block, cursor, true), { preventDefault: true }], ['alt + shift + B', () => insertAtId(SupportedEvent.Block, cursor, true), { preventDefault: true }],
['alt + D', () => insertAtCursor(SupportedEvent.Delay, cursor), { preventDefault: true }], ['alt + D', () => insertAtId(SupportedEvent.Delay, cursor), { preventDefault: true }],
['alt + shift + D', () => insertAtCursor(SupportedEvent.Delay, cursor, true), { preventDefault: true }], ['alt + shift + D', () => insertAtId(SupportedEvent.Delay, cursor, true), { preventDefault: true }],
['mod + C', () => setEntryCopyId(cursor), { preventDefault: true }], ['mod + C', () => setEntryCopyId(cursor), { preventDefault: true }],
['mod + V', () => insertAtCursor('clone', entryCopyId), { preventDefault: true }], ['mod + V', () => insertCopyAtId(cursor, entryCopyId), { preventDefault: true }],
['mod + shift + V', () => insertAtCursor('clone', entryCopyId, true), { preventDefault: true }], ['mod + shift + V', () => insertCopyAtId(cursor, entryCopyId, true), { preventDefault: true }],
['alt + backspace', () => deleteAtCursor(cursor), { preventDefault: true }], ['alt + backspace', () => deleteAtCursor(cursor), { preventDefault: true }],
]); ]);
@@ -165,8 +179,9 @@ export default function Rundown({ data }: RundownProps) {
if (appMode !== AppMode.Run || !featureData?.selectedEventId) { if (appMode !== AppMode.Run || !featureData?.selectedEventId) {
return; return;
} }
setCursor(featureData.selectedEventId); const index = order.findIndex((id) => id === featureData.selectedEventId);
}, [appMode, featureData.selectedEventId, setCursor]); setSelectedEvents({ id: featureData.selectedEventId, selectMode: 'click', index });
}, [appMode, featureData.selectedEventId, order, setSelectedEvents]);
const handleOnDragEnd = (event: DragEndEvent) => { const handleOnDragEnd = (event: DragEndEvent) => {
const { active, over } = event; const { active, over } = event;
@@ -185,7 +200,7 @@ export default function Rundown({ data }: RundownProps) {
}; };
if (statefulEntries.length < 1) { if (statefulEntries.length < 1) {
return <RundownEmpty handleAddNew={() => insertAtCursor(SupportedEvent.Event, null)} />; return <RundownEmpty handleAddNew={() => insertAtId(SupportedEvent.Event, cursor)} />;
} }
let previousStart: MaybeNumber = null; let previousStart: MaybeNumber = null;
@@ -3,7 +3,6 @@ import { MaybeNumber, OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent
import { useEventAction } from '../../common/hooks/useEventAction'; import { useEventAction } from '../../common/hooks/useEventAction';
import useMemoisedFn from '../../common/hooks/useMemoisedFn'; import useMemoisedFn from '../../common/hooks/useMemoisedFn';
import { useAppMode } from '../../common/stores/appModeStore';
import { useEmitLog } from '../../common/stores/logger'; import { useEmitLog } from '../../common/stores/logger';
import { cloneEvent } from '../../common/utils/eventsManager'; import { cloneEvent } from '../../common/utils/eventsManager';
@@ -56,22 +55,15 @@ export default function RundownEntry(props: RundownEntryProps) {
} = props; } = props;
const { emitError } = useEmitLog(); const { emitError } = useEmitLog();
const { addEvent, updateEvent, batchUpdateEvents, deleteEvent, swapEvents } = useEventAction(); const { addEvent, updateEvent, batchUpdateEvents, deleteEvent, swapEvents } = useEventAction();
const cursor = useAppMode((state) => state.cursor);
const setCursor = useAppMode((state) => state.setCursor);
const { selectedEvents, unselect, clearSelectedEvents } = useEventSelection(); const { selectedEvents, unselect, clearSelectedEvents } = useEventSelection();
const removeOpenEvent = useCallback(() => { const removeOpenEvent = useCallback(() => {
unselect(data.id); unselect(data.id);
// clear cursor if we are deleting the event that is currently selected }, [unselect, data.id]);
if (cursor === data.id) {
setCursor(null);
}
}, [unselect, data.id, cursor, setCursor]);
const clearMultiSelection = useCallback(() => { const clearMultiSelection = useCallback(() => {
clearSelectedEvents(); clearSelectedEvents();
setCursor(null); }, [clearSelectedEvents]);
}, [clearSelectedEvents, setCursor]);
// Create / delete new events // Create / delete new events
type FieldValue = { type FieldValue = {
@@ -11,7 +11,6 @@ import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { EndAction, MaybeNumber, MaybeString, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types'; import { EndAction, MaybeNumber, MaybeString, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types';
import { useContextMenu } from '../../../common/hooks/useContextMenu'; import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useAppMode } from '../../../common/stores/appModeStore';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import type { EventItemActions } from '../RundownEntry'; import type { EventItemActions } from '../RundownEntry';
import { useEventIdSwapping } from '../useEventIdSwapping'; import { useEventIdSwapping } from '../useEventIdSwapping';
@@ -88,7 +87,6 @@ export default function EventBlock(props: EventBlockProps) {
} = props; } = props;
const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping(); const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping();
const { selectedEvents, setSelectedEvents } = useEventSelection(); const { selectedEvents, setSelectedEvents } = useEventSelection();
const setCursor = useAppMode((state) => state.setCursor);
const handleRef = useRef<null | HTMLSpanElement>(null); const handleRef = useRef<null | HTMLSpanElement>(null);
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
@@ -231,7 +229,6 @@ export default function EventBlock(props: EventBlockProps) {
const index = eventIndex - 1; const index = eventIndex - 1;
const editMode = getSelectionMode(event); const editMode = getSelectionMode(event);
setSelectedEvents({ id: eventId, index, selectMode: editMode }); setSelectedEvents({ id: eventId, index, selectMode: editMode });
setCursor(eventId);
}; };
return ( return (
@@ -40,7 +40,7 @@ function EventEditorEmpty() {
<AuxKey>+</AuxKey> <AuxKey>+</AuxKey>
<Kbd></Kbd> <Kbd></Kbd>
<AuxKey>/</AuxKey> <AuxKey>/</AuxKey>
<Kbd></Kbd> <Kbd></Kbd>
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -56,7 +56,7 @@ function EventEditorEmpty() {
<td> <td>
<Kbd>{deviceMod}</Kbd> <Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey> <AuxKey>+</AuxKey>
<Kbd></Kbd> <Kbd>Shift</Kbd>
<AuxKey>+</AuxKey> <AuxKey>+</AuxKey>
<Kbd>V</Kbd> <Kbd>V</Kbd>
</td> </td>
@@ -74,7 +74,7 @@ function EventEditorEmpty() {
<td> <td>
<Kbd>{deviceMod}</Kbd> <Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey> <AuxKey>+</AuxKey>
<Kbd></Kbd> <Kbd>Backspace</Kbd>
</td> </td>
</tr> </tr>
<tr className={style.spacer} /> <tr className={style.spacer} />
@@ -91,7 +91,7 @@ function EventEditorEmpty() {
<td> <td>
<Kbd>{deviceAlt}</Kbd> <Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey> <AuxKey>+</AuxKey>
<Kbd></Kbd> <Kbd>Shift</Kbd>
<AuxKey>+</AuxKey> <AuxKey>+</AuxKey>
<Kbd>E</Kbd> <Kbd>E</Kbd>
</td> </td>
@@ -109,7 +109,7 @@ function EventEditorEmpty() {
<td> <td>
<Kbd>{deviceAlt}</Kbd> <Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey> <AuxKey>+</AuxKey>
<Kbd></Kbd> <Kbd>Shift</Kbd>
<AuxKey>+</AuxKey> <AuxKey>+</AuxKey>
<Kbd>B</Kbd> <Kbd>B</Kbd>
</td> </td>
@@ -127,7 +127,7 @@ function EventEditorEmpty() {
<td> <td>
<Kbd>{deviceAlt}</Kbd> <Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey> <AuxKey>+</AuxKey>
<Kbd></Kbd> <Kbd>Shift</Kbd>
<AuxKey>+</AuxKey> <AuxKey>+</AuxKey>
<Kbd>D</Kbd> <Kbd>D</Kbd>
</td> </td>
@@ -6,14 +6,14 @@ import { EditorUpdateFields } from '../EventEditor';
import style from '../EventEditor.module.scss'; import style from '../EventEditor.module.scss';
interface CountedTextInputProps extends InputProps { interface EventTextInputProps extends InputProps {
field: EditorUpdateFields; field: EditorUpdateFields;
label: string; label: string;
initialValue: string; initialValue: string;
submitHandler: (field: EditorUpdateFields, value: string) => void; submitHandler: (field: EditorUpdateFields, value: string) => void;
} }
export default function EventTextInput(props: CountedTextInputProps) { export default function EventTextInput(props: EventTextInputProps) {
const { field, label, initialValue, submitHandler, maxLength } = props; const { field, label, initialValue, submitHandler, maxLength } = props;
const ref = useRef<HTMLInputElement | null>(null); const ref = useRef<HTMLInputElement | null>(null);
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]); const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
@@ -34,7 +34,7 @@ export default function EventTextInput(props: CountedTextInputProps) {
variant='ontime-filled' variant='ontime-filled'
data-testid='input-textfield' data-testid='input-textfield'
value={value} value={value}
maxLength={maxLength || 50} maxLength={maxLength || 100}
onChange={onChange} onChange={onChange}
onBlur={onBlur} onBlur={onBlur}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
@@ -17,7 +17,6 @@ import { useEventSelection } from '../useEventSelection';
export default function RundownMenu() { export default function RundownMenu() {
const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents); const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents);
const setCursor = useAppMode((state) => state.setCursor);
const appMode = useAppMode((state) => state.mode); const appMode = useAppMode((state) => state.mode);
const { deleteAllEvents } = useEventAction(); const { deleteAllEvents } = useEventAction();
@@ -27,9 +26,8 @@ export default function RundownMenu() {
const deleteAll = useCallback(() => { const deleteAll = useCallback(() => {
deleteAllEvents(); deleteAllEvents();
clearSelectedEvents(); clearSelectedEvents();
setCursor(null);
onClose(); onClose();
}, [clearSelectedEvents, deleteAllEvents, onClose, setCursor]); }, [clearSelectedEvents, deleteAllEvents, onClose]);
return ( return (
<> <>
@@ -1,5 +1,5 @@
import { MouseEvent } from 'react'; import { MouseEvent } from 'react';
import { isOntimeEvent, OntimeEvent, RundownCached } from 'ontime-types'; import { isOntimeEvent, MaybeNumber, MaybeString, OntimeEvent, RundownCached } from 'ontime-types';
import { create } from 'zustand'; import { create } from 'zustand';
import { RUNDOWN } from '../../common/api/constants'; import { RUNDOWN } from '../../common/api/constants';
@@ -10,7 +10,8 @@ export type SelectionMode = 'shift' | 'click' | 'ctrl';
interface EventSelectionStore { interface EventSelectionStore {
selectedEvents: Set<string>; selectedEvents: Set<string>;
anchoredIndex: number | null; anchoredIndex: MaybeNumber;
cursor: MaybeString;
setSelectedEvents: (selectionArgs: { id: string; index: number; selectMode: SelectionMode }) => void; setSelectedEvents: (selectionArgs: { id: string; index: number; selectMode: SelectionMode }) => void;
clearSelectedEvents: () => void; clearSelectedEvents: () => void;
clearMultiSelect: () => void; clearMultiSelect: () => void;
@@ -20,13 +21,14 @@ interface EventSelectionStore {
export const useEventSelection = create<EventSelectionStore>()((set, get) => ({ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
selectedEvents: new Set(), selectedEvents: new Set(),
anchoredIndex: null, anchoredIndex: null,
cursor: null,
setSelectedEvents: (selectionArgs) => { setSelectedEvents: (selectionArgs) => {
const { id, index, selectMode } = selectionArgs; const { id, index, selectMode } = selectionArgs;
const { selectedEvents, anchoredIndex } = get(); const { selectedEvents, anchoredIndex } = get();
// on click, we replace selection with event // on click, we replace selection with event
if (selectMode === 'click') { if (selectMode === 'click') {
return set({ selectedEvents: new Set([id]), anchoredIndex: index }); return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id });
} }
// on ctrl + click, we toggle the selection of that event // on ctrl + click, we toggle the selection of that event
@@ -39,6 +41,7 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
return set({ return set({
selectedEvents: selectedEvents.add(id), selectedEvents: selectedEvents.add(id),
anchoredIndex: index, anchoredIndex: index,
cursor: id,
}); });
} }
@@ -83,7 +86,7 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
}); });
} }
}, },
clearSelectedEvents: () => set({ selectedEvents: new Set(), anchoredIndex: null }), clearSelectedEvents: () => set({ selectedEvents: new Set(), anchoredIndex: null, cursor: null }),
clearMultiSelect: () => { clearMultiSelect: () => {
const { selectedEvents } = get(); const { selectedEvents } = get();
const [firstSelected] = selectedEvents; const [firstSelected] = selectedEvents;
@@ -5,6 +5,7 @@ import {
Message, Message,
OntimeEvent, OntimeEvent,
ProjectData, ProjectData,
Runtime,
Settings, Settings,
SupportedEvent, SupportedEvent,
TimerMessage, TimerMessage,
@@ -36,6 +37,7 @@ type WithDataProps = {
publicEventNext: OntimeEvent | null; publicEventNext: OntimeEvent | null;
publicEventNow: OntimeEvent | null; publicEventNow: OntimeEvent | null;
publicSelectedId: string | null; publicSelectedId: string | null;
runtime: Runtime;
selectedId: string | null; selectedId: string | null;
settings: Settings | undefined; settings: Settings | undefined;
time: ViewExtendedTimer; time: ViewExtendedTimer;
@@ -66,7 +68,7 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
}, [rundownData]); }, [rundownData]);
// websocket data // websocket data
const { clock, timer, message, onAir, eventNext, publicEventNext, publicEventNow, eventNow } = const { clock, timer, message, onAir, eventNext, publicEventNext, publicEventNow, eventNow, runtime } =
useStore(runtimeStore); useStore(runtimeStore);
const publicSelectedId = publicEventNow?.id ?? null; const publicSelectedId = publicEventNow?.id ?? null;
const selectedId = eventNow?.id ?? null; const selectedId = eventNow?.id ?? null;
@@ -82,8 +84,6 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
...timer, ...timer,
clock, clock,
timerType: eventNow?.timerType ?? null, timerType: eventNow?.timerType ?? null,
timeWarning: eventNow?.timeWarning ?? null,
timeDanger: eventNow?.timeWarning ?? null,
}; };
// prevent render until we get all the data we need // prevent render until we get all the data we need
@@ -110,6 +110,7 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
publicEventNext={publicEventNext} publicEventNext={publicEventNext}
publicEventNow={publicEventNow} publicEventNow={publicEventNow}
publicSelectedId={publicSelectedId} publicSelectedId={publicSelectedId}
runtime={runtime}
selectedId={selectedId} selectedId={selectedId}
settings={settings} settings={settings}
time={TimeManagerType} time={TimeManagerType}
@@ -11,7 +11,6 @@ import Schedule from '../../../common/components/schedule/Schedule';
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext'; import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
import ScheduleNav from '../../../common/components/schedule/ScheduleNav'; import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
import TitleCard from '../../../common/components/title-card/TitleCard'; import TitleCard from '../../../common/components/title-card/TitleCard';
import { getBackstageOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle'; import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
@@ -23,8 +22,12 @@ import { titleVariants } from '../common/animation';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime'; import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getPropertyValue } from '../common/viewUtils'; import { getPropertyValue } from '../common/viewUtils';
import { getBackstageOptions } from './backstage.options';
import './Backstage.scss'; import './Backstage.scss';
export const MotionTitleCard = motion(TitleCard);
interface BackstageProps { interface BackstageProps {
customFields: CustomFields; customFields: CustomFields;
isMirrored: boolean; isMirrored: boolean;
@@ -97,7 +100,7 @@ export default function Backstage(props: BackstageProps) {
return ( return (
<div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'> <div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'>
<ViewParamsEditor paramFields={backstageOptions} /> <ViewParamsEditor viewOptions={backstageOptions} />
<div className='project-header'> <div className='project-header'>
{general.title} {general.title}
<div className='clock-container'> <div className='clock-container'>
@@ -124,7 +127,7 @@ export default function Backstage(props: BackstageProps) {
animate='visible' animate='visible'
exit='exit' exit='exit'
> >
<TitleCard label='now' title={eventNow.title} secondary={secondaryTextNow} /> <TitleCard title={eventNow.title} secondary={secondaryTextNow} />
<div className='timer-group'> <div className='timer-group'>
<div className='aux-timers'> <div className='aux-timers'>
<div className='aux-timers__label'>{getLocalizedString('common.started_at')}</div> <div className='aux-timers__label'>{getLocalizedString('common.started_at')}</div>
@@ -151,16 +154,17 @@ export default function Backstage(props: BackstageProps) {
<AnimatePresence> <AnimatePresence>
{eventNext && ( {eventNext && (
<motion.div <MotionTitleCard
className='event next' className='event next'
key='next' key='next'
variants={titleVariants} variants={titleVariants}
initial='hidden' initial='hidden'
animate='visible' animate='visible'
exit='exit' exit='exit'
> label='next'
<TitleCard label='next' title={eventNext.title} secondary={secondaryTextNext} /> title={eventNext.title}
</motion.div> secondary={secondaryTextNext}
/>
)} )}
</AnimatePresence> </AnimatePresence>
</div> </div>
@@ -0,0 +1,44 @@
import { CustomFields } from 'ontime-types';
import { getTimeOption, makeOptionsFromCustomFields } from '../../../common/components/view-params-editor/constants';
import { ViewOption } from '../../../common/components/view-params-editor/types';
export const getBackstageOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
return [
{ section: 'Clock Options' },
getTimeOption(timeFormat),
{ section: 'Data sources' },
{
id: 'secondary-src',
title: 'Event secondary text',
description: 'Select the data source for auxiliary text shown in now and next cards',
type: 'option',
values: secondaryOptions,
defaultValue: '',
},
{ section: 'Schedule options' },
{
id: 'eventsPerPage',
title: 'Events per page',
description: 'Sets the number of events on the page, can cause overflow',
type: 'number',
placeholder: '8 (default)',
},
{
id: 'hidePast',
title: 'Hide past events',
description: 'Scheduler will only show upcoming events',
type: 'boolean',
defaultValue: false,
},
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
description: 'Schedule will not auto-cycle through events',
type: 'boolean',
defaultValue: false,
},
];
};
@@ -2,7 +2,6 @@ import { useSearchParams } from 'react-router-dom';
import { Settings, ViewSettings } from 'ontime-types'; import { Settings, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/constants'; import { overrideStylesURL } from '../../../common/api/constants';
import { getClockOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle'; import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
@@ -11,6 +10,8 @@ import { OverridableOptions } from '../../../common/models/View.types';
import { formatTime, getDefaultFormat } from '../../../common/utils/time'; import { formatTime, getDefaultFormat } from '../../../common/utils/time';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime'; import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getClockOptions } from './clock.options';
import './Clock.scss'; import './Clock.scss';
interface ClockProps { interface ClockProps {
@@ -130,7 +131,7 @@ export default function Clock(props: ClockProps) {
}} }}
data-testid='clock-view' data-testid='clock-view'
> >
<ViewParamsEditor paramFields={clockOptions} /> <ViewParamsEditor viewOptions={clockOptions} />
<SuperscriptTime <SuperscriptTime
time={clock} time={clock}
className='clock' className='clock'

Some files were not shown because too many files have changed in this diff Show More