Compare commits

...

135 Commits

Author SHA1 Message Date
Carlos Valente 75cbcd8e85 bump version to 4.3.0 2025-12-22 16:57:52 +01:00
Carlos Valente 701f24cece fix: automation form validation 2025-12-22 16:57:52 +01:00
Carlos Valente 166160dda9 chore: upgrade client dependencies 2025-12-22 16:57:52 +01:00
Carlos Valente 9c5116f197 refactor: handle prefix on login
- post-login redirect should account for prefix
- scope cookies to each path by prefix
2025-12-22 15:59:38 +01:00
Alex Christoffer Rasmussen deb1f043c7 fix: set not initialised custom field from api (#1926) 2025-12-22 15:27:06 +01:00
Carlos Valente cbba19a9b1 fix: user can quick start project from partial data 2025-12-21 20:25:14 +01:00
Carlos Valente a5daa76616 refactor: improve merge logic 2025-12-21 20:25:14 +01:00
Carlos Valente 08b8e73393 refactor: finish view directory migration 2025-12-21 13:32:12 +01:00
Carlos Valente 2f3cf9825a refactor(css): migrate use of deprecated import in messagecontrol 2025-12-21 13:32:12 +01:00
Carlos Valente b0f02e726d refactor(css): migrate use of deprecated import in cuesheet 2025-12-21 13:32:12 +01:00
Carlos Valente ac40292813 refactor(css): migrate use of deprecated import in operator 2025-12-21 13:32:12 +01:00
Carlos Valente 2ac00ee8cc refactor(css): migrate use of deprecated import in buttons 2025-12-21 13:32:12 +01:00
Carlos Valente 972f9e35fd refactor: create separation between editor wrapper and contents 2025-12-21 13:32:12 +01:00
Carlos Valente 1d7ee163a0 Revert "feat: display offline indicators in all views"
This reverts commit 71b2fa8e21.
2025-12-20 15:25:15 +01:00
Carlos Valente 1054ec0a5e refactor: expose missing types 2025-12-20 14:18:07 +01:00
Carlos Valente fc2e76db0a refactor: stabilise filtering function 2025-12-19 09:54:19 +01:00
Carlos Valente ef3d541eb3 refactor: simplify element lookup 2025-12-19 09:54:19 +01:00
Carlos Valente 8daf3313f2 refactor: simplify flat rundown 2025-12-19 09:54:19 +01:00
Carlos Valente 93c913ec5a refactor: remove placeholderData pattern 2025-12-19 09:54:19 +01:00
Carlos Valente 71b2fa8e21 feat: display offline indicators in all views 2025-12-19 08:51:54 +01:00
Carlos Valente f2b78e0f32 refactor: generate ids for new rundowns 2025-12-18 14:42:01 +01:00
Carlos Valente b70ef80320 refactor: patch does not load rundown 2025-12-18 14:42:01 +01:00
Carlos Valente 426d42ab63 refactor: add guards around trivial index checks 2025-12-18 14:42:01 +01:00
Carlos Valente 242cefd35c tooling: lax typechecking in tests 2025-12-18 14:42:01 +01:00
arc-alex 6beda1575a chore: marke change api call async 2025-12-16 21:56:39 +01:00
arc-alex ba75d52d29 chore: marking a functions as throwing 2025-12-16 21:56:39 +01:00
arc-alex 35a4361ae9 fix: expectedFinish flashing incorrect value when adding timer in overtime 2025-12-16 21:56:39 +01:00
arc-alex 77225383a4 fix: block uploading already existing file name 2025-12-16 21:56:39 +01:00
arc-alex f0e81c0cf0 fix: stop timer on rundown switch 2025-12-16 21:56:39 +01:00
Joel Wetzell 9355d7bf27 feat: add arm architectures to linux target 2025-12-15 20:03:57 +01:00
Carlos Valente cd39227af7 fix: prevent unsupported wakelock 2025-12-09 19:13:36 +01:00
Carlos Valente 14e21214af fix: subdue paused only for running timers 2025-12-09 19:13:36 +01:00
Carlos Valente 03184b0321 fix: prevent accessing undefined index 2025-12-09 19:13:36 +01:00
Carlos Valente cd5af769ab fix: undefined flatRundown 2025-12-09 19:13:36 +01:00
Carlos Valente d116670318 fix: safe copy to clipboard 2025-12-09 19:13:36 +01:00
Carlos Valente 9ba5ebd4bd bump version to 4.2.1 (#1896) 2025-11-28 16:26:53 +01:00
Carlos Valente 3f1f06f7c5 Improve Clone (#1897)
* refactor(rundown): optimise copy-paste performance

* chore: configure opt-in compiler

* refactor(rundown): stabilise frequently accessed data

* feat(clone): allow cloning any element

* remove paste above and cue increment from test

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
2025-11-28 16:20:52 +01:00
arc-alex a78586d2fa allow count up to show negative value 2025-11-28 15:03:28 +01:00
arc-alex 8e93b0af25 fix: countToEnd should not override display options 2025-11-28 15:03:28 +01:00
Carlos Valente 9a5d50d933 fix(wakelock): check for feature support 2025-11-28 11:26:23 +01:00
Carlos Valente 1189a94bff refactor(wakelock): simplify logic into hook 2025-11-28 11:26:23 +01:00
Alex Christoffer Rasmussen f128f7acd2 fix: api change custom fields (#1888)
* fix: validate nested custom path

* chore: add test
2025-11-27 09:15:02 +01:00
Carlos Valente 80ec2d1186 refactor(pip): match timer formatting defaults 2025-11-26 20:10:01 +01:00
Carlos Valente 18e6f0d7c4 refactor(timer): allow multiline secondary text 2025-11-26 20:10:01 +01:00
Alex Christoffer Rasmussen 48f2511764 fix: custom fields not persisted (#1891) 2025-11-26 17:58:00 +01:00
Carlos Valente c20ea88021 docs: add link to subreddit 2025-11-20 18:44:23 +01:00
Carlos Valente ce2b2b9606 bump version to 4.2.0 2025-11-20 18:44:23 +01:00
Alex Christoffer Rasmussen f223766445 feat: pip for timer preview (#1862)
---------

Co-authored-by: Shobhit Nagpal <74096450+Shobhit-Nagpal@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2025-11-20 18:00:16 +01:00
Carlos Valente 3dfb49e63f refactor(overview): prevent vertical scrollbar 2025-11-20 17:58:53 +01:00
Carlos Valente a13d3dad93 refactor(op): style tweaks to tags and subscriptions 2025-11-20 17:58:53 +01:00
Carlos Valente fa9695873a refactor(timeline): style tweaks on status tags 2025-11-20 17:58:53 +01:00
Carlos Valente 70ce1d5553 feat(projects): allow sorting project table 2025-11-19 06:52:58 +01:00
Shobhit Nagpal 9974ce24cd refactor(operator): hide groups where all events have passed (#1878) 2025-11-18 06:12:02 +01:00
Carlos Valente 93e9c19e0d refactor(input): consistent placeholder colours 2025-11-16 07:44:49 +01:00
Carlos Valente 1694cca92c refactor(settings): improve error message on version fetch 2025-11-16 07:44:49 +01:00
Carlos Valente c1d9276dd5 refactor(timeline): style tweaks to small screens 2025-11-16 07:44:49 +01:00
Carlos Valente f5753a0f5a refactor(timeline): default to fixed size timeline 2025-11-16 07:44:49 +01:00
Carlos Valente 843f5a968d refactor(operator): style tweaks for small screens 2025-11-16 07:44:49 +01:00
Carlos Valente e09b6bbc16 refactor: close menu in small screen navigation 2025-11-16 07:44:49 +01:00
Carlos Valente 19d6b40bdb refactor: hide fullscreen button if not feature available 2025-11-16 07:44:49 +01:00
Carlos Valente 1f2706a063 refactor(operator): improvements to editing 2025-11-16 07:44:49 +01:00
Carlos Valente aaeadd13cf chore(operator): clarify edit prompt 2025-11-16 07:44:49 +01:00
Carlos Valente 72e3eb11d7 chore: upgrade mantine dependencies 2025-11-16 07:44:49 +01:00
Alex Christoffer Rasmussen 6f55d8a6c9 use data in <base> componenet to mark ontime cloud status (#1872) 2025-11-12 19:28:29 +08:00
Shobhit Nagpal 70846aefbe refactor: time input labels on hover (#1855) 2025-11-11 06:42:55 +01:00
Carlos Valente 47ba90f191 refactor: ensure preset names are url safe 2025-11-11 06:41:48 +01:00
Alex Christoffer Rasmussen cee3c9c070 Fix integration api (#1866)
* allow change api to set flag and countToEnd

* fix: custom fields can be uppercase

* check that prop exists and add target duration for group
2025-11-09 14:36:13 +01:00
Carlos Valente 15f3b9625c refactor: review overview styles 2025-11-09 06:44:24 +01:00
Carlos Valente d34a1f0fd8 chore: improve documentation on the 12 hour view 2025-11-09 06:44:24 +01:00
Carlos Valente 0e3b3bcf9a refactor: consistent time formatting for 12h mode 2025-11-09 06:44:24 +01:00
Carlos Valente 50c91f976b refactor: show welcome modal on connect 2025-11-02 18:58:13 +01:00
Carlos Valente 50599eccd2 refactor: show countdown if less than 10 min from start 2025-11-02 18:53:03 +01:00
Carlos Valente 9f2db10548 fix: prevent rounding error show 60s 2025-11-02 18:53:03 +01:00
Carlos Valente 5dce1a40bc refactor: prevent ids from being overriden 2025-11-02 13:27:53 +01:00
Carlos Valente b75b69c3b1 chore: cleanup directory structure 2025-11-02 13:23:05 +01:00
Carlos Valente e6070e6efd refactor: add rundown summary to import preview 2025-11-02 13:23:05 +01:00
Carlos Valente 3918664945 fix: ensure IDs on import from excel 2025-11-02 13:23:05 +01:00
Carlos Valente b65b3fa6d2 refactor: get worksheet names from excel upload 2025-11-02 13:23:05 +01:00
Carlos Valente 162693e19e refactor: allow user to review import map 2025-11-02 13:23:05 +01:00
Carlos Valente becd734ea8 refactor: simplify ux for sheet import 2025-11-02 13:23:05 +01:00
Carlos Valente 9dfc8083f0 style: run code formatting 2025-10-31 19:30:34 +01:00
Carlos Valente 7bd98c23a4 bump version to 4.1.0 2025-10-31 19:30:34 +01:00
arc-alex 6f19d78e53 addedTimer always updates the restore point 2025-10-31 19:26:30 +01:00
arc-alex 163baa752f keep restorpoint on container shutdown 2025-10-31 19:26:30 +01:00
mdavies1 6f708df6f6 feat: lock extracted rundown panels 2025-10-31 19:23:08 +01:00
Carlos Valente eb5c62cca1 chore: simplify prettier setup 2025-10-30 17:26:49 +01:00
Carlos Valente 99d2debca8 typo: incorrect title in delete confirmation 2025-10-30 17:26:49 +01:00
Carlos Valente 74e59dccdb feat: allow renaming rundown 2025-10-30 17:26:49 +01:00
Carlos Valente 812b17a221 refactor: create destructive menu type 2025-10-30 17:26:49 +01:00
Carlos Valente 32bf0f53f6 feat: allow user to duplicate a rundown 2025-10-30 17:26:49 +01:00
Carlos Valente 17f80baf48 chore: improve rundown API documentation 2025-10-30 17:26:49 +01:00
Carlos Valente e0377a7244 feat: implement simple healthcheck endpoint 2025-10-30 17:26:18 +01:00
Alex Christoffer Rasmussen b7e5723fb2 Fix multiday (#1850)
* chore: cleanup typeings

* fix: total gap should only accumulate after the curent loaded

* fix: add the current day of the event to start day offset
2025-10-30 14:22:14 +01:00
Alex Christoffer Rasmussen 897210d5e3 add timer type for css override selector (#1845) 2025-10-30 13:15:14 +01:00
Alex Christoffer Rasmussen ee3d38fce8 Fix link to host in local network (#1843)
* descripe testing for cloud

* use base url directly

* lint
2025-10-27 11:19:21 +01:00
Carlos Valente 37abd3fe47 chore(ci): prevent publishing resolver on release 2025-10-22 06:26:35 +02:00
Carlos Valente b509ee5b1f bump to version 4.0.2 2025-10-22 06:26:35 +02:00
Shobhit-Nagpal dd2b59efe5 fix: move blue line to pseudo element 2025-10-21 16:29:59 +02:00
Shobhit-Nagpal 5788785b3e fix: minor ui shift on quick add inline 2025-10-21 16:29:59 +02:00
Carlos Valente e488ad19b3 fix: ensure well formed URLs for IP address destinations 2025-10-18 15:29:16 +02:00
Carlos Valente cbc03c898c fix: correct alignment to end message 2025-10-18 15:28:47 +02:00
jwetzell 155bfbdc42 Merge pull request #1833 from cpvalente/fix/npm-action-access-error 2025-10-18 07:13:05 -05:00
jwetzell 5d3714a4fc Merge pull request #1836 from cpvalente/fix/use-flat-rundown 2025-10-18 07:12:39 -05:00
jwetzell a8a1d210d8 use flat order when building flat rundown 2025-10-17 21:46:13 -05:00
jwetzell 49de8b63a7 add setup-node step back to resolve npm access issues 2025-10-17 08:13:11 -05:00
Carlos Valente 03c9f51b47 chore: update demo project 2025-10-17 12:20:18 +02:00
Carlos Valente 8d00fd3966 bump version to 4.0.1 2025-10-17 12:20:18 +02:00
Carlos Valente 80fd21354e fix: apply styling to overtime timer 2025-10-16 21:13:26 +02:00
Carlos Valente a87b15825a fix: end message scaling 2025-10-16 21:13:26 +02:00
Carlos Valente 6a8f98c469 fix: end message only shows if timer is in overtime 2025-10-16 21:13:26 +02:00
jwetzell 8685298430 Merge pull request #1830 from cpvalente/fix/linux-project-download 2025-10-15 07:25:57 -05:00
jwetzell d43af2db57 pass options to project download to allow folders beginning with a dot 2025-10-14 19:51:15 -05:00
jwetzell ef718c48e4 Merge pull request #1828 from cpvalente/npm-trusted-publisher
update cli and resolver workflows for new NPM auth
2025-10-14 16:18:53 -05:00
Alex Christoffer Rasmussen 45e7373279 Small clean-up (#1827)
* chore: avoid shadow variable name

* chore: map could just be foreach

* clean uneeded template literal

* lint
2025-10-13 20:24:25 +02:00
jwetzell 6af2d6e021 update cli and resolver workflows for new NPM auth 2025-10-12 14:11:52 -05:00
Alex Christoffer Rasmussen 9b36f36f73 fix: next cue (#1821)
* fix next cue

* Update apps/server/src/services/runtime-service/runtime.service.ts

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

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-10-12 14:42:55 +02:00
Carlos Valente 3a0dbf057e chore: update docs 2025-10-12 07:48:25 +02:00
Carlos Valente dae84ab48a chore: update screenshots 2025-10-12 07:48:25 +02:00
Carlos Valente aa927597f3 fix: product name ontime 2025-10-10 19:03:53 +02:00
Carlos Valente ef7f0fb54a refactor: bump modal sizes 2025-10-10 18:32:29 +02:00
Carlos Valente 324320fdc8 fix: custom field is multiline 2025-10-10 18:32:29 +02:00
Carlos Valente 4b27d080b6 fix: background colour in not found page 2025-10-10 18:13:58 +02:00
Carlos Valente 7918063753 bump version to 4.0.0 2025-10-10 18:13:58 +02:00
arc-alex e2202c2417 fix: dynamic full hight for mobile view param editor 2025-10-10 18:07:39 +02:00
Carlos Valente 9817c8f8c0 fix: selected styling overrides 2025-10-10 17:41:26 +02:00
Carlos Valente 1d8c04d0b4 feat: export excel file
refactor: align id columns between export and import

refactor: match column names with template
2025-10-10 17:41:26 +02:00
Carlos Valente 10762f10bf docs: caveat on proxy not working before project build 2025-10-10 17:41:26 +02:00
Alex Christoffer Rasmussen da7c0d4ff2 Small clean-up (#1815)
* chore: remove old comment

* chore: smal lclean up of a e2e test

* chore: bump to beta.6

* chore: cleanup imports

* fix integration values com in ms

* allow linkStart and timeStrategy from API

* remove local mac dist
2025-10-10 17:37:55 +02:00
Carlos Valente 4a27973de1 refactor: allow locking op settings and navigation 2025-10-10 16:01:41 +02:00
Carlos Valente bdeb087e28 fix: op gets params from preset context 2025-10-10 16:01:41 +02:00
Carlos Valente 3662cd58f5 refactor: extract operator data loading 2025-10-10 16:01:41 +02:00
Carlos Valente 62c52bca07 refactor: larger secondary field in timer 2025-10-09 21:03:22 +02:00
Carlos Valente 1fc4a06133 fix: allow user to reset schedule filter 2025-10-09 21:03:22 +02:00
Alex Christoffer Rasmussen 147862e647 Fix opview with groups (#1814)
* create failing test

* fix: op evet used past state of the group
2025-10-08 21:19:57 +02:00
Alex Christoffer Rasmussen 12bc323e57 Fix build (#1813)
* fix resolver build

* fix docker build

* node version in docker via arg

* add step to setup auth for pnpm

* lint

---------

Co-authored-by: Joel Wetzell <jwetzell@yahoo.com>
2025-10-08 10:44:53 +02:00
296 changed files with 4959 additions and 1921 deletions
-1
View File
@@ -17,7 +17,6 @@
# Ignore build folders # Ignore build folders
node_modules node_modules
**/node_modules **/node_modules
**/dist
# Ignore default volumes created by running docker compose up # Ignore default volumes created by running docker compose up
ontime-db ontime-db
+1 -1
View File
@@ -8,7 +8,7 @@
"jest": true "jest": true
}, },
"parser": "@typescript-eslint/parser", "parser": "@typescript-eslint/parser",
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier", "eslint-config-prettier"], "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"],
"plugins": ["@typescript-eslint", "prettier"], "plugins": ["@typescript-eslint", "prettier"],
"overrides": [ "overrides": [
{ {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 336 KiB

After

Width:  |  Height:  |  Size: 149 KiB

+9 -1
View File
@@ -14,6 +14,11 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# This step is only needed to setup the permissions to update npm as pnpm will setup the correct node version
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
@@ -45,9 +50,12 @@ jobs:
- name: Copy external - name: Copy external
run: cp -R apps/server/src/external apps/cli/external run: cp -R apps/server/src/external apps/cli/external
# This will be included in v24 of NodeJS so when the project upgrades to that this can be removed
- name: Install newer version of npm
run: npm install -g npm@11
- name: Publish to NPM - name: Publish to NPM
run: pnpm publish --access public --no-git-checks run: pnpm publish --access public --no-git-checks
env: env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_CONFIG_PROVENANCE: true NPM_CONFIG_PROVENANCE: true
working-directory: ./apps/cli working-directory: ./apps/cli
+22 -2
View File
@@ -6,7 +6,6 @@ on:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
publish_docker: publish_docker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
@@ -15,6 +14,28 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm build:docker
- name: Docker Login - name: Docker Login
uses: docker/login-action@v2.1.0 uses: docker/login-action@v2.1.0
with: with:
@@ -45,4 +66,3 @@ jobs:
# 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:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
+5 -2
View File
@@ -2,7 +2,7 @@ name: Ontime build
on: on:
push: push:
tags: [ "*" ] tags: ['*']
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@@ -103,6 +103,9 @@ jobs:
- name: Release - name: Release
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v1
with: with:
files: './apps/electron/dist/ontime-linux.AppImage' files: |
./apps/electron/dist/ontime-linux-x86_64.AppImage
./apps/electron/dist/ontime-linux-arm64.AppImage
./apps/electron/dist/ontime-linux-armv7l.AppImage
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+9 -3
View File
@@ -1,8 +1,6 @@
name: Ontime Resolver build name: Ontime Resolver build
on: on:
release:
types: [published]
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@@ -14,6 +12,11 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# This step is only needed to setup the permissions to update npm as pnpm will setup the correct node version
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
@@ -34,9 +37,12 @@ jobs:
- name: Build project packages - name: Build project packages
run: pnpm build:resolver run: pnpm build:resolver
# This will be included in v24 of NodeJS so when the project upgrades to that this can be removed
- name: Install newer version of npm
run: npm install -g npm@11
- name: Publish to NPM - name: Publish to NPM
run: pnpm publish --access public --no-git-checks run: pnpm publish --access public --no-git-checks
env: env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_CONFIG_PROVENANCE: true NPM_CONFIG_PROVENANCE: true
working-directory: ./apps/resolver working-directory: ./apps/resolver
+1
View File
@@ -40,6 +40,7 @@ override.css
# working stuff # working stuff
**/TODO.md **/TODO.md
**.local.**
# docker utils # docker utils
ontime-db ontime-db
+2 -1
View File
@@ -5,8 +5,9 @@ node_modules
playwright-report playwright-report
pnpm-lock.yaml
**/*.toml **/*.toml
**/*.yml
**/*.json **/*.json
!tsconfig.common.json !tsconfig.common.json
!turbo.json !turbo.json
+1 -3
View File
@@ -21,6 +21,7 @@ Locally, we would need to run both the React client and the node.js server in de
From the project root, run the following commands From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i` - __Install the project dependencies__ by running `pnpm i`
- __Create a local build__ by running `pnpm build`, this will populate local dependencies
- __Run dev mode__ by running `pnpm dev` or `pnpm dev:electron` to get the electron window - __Run dev mode__ by running `pnpm dev` or `pnpm dev:electron` to get the electron window
@@ -82,9 +83,6 @@ 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 `pnpm dist-mac:local` command to build a MacOS distribution locally and skip the notary process.
## 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.
+9 -15
View File
@@ -1,13 +1,5 @@
FROM node:22-bullseye AS builder ARG NODE_VERSION=22.15.1
ENV PNPM_HOME="/pnpm" FROM node:${NODE_VERSION}-alpine
ENV PATH="$PNPM_HOME:$PATH"
RUN npm install -g pnpm@10.11.0
COPY . /app
WORKDIR /app
RUN pnpm --filter=ontime-ui --filter=ontime-server --filter=ontime-utils --filter=ontime-types install --config.dedupe-peer-dependents=false --frozen-lockfile
RUN pnpm run build:docker
FROM node:22-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
@@ -15,16 +7,17 @@ ENV NODE_ENV=docker
# Ontime Data path # Ontime Data path
ENV ONTIME_DATA=/data/ ENV ONTIME_DATA=/data/
RUN mkdir /app
WORKDIR /app/ WORKDIR /app/
# Prepare UI # Prepare UI
COPY --from=builder /app/apps/client/build ./client/ COPY apps/client/build/ ./client/
# Prepare Backend # Prepare Backend
COPY --from=builder /app/apps/server/dist/ ./server/ COPY apps/server/dist/ ./server/
COPY --from=builder /app/apps/server/src/external/ ./external/ COPY apps/server/src/external/ ./external/
COPY --from=builder /app/apps/server/src/user/ ./user/ COPY apps/server/src/user/ ./user/
COPY --from=builder /app/apps/server/src/html/ ./html/ COPY apps/server/src/html/ ./html/
# Export default ports # Export default ports
EXPOSE 4001/tcp 8888/udp 9999/udp EXPOSE 4001/tcp 8888/udp 9999/udp
@@ -32,5 +25,6 @@ EXPOSE 4001/tcp 8888/udp 9999/udp
CMD ["node", "server/docker.cjs"] CMD ["node", "server/docker.cjs"]
# Build and run commands # Build and run commands
# pnpm build:docker
# docker buildx build . -t getontime/ontime # docker buildx build . -t getontime/ontime
# docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/data/ getontime/ontime # docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/data/ getontime/ontime
-2
View File
@@ -26,8 +26,6 @@ We do our best to have most topics covered by the documentation. However, if you
Let us know! Let us know!
Ontime improves from the collaboration with its users. We would like to understand how you use Ontime and appreciate your feedback. Ontime improves from the collaboration with its users. We would like to understand how you use Ontime and appreciate your feedback.
We would also like to include a testimonials section in our ✨new website✨. It would be great to showcase the diversity of users running Ontime.
# Ontime # Ontime
Ontime is a browser-based application that manages event rundowns, scheduling, and cueing. Ontime is a browser-based application that manages event rundowns, scheduling, and cueing.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "4.0.0-beta.5", "version": "4.3.0",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+9 -9
View File
@@ -1,15 +1,15 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "4.0.0-beta.5", "version": "4.3.0",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@base-ui-components/react": "1.0.0-beta.4", "@base-ui/react": "1.0.0",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@fontsource/open-sans": "^5.2.6", "@fontsource/open-sans": "^5.2.6",
"@mantine/hooks": "^8.2.8", "@mantine/hooks": "^8.3.7",
"@sentry/react": "^10.2.0", "@sentry/react": "^10.2.0",
"@table-nav/react": "^0.0.7", "@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.85.9", "@tanstack/react-query": "^5.85.9",
@@ -17,21 +17,20 @@
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"axios": "^1.12.2", "axios": "^1.12.2",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"csv-stringify": "^6.6.0", "csv-stringify": "^6.6.0",
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
"react": "^19.1.1", "react": "^19.2.3",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-dom": "^19.1.1", "react-dom": "^19.2.3",
"react-fast-compare": "^3.2.2", "react-fast-compare": "^3.2.2",
"react-hook-form": "^7.62.0", "react-hook-form": "^7.62.0",
"react-icons": "5.5.0", "react-icons": "5.5.0",
"react-qr-code": "^2.0.18", "react-qr-code": "^2.0.18",
"react-router": "^7.8.2", "react-router": "^7.11.0",
"react-simple-code-editor": "^0.14.1", "react-simple-code-editor": "^0.14.1",
"react-virtuoso": "^4.14.0", "react-virtuoso": "^4.17.0",
"web-vitals": "^5.1.0", "web-vitals": "^5.1.0",
"zustand": "^5.0.8" "zustand": "^5.0.9"
}, },
"scripts": { "scripts": {
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js", "addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
@@ -66,6 +65,7 @@
"@typescript-eslint/eslint-plugin": "catalog:", "@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:", "@typescript-eslint/parser": "catalog:",
"@vitejs/plugin-react": "4.5.1", "@vitejs/plugin-react": "4.5.1",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "catalog:", "eslint": "catalog:",
"eslint-config-prettier": "catalog:", "eslint-config-prettier": "catalog:",
"eslint-plugin-jest": "^28.6.0", "eslint-plugin-jest": "^28.6.0",
+1 -1
View File
@@ -1,5 +1,5 @@
import { BrowserRouter } from 'react-router'; import { BrowserRouter } from 'react-router';
import { Tooltip } from '@base-ui-components/react/tooltip'; import { Tooltip } from '@base-ui/react/tooltip';
import { QueryClientProvider } from '@tanstack/react-query'; import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
+3 -2
View File
@@ -102,6 +102,7 @@ export default function AppRouter() {
path='op' path='op'
element={ element={
<ViewLoader> <ViewLoader>
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
<Operator /> <Operator />
</ViewLoader> </ViewLoader>
} }
@@ -207,13 +208,13 @@ function PresetView() {
/** /**
* Locked presets do not allow configuration changes * Locked presets do not allow configuration changes
* Whether the user can navigate is determined by the locked param * Whether the user can navigate is determined by the locked param
*
* We inject the preset to the context value for the view to consume
*/ */
const Component = PresetViewMap[preset.target as OntimeViewPresettable]; const Component = PresetViewMap[preset.target as OntimeViewPresettable];
return ( return (
<PresetContext value={preset}> <PresetContext value={preset}>
{preset.target !== OntimeView.Cuesheet && (
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSettings /> <ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSettings />
)}
{Component ? <Component /> : <NotFound />} {Component ? <Component /> : <NotFound />}
</PresetContext> </PresetContext>
); );
+20 -11
View File
@@ -1,8 +1,9 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { CustomFields, Rundown } from 'ontime-types'; import { CustomFields, Rundown, RundownSummary } from 'ontime-types';
import { ImportMap } from 'ontime-utils'; import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import { downloadBlob } from './utils';
const excelPath = `${apiEntryUrl}/excel`; const excelPath = `${apiEntryUrl}/excel`;
@@ -10,28 +11,21 @@ const excelPath = `${apiEntryUrl}/excel`;
* upload Excel file to server * upload Excel file to server
* @return string - file ID op the uploaded file * @return string - file ID op the uploaded file
*/ */
export async function upload(file: File) { export async function upload(file: File): Promise<string[]> {
const formData = new FormData(); const formData = new FormData();
formData.append('excel', file); formData.append('excel', file);
await axios.post(`${excelPath}/upload`, formData, { const response = await axios.post(`${excelPath}/upload`, formData, {
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
}, },
}); });
}
/**
* Get Worksheet names
* @return string[] - array of available worksheets
*/
export async function getWorksheetNames(): Promise<string[]> {
const response: AxiosResponse<string[]> = await axios.get(`${excelPath}/worksheets`);
return response.data; return response.data;
} }
type PreviewSpreadsheetResponse = { type PreviewSpreadsheetResponse = {
rundown: Rundown; rundown: Rundown;
customFields: CustomFields; customFields: CustomFields;
summary: RundownSummary;
}; };
export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> { export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> {
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, { const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, {
@@ -39,3 +33,18 @@ export async function importRundownPreview(options: ImportMap): Promise<PreviewS
}); });
return response.data; return response.data;
} }
/**
* Downloads a xlsx representation of the rundown from the server
*/
export async function downloadAsExcel(rundownId: string, fileName?: string) {
try {
const response = await axios.get(`${excelPath}/${rundownId}/export`, {
responseType: 'blob',
});
downloadBlob(response.data, `${fileName ?? 'Ontime_rundown'}.xlsx`);
} catch (error) {
console.error('Error downloading file:', error);
}
}
+58 -16
View File
@@ -3,6 +3,7 @@ import { EntryId, OntimeEntry, OntimeEvent, ProjectRundownsList, Rundown, Transi
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
type RundownId = string;
const rundownPath = `${apiEntryUrl}/rundowns`; const rundownPath = `${apiEntryUrl}/rundowns`;
// #region operations on project rundowns ========================= // #region operations on project rundowns =========================
@@ -20,14 +21,30 @@ export async function fetchProjectRundownList(): Promise<ProjectRundownsList> {
*/ */
export async function fetchCurrentRundown(): Promise<Rundown> { export async function fetchCurrentRundown(): Promise<Rundown> {
const res = await axios.get(`${rundownPath}/current`); const res = await axios.get(`${rundownPath}/current`);
if (!isValidRundown(res.data)) {
throw new Error('Invalid rundown payload');
}
return res.data; return res.data;
function isValidRundown(x: any): x is Rundown {
return (
x &&
typeof x === 'object' &&
typeof x.id === 'string' &&
Array.isArray(x.order) &&
Array.isArray(x.flatOrder) &&
x.entries &&
typeof x.entries === 'object' &&
typeof x.revision === 'number'
);
}
} }
/** /**
* HTTP request to switch the currently loaded rundown * HTTP request to switch the currently loaded rundown
*/ */
export async function loadRundown(id: string): Promise<AxiosResponse<ProjectRundownsList>> { export async function loadRundown(rundownId: RundownId): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(`${rundownPath}/${id}/load`); return axios.post(`${rundownPath}/${rundownId}/load`);
} }
/** /**
@@ -37,11 +54,25 @@ export async function createRundown(title: string): Promise<AxiosResponse<Projec
return axios.post(rundownPath, { title }); return axios.post(rundownPath, { title });
} }
/**
* HTTP request to duplicate an existing rundown
*/
export async function duplicateRundown(rundownId: RundownId): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(`${rundownPath}/${rundownId}/duplicate`);
}
/**
* HTTP request to rename an existing rundown
*/
export async function renameRundown(rundownId: RundownId, title: string): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.patch(`${rundownPath}/${rundownId}`, { title });
}
/** /**
* HTTP request to delete a rundown * HTTP request to delete a rundown
*/ */
export async function deleteRundown(id: string): Promise<AxiosResponse<ProjectRundownsList>> { export async function deleteRundown(rundownId: RundownId): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.delete(`${rundownPath}/${id}`); return axios.delete(`${rundownPath}/${rundownId}`);
} }
// #endregion operations on project rundowns ====================== // #endregion operations on project rundowns ======================
@@ -51,7 +82,7 @@ export async function deleteRundown(id: string): Promise<AxiosResponse<ProjectRu
* HTTP request to post new entry * HTTP request to post new entry
*/ */
export async function postAddEntry( export async function postAddEntry(
rundownId: string, rundownId: RundownId,
data: TransientEventPayload, data: TransientEventPayload,
): Promise<AxiosResponse<OntimeEntry>> { ): Promise<AxiosResponse<OntimeEntry>> {
return axios.post(`${rundownPath}/${rundownId}/entry`, data); return axios.post(`${rundownPath}/${rundownId}/entry`, data);
@@ -60,7 +91,10 @@ export async function postAddEntry(
/** /**
* HTTP request to edit an entry * HTTP request to edit an entry
*/ */
export async function putEditEntry(rundownId: string, data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> { export async function putEditEntry(
rundownId: RundownId,
data: Partial<OntimeEntry>,
): Promise<AxiosResponse<OntimeEntry>> {
return axios.put(`${rundownPath}/${rundownId}/entry`, data); return axios.put(`${rundownPath}/${rundownId}/entry`, data);
} }
@@ -72,7 +106,7 @@ export type BatchEditEntry = {
/** /**
* HTTP request to edit multiple events * HTTP request to edit multiple events
*/ */
export async function putBatchEditEvents(rundownId: string, data: BatchEditEntry): Promise<AxiosResponse<Rundown>> { export async function putBatchEditEvents(rundownId: RundownId, data: BatchEditEntry): Promise<AxiosResponse<Rundown>> {
return axios.put(`${rundownPath}/${rundownId}/batch`, data); return axios.put(`${rundownPath}/${rundownId}/batch`, data);
} }
@@ -85,56 +119,64 @@ export type ReorderEntry = {
/** /**
* HTTP request to reorder an entry * HTTP request to reorder an entry
*/ */
export async function patchReorderEntry(rundownId: string, data: ReorderEntry): Promise<AxiosResponse<Rundown>> { export async function patchReorderEntry(rundownId: RundownId, data: ReorderEntry): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/${rundownId}/reorder`, data); return axios.patch(`${rundownPath}/${rundownId}/reorder`, data);
} }
/** /**
* HTTP request to swap two events * HTTP request to swap two events
*/ */
export async function requestEventSwap(rundownId: string, from: EntryId, to: EntryId): Promise<AxiosResponse<Rundown>> { export async function requestEventSwap(
rundownId: RundownId,
from: EntryId,
to: EntryId,
): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/${rundownId}/swap`, { from, to }); return axios.patch(`${rundownPath}/${rundownId}/swap`, { from, to });
} }
/** /**
* HTTP request to request application of delay * HTTP request to request application of delay
*/ */
export async function requestApplyDelay(rundownId: string, delayId: EntryId): Promise<AxiosResponse<Rundown>> { export async function requestApplyDelay(rundownId: RundownId, delayId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/${rundownId}/applydelay/${delayId}`); return axios.patch(`${rundownPath}/${rundownId}/applydelay/${delayId}`);
} }
/** /**
* HTTP request for cloning an entry * HTTP request for cloning an entry
*/ */
export async function postCloneEntry(rundownId: string, entryId: EntryId): Promise<AxiosResponse<Rundown>> { export async function postCloneEntry(
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`); rundownId: RundownId,
entryId: EntryId,
options?: { before?: EntryId; after?: EntryId },
): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`, options);
} }
/** /**
* HTTP request for grouping a list of entries into a group * HTTP request for grouping a list of entries into a group
*/ */
export async function requestGroupEntries(rundownId: string, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> { export async function requestGroupEntries(rundownId: RundownId, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/group`, { ids: entryIds }); return axios.post(`${rundownPath}/${rundownId}/group`, { ids: entryIds });
} }
/** /**
* HTTP request for dissolving of a group * HTTP request for dissolving of a group
*/ */
export async function requestUngroup(rundownId: string, groupId: EntryId): Promise<AxiosResponse<Rundown>> { export async function requestUngroup(rundownId: RundownId, groupId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/ungroup/${groupId}`); return axios.post(`${rundownPath}/${rundownId}/ungroup/${groupId}`);
} }
/** /**
* HTTP request to delete entries of a given rundown * HTTP request to delete entries of a given rundown
*/ */
export async function deleteEntries(rundownId: string, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> { export async function deleteEntries(rundownId: RundownId, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
return axios.delete(`${rundownPath}/${rundownId}/entries`, { data: { ids: entryIds } }); return axios.delete(`${rundownPath}/${rundownId}/entries`, { data: { ids: entryIds } });
} }
/** /**
* HTTP request to delete all entries of a given rundown * HTTP request to delete all entries of a given rundown
*/ */
export async function requestDeleteAll(rundownId: string): Promise<AxiosResponse<Rundown>> { export async function requestDeleteAll(rundownId: RundownId): Promise<AxiosResponse<Rundown>> {
return axios.delete(`${rundownPath}/${rundownId}/all`); return axios.delete(`${rundownPath}/${rundownId}/all`);
} }
+2 -1
View File
@@ -1,5 +1,5 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { AuthenticationStatus, CustomFields, Rundown } from 'ontime-types'; import { AuthenticationStatus, CustomFields, Rundown, RundownSummary } from 'ontime-types';
import { ImportMap } from 'ontime-utils'; import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
@@ -56,6 +56,7 @@ export const previewRundown = async (
): Promise<{ ): Promise<{
rundown: Rundown; rundown: Rundown;
customFields: CustomFields; customFields: CustomFields;
summary: RundownSummary;
}> => { }> => {
const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options }); const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options });
return response.data; return response.data;
@@ -1,140 +0,0 @@
.subtle {
background: $gray-1050;
color: $blue-400;
line-height: 1em;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $blue-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
}
.subtle-white {
background: $gray-1050;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $ui-white;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
background: $gray-1050;
}
}
.primary {
background: $blue-700;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $blue-600;
}
&:active:not(:disabled) {
background: $blue-800;
border-color: $blue-900;
}
&:disabled {
opacity: $viewer-opacity-disabled;
}
}
.destructive {
background: $red-700;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $red-600;
color: $ui-white;
}
&:active:not(:disabled) {
background: $red-800;
border-color: $red-900;
}
}
.subtle-destructive {
background: $gray-1050;
color: $red-400;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $red-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
}
.ghosted {
background: transparent;
color: $blue-500;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $blue-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
.ghosted-white {
background: transparent;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $ui-white;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
.ghosted-destructive {
background: transparent;
color: $red-500;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $red-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
@@ -1,5 +1,6 @@
@use '@/theme/viewerDefs' as *; @use './buttonVariants' as base;
@import './BaseButtonStyles.module.scss';
@include base.button-variants;
.baseButton { .baseButton {
position: relative; position: relative;
@@ -81,4 +82,3 @@
.fluid { .fluid {
width: 100%; width: 100%;
} }
@@ -1,5 +1,6 @@
@use '@/theme/viewerDefs' as *; @use './buttonVariants' as base;
@import './BaseButtonStyles.module.scss';
@include base.button-variants;
.baseIconButton { .baseIconButton {
aspect-ratio: 1; aspect-ratio: 1;
@@ -0,0 +1,144 @@
@use '@/theme/themeTokens' as *;
@use '@/theme/viewerDefs' as *;
@mixin button-variants {
.subtle {
background: $gray-1050;
color: $blue-400;
line-height: 1em;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $blue-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
}
.subtle-white {
background: $gray-1050;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $ui-white;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
background: $gray-1050;
}
}
.primary {
background: $blue-700;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $blue-600;
}
&:active:not(:disabled) {
background: $blue-800;
border-color: $blue-900;
}
&:disabled {
opacity: $viewer-opacity-disabled;
}
}
.destructive {
background: $red-700;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $red-600;
color: $ui-white;
}
&:active:not(:disabled) {
background: $red-800;
border-color: $red-900;
}
}
.subtle-destructive {
background: $gray-1050;
color: $red-400;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $red-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
}
.ghosted {
background: transparent;
color: $blue-500;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $blue-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
.ghosted-white {
background: transparent;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $ui-white;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
.ghosted-destructive {
background: transparent;
color: $red-500;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $red-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
}
@@ -1,5 +1,5 @@
import { IoCheckmark } from 'react-icons/io5'; import { IoCheckmark } from 'react-icons/io5';
import { Checkbox as BaseCheckbox } from '@base-ui-components/react/checkbox'; import { Checkbox as BaseCheckbox } from '@base-ui/react/checkbox';
import style from './Checkbox.module.scss'; import style from './Checkbox.module.scss';
@@ -5,10 +5,10 @@ import { navigatorConstants } from '../../../viewerConfig';
import { setClientRemote } from '../../hooks/useSocket'; import { setClientRemote } from '../../hooks/useSocket';
import useUrlPresets from '../../hooks-query/useUrlPresets'; import useUrlPresets from '../../hooks-query/useUrlPresets';
import Button from '../buttons/Button'; import Button from '../buttons/Button';
import Dialog from '../dialog/Dialog';
import Info from '../info/Info'; import Info from '../info/Info';
import Input from '../input/input/Input'; import Input from '../input/input/Input';
import AppLink from '../link/app-link/AppLink'; import AppLink from '../link/app-link/AppLink';
import Modal from '../modal/Modal';
import Select from '../select/Select'; import Select from '../select/Select';
import style from './RedirectClientModal.module.scss'; import style from './RedirectClientModal.module.scss';
@@ -57,7 +57,7 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
]; ];
return ( return (
<Modal <Dialog
isOpen={isOpen} isOpen={isOpen}
onClose={onClose} onClose={onClose}
showCloseButton showCloseButton
@@ -98,7 +98,10 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
fluid fluid
options={viewOptions} options={viewOptions}
defaultValue={viewOptions[0].value} defaultValue={viewOptions[0].value}
onValueChange={(value) => setSelected(value)} onValueChange={(value) => {
if (value === null) return;
setSelected(value);
}}
disabled={enabledPresets.length === 0} disabled={enabledPresets.length === 0}
/> />
</label> </label>
@@ -1,8 +1,7 @@
import { PropsWithChildren, useRef, useState } from 'react'; import { PropsWithChildren, useRef, useState } from 'react';
import { IoCheckmark } from 'react-icons/io5'; import { IoCheckmark, IoCopy } from 'react-icons/io5';
import { IoCopy } from 'react-icons/io5';
import copyToClipboard from '../../utils/copyToClipboard'; import { copyToClipboard } from '../../utils/copyToClipboard';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
import Button from '../buttons/Button'; import Button from '../buttons/Button';
import IconButton from '../buttons/IconButton'; import IconButton from '../buttons/IconButton';
@@ -26,8 +25,9 @@ export default function CopyTag({
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null); const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleClick = () => { const handleClick = async () => {
copyToClipboard(copyValue); try {
await copyToClipboard(copyValue);
setCopied(true); setCopied(true);
// reset copied state // reset copied state
@@ -35,6 +35,9 @@ export default function CopyTag({
clearTimeout(timeoutRef.current); clearTimeout(timeoutRef.current);
} }
timeoutRef.current = setTimeout(() => setCopied(false), 2000); timeoutRef.current = setTimeout(() => setCopied(false), 2000);
} catch {
// ignore errors
}
}; };
return ( return (
@@ -6,7 +6,7 @@
transform: translateX(-50%); transform: translateX(-50%);
padding-inline: 1rem; padding-inline: 1rem;
min-width: min(420px, 90vw); min-width: min(600px, 90vw);
background-color: $gray-1250; background-color: $gray-1250;
color: $ui-white; color: $ui-white;
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { IoClose } from 'react-icons/io5'; import { IoClose } from 'react-icons/io5';
import { Dialog as BaseDialog } from '@base-ui-components/react/dialog'; import { Dialog as BaseDialog } from '@base-ui/react/dialog';
import IconButton from '../buttons/IconButton'; import IconButton from '../buttons/IconButton';
@@ -12,7 +12,7 @@ interface DialogProps {
showCloseButton?: boolean; showCloseButton?: boolean;
showBackdrop?: boolean; showBackdrop?: boolean;
bodyElements: ReactNode; bodyElements: ReactNode;
footerElements: ReactNode; footerElements?: ReactNode;
onClose: () => void; onClose: () => void;
} }
@@ -27,7 +27,6 @@
} }
} }
.item { .item {
outline: 0; outline: 0;
cursor: default; cursor: default;
@@ -61,6 +60,13 @@
border-radius: 3px; border-radius: 3px;
background-color: $gray-1000; background-color: $gray-1000;
} }
&[data-type='destructive'] {
color: $red-500;
svg {
color: $red-500;
}
}
} }
.separator { .separator {
@@ -1,12 +1,12 @@
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { IconType } from 'react-icons'; import { IconType } from 'react-icons';
import { Menu as BaseMenu } from '@base-ui-components/react/menu'; import { Menu as BaseMenu } from '@base-ui/react/menu';
import style from './DropdownMenu.module.scss'; import style from './DropdownMenu.module.scss';
type DropdownMenuItemDivider = { type: 'divider' }; type DropdownMenuItemDivider = { type: 'divider' };
type DropdownMenuItem = { type DropdownMenuItem = {
type: 'item'; type: 'item' | 'destructive';
label: string; label: string;
icon?: IconType; icon?: IconType;
disabled?: boolean; disabled?: boolean;
@@ -31,7 +31,13 @@ export function DropdownMenu({ items, children, ...triggerProps }: PropsWithChil
return <BaseMenu.Separator key={index} className={style.separator} />; return <BaseMenu.Separator key={index} className={style.separator} />;
} }
return ( return (
<BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled}> <BaseMenu.Item
key={index}
className={style.item}
onClick={item.onClick}
disabled={item.disabled}
data-type={item.type}
>
{item.icon && <item.icon />} {item.icon && <item.icon />}
{item.label} {item.label}
</BaseMenu.Item> </BaseMenu.Item>
@@ -1,6 +1,7 @@
.corner { .arrow {
transform: rotate(45deg); transform: rotate(45deg);
}
.corner {
position: absolute; position: absolute;
top: 0.5rem; top: 0.5rem;
right: 0.5rem; right: 0.5rem;
@@ -21,6 +22,10 @@
} }
} }
.offsetCorner {
right: 2rem;
}
.header { .header {
font-size: 1.5rem; font-size: 1.5rem;
} }
@@ -54,3 +59,10 @@
height: 0.75em; height: 0.75em;
} }
} }
.panel {
position: relative;
border-radius: $panel-border-radius;
background-color: $bg-container-l2;
padding: 1rem;
}
@@ -1,13 +1,33 @@
import type { HTMLAttributes, LabelHTMLAttributes } from 'react'; import type { HTMLAttributes, JSX, LabelHTMLAttributes, MouseEventHandler } from 'react';
import { IconBaseProps } from 'react-icons'; import { IconBaseProps } from 'react-icons';
import { IoArrowUp } from 'react-icons/io5'; import { IoArrowUp } from 'react-icons/io5';
import { TbPictureInPictureOff } from 'react-icons/tb';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
import style from './EditorUtils.module.scss'; import style from './EditorUtils.module.scss';
export function Corner({ className, ...elementProps }: IconBaseProps) { export function CornerExtract({ className, ...elementProps }: IconBaseProps) {
return <IoArrowUp className={cx([style.corner, className])} {...elementProps} />; return <IoArrowUp className={cx([style.corner, style.arrow, className])} {...elementProps} />;
}
export function CornerPipButton({ className, ...elementProps }: IconBaseProps) {
return <TbPictureInPictureOff className={cx([style.corner, style.offsetCorner, className])} {...elementProps} />;
}
interface ExtractAndPip extends IconBaseProps {
onExtractClick: MouseEventHandler<SVGElement>;
pipElement: JSX.Element;
}
export function CornerWithPip({ className, pipElement, onExtractClick }: ExtractAndPip) {
return (
<>
<IoArrowUp className={cx([style.corner, style.arrow, className])} onClick={onExtractClick} />
{/* the pip element returns the icon button */}
{pipElement}
</>
);
} }
export function Title({ children, className, ...elementProps }: HTMLAttributes<HTMLHeadingElement>) { export function Title({ children, className, ...elementProps }: HTMLAttributes<HTMLHeadingElement>) {
@@ -35,3 +55,7 @@ interface SeparatorProps extends HTMLAttributes<HTMLDivElement> {
export function Separator({ className, orientation = 'vertical', ...elementProps }: SeparatorProps) { export function Separator({ className, orientation = 'vertical', ...elementProps }: SeparatorProps) {
return <div className={cx([style.separator, style[orientation], className])} role='separator' {...elementProps} />; return <div className={cx([style.separator, style[orientation], className])} role='separator' {...elementProps} />;
} }
export function Panel({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cx([style.panel, className])} {...props} />;
}
@@ -1,5 +1,5 @@
import { Radio } from '@base-ui-components/react/radio'; import { Radio } from '@base-ui/react/radio';
import { RadioGroup as BaseRadioGroup } from '@base-ui-components/react/radio-group'; import { RadioGroup as BaseRadioGroup } from '@base-ui/react/radio-group';
import style from './BlockRadio.module.scss'; import style from './BlockRadio.module.scss';
@@ -11,7 +11,7 @@
outline: none; outline: none;
&::placeholder { &::placeholder {
color: $gray-500; color: $gray-600;
letter-spacing: 0; letter-spacing: 0;
} }
@@ -1,6 +1,6 @@
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { HexAlphaColorPicker, HexColorInput } from 'react-colorful'; import { HexAlphaColorPicker, HexColorInput } from 'react-colorful';
import { Popover } from '@base-ui-components/react/popover'; import { Popover } from '@base-ui/react/popover';
import PopoverContents from '../../popover/Popover'; import PopoverContents from '../../popover/Popover';
@@ -5,9 +5,9 @@
transform: translateX(-50%); transform: translateX(-50%);
padding-inline: 1rem; padding-inline: 1rem;
min-width: min(680px, 90vw); min-width: min(880px, 90vw);
min-height: min(200px, 10vh); min-height: min(200px, 10vh);
max-width: min(900px, 90vw); max-width: min(1200px, 90vw);
background-color: $gray-1250; background-color: $gray-1250;
color: $ui-white; color: $ui-white;
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { IoClose } from 'react-icons/io5'; import { IoClose } from 'react-icons/io5';
import { Dialog as BaseDialog } from '@base-ui-components/react/dialog'; import { Dialog as BaseDialog } from '@base-ui/react/dialog';
import IconButton from '../buttons/IconButton'; import IconButton from '../buttons/IconButton';
@@ -31,7 +31,7 @@ export default function Modal({
onOpenChange={(isOpen) => { onOpenChange={(isOpen) => {
if (!isOpen) onClose(); if (!isOpen) onClose();
}} }}
dismissible={false} disablePointerDismissal
> >
<BaseDialog.Portal> <BaseDialog.Portal>
{showBackdrop && <BaseDialog.Backdrop className={style.backdrop} />} {showBackdrop && <BaseDialog.Backdrop className={style.backdrop} />}
@@ -2,12 +2,13 @@ import { memo } from 'react';
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5'; import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
import { LuCoffee } from 'react-icons/lu'; import { LuCoffee } from 'react-icons/lu';
import { useLocation } from 'react-router'; import { useLocation } from 'react-router';
import { Dialog } from '@base-ui-components/react/dialog'; import { Dialog } from '@base-ui/react/dialog';
import { useDisclosure, useFullscreen } from '@mantine/hooks'; import { useDisclosure, useFullscreen } from '@mantine/hooks';
import { isLocalhost } from '../../../externals'; import { isLocalhost, supportsFullscreen } from '../../../externals';
import { useKeepAwakeOptions } from '../../../features/keep-awake/KeepAwake'; import { canUseWakeLock, useKeepAwakeOptions } from '../../../features/keep-awake/useWakeLock';
import { navigatorConstants } from '../../../viewerConfig'; import { navigatorConstants } from '../../../viewerConfig';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import { useClientStore } from '../../stores/clientStore'; import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions'; import { useViewOptionsStore } from '../../stores/viewOptions';
import IconButton from '../buttons/IconButton'; import IconButton from '../buttons/IconButton';
@@ -29,6 +30,7 @@ export default memo(NavigationMenu);
function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) { function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
const id = useClientStore((store) => store.id); const id = useClientStore((store) => store.id);
const name = useClientStore((store) => store.name); const name = useClientStore((store) => store.name);
const isSmallScreen = useIsSmallScreen();
const [isRenameOpen, handlers] = useDisclosure(false); const [isRenameOpen, handlers] = useDisclosure(false);
const { fullscreen, toggle } = useFullscreen(); const { fullscreen, toggle } = useFullscreen();
@@ -56,16 +58,18 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
</IconButton> </IconButton>
</div> </div>
<div className={style.body}> <div className={style.body}>
{supportsFullscreen && (
<NavigationMenuItem active={fullscreen} onClick={toggle}> <NavigationMenuItem active={fullscreen} onClick={toggle}>
Toggle Fullscreen Toggle Fullscreen
{fullscreen ? <IoContract /> : <IoExpand />} {fullscreen ? <IoContract /> : <IoExpand />}
</NavigationMenuItem> </NavigationMenuItem>
)}
<NavigationMenuItem active={mirror} onClick={() => toggleMirror()}> <NavigationMenuItem active={mirror} onClick={() => toggleMirror()}>
Flip Screen Flip Screen
<IoSwapVertical /> <IoSwapVertical />
{mirror && <span className={style.note}>Active</span>} {mirror && <span className={style.note}>Active</span>}
</NavigationMenuItem> </NavigationMenuItem>
{window.isSecureContext && ( {canUseWakeLock && (
<NavigationMenuItem active={keepAwake} onClick={toggleKeepAwake}> <NavigationMenuItem active={keepAwake} onClick={toggleKeepAwake}>
Keep Awake Keep Awake
<LuCoffee /> <LuCoffee />
@@ -77,11 +81,15 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
<hr className={style.separator} /> <hr className={style.separator} />
<EditorNavigation /> <EditorNavigation />
<ClientLink to='cuesheet' current={location.pathname === '/cuesheet'}> <ClientLink
to='cuesheet'
current={location.pathname === '/cuesheet'}
postAction={isSmallScreen ? onClose : undefined}
>
<IoLockClosedOutline /> <IoLockClosedOutline />
Cuesheet Cuesheet
</ClientLink> </ClientLink>
<ClientLink to='op' current={location.pathname === '/op'}> <ClientLink to='op' current={location.pathname === '/op'} postAction={isSmallScreen ? onClose : undefined}>
<IoLockClosedOutline /> <IoLockClosedOutline />
Operator Operator
</ClientLink> </ClientLink>
@@ -89,7 +97,12 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
<hr className={style.separator} /> <hr className={style.separator} />
{navigatorConstants.map((route) => ( {navigatorConstants.map((route) => (
<ClientLink key={route.url} to={route.url} current={location.pathname === `/${route.url}`}> <ClientLink
key={route.url}
to={route.url}
current={location.pathname === `/${route.url}`}
postAction={isSmallScreen ? onClose : undefined}
>
{route.label} {route.label}
</ClientLink> </ClientLink>
))} ))}
@@ -11,15 +11,22 @@ import style from './ClientLink.module.scss';
interface ClientLinkProps { interface ClientLinkProps {
current: boolean; current: boolean;
to: string; to: string;
postAction?: () => void;
} }
export default function ClientLink({ current, to, children }: PropsWithChildren<ClientLinkProps>) { export default function ClientLink({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) {
const { isElectron } = useElectronEvent(); const { isElectron } = useElectronEvent();
const navigate = useNavigate(); const navigate = useNavigate();
if (isElectron) { if (isElectron) {
return ( return (
<NavigationMenuItem active={current} onClick={() => handleLinks(to)}> <NavigationMenuItem
active={current}
onClick={() => {
handleLinks(to);
postAction?.();
}}
>
{children} {children}
<IoArrowUp className={style.linkIcon} /> <IoArrowUp className={style.linkIcon} />
</NavigationMenuItem> </NavigationMenuItem>
@@ -27,7 +34,13 @@ export default function ClientLink({ current, to, children }: PropsWithChildren<
} }
return ( return (
<NavigationMenuItem active={current} onClick={() => navigate(`/${to}`)}> <NavigationMenuItem
active={current}
onClick={() => {
navigate(`/${to}`);
postAction?.();
}}
>
{children} {children}
</NavigationMenuItem> </NavigationMenuItem>
); );
@@ -1,5 +1,4 @@
import { IoApps } from 'react-icons/io5'; import { IoApps, IoSettingsOutline } from 'react-icons/io5';
import { IoSettingsOutline } from 'react-icons/io5';
import { useFadeOutOnInactivity } from '../../../hooks/useFadeOutOnInactivity'; import { useFadeOutOnInactivity } from '../../../hooks/useFadeOutOnInactivity';
import { cx } from '../../../utils/styleUtils'; import { cx } from '../../../utils/styleUtils';
@@ -1,5 +1,5 @@
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { Popover } from '@base-ui-components/react/popover'; import { Popover } from '@base-ui/react/popover';
import style from './Popover.module.scss'; import style from './Popover.module.scss';
@@ -1,5 +1,5 @@
import { Radio } from '@base-ui-components/react/radio'; import { Radio } from '@base-ui/react/radio';
import { RadioGroup as BaseRadioGroup } from '@base-ui-components/react/radio-group'; import { RadioGroup as BaseRadioGroup } from '@base-ui/react/radio-group';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
@@ -1,6 +1,6 @@
import { IoCheckmark } from 'react-icons/io5'; import { IoCheckmark } from 'react-icons/io5';
import { LuChevronsUpDown } from 'react-icons/lu'; import { LuChevronsUpDown } from 'react-icons/lu';
import { Select as BaseSelect } from '@base-ui-components/react/select'; import { Select as BaseSelect } from '@base-ui/react/select';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
@@ -1,4 +1,4 @@
import { Switch as BaseSwitch } from '@base-ui-components/react/switch'; import { Switch as BaseSwitch } from '@base-ui/react/switch';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
@@ -1,5 +1,5 @@
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { Tooltip as BaseTooltip } from '@base-ui-components/react/tooltip'; import { Tooltip as BaseTooltip } from '@base-ui/react/tooltip';
import style from './Tooltip.module.scss'; import style from './Tooltip.module.scss';
@@ -32,7 +32,7 @@
margin-left: 0.25rem; margin-left: 0.25rem;
width: 0.75em; width: 0.75em;
height: 0.75em; height: 0.75em;
background: var(--user-bg); background: var(--user-bg, $gray-900);
border-radius: 50%; border-radius: 50%;
} }
} }
@@ -1,7 +1,7 @@
import { ComponentProps, useEffect, useState } from 'react'; import { ComponentProps, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils'; import { isStringBoolean } from '../../../views/common/viewUtils';
import Checkbox from '../checkbox/Checkbox'; import Checkbox from '../checkbox/Checkbox';
import Input from '../input/input/Input'; import Input from '../input/input/Input';
import Select, { SelectOption } from '../select/Select'; import Select, { SelectOption } from '../select/Select';
@@ -160,7 +160,16 @@ function ControlledSelect({ id, initialValue, options }: ControlledSelectProps)
}, [initialValue]); }, [initialValue]);
return ( return (
<Select size='large' name={id} options={options} value={selected} onValueChange={(value) => setSelected(value)} /> <Select
size='large'
name={id}
options={options}
value={selected}
onValueChange={(value) => {
if (value === null) return;
setSelected(value);
}}
/>
); );
} }
@@ -30,6 +30,7 @@
width: 40rem; width: 40rem;
max-width: 100vw; max-width: 100vw;
height: 100vh; height: 100vh;
height: 100dvh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1,9 +1,10 @@
import { FormEvent, memo } from 'react'; import { FormEvent, memo } from 'react';
import { IoClose } from 'react-icons/io5'; import { IoClose } from 'react-icons/io5';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { Dialog } from '@base-ui-components/react/dialog'; import { Dialog } from '@base-ui/react/dialog';
import { OntimeView } from 'ontime-types'; import { OntimeView } from 'ontime-types';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import useViewSettings from '../../hooks-query/useViewSettings'; import useViewSettings from '../../hooks-query/useViewSettings';
import Button from '../buttons/Button'; import Button from '../buttons/Button';
import IconButton from '../buttons/IconButton'; import IconButton from '../buttons/IconButton';
@@ -27,6 +28,7 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
const [_, setSearchParams] = useSearchParams(); const [_, setSearchParams] = useSearchParams();
const { data: viewSettings } = useViewSettings(); const { data: viewSettings } = useViewSettings();
const { isOpen, close } = useViewParamsEditorStore(); const { isOpen, close } = useViewParamsEditorStore();
const isSmallScreen = useIsSmallScreen();
const handleClose = () => { const handleClose = () => {
close(); close();
@@ -42,6 +44,10 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget)); const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions); const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
setSearchParams(newSearchParams); setSearchParams(newSearchParams);
if (isSmallScreen) {
close();
}
}; };
return ( return (
@@ -46,7 +46,7 @@ export function makeCustomFieldSelectOptions(customFields: CustomFields, filterI
options.push({ options.push({
value: key, value: key,
label: value.label, label: value.label,
colour: value.colour || 'transparent', colour: value.colour,
}); });
} }
@@ -1,6 +1,6 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { ProjectFileListResponse } from 'ontime-types'; import { ProjectFile, ProjectFileList, ProjectFileListResponse } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_LIST } from '../api/constants'; import { PROJECT_LIST } from '../api/constants';
@@ -24,22 +24,33 @@ function useProjectList() {
return { data: data ?? placeholderProjectList, status, refetch }; return { data: data ?? placeholderProjectList, status, refetch };
} }
export function useOrderedProjectList() { export type ProjectSortMode = 'alphabetical-asc' | 'alphabetical-desc' | 'modified-asc' | 'modified-desc';
type SortComparator = (a: ProjectFile, b: ProjectFile) => number;
const sortComparators: Record<ProjectSortMode, SortComparator> = {
'alphabetical-asc': (a, b) => a.filename.localeCompare(b.filename),
'alphabetical-desc': (a, b) => b.filename.localeCompare(a.filename),
'modified-asc': (a, b) => new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime(),
'modified-desc': (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
};
export function useOrderedProjectList(sort: ProjectSortMode = 'modified-desc') {
const response = useProjectList(); const response = useProjectList();
const { files, lastLoadedProject } = response.data; const { files, lastLoadedProject } = response.data;
const reorderedProjectFiles = useMemo(() => { const reorderedProjectFiles: ProjectFileList = useMemo(() => {
if (!files.length) return []; if (!files.length) return [];
const currentlyLoadedIndex = files.findIndex((project) => project.filename === lastLoadedProject); const sorted = [...files].sort(sortComparators[sort]);
if (currentlyLoadedIndex === -1) return files; // keep loaded always on top
const currentlyLoadedIndex = sorted.findIndex((project) => project.filename === lastLoadedProject);
if (currentlyLoadedIndex > 0) {
const [loaded] = sorted.splice(currentlyLoadedIndex, 1);
sorted.unshift(loaded);
}
const projectFiles = [...files]; return sorted;
const current = projectFiles.splice(currentlyLoadedIndex, 1)[0]; }, [files, lastLoadedProject, sort]);
return [current, ...projectFiles];
}, [files, lastLoadedProject]);
return { ...response, data: { reorderedProjectFiles, lastLoadedProject: response.data.lastLoadedProject } }; return { ...response, data: { reorderedProjectFiles, lastLoadedProject: response.data.lastLoadedProject } };
} }
@@ -3,7 +3,7 @@ import { ProjectRundownsList } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_RUNDOWNS } from '../api/constants'; import { PROJECT_RUNDOWNS } from '../api/constants';
import { createRundown, deleteRundown, fetchProjectRundownList, loadRundown } from '../api/rundown'; import { createRundown, deleteRundown, duplicateRundown, fetchProjectRundownList, loadRundown, renameRundown } from '../api/rundown';
/** /**
* Project rundowns * Project rundowns
@@ -31,6 +31,26 @@ export function useMutateProjectRundowns() {
}, },
}); });
const { mutateAsync: duplicate } = useMutation({
mutationFn: duplicateRundown,
onMutate: () => {
ontimeQueryClient.cancelQueries({ queryKey: PROJECT_RUNDOWNS });
},
onSuccess: (response) => {
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
},
});
const { mutateAsync: rename } = useMutation({
mutationFn: ([rundownId, title]: Parameters<typeof renameRundown>) => renameRundown(rundownId, title),
onMutate: () => {
ontimeQueryClient.cancelQueries({ queryKey: PROJECT_RUNDOWNS });
},
onSuccess: (response) => {
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
},
});
const { mutateAsync: remove } = useMutation({ const { mutateAsync: remove } = useMutation({
mutationFn: deleteRundown, mutationFn: deleteRundown,
onMutate: () => { onMutate: () => {
@@ -51,5 +71,5 @@ export function useMutateProjectRundowns() {
}, },
}); });
return { create, remove, load }; return { create, duplicate, remove, load, rename };
} }
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { EntryId, OntimeEntry, Rundown } from 'ontime-types'; import { EntryId, OntimeEntry, Rundown } from 'ontime-types';
@@ -8,8 +8,6 @@ import { fetchCurrentRundown } from '../api/rundown';
import { useSelectedEventId } from '../hooks/useSocket'; import { useSelectedEventId } from '../hooks/useSocket';
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata'; import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import useProjectData from './useProjectData';
// revision is -1 so that the remote revision is higher // revision is -1 so that the remote revision is higher
const cachedRundownPlaceholder: Rundown = { const cachedRundownPlaceholder: Rundown = {
id: 'default', id: 'default',
@@ -27,9 +25,9 @@ export default function useRundown() {
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({ const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: RUNDOWN, queryKey: RUNDOWN,
queryFn: fetchCurrentRundown, queryFn: fetchCurrentRundown,
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
}); });
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching }; return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
} }
@@ -46,30 +44,13 @@ export function useRundownWithMetadata() {
*/ */
export function useFlatRundown() { export function useFlatRundown() {
const { data, status } = useRundown(); const { data, status } = useRundown();
const { data: projectData } = useProjectData();
const loadedProject = useRef<string>(''); const flatRundown = useMemo(() => {
const [prevRevision, setPrevRevision] = useState<number>(-1); if (data.revision === -1) {
const [flatRundown, setFlatRundown] = useState<OntimeEntry[]>([]); return [];
// update data whenever the revision changes
useEffect(() => {
if (data.revision !== -1 || data.revision !== prevRevision) {
const flatRundown = data.order.map((id) => data.entries[id]);
setFlatRundown(flatRundown);
setPrevRevision(data.revision);
} }
}, [data.entries, data.flatOrder, data.revision, prevRevision]); return data.flatOrder.map((id) => data.entries[id]).filter((entry): entry is OntimeEntry => entry !== undefined);
}, [data]);
// TODO: should we have a project id field?
// TODO(v4): cleanup as part of load multiple rundowns
// invalidate current version if project changes
useEffect(() => {
if (projectData?.title !== loadedProject.current) {
setPrevRevision(-1);
loadedProject.current = projectData?.title ?? '';
}
}, [projectData]);
return { data: flatRundown, rundownId: data.id, status }; return { data: flatRundown, rundownId: data.id, status };
} }
@@ -84,6 +65,10 @@ export function useFlatRundownWithMetadata() {
/** /**
* Provides access to a partial rundown based on a filter callback * Provides access to a partial rundown based on a filter callback
*
* Callers MUST memoize the callback with useCallback to prevent
* re-filtering on every render.
*
*/ */
export function usePartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boolean) { export function usePartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boolean) {
const { data, status } = useFlatRundownWithMetadata(); const { data, status } = useFlatRundownWithMetadata();
@@ -100,11 +85,6 @@ export function usePartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boo
export function useEntry(entryId: EntryId | null): OntimeEntry | null { export function useEntry(entryId: EntryId | null): OntimeEntry | null {
const { data: rundown } = useRundown(); const { data: rundown } = useRundown();
// track the specific entry we care about
const entry = useMemo(() => {
if (entryId === null) return null; if (entryId === null) return null;
return rundown.entries[entryId]; return rundown.entries[entryId] ?? null;
}, [entryId, rundown.entries]);
return entry;
} }
@@ -2,6 +2,7 @@ import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { import {
EntryId, EntryId,
InsertOptions,
isOntimeEvent, isOntimeEvent,
isOntimeGroup, isOntimeGroup,
MaybeString, MaybeString,
@@ -173,7 +174,8 @@ export const useEntryActions = () => {
* @private * @private
*/ */
const { mutateAsync: cloneEntryMutation } = useMutation({ const { mutateAsync: cloneEntryMutation } = useMutation({
mutationFn: ([rundownId, entryId]: Parameters<typeof postCloneEntry>) => postCloneEntry(rundownId, entryId), mutationFn: ([rundownId, entryId, options]: Parameters<typeof postCloneEntry>) =>
postCloneEntry(rundownId, entryId, options),
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }), onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }), onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
}); });
@@ -182,14 +184,14 @@ export const useEntryActions = () => {
* Clone an entry * Clone an entry
*/ */
const clone = useCallback( const clone = useCallback(
async (entryId: EntryId) => { async (entryId: EntryId, options?: InsertOptions) => {
try { try {
const rundownId = getCurrentRundownData()?.id; const rundownId = getCurrentRundownData()?.id;
if (!rundownId) { if (!rundownId) {
throw new Error('Rundown not initialised'); throw new Error('Rundown not initialised');
} }
await cloneEntryMutation([rundownId, entryId]); await cloneEntryMutation([rundownId, entryId, options]);
} catch (error) { } catch (error) {
logAxiosError('Error cloning entry', error); logAxiosError('Error cloning entry', error);
} }
@@ -0,0 +1,95 @@
import React, { useEffect, useMemo, useRef } from 'react';
import { throttle } from '../utils/throttle';
export interface UseLongPressOptions {
/** Time in milliseconds to trigger the long press, default is 400ms */
threshold?: number;
/** Callback triggered when the long press starts */
onStart?: (event: React.MouseEvent | React.TouchEvent) => void;
/** Callback triggered when the long press finishes */
onFinish?: (event: React.MouseEvent | React.TouchEvent) => void;
/** Callback triggered when the long press is canceled */
onCancel?: (event: React.MouseEvent | React.TouchEvent) => void;
}
export interface UseLongPressReturnValue {
onMouseDown: (event: React.MouseEvent) => void;
onMouseUp: (event: React.MouseEvent) => void;
onMouseLeave: (event: React.MouseEvent) => void;
onTouchStart: (event: React.TouchEvent) => void;
onTouchEnd: (event: React.TouchEvent) => void;
}
export function useLongPress(
onLongPress: (event: React.MouseEvent | React.TouchEvent) => void,
options: UseLongPressOptions = {},
): UseLongPressReturnValue {
const { threshold = 700, onStart, onFinish, onCancel } = options;
const isLongPressActive = useRef(false);
const isPressed = useRef(false);
const timeout = useRef<number>(-1);
useEffect(() => () => window.clearTimeout(timeout.current), []);
return useMemo(() => {
if (typeof onLongPress !== 'function') {
return {} as UseLongPressReturnValue;
}
const start = (event: React.MouseEvent | React.TouchEvent) => {
if (!isMouseEvent(event) && !isTouchEvent(event)) {
return;
}
if (onStart) {
onStart(event);
}
isPressed.current = true;
timeout.current = window.setTimeout(() => {
onLongPress(event);
isLongPressActive.current = true;
}, threshold);
};
const cancel = (event: React.MouseEvent | React.TouchEvent) => {
if (!isMouseEvent(event) && !isTouchEvent(event)) {
return;
}
if (isLongPressActive.current) {
onFinish?.(event);
} else if (isPressed.current) {
onCancel?.(event);
}
isLongPressActive.current = false;
isPressed.current = false;
if (timeout.current) {
window.clearTimeout(timeout.current);
}
};
return {
onMouseDown: start,
onMouseUp: cancel,
onMouseLeave: cancel,
onTouchStart: start,
onTouchEnd: cancel,
onTouchMove: throttle(cancel, 150),
};
}, [onLongPress, threshold, onCancel, onFinish, onStart]);
}
function isTouchEvent(event: React.MouseEvent | React.TouchEvent): event is React.TouchEvent {
return window.TouchEvent ? event.nativeEvent instanceof TouchEvent : 'touches' in event.nativeEvent;
}
function isMouseEvent(event: React.MouseEvent | React.TouchEvent): event is React.MouseEvent {
return event.nativeEvent instanceof MouseEvent;
}
@@ -202,6 +202,9 @@ export const useStartTimesOverview = createSelector((state: RuntimeStore) => ({
plannedStart: state.rundown.plannedStart, plannedStart: state.rundown.plannedStart,
actualStart: state.rundown.actualStart, actualStart: state.rundown.actualStart,
plannedEnd: state.rundown.plannedEnd, plannedEnd: state.rundown.plannedEnd,
}));
export const useRundownExpectedEnd = createSelector((state: RuntimeStore) => ({
expectedEnd: state.offset.expectedRundownEnd, expectedEnd: state.offset.expectedRundownEnd,
})); }));
@@ -1,68 +0,0 @@
import { EndAction, EntryCustomFields, OntimeEvent, SupportedEntry, TimerType, TimeStrategy } from 'ontime-types';
import { cloneEvent } from '../clone';
describe('cloneEvent()', () => {
it('creates a stem from a given event', () => {
const original: OntimeEvent = {
id: 'unique',
type: SupportedEntry.Event,
flag: false,
title: 'title',
cue: 'cue',
note: 'note',
timeStart: 0,
duration: 10,
timeEnd: 10,
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockEnd,
parent: 'test',
linkStart: false,
countToEnd: false,
endAction: EndAction.None,
skip: false,
colour: 'F00',
revision: 10,
timeWarning: 120000,
timeDanger: 60000,
delay: 0,
dayOffset: 0,
gap: 0,
triggers: [],
custom: {
lighting: '3',
} as EntryCustomFields,
};
const cloned = cloneEvent(original);
expect(cloned).not.toBe(original);
expect(cloned.custom).not.toBe(original.custom);
expect(cloned.triggers).not.toBe(original.triggers);
expect(cloned).toMatchObject({
type: SupportedEntry.Event,
flag: original.flag,
title: original.title,
note: original.note,
timeStart: original.timeStart,
duration: original.duration,
timeEnd: original.timeEnd,
timerType: original.timerType,
timeStrategy: original.timeStrategy,
parent: 'test',
countToEnd: original.countToEnd,
linkStart: original.linkStart,
endAction: original.endAction,
skip: original.skip,
colour: original.colour,
revision: 0,
delay: original.delay,
dayOffset: original.dayOffset,
gap: 0,
timeWarning: original.timeWarning,
timeDanger: original.timeDanger,
triggers: original.triggers,
custom: original.custom,
});
});
});
@@ -14,4 +14,11 @@ describe('linkToOTherHost', () => {
const destination = linkToOtherHost('cloud.getontime.no', 'path', serverUrl, baseUri); const destination = linkToOtherHost('cloud.getontime.no', 'path', serverUrl, baseUri);
expect(destination).toBe('https://cloud.getontime.no/user-hash/path'); expect(destination).toBe('https://cloud.getontime.no/user-hash/path');
}); });
it('should handle ontime app links', () => {
const serverUrl = 'https://app.getontime.no/user-hash';
const baseUri = 'user-hash';
const destination = linkToOtherHost('app.getontime.no', 'path', serverUrl, baseUri);
expect(destination).toBe('https://app.getontime.no/user-hash/path');
});
}); });
@@ -140,7 +140,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 2, eventIndex: 2,
isPast: true, isPast: true,
isNextDay: false, isNextDay: false,
totalGap: 10, totalGap: 0,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
groupId: 'group', groupId: 'group',
@@ -156,7 +156,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 2, eventIndex: 2,
isPast: true, isPast: true,
isNextDay: false, isNextDay: false,
totalGap: 10, totalGap: 0,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
groupId: 'group', groupId: 'group',
@@ -172,7 +172,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 3, eventIndex: 3,
isPast: false, isPast: false,
isNextDay: false, isNextDay: false,
totalGap: 10, totalGap: 0,
isLinkedToLoaded: true, isLinkedToLoaded: true,
isLoaded: true, isLoaded: true,
groupId: 'group', groupId: 'group',
@@ -188,7 +188,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 4, eventIndex: 4,
isPast: false, isPast: false,
isNextDay: false, isNextDay: false,
totalGap: 10, totalGap: 0,
isLinkedToLoaded: true, isLinkedToLoaded: true,
isLoaded: false, isLoaded: false,
groupId: 'group', groupId: 'group',
@@ -204,7 +204,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 5, eventIndex: 5,
isPast: false, isPast: false,
isNextDay: false, isNextDay: false,
totalGap: 17, totalGap: 7,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
groupId: null, groupId: null,
@@ -1,4 +1,6 @@
import { formatTime, nowInMillis } from '../time'; import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
import { formatDuration, formatTime, nowInMillis } from '../time';
describe('nowInMillis()', () => { describe('nowInMillis()', () => {
it('should return the current time in milliseconds', () => { it('should return the current time in milliseconds', () => {
@@ -38,3 +40,18 @@ describe('formatTime()', () => {
expect(time).toStrictEqual('-01:00'); expect(time).toStrictEqual('-01:00');
}); });
}); });
describe('formatDuration()', () => {
it('formats durations correctly', () => {
expect(formatDuration(0)).toBe('0m');
expect(formatDuration(-5000)).toBe('0m');
expect(formatDuration(MILLIS_PER_MINUTE)).toBe('1m');
expect(formatDuration(6 * MILLIS_PER_MINUTE + 11 * MILLIS_PER_SECOND)).toBe('6m');
expect(formatDuration(MILLIS_PER_MINUTE * 10)).toBe('10m');
expect(formatDuration(MILLIS_PER_MINUTE * 10 + 100)).toBe('10m');
expect(formatDuration(MILLIS_PER_MINUTE * 10 - 100)).toBe('9m');
expect(formatDuration(2 * MILLIS_PER_HOUR + 6 * MILLIS_PER_MINUTE)).toBe('2h6m');
expect(formatDuration(2 * MILLIS_PER_HOUR + 6 * MILLIS_PER_MINUTE + 45 * MILLIS_PER_SECOND, false)).toBe('2h6m45s');
expect(formatDuration(599702, false)).toBe('9m59s');
});
});
@@ -175,7 +175,7 @@ describe('generateUrlPresetOptions', () => {
expect(() => generateUrlPresetOptions('test', 'invalid-url')).toThrow(); expect(() => generateUrlPresetOptions('test', 'invalid-url')).toThrow();
}); });
it('throws on on invalid route', () => { it('throws on invalid route', () => {
expect(() => generateUrlPresetOptions('test', 'www.getontime.no/somethingelse/')).toThrow(); expect(() => generateUrlPresetOptions('test', 'www.getontime.no/somethingelse/')).toThrow();
}); });
}); });
-36
View File
@@ -1,36 +0,0 @@
import { OntimeEvent, SupportedEntry } from 'ontime-types';
/**
* @description Creates a safe duplicate of an event
* @param {OntimeEvent} event
* @param {string} [after]
* @return {OntimeEvent} clean event
*/
type ClonedEvent = Omit<OntimeEvent, 'id' | 'cue'>;
export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
return {
type: SupportedEntry.Event,
flag: event.flag,
title: event.title,
note: event.note,
timeStart: event.timeStart,
duration: event.duration,
timeEnd: event.timeEnd,
timerType: event.timerType,
timeStrategy: event.timeStrategy,
countToEnd: event.countToEnd,
linkStart: event.linkStart,
endAction: event.endAction,
skip: event.skip,
colour: event.colour,
parent: event.parent,
revision: 0,
delay: event.delay, // the events will be collocated, so having the same metadata is a good start
dayOffset: event.dayOffset,
gap: 0,
timeWarning: event.timeWarning,
timeDanger: event.timeDanger,
triggers: structuredClone(event.triggers),
custom: structuredClone(event.custom),
};
};
@@ -1,4 +1,18 @@
// we need to this as a promise because safari /**
export default async function copyToClipboard(text: string) { * copy text to clipboard
setTimeout(async () => await navigator.clipboard?.writeText(text)); * @throws if not supported or permission denied
*/
export async function copyToClipboard(text: string) {
await navigator.clipboard?.writeText(text);
}
/**
* Copy to clipboard but safely ignore errors
*/
export async function safeCopyToClipboard(text: string): Promise<void> {
try {
await copyToClipboard(text);
} catch {
// Silently ignore errors
}
} }
+2 -2
View File
@@ -1,5 +1,5 @@
import { MaybeNumber } from 'ontime-types'; import { MaybeNumber } from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { millisToString, removeLeadingZero } from 'ontime-utils';
import { enDash } from './styleUtils'; import { enDash } from './styleUtils';
@@ -14,7 +14,7 @@ export function getOffsetText(offset: MaybeNumber): string {
let offsetText = ''; let offsetText = '';
if (offset < 0) offsetText += '-'; if (offset < 0) offsetText += '-';
if (offset > 0) offsetText += '+'; if (offset > 0) offsetText += '+';
offsetText += millisToString(Math.abs(offset)); offsetText += removeLeadingZero(millisToString(Math.abs(offset)));
return offsetText; return offsetText;
} }
@@ -151,7 +151,6 @@ function processEntry(
if (isPlayableEvent(entry)) { if (isPlayableEvent(entry)) {
processedData.isNextDay = checkIsNextDay(entry, processedData.previousEvent); processedData.isNextDay = checkIsNextDay(entry, processedData.previousEvent);
processedData.totalGap += entry.gap;
if (!processedData.isPast && !processedData.isLoaded) { if (!processedData.isPast && !processedData.isLoaded) {
/** /**
@@ -159,6 +158,7 @@ function processEntry(
* a) find an unlinked event * a) find an unlinked event
* b) find a countToEnd event * b) find a countToEnd event
*/ */
processedData.totalGap += entry.gap;
processedData.isLinkedToLoaded = processedData.isLinkedToLoaded =
entry.linkStart && !processedData.previousEvent?.countToEnd && processedData.isLinkedToLoaded; entry.linkStart && !processedData.previousEvent?.countToEnd && processedData.isLinkedToLoaded;
} }
+8 -2
View File
@@ -112,11 +112,12 @@ export const formatTime = (
export function formatDuration(duration: number, hideSeconds = true): string { export function formatDuration(duration: number, hideSeconds = true): string {
// durations should never be negative, we handle it here to flag if there is an issue in future // durations should never be negative, we handle it here to flag if there is an issue in future
if (duration <= 0) { if (duration <= 0) {
return '0h 0m'; return '0m';
} }
const hours = Math.floor(duration / MILLIS_PER_HOUR); const hours = Math.floor(duration / MILLIS_PER_HOUR);
const minutes = Math.floor((duration % MILLIS_PER_HOUR) / MILLIS_PER_MINUTE); const minutes = Math.floor((duration % MILLIS_PER_HOUR) / MILLIS_PER_MINUTE);
let result = ''; let result = '';
if (hours > 0) { if (hours > 0) {
result += `${hours}h`; result += `${hours}h`;
@@ -126,11 +127,16 @@ export function formatDuration(duration: number, hideSeconds = true): string {
} }
if (!hideSeconds) { if (!hideSeconds) {
const seconds = Math.ceil((duration % MILLIS_PER_MINUTE) / MILLIS_PER_SECOND); const remainingMs = duration % MILLIS_PER_MINUTE;
const exactSeconds = remainingMs / MILLIS_PER_SECOND;
// cap at 59 to avoid showing 60s
const seconds = Math.min(59, Math.ceil(exactSeconds));
if (seconds > 0) { if (seconds > 0) {
result += `${seconds}s`; result += `${seconds}s`;
} }
} }
return result; return result;
} }
+5
View File
@@ -19,6 +19,11 @@ declare global {
process: { process: {
type: string; type: string;
}; };
// Experimental browser feature
documentPictureInPicture: {
requestWindow: () => Promise<Window>;
window: Window;
};
} }
} }
+5 -1
View File
@@ -8,6 +8,7 @@ export const githubUrl = 'https://www.github.com/cpvalente/ontime';
export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/releases/latest'; export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/releases/latest';
export const websiteUrl = 'https://www.getontime.no'; export const websiteUrl = 'https://www.getontime.no';
export const discordUrl = 'https://discord.com/invite/eje3CSUEXm'; export const discordUrl = 'https://discord.com/invite/eje3CSUEXm';
export const subredditUrl = 'https://www.reddit.com/r/ontimeapp/';
export const documentationUrl = 'https://docs.getontime.no'; export const documentationUrl = 'https://docs.getontime.no';
export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/'; export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
@@ -22,7 +23,10 @@ export const isProduction = import.meta.env.PROD;
export const isDev = import.meta.env.DEV; export const isDev = import.meta.env.DEV;
export const currentHostName = window.location.hostname; export const currentHostName = window.location.hostname;
export const isLocalhost = currentHostName === 'localhost' || currentHostName === '127.0.0.1'; export const isLocalhost = currentHostName === 'localhost' || currentHostName === '127.0.0.1';
export const isOntimeCloud = currentHostName.includes('cloud.getontime.no'); export const isOntimeCloud = document.querySelector('base')?.hasAttribute('data-is-cloud')
export const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
export const supportsFullscreen = document.fullscreenEnabled;
// resolve entrypoint URLs // resolve entrypoint URLs
@@ -5,6 +5,7 @@ import {
documentationUrl, documentationUrl,
githubSponsorUrl, githubSponsorUrl,
githubUrl, githubUrl,
subredditUrl,
websiteUrl, websiteUrl,
} from '../../../../externals'; } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
@@ -38,6 +39,7 @@ export default function AboutPanel() {
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink> <ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink> <ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
<ExternalLink href={discordUrl}>Discord server</ExternalLink> <ExternalLink href={discordUrl}>Discord server</ExternalLink>
<ExternalLink href={subredditUrl}>Subreddit</ExternalLink>
</Panel.Section> </Panel.Section>
</> </>
); );
@@ -10,7 +10,7 @@ export default function AppVersion() {
return ( return (
<Panel.Paragraph> <Panel.Paragraph>
{`You are currently using Ontime version ${appVersion}`} {`You are currently using Ontime version ${appVersion}`}
<Panel.Error>{`Could not fetch version information: ${isError}`}</Panel.Error> <Panel.Error>Could not fetch version information</Panel.Error>
</Panel.Paragraph> </Panel.Paragraph>
); );
} }
@@ -230,10 +230,18 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
<div key={key} className={style.filterSection}> <div key={key} className={style.filterSection}>
<label> <label>
Runtime data source Runtime data source
<Select <Select<string | null>
value={watch(`filters.${index}.field`)} // need to normalize '' to null for the Select to show the placeholder
onValueChange={(value) => setValue(`filters.${index}.field`, value, { shouldDirty: true })} value={watch(`filters.${index}.field`) || null}
options={fieldList.map(({ value, label }) => ({ value, label }))} onValueChange={(value) => {
if (value === null) return;
setValue(`filters.${index}.field`, value, { shouldDirty: true });
}}
options={fieldList.map(({ value, label }) => ({
value,
label,
disabled: value === null,
}))}
aria-label='Event field' aria-label='Event field'
/> />
<Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error> <Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error>
@@ -242,11 +250,14 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
Matching condition Matching condition
<Select <Select
value={watch(`filters.${index}.operator`)} value={watch(`filters.${index}.operator`)}
onValueChange={(value) => onValueChange={(value: string | null) => {
setValue(`filters.${index}.operator`, value as 'equals' | 'not_equals' | 'contains', { if (value === null) return;
shouldDirty: true, setValue(
}) `filters.${index}.operator`,
} value as 'equals' | 'not_equals' | 'greater_than' | 'less_than' | 'contains' | 'not_contains',
{ shouldDirty: true },
);
}}
options={[ options={[
{ value: 'equals', label: 'equals' }, { value: 'equals', label: 'equals' },
{ value: 'not_equals', label: 'not equals' }, { value: 'not_equals', label: 'not equals' },
@@ -282,7 +293,8 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
<div className={style.innerColumn}> <div className={style.innerColumn}>
<h3>Outputs</h3> <h3>Outputs</h3>
<Info> <Info>
Automation outputs can be used to send data from Ontime to external software. Automation outputs can be used to send data from Ontime to external software <br />
or to change properties of Ontime itself.
<ExternalLink href={integrationsDocsUrl}>See the documentation for templates</ExternalLink> <ExternalLink href={integrationsDocsUrl}>See the documentation for templates</ExternalLink>
</Info> </Info>
@@ -44,8 +44,9 @@ export default function OntimeActionForm({
<label> <label>
Action Action
<Select <Select
onValueChange={(value) => { onValueChange={(value: OntimeActionKey | null) => {
handleSetAction(value as OntimeActionKey); if (value === null) return;
handleSetAction(value);
}} }}
value={watch(`outputs.${index}.action`)} value={watch(`outputs.${index}.action`)}
options={[ options={[
@@ -77,7 +78,7 @@ export default function OntimeActionForm({
New time New time
<Input <Input
{...register(`outputs.${index}.time`, { {...register(`outputs.${index}.time`, {
required: { value: true, message: 'Required field' }, //TODO:(automation set aux) not sure what way around to have the string and where to have the ms value required: { value: true, message: 'Required field' },
})} })}
fluid fluid
placeholder='eg: 10m5s' placeholder='eg: 10m5s'
@@ -97,13 +98,13 @@ export default function OntimeActionForm({
Visibility Visibility
<Select <Select
onValueChange={(value) => { onValueChange={(value) => {
// we need to translate the undefined value to 'untouched' // we need to translate the null to undefined so it becomes 'untouched'
const translatedValue = value === 'untouched' ? undefined : (value as boolean | undefined); const translatedValue = value === null ? undefined : value;
setValue(`outputs.${index}.visible`, translatedValue, { shouldDirty: true }); setValue(`outputs.${index}.visible`, translatedValue, { shouldDirty: true });
}} }}
value={watch(`outputs.${index}.visible`) === undefined ? 'untouched' : watch(`outputs.${index}.visible`)} value={watch(`outputs.${index}.visible`)}
options={[ options={[
{ value: 'untouched', label: 'Untouched' }, { value: null, label: 'Untouched' },
{ value: true, label: 'Show' }, { value: true, label: 'Show' },
{ value: false, label: 'Hide' }, { value: false, label: 'Hide' },
]} ]}
@@ -116,9 +117,16 @@ export default function OntimeActionForm({
{selectedAction === 'message-secondary' && ( {selectedAction === 'message-secondary' && (
<label> <label>
Timer secondary source Timer secondary source
<Select <Select<SecondarySource | 'null' | null>
onValueChange={(value) => { onValueChange={(value) => {
setValue(`outputs.${index}.secondarySource`, value as SecondarySource, { shouldDirty: true }); // null -> no selection
if (value === null) return;
// 'null' -> clear the secondary source
if (value === 'null') {
setValue(`outputs.${index}.secondarySource`, null, { shouldDirty: true });
return;
}
setValue(`outputs.${index}.secondarySource`, value, { shouldDirty: true });
}} }}
value={watch(`outputs.${index}.secondarySource`)} value={watch(`outputs.${index}.secondarySource`)}
options={[ options={[
@@ -126,13 +134,14 @@ export default function OntimeActionForm({
{ value: 'aux1', label: 'Auxiliary timer 1' }, { value: 'aux1', label: 'Auxiliary timer 1' },
{ value: 'aux2', label: 'Auxiliary timer 2' }, { value: 'aux2', label: 'Auxiliary timer 2' },
{ value: 'aux3', label: 'Auxiliary timer 3' }, { value: 'aux3', label: 'Auxiliary timer 3' },
{ value: 'external', label: 'External' }, { value: 'secondary', label: 'Secondary' },
{ value: 'null', label: 'None' }, { value: 'null', label: 'None' }, // allow the user to clear the secondary source
]} ]}
/> />
<Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error> <Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error>
</label> </label>
)} )}
<div className={style.test}>{children}</div> <div className={style.test}>{children}</div>
</div> </div>
); );
@@ -106,7 +106,10 @@ export default function TriggerForm({
Lifecycle trigger Lifecycle trigger
<Select <Select
value={watch('trigger')} value={watch('trigger')}
onValueChange={(value) => setValue('trigger', value as TimerLifeCycle, { shouldDirty: true })} onValueChange={(value) => {
if (value === null) return;
setValue('trigger', value as TimerLifeCycle, { shouldDirty: true });
}}
options={cycles.map((cycle) => ({ value: cycle.value, label: cycle.label }))} options={cycles.map((cycle) => ({ value: cycle.value, label: cycle.label }))}
aria-label='Lifecycle trigger' aria-label='Lifecycle trigger'
/> />
@@ -116,7 +119,10 @@ export default function TriggerForm({
Automation title Automation title
<Select <Select
value={watch('automationId')} value={watch('automationId')}
onValueChange={(value) => setValue('automationId', value, { shouldDirty: true })} onValueChange={(value: string | null) => {
if (value === null) return;
setValue('automationId', value, { shouldDirty: true });
}}
options={automationSelect} options={automationSelect}
aria-label='Automation title' aria-label='Automation title'
/> />
@@ -27,6 +27,7 @@ export function isAutomation(automation: AutomationDTO | Automation): automation
} }
const staticSelectProperties = [ const staticSelectProperties = [
{ value: null, label: 'Select field' },
{ value: 'eventNow.id', label: 'ID' }, { value: 'eventNow.id', label: 'ID' },
{ value: 'eventNow.title', label: 'Title' }, { value: 'eventNow.title', label: 'Title' },
{ value: 'eventNow.cue', label: 'Cue' }, { value: 'eventNow.cue', label: 'Cue' },
@@ -42,7 +43,7 @@ const staticNextSelectProperties = [
]; ];
type SelectableField = { type SelectableField = {
value: string; // string encodes path in runtime state object value: string | null; // string encodes path in runtime state object
label: string; label: string;
}; };
@@ -9,6 +9,8 @@ import Textarea from '../../../../../common/components/input/textarea/Textarea';
import Select, { SelectOption } from '../../../../../common/components/select/Select'; import Select, { SelectOption } from '../../../../../common/components/select/Select';
import { useUpdateUrlPreset } from '../../../../../common/hooks-query/useUrlPresets'; import { useUpdateUrlPreset } from '../../../../../common/hooks-query/useUrlPresets';
import { preventEscape } from '../../../../../common/utils/keyEvent'; import { preventEscape } from '../../../../../common/utils/keyEvent';
import { isUrlSafe } from '../../../../../common/utils/regex';
import { enDash } from '../../../../../common/utils/styleUtils';
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets'; import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
@@ -52,6 +54,7 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
formState: { errors, isSubmitting, isValid, isDirty }, formState: { errors, isSubmitting, isValid, isDirty },
} = useForm<URLPreset>({ } = useForm<URLPreset>({
defaultValues: urlPreset ?? defaultValues, defaultValues: urlPreset ?? defaultValues,
mode: 'onChange',
resetOptions: { resetOptions: {
keepDirtyValues: true, keepDirtyValues: true,
}, },
@@ -110,7 +113,15 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
<Panel.InlineElements> <Panel.InlineElements>
<div> <div>
<Panel.Description>Alias</Panel.Description> <Panel.Description>Alias</Panel.Description>
<Input {...register('alias', { required: 'Alias is required' })} /> <Input
{...register('alias', {
required: 'Alias is required',
pattern: {
value: isUrlSafe,
message: 'Field can only contain URL safe characters (a-z, 0-9, _ and -)',
},
})}
/>
</div> </div>
<div className={style.expand}> <div className={style.expand}>
<Panel.Description>Generate options (paste URL to generate options)</Panel.Description> <Panel.Description>Generate options (paste URL to generate options)</Panel.Description>
@@ -120,7 +131,10 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
</Panel.InlineElements> </Panel.InlineElements>
</div> </div>
</Panel.InlineElements> </Panel.InlineElements>
<div> - or -</div> {errors.alias?.message && <Panel.Error>{errors.alias.message}</Panel.Error>}
<div>
{enDash} or {enDash}
</div>
<div>2. Choose a view and its parameters</div> <div>2. Choose a view and its parameters</div>
<div> <div>
<Panel.Description>Target</Panel.Description> <Panel.Description>Target</Panel.Description>
@@ -128,7 +142,10 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
options={targetOptions} options={targetOptions}
{...register('target', { required: 'Target is required' })} {...register('target', { required: 'Target is required' })}
value={watch('target')} value={watch('target')}
onValueChange={(value) => setValue('target', value, { shouldDirty: true })} onValueChange={(value: OntimeViewPresettable | null) => {
if (value === null) return;
setValue('target', value, { shouldDirty: true });
}}
/> />
</div> </div>
<div> <div>
@@ -13,5 +13,5 @@
} }
.current { .current {
background-color: $blue-1100; background-color: $blue-1100 !important; // fighting zebra styles
} }
@@ -1,26 +1,39 @@
import { useState } from 'react'; import { useState } from 'react';
import { IoAdd } from 'react-icons/io5'; import {
IoAdd,
IoDocumentOutline,
IoDownloadOutline,
IoDuplicateOutline,
IoEllipsisHorizontal,
IoPencilOutline,
IoTrash,
} from 'react-icons/io5';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { downloadAsExcel } from '../../../../common/api/excel';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton';
import Dialog from '../../../../common/components/dialog/Dialog'; import Dialog from '../../../../common/components/dialog/Dialog';
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
import Tag from '../../../../common/components/tag/Tag'; import Tag from '../../../../common/components/tag/Tag';
import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns'; import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
import { cx } from '../../../../common/utils/styleUtils'; import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import RundownRenameForm from './composite/RundownRenameForm';
import { ManageRundownForm } from './ManageRundownForm'; import { ManageRundownForm } from './ManageRundownForm';
import style from './ManagePanel.module.scss'; import style from './ManagePanel.module.scss';
export default function ManageRundowns() { export default function ManageRundowns() {
const { data } = useProjectRundowns(); const { data } = useProjectRundowns();
const { remove, load } = useMutateProjectRundowns(); const { duplicate, remove, load, rename } = useMutateProjectRundowns();
const [isOpenDelete, deleteHandlers] = useDisclosure(); const [isOpenDelete, deleteHandlers] = useDisclosure();
const [isOpenLoad, loadHandlers] = useDisclosure(); const [isOpenLoad, loadHandlers] = useDisclosure();
const [isNewLoad, newHandlers] = useDisclosure(); const [isNewLoad, newHandlers] = useDisclosure();
const [targetRundown, setTargetRundown] = useState(''); const [targetRundown, setTargetRundown] = useState('');
const [renamingRundown, setRenamingRundown] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null); const [actionError, setActionError] = useState<string | null>(null);
const openLoad = (id: string) => { const openLoad = (id: string) => {
@@ -35,6 +48,11 @@ export default function ManageRundowns() {
deleteHandlers.open(); deleteHandlers.open();
}; };
const openRename = (id: string) => {
setActionError(null);
setRenamingRundown(id);
};
const submitRundownLoad = async () => { const submitRundownLoad = async () => {
try { try {
await load(targetRundown); await load(targetRundown);
@@ -45,6 +63,27 @@ export default function ManageRundowns() {
} }
}; };
const submitRundownDuplicate = async (id: string) => {
setActionError(null);
setRenamingRundown(null);
setTargetRundown('');
try {
await duplicate(id);
} catch (error) {
setActionError(`Failed to duplicate rundown. ${maybeAxiosError(error)}`);
}
};
const submitRundownRename = async (id: string, newTitle: string) => {
try {
await rename([id, newTitle]);
setRenamingRundown(null);
} catch (error) {
setActionError(`Failed to rename rundown. ${maybeAxiosError(error)}`);
}
};
const submitRundownDelete = async () => { const submitRundownDelete = async () => {
try { try {
await remove(targetRundown); await remove(targetRundown);
@@ -55,6 +94,10 @@ export default function ManageRundowns() {
} }
}; };
const handleDownloadXlsx = async (rundownId: string, title: string) => {
await downloadAsExcel(rundownId, title);
};
return ( return (
<> <>
<Panel.Section> <Panel.Section>
@@ -87,25 +130,70 @@ export default function ManageRundowns() {
<tbody> <tbody>
{data?.rundowns?.map(({ id, numEntries, title }) => { {data?.rundowns?.map(({ id, numEntries, title }) => {
const isLoaded = data.loaded === id; const isLoaded = data.loaded === id;
const isRenaming = renamingRundown === id;
if (isRenaming) {
return (
<tr key={id}>
<td colSpan={3}>
<RundownRenameForm
onCancel={() => setRenamingRundown(null)}
onSubmit={(newTitle: string) => submitRundownRename(id, newTitle)}
initialTitle={title}
/>
</td>
</tr>
);
}
return ( return (
<tr key={id} className={cx([isLoaded && style.current])}> <tr key={id} className={cx([isLoaded && style.current])}>
<td>{numEntries}</td> <td>{numEntries}</td>
<td> <td>
{title} {isLoaded && <Tag>Loaded</Tag>} {title} {isLoaded && <Tag>Loaded</Tag>}
</td> </td>
<Panel.InlineElements as='td'> <td>
<Button size='small' onClick={() => openLoad(id)} disabled={isLoaded}> <DropdownMenu
Load render={<IconButton variant='ghosted-white' />}
</Button> items={[
<Button {
size='small' type: 'item',
variant='subtle-destructive' icon: IoPencilOutline,
onClick={() => openDelete(id)} label: 'Rename',
disabled={isLoaded} onClick: () => openRename(id),
},
{
type: 'item',
icon: IoDownloadOutline,
label: 'Load',
onClick: () => openLoad(id),
disabled: isLoaded,
},
{
type: 'item',
icon: IoDocumentOutline,
label: 'Download .xlsx',
onClick: () => handleDownloadXlsx(id, title),
},
{
type: 'item',
icon: IoDuplicateOutline,
label: 'Duplicate',
onClick: () => submitRundownDuplicate(id),
},
{ type: 'divider' },
{
type: 'destructive',
icon: IoTrash,
label: 'Delete',
onClick: () => openDelete(id),
disabled: isLoaded,
},
]}
> >
Delete <IoEllipsisHorizontal />
</Button> </DropdownMenu>
</Panel.InlineElements> </td>
</tr> </tr>
); );
})} })}
@@ -117,7 +205,7 @@ export default function ManageRundowns() {
<Dialog <Dialog
isOpen={isOpenDelete} isOpen={isOpenDelete}
onClose={deleteHandlers.close} onClose={deleteHandlers.close}
title='Load rundown' title='Delete rundown'
showBackdrop showBackdrop
showCloseButton showCloseButton
bodyElements={ bodyElements={
@@ -51,7 +51,10 @@ export default function RundownDefaultSettings() {
/> />
<Select <Select
value={defaultTimeStrategy} value={defaultTimeStrategy}
onValueChange={(value) => setTimeStrategy(value as TimeStrategy)} onValueChange={(value: TimeStrategy | null) => {
if (value === null) return;
setTimeStrategy(value);
}}
options={[ options={[
{ value: TimeStrategy.LockDuration, label: 'Duration' }, { value: TimeStrategy.LockDuration, label: 'Duration' },
{ value: TimeStrategy.LockEnd, label: 'End Time' }, { value: TimeStrategy.LockEnd, label: 'End Time' },
@@ -73,7 +76,10 @@ export default function RundownDefaultSettings() {
<Panel.Field title='Timer type' description='Default type of timer for new events' /> <Panel.Field title='Timer type' description='Default type of timer for new events' />
<Select <Select
value={defaultTimerType} value={defaultTimerType}
onValueChange={(value) => setDefaultTimerType(value as TimerType)} onValueChange={(value: TimerType | null) => {
if (value === null) return;
setDefaultTimerType(value);
}}
options={[ options={[
{ value: TimerType.CountDown, label: 'Count down' }, { value: TimerType.CountDown, label: 'Count down' },
{ value: TimerType.CountUp, label: 'Count up' }, { value: TimerType.CountUp, label: 'Count up' },
@@ -86,7 +92,10 @@ export default function RundownDefaultSettings() {
<Panel.Field title='End Action' description='Default end action for new events' /> <Panel.Field title='End Action' description='Default end action for new events' />
<Select <Select
value={defaultEndAction} value={defaultEndAction}
onValueChange={(value) => setDefaultEndAction(value as EndAction)} onValueChange={(value: EndAction | null) => {
if (value === null) return;
setDefaultEndAction(value);
}}
options={[ options={[
{ value: EndAction.None, label: 'None' }, { value: EndAction.None, label: 'None' },
{ value: EndAction.LoadNext, label: 'Load next' }, { value: EndAction.LoadNext, label: 'Load next' },
@@ -0,0 +1,77 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { checkRegex } from 'ontime-utils';
import { maybeAxiosError } from '../../../../../common/api/utils';
import Button from '../../../../../common/components/buttons/Button';
import Input from '../../../../../common/components/input/input/Input';
import { preventEscape } from '../../../../../common/utils/keyEvent';
import * as Panel from '../../../panel-utils/PanelUtils';
interface RundownRenameFormProps {
onSubmit: (newTitle: string) => Promise<void>;
onCancel: () => void;
initialTitle: string;
}
interface FormData {
title: string;
}
export default function RundownRenameForm({ onSubmit, onCancel, initialTitle }: RundownRenameFormProps) {
const {
handleSubmit,
register,
setFocus,
setError,
formState: { errors, isSubmitting, isValid, isDirty },
} = useForm<FormData>({
defaultValues: { title: initialTitle },
mode: 'onChange',
});
const setupSubmit = async (values: FormData) => {
try {
await onSubmit(values.title);
} catch (error) {
setError('root', { type: 'custom', message: maybeAxiosError(error) });
}
};
// Give initial focus to the title input
useEffect(() => {
setFocus('title');
}, [setFocus]);
const canSubmit = isDirty && isValid;
return (
<Panel.Indent as='form' onSubmit={handleSubmit(setupSubmit)} onKeyDown={(event) => preventEscape(event, onCancel)}>
<label>
<Panel.Description>Rundown title</Panel.Description>
<Input
{...register('title', {
required: { value: true, message: 'Title is required' },
validate: (value) => {
if (value.trim().length === 0) return 'Title cannot be empty';
if (checkRegex.isAlphanumericWithSpace(value) === false)
return 'Title can only contain alphanumeric characters, spaces and underscores';
return true;
},
})}
fluid
/>
{errors.title && <Panel.Error>{errors.title.message}</Panel.Error>}
</label>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.InlineElements relation='inner' align='end'>
<Button variant='ghosted' onClick={onCancel}>
Cancel
</Button>
<Button type='submit' variant='primary' disabled={!canSubmit} loading={isSubmitting}>
Save
</Button>
</Panel.InlineElements>
</Panel.Indent>
);
}
@@ -1,8 +1,10 @@
import { useState } from 'react'; import { useState } from 'react';
import { CustomFields, Rundown } from 'ontime-types'; import { CustomFields, Rundown, RundownSummary } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import Button from '../../../../../common/components/buttons/Button'; import Button from '../../../../../common/components/buttons/Button';
import useRundown from '../../../../../common/hooks-query/useRundown'; import useRundown from '../../../../../common/hooks-query/useRundown';
import { formatDuration } from '../../../../../common/utils/time';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
import PreviewSpreadsheet from './preview/PreviewRundown'; import PreviewSpreadsheet from './preview/PreviewRundown';
@@ -12,12 +14,20 @@ import { useSheetStore } from './useSheetStore';
interface ImportReviewProps { interface ImportReviewProps {
rundown: Rundown; rundown: Rundown;
customFields: CustomFields; customFields: CustomFields;
summary: RundownSummary;
onFinished: () => void; onFinished: () => void;
onCancel: () => void; onCancel: () => void;
onBack: () => void;
} }
export default function ImportReview(props: ImportReviewProps) { export default function ImportReview({
const { rundown, customFields, onFinished, onCancel } = props; rundown,
customFields,
summary,
onFinished,
onCancel,
onBack,
}: ImportReviewProps) {
const { data: currentRundown } = useRundown(); const { data: currentRundown } = useRundown();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { importRundown } = useGoogleSheet(); const { importRundown } = useGoogleSheet();
@@ -51,11 +61,31 @@ export default function ImportReview(props: ImportReviewProps) {
<Button onClick={handleCancel} variant='ghosted' disabled={loading}> <Button onClick={handleCancel} variant='ghosted' disabled={loading}>
Cancel Cancel
</Button> </Button>
<Button onClick={onBack} variant='subtle' disabled={loading}>
Back
</Button>
<Button onClick={applyImport} variant='primary' loading={loading}> <Button onClick={applyImport} variant='primary' loading={loading}>
Apply Apply
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
</Panel.Title> </Panel.Title>
<Panel.ListGroup>
<Panel.ListItem>
<b>Title</b> {rundown.title}
</Panel.ListItem>
<Panel.ListItem>
<b>Number of entries</b> {rundown.flatOrder.length}
</Panel.ListItem>
<Panel.ListItem>
<b>Start time</b> {millisToString(summary.start)}
</Panel.ListItem>
<Panel.ListItem>
<b>End time</b> {millisToString(summary.end)}
</Panel.ListItem>
<Panel.ListItem>
<b>Total duration</b> {formatDuration(summary.duration)}
</Panel.ListItem>
</Panel.ListGroup>
<PreviewSpreadsheet rundown={rundown} customFields={customFields} /> <PreviewSpreadsheet rundown={rundown} customFields={customFields} />
</Panel.Section> </Panel.Section>
); );
@@ -3,7 +3,6 @@ import { IoCloudOutline, IoDownloadOutline } from 'react-icons/io5';
import { getErrorMessage, ImportMap } from 'ontime-utils'; import { getErrorMessage, ImportMap } from 'ontime-utils';
import { import {
getWorksheetNames as getWorksheetNamesExcel,
importRundownPreview as importRundownPreviewExcel, importRundownPreview as importRundownPreviewExcel,
upload as uploadExcel, upload as uploadExcel,
} from '../../../../../common/api/excel'; } from '../../../../../common/api/excel';
@@ -37,8 +36,11 @@ export default function SourcesPanel() {
const setRundown = useSheetStore((state) => state.setRundown); const setRundown = useSheetStore((state) => state.setRundown);
const customFields = useSheetStore((state) => state.customFields); const customFields = useSheetStore((state) => state.customFields);
const setCustomFields = useSheetStore((state) => state.setCustomFields); const setCustomFields = useSheetStore((state) => state.setCustomFields);
const summary = useSheetStore((state) => state.summary);
const setSummary = useSheetStore((state) => state.setSummary);
const setSheetId = useSheetStore((state) => state.setSheetId); const setSheetId = useSheetStore((state) => state.setSheetId);
const sheetId = useSheetStore((state) => state.sheetId); const sheetId = useSheetStore((state) => state.sheetId);
const resetPreview = useSheetStore((state) => state.resetPreview);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -53,8 +55,7 @@ export default function SourcesPanel() {
try { try {
setHasFile('loading'); setHasFile('loading');
validateExcelImport(fileToUpload); validateExcelImport(fileToUpload);
await uploadExcel(fileToUpload); const names = await uploadExcel(fileToUpload);
const names = await getWorksheetNamesExcel();
setWorksheets(names); setWorksheets(names);
setImportFlow('excel'); setImportFlow('excel');
setHasFile('done'); setHasFile('done');
@@ -77,6 +78,7 @@ export default function SourcesPanel() {
setHasFile('none'); setHasFile('none');
setWorksheets(null); setWorksheets(null);
setCustomFields(null); setCustomFields(null);
setSummary(null);
setError(''); setError('');
setSheetId(null); setSheetId(null);
}; };
@@ -110,6 +112,7 @@ export default function SourcesPanel() {
const previewData = await importRundownPreviewExcel(importMap); const previewData = await importRundownPreviewExcel(importMap);
setRundown(previewData.rundown); setRundown(previewData.rundown);
setCustomFields(previewData.customFields); setCustomFields(previewData.customFields);
setSummary(previewData.summary);
} catch (error) { } catch (error) {
setError(maybeAxiosError(error)); setError(maybeAxiosError(error));
} }
@@ -152,7 +155,7 @@ export default function SourcesPanel() {
const showCompleted = 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 && summary !== null;
return ( return (
<Panel.Section> <Panel.Section>
@@ -219,8 +222,10 @@ export default function SourcesPanel() {
<ImportReview <ImportReview
rundown={rundown} rundown={rundown}
customFields={customFields} customFields={customFields}
summary={summary}
onFinished={handleFinished} onFinished={handleFinished}
onCancel={cancelImportMap} onCancel={cancelImportMap}
onBack={resetPreview}
/> />
)} )}
</Panel.Card> </Panel.Card>
@@ -5,6 +5,7 @@ import { checkRegex, ImportMap } from 'ontime-utils';
import Button from '../../../../../../common/components/buttons/Button'; import Button from '../../../../../../common/components/buttons/Button';
import IconButton from '../../../../../../common/components/buttons/IconButton'; import IconButton from '../../../../../../common/components/buttons/IconButton';
import Info from '../../../../../../common/components/info/Info';
import Input from '../../../../../../common/components/input/input/Input'; import Input from '../../../../../../common/components/input/input/Input';
import Select from '../../../../../../common/components/select/Select'; import Select from '../../../../../../common/components/select/Select';
import Tooltip from '../../../../../../common/components/tooltip/Tooltip'; import Tooltip from '../../../../../../common/components/tooltip/Tooltip';
@@ -136,6 +137,10 @@ export default function ImportMapForm({
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
</Panel.Title> </Panel.Title>
<Info>
Match your spreadsheet columns to Ontime fields. <br />
You can also add Custom Fields by providing a name for Ontime and the spreadsheet column name.
</Info>
<Panel.Table> <Panel.Table>
<thead> <thead>
<tr> <tr>
@@ -157,9 +162,10 @@ export default function ImportMapForm({
<Select <Select
id={importName as string} id={importName as string}
value={watch(label as keyof NamedImportMap) as string} value={watch(label as keyof NamedImportMap) as string}
onValueChange={(value: string) => onValueChange={(value: string | null) => {
setValue(label as keyof NamedImportMap, value, { shouldDirty: true }) if (value === null) return;
} setValue(label as keyof NamedImportMap, value, { shouldDirty: true });
}}
options={worksheetNames?.map((name) => ({ value: name, label: name })) || []} options={worksheetNames?.map((name) => ({ value: name, label: name })) || []}
/> />
</td> </td>
@@ -59,7 +59,7 @@ export function convertToImportMap(namedImportMap: NamedImportMap): ImportMap {
timeWarning: namedImportMap['Time warning'], timeWarning: namedImportMap['Time warning'],
timeDanger: namedImportMap['Time danger'], timeDanger: namedImportMap['Time danger'],
custom, custom,
entryId: namedImportMap.ID, id: namedImportMap.ID,
}; };
} }
@@ -18,9 +18,7 @@ function booleanToText(value?: boolean) {
return value ? 'Yes' : undefined; return value ? 'Yes' : undefined;
} }
export default function PreviewRundown(props: PreviewRundownProps) { export default function PreviewRundown({ rundown, customFields }: PreviewRundownProps) {
const { rundown, customFields } = props;
// we only count Ontime Events which are 1 based in client // we only count Ontime Events which are 1 based in client
let eventIndex = 0; let eventIndex = 0;
@@ -59,9 +57,7 @@ export default function PreviewRundown(props: PreviewRundownProps) {
const colour = entry.colour ? getAccessibleColour(entry.colour) : {}; const colour = entry.colour ? getAccessibleColour(entry.colour) : {};
return ( return (
<tr key={entry.id}> <tr key={entry.id}>
<td className={style.center}> <td /> {/** Index */}
<Tag>-</Tag>
</td>
<td className={style.center}> <td className={style.center}>
<Tag>{entry.type}</Tag> <Tag>{entry.type}</Tag>
</td> </td>
@@ -21,6 +21,7 @@ export default function useGoogleSheet() {
const patchStepData = useSheetStore((state) => state.patchStepData); const patchStepData = useSheetStore((state) => state.patchStepData);
const setRundown = useSheetStore((state) => state.setRundown); const setRundown = useSheetStore((state) => state.setRundown);
const setCustomFields = useSheetStore((state) => state.setCustomFields); const setCustomFields = useSheetStore((state) => state.setCustomFields);
const setSummary = useSheetStore((state) => state.setSummary);
/** whether the current session has been authenticated */ /** whether the current session has been authenticated */
const verifyAuth = async (): Promise<{ authenticated: AuthenticationStatus; sheetId: string } | void> => { const verifyAuth = async (): Promise<{ authenticated: AuthenticationStatus; sheetId: string } | void> => {
@@ -58,6 +59,7 @@ export default function useGoogleSheet() {
const data = await previewRundown(sheetId, fileOptions); const data = await previewRundown(sheetId, fileOptions);
setRundown(data.rundown); setRundown(data.rundown);
setCustomFields(data.customFields); setCustomFields(data.customFields);
setSummary(data.summary);
} catch (error) { } catch (error) {
patchStepData({ pullPush: { available: true, error: maybeAxiosError(error) } }); patchStepData({ pullPush: { available: true, error: maybeAxiosError(error) } });
} }
@@ -1,4 +1,4 @@
import { AuthenticationStatus, CustomFields, Rundown } from 'ontime-types'; import { AuthenticationStatus, CustomFields, Rundown, RundownSummary } from 'ontime-types';
import { defaultImportMap, ImportMap } from 'ontime-utils'; import { defaultImportMap, ImportMap } from 'ontime-utils';
import { create } from 'zustand'; import { create } from 'zustand';
@@ -17,10 +17,10 @@ type SheetStore = {
// we get this from a preview response // we get this from a preview response
rundown: Rundown | null; rundown: Rundown | null;
setRundown: (rundown: Rundown | null) => void; setRundown: (rundown: Rundown | null) => void;
// we get this from a preview response
customFields: CustomFields | null; customFields: CustomFields | null;
setCustomFields: (customFields: CustomFields | null) => void; setCustomFields: (customFields: CustomFields | null) => void;
summary: RundownSummary | null;
setSummary: (metadata: RundownSummary | null) => void;
spreadsheetImportMap: ImportMap; spreadsheetImportMap: ImportMap;
patchSpreadsheetImportMap: <T extends keyof ImportMap>(field: T, value: ImportMap[T]) => void; patchSpreadsheetImportMap: <T extends keyof ImportMap>(field: T, value: ImportMap[T]) => void;
@@ -43,6 +43,7 @@ const initialState = {
authenticationStatus: 'not_authenticated' as AuthenticationStatus, authenticationStatus: 'not_authenticated' as AuthenticationStatus,
rundown: null, rundown: null,
customFields: null, customFields: null,
summary: null,
spreadsheetImportMap: defaultImportMap, spreadsheetImportMap: defaultImportMap,
}; };
@@ -64,6 +65,8 @@ export const useSheetStore = create<SheetStore>((set, get) => ({
setCustomFields: (customFields: CustomFields | null) => set({ customFields }), setCustomFields: (customFields: CustomFields | null) => set({ customFields }),
setSummary: (summary: RundownSummary | null) => set({ summary }),
patchSpreadsheetImportMap: <T extends keyof ImportMap>(field: T, value: ImportMap[T]) => { patchSpreadsheetImportMap: <T extends keyof ImportMap>(field: T, value: ImportMap[T]) => {
const currentImportMap = get().spreadsheetImportMap; const currentImportMap = get().spreadsheetImportMap;
if (currentImportMap[field] !== value) { if (currentImportMap[field] !== value) {
@@ -72,5 +75,5 @@ export const useSheetStore = create<SheetStore>((set, get) => ({
}, },
reset: () => set(initialState), reset: () => set(initialState),
resetPreview: () => set({ rundown: null, customFields: null }), resetPreview: () => set({ rundown: null, customFields: null, summary: null }),
})); }));
@@ -1,18 +1,22 @@
import { useState } from 'react'; import { useState } from 'react';
import { IoArrowDown, IoArrowUp } from 'react-icons/io5';
import Info from '../../../../common/components/info/Info'; import Info from '../../../../common/components/info/Info';
import { useOrderedProjectList } from '../../../../common/hooks-query/useProjectList'; import { ProjectSortMode, useOrderedProjectList } from '../../../../common/hooks-query/useProjectList';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import ProjectListItem, { EditMode } from './ProjectListItem'; import ProjectListItem, { EditMode } from './ProjectListItem';
import style from './ProjectPanel.module.scss'; import style from './ProjectPanel.module.scss';
export default function ProjectList() { type SortParameter = 'alphabetical' | 'modified';
const { data, refetch, status } = useOrderedProjectList();
export default function ProjectList() {
const [editingMode, setEditingMode] = useState<EditMode | null>(null); const [editingMode, setEditingMode] = useState<EditMode | null>(null);
const [editingFilename, setEditingFilename] = useState<string | null>(null); const [editingFilename, setEditingFilename] = useState<string | null>(null);
const [sortMode, setSortMode] = useState<ProjectSortMode>('modified-desc');
const { data, refetch, status } = useOrderedProjectList(sortMode);
const handleToggleEditMode = (editMode: EditMode, filename: string | null) => { const handleToggleEditMode = (editMode: EditMode, filename: string | null) => {
setEditingMode((prev) => (prev === editMode && filename === editingFilename ? null : editMode)); setEditingMode((prev) => (prev === editMode && filename === editingFilename ? null : editMode));
@@ -28,6 +32,13 @@ export default function ProjectList() {
await refetch(); await refetch();
}; };
const handleSort = (sortParameter: SortParameter) => {
setSortMode((current) => {
const isAscending = current === `${sortParameter}-asc`;
return `${sortParameter}-${isAscending ? 'desc' : 'asc'}` as ProjectSortMode;
});
};
if (status === 'pending') { if (status === 'pending') {
return ( return (
<div className={style.empty}> <div className={style.empty}>
@@ -48,8 +59,18 @@ export default function ProjectList() {
<Panel.Table> <Panel.Table>
<thead> <thead>
<tr> <tr>
<th className={style.containCell}>File Name</th> <th className={style.containCell} onClick={() => handleSort('alphabetical')}>
<th>Last Used</th> <span className={style.sortableHeader}>
File Name
<SortIcon sortMode={sortMode} type='alphabetical' />
</span>
</th>
<th onClick={() => handleSort('modified')}>
<span className={style.sortableHeader}>
Last Used
<SortIcon sortMode={sortMode} type='modified' />
</span>
</th>
<th /> <th />
</tr> </tr>
</thead> </thead>
@@ -72,3 +93,10 @@ export default function ProjectList() {
</> </>
); );
} }
function SortIcon({ sortMode, type }: { sortMode: ProjectSortMode; type: SortParameter }) {
const prefix = `${type}-`;
if (sortMode === `${prefix}asc`) return <IoArrowDown />;
if (sortMode === `${prefix}desc`) return <IoArrowUp />;
return null;
}
@@ -23,7 +23,13 @@
} }
.containCell { .containCell {
max-width: 400px; max-width: 50%;
}
.sortableHeader {
display: inline-flex;
align-items: center;
gap: 0.5em;
} }
.fullWidth { .fullWidth {
@@ -6,6 +6,7 @@ import { Settings } from 'ontime-types';
import { postSettings } from '../../../../common/api/settings'; import { postSettings } from '../../../../common/api/settings';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import Info from '../../../../common/components/info/Info';
import Input from '../../../../common/components/input/input/Input'; import Input from '../../../../common/components/input/input/Input';
import Select from '../../../../common/components/select/Select'; import Select from '../../../../common/components/select/Select';
import useSettings from '../../../../common/hooks-query/useSettings'; import useSettings from '../../../../common/hooks-query/useSettings';
@@ -98,6 +99,7 @@ export default function GeneralSettings() {
{submitError && <Panel.Error>{submitError}</Panel.Error>} {submitError && <Panel.Error>{submitError}</Panel.Error>}
<Panel.Divider /> <Panel.Divider />
<Panel.Section> <Panel.Section>
<Info>Changes to the time format and views language do not affect the editor view</Info>
<Panel.Loader isLoading={isLoading} /> <Panel.Loader isLoading={isLoading} />
<Panel.ListGroup> <Panel.ListGroup>
<Panel.ListItem> <Panel.ListItem>
@@ -151,7 +153,10 @@ export default function GeneralSettings() {
/> />
<Select <Select
value={watch('timeFormat')} value={watch('timeFormat')}
onValueChange={(value) => setValue('timeFormat', value as '12' | '24', { shouldDirty: true })} onValueChange={(value: '12' | '24' | null) => {
if (value === null) return;
setValue('timeFormat', value, { shouldDirty: true });
}}
defaultValue='24' defaultValue='24'
options={[ options={[
{ value: '12', label: '12 hours 11:00:10 PM' }, { value: '12', label: '12 hours 11:00:10 PM' },
@@ -167,7 +172,10 @@ export default function GeneralSettings() {
/> />
<Select <Select
value={watch('language')} value={watch('language')}
onValueChange={(value) => setValue('language', value, { shouldDirty: true })} onValueChange={(value: string | null) => {
if (value === null) return;
setValue('language', value, { shouldDirty: true });
}}
disabled={disableInputs} disabled={disableInputs}
defaultValue='en' defaultValue='en'
options={[ options={[
@@ -70,12 +70,15 @@ export default function QuickStart({ isOpen, onClose }: QuickStartProps) {
<Panel.ListItem> <Panel.ListItem>
<Panel.Field <Panel.Field
title='Time format' title='Time format'
description='Default time format to show in views 12 /24 hours' description='Default time format to show in views 12 / 24 hours (does not affect editor)'
error={errors.settings?.timeFormat?.message} error={errors.settings?.timeFormat?.message}
/> />
<Select <Select
value={watch('settings.timeFormat')} value={watch('settings.timeFormat')}
onValueChange={(value: '12' | '24') => setValue('settings.timeFormat', value, { shouldDirty: true })} onValueChange={(value: '12' | '24' | null) => {
if (value === null) return;
setValue('settings.timeFormat', value, { shouldDirty: true });
}}
defaultValue='24' defaultValue='24'
options={[ options={[
{ value: '12', label: '12 hours 11:00:10 PM' }, { value: '12', label: '12 hours 11:00:10 PM' },
@@ -91,7 +94,10 @@ export default function QuickStart({ isOpen, onClose }: QuickStartProps) {
/> />
<Select <Select
value={watch('settings.language')} value={watch('settings.language')}
onValueChange={(value: string) => setValue('settings.language', value, { shouldDirty: true })} onValueChange={(value: string | null) => {
if (value === null) return;
setValue('settings.language', value, { shouldDirty: true });
}}
defaultValue='en' defaultValue='en'
options={[ options={[
{ value: 'en', label: 'English' }, { value: 'en', label: 'English' },
@@ -1,5 +1,3 @@
@import "./MessageControl.module.scss";
.inputItems { .inputItems {
display: grid; display: grid;
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;
@@ -8,5 +6,10 @@
} }
.label { .label {
@extend .label; font-size: $inner-section-text-size;
color: $label-gray;
&.active {
color: $action-text-color;
}
} }
@@ -42,7 +42,7 @@ export default function InputRow(props: PropsWithChildren<InputRowProps>) {
}; };
return ( return (
<div className={style.inputRow}> <div>
<label className={cx([style.label, visible ?? style.active])} htmlFor={label}> <label className={cx([style.label, visible ?? style.active])} htmlFor={label}>
{label} {label}
</label> </label>
@@ -0,0 +1,11 @@
.growPanel {
flex: 1;
}
.contentLayout {
display: flex;
flex-direction: column;
gap: $section-spacing;
color: $ui-white;
padding-top: $panel-top-padding;
}
@@ -1,33 +1,32 @@
import { memo } from 'react'; import { memo } from 'react';
import { Corner } from '../../../common/components/editor-utils/EditorUtils'; import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary'; import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary';
import ViewNavigationMenu from '../../../common/components/navigation-menu/ViewNavigationMenu'; import ViewNavigationMenu from '../../../common/components/navigation-menu/ViewNavigationMenu';
import ProtectRoute from '../../../common/components/protect-route/ProtectRoute'; import ProtectRoute from '../../../common/components/protect-route/ProtectRoute';
import { handleLinks } from '../../../common/utils/linkUtils'; import { handleLinks } from '../../../common/utils/linkUtils';
import { cx } from '../../../common/utils/styleUtils'; import { getIsNavigationLocked } from '../../../externals';
import MessageControl from './MessageControl'; import MessageControl from './MessageControl';
import style from '../../../views/editor/Editor.module.scss'; import style from './MessageControlExport.module.scss';
export default memo(MessageControlExport); export default memo(MessageControlExport);
function MessageControlExport() { function MessageControlExport() {
const isExtracted = window.location.pathname.includes('/messagecontrol'); const isExtracted = window.location.pathname.includes('/messagecontrol');
const classes = cx([style.content, style.contentColumnLayout]);
return ( return (
<ProtectRoute permission='editor'> <ProtectRoute permission='editor'>
<div className={style.messages} data-testid='panel-messages-control'> <Editor.Panel className={style.growPanel} data-testid='panel-messages-control'>
{!isExtracted && <Corner onClick={(event) => handleLinks('messagecontrol', event)} />} {!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('messagecontrol', event)} />}
{isExtracted && <ViewNavigationMenu suppressSettings />} {isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
<div className={classes}> <div className={style.contentLayout}>
<ErrorBoundary> <ErrorBoundary>
<MessageControl /> <MessageControl />
</ErrorBoundary> </ErrorBoundary>
</div> </div>
</div> </Editor.Panel>
</ProtectRoute> </ProtectRoute>
); );
} }
@@ -1,18 +1,3 @@
.label {
font-size: $inner-section-text-size;
color: $label-gray;
&.active {
color: $action-text-color;
}
}
.previewContainer {
display: grid;
gap: $element-spacing;
grid-template-columns: 3fr 2fr;
}
.preview { .preview {
background-color: $ui-black; background-color: $ui-black;
display: grid; display: grid;
@@ -21,21 +6,6 @@
position: relative; position: relative;
} }
.options {
display: flex;
flex-direction: column;
gap: $element-spacing;
}
.eventStatus {
position: absolute;
left: 0;
margin: 0.5rem 0.25rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.mainContent { .mainContent {
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
@@ -61,9 +31,13 @@
display: none; display: none;
} }
.timerIndicators { .eventStatus {
position: absolute;
left: 0;
margin: 0.5rem 0.25rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.25rem;
} }
.statusIcon { .statusIcon {
@@ -73,3 +47,4 @@
color: $active-indicator; color: $active-indicator;
} }
} }
@@ -2,14 +2,15 @@ import { IoArrowDown, IoArrowUp, IoBan, IoTime } from 'react-icons/io5';
import { LuArrowDownToLine } from 'react-icons/lu'; import { LuArrowDownToLine } from 'react-icons/lu';
import { TimerPhase, TimerType } from 'ontime-types'; import { TimerPhase, TimerType } from 'ontime-types';
import { Corner } from '../../../common/components/editor-utils/EditorUtils'; import { CornerWithPip } from '../../../common/components/editor-utils/EditorUtils';
import Tooltip from '../../../common/components/tooltip/Tooltip'; import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useMessagePreview } from '../../../common/hooks/useSocket'; import { useMessagePreview } from '../../../common/hooks/useSocket';
import useViewSettings from '../../../common/hooks-query/useViewSettings'; import useViewSettings from '../../../common/hooks-query/useViewSettings';
import { handleLinks } from '../../../common/utils/linkUtils'; import { handleLinks } from '../../../common/utils/linkUtils';
import { cx, timerPlaceholder } from '../../../common/utils/styleUtils'; import { cx, timerPlaceholder } from '../../../common/utils/styleUtils';
import PipRoot from '../../../views/editor/pip-timer/PipRoot';
import style from './MessageControl.module.scss'; import style from './TimerPreview.module.scss';
const secondarySourceLabels: Record<string, string> = { const secondarySourceLabels: Record<string, string> = {
aux1: 'Aux 1', aux1: 'Aux 1',
@@ -52,7 +53,7 @@ export default function TimerPreview() {
return ( return (
<div className={style.preview}> <div className={style.preview}>
<Corner onClick={(event) => handleLinks('timer', event)} /> <CornerWithPip onExtractClick={(event) => handleLinks('timer', event)} pipElement={<PipRoot />} />
<div className={contentClasses}> <div className={contentClasses}>
<div <div
className={style.mainContent} className={style.mainContent}
@@ -0,0 +1,12 @@
.previewContainer {
display: grid;
gap: $element-spacing;
grid-template-columns: 3fr 2fr;
}
.options {
display: flex;
flex-direction: column;
gap: $element-spacing;
}
@@ -8,7 +8,7 @@ import { setMessage, useTimerViewControl } from '../../../common/hooks/useSocket
import TimerPreview from './TimerPreview'; import TimerPreview from './TimerPreview';
import style from './MessageControl.module.scss'; import style from './TimerViewControl.module.scss';
export default function TimerControlsPreview() { export default function TimerControlsPreview() {
const { blackout, blink } = useTimerViewControl(); const { blackout, blink } = useTimerViewControl();
@@ -71,12 +71,13 @@ function SecondarySourceControl() {
{ value: 'aux3', label: 'Aux 3' }, { value: 'aux3', label: 'Aux 3' },
{ value: 'secondary', label: 'Secondary message' }, { value: 'secondary', label: 'Secondary message' },
]} ]}
onValueChange={(value) => { onValueChange={(value: SecondarySource | null) => {
if (value === null) return;
// we can only update the remote if it is enabled // we can only update the remote if it is enabled
if (secondarySource !== null) { if (secondarySource !== null) {
setMessage.timerSecondarySource(value as SecondarySource); setMessage.timerSecondarySource(value);
} }
setValue(value as SecondarySource); setValue(value);
}} }}
/> />
<Button <Button
@@ -0,0 +1,3 @@
.content {
padding-top: $panel-top-padding;
}

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