Compare commits

..

90 Commits

Author SHA1 Message Date
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
179 changed files with 2933 additions and 711 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

+13 -5
View File
@@ -13,13 +13,18 @@ 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
- name: Get pnpm store directory - name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache - name: Setup pnpm cache
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -38,16 +43,19 @@ jobs:
- name: Copy server - name: Copy server
run: mkdir -p apps/cli/server && cp apps/server/dist/index.cjs apps/cli/server/index.cjs run: mkdir -p apps/cli/server && cp apps/server/dist/index.cjs apps/cli/server/index.cjs
- name: Copy client - name: Copy client
run: cp -R apps/client/build apps/cli/client run: cp -R apps/client/build apps/cli/client
- 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
+49 -29
View File
@@ -6,43 +6,63 @@ on:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
publish_docker: publish_docker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
CI: '' CI: ''
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Docker Login - name: Setup pnpm
uses: docker/login-action@v2.1.0 uses: pnpm/action-setup@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Docker Setup Buildx - name: Get pnpm store directory
uses: docker/setup-buildx-action@v2.5.0 run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Build and push stable release - name: Setup pnpm cache
if: github.event.release.prerelease == false uses: actions/cache@v4
uses: docker/build-push-action@v4.0.0 with:
with: path: ${{ env.PNPM_STORE_PATH }}
context: . key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
file: ./Dockerfile restore-keys: |
platforms: linux/amd64,linux/arm64,linux/arm/v7 ${{ runner.os }}-pnpm-store-
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
- name: Build and push pre-release - name: Install dependencies
if: github.event.release.prerelease == true run: pnpm install --frozen-lockfile
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
- name: Build project packages
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm build:docker
- name: Docker Login
uses: docker/login-action@v2.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Docker Setup Buildx
uses: docker/setup-buildx-action@v2.5.0
- name: Build and push stable release
if: github.event.release.prerelease == false
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
- name: Build and push pre-release
if: github.event.release.prerelease == true
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
+4 -4
View File
@@ -2,7 +2,7 @@ name: Ontime build
on: on:
push: push:
tags: [ "*" ] tags: ['*']
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@@ -11,13 +11,13 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
- name: Get pnpm store directory - name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache - name: Setup pnpm cache
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -35,7 +35,7 @@ jobs:
run: pnpm build run: pnpm build
- name: Electron - Build app - name: Electron - Build app
env: env:
APPLE_ID: ${{ secrets.APPLEID }} APPLE_ID: ${{ secrets.APPLEID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLEIDPASS }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLEIDPASS }}
APPLE_TEAM_ID: ${{ secrets.TEAMID }} APPLE_TEAM_ID: ${{ secrets.TEAMID }}
+11 -5
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:
@@ -13,13 +11,18 @@ 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
- name: Get pnpm store directory - name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache - name: Setup pnpm cache
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -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
+5 -5
View File
@@ -20,7 +20,7 @@ jobs:
- name: Get pnpm store directory - name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache - name: Setup pnpm cache
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -32,10 +32,10 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
# Run code quality # Run code quality
- name: Run linter - name: Run linter
run: pnpm lint run: pnpm lint
- name: Run TypeScript checks - name: Run TypeScript checks
run: pnpm typecheck run: pnpm typecheck
@@ -56,7 +56,7 @@ jobs:
- name: Get pnpm store directory - name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache - name: Setup pnpm cache
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -74,7 +74,7 @@ jobs:
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: | path: |
~/.cache/ms-playwright ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }} key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
restore-keys: | restore-keys: |
${{ runner.os }}-playwright- ${{ runner.os }}-playwright-
+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.2.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",
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "4.0.0-beta.5", "version": "4.2.0",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
@@ -9,7 +9,7 @@
"@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",
+4 -3
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);
}
}
+30 -15
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 =========================
@@ -26,8 +27,8 @@ export async function fetchCurrentRundown(): Promise<Rundown> {
/** /**
* 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 +38,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 +66,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 +75,7 @@ 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 +87,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 +100,56 @@ 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(rundownId: RundownId, entryId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`); return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`);
} }
/** /**
* 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,6 +1,5 @@
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';
@@ -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 {
@@ -6,7 +6,7 @@ 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,7 @@ 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;
} }
@@ -51,6 +56,6 @@
&.vertical { &.vertical {
width: 1px; width: 1px;
height: 0.75em; height: 0.75em;
} }
} }
@@ -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>) {
@@ -11,7 +11,7 @@
outline: none; outline: none;
&::placeholder { &::placeholder {
color: $gray-500; color: $gray-600;
letter-spacing: 0; letter-spacing: 0;
} }
@@ -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;
@@ -5,9 +5,10 @@ import { useLocation } from 'react-router';
import { Dialog } from '@base-ui-components/react/dialog'; import { Dialog } from '@base-ui-components/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 { useKeepAwakeOptions } from '../../../features/keep-awake/KeepAwake';
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,10 +58,12 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
</IconButton> </IconButton>
</div> </div>
<div className={style.body}> <div className={style.body}>
<NavigationMenuItem active={fullscreen} onClick={toggle}> {supportsFullscreen && (
Toggle Fullscreen <NavigationMenuItem active={fullscreen} onClick={toggle}>
{fullscreen ? <IoContract /> : <IoExpand />} Toggle Fullscreen
</NavigationMenuItem> {fullscreen ? <IoContract /> : <IoExpand />}
</NavigationMenuItem>
)}
<NavigationMenuItem active={mirror} onClick={() => toggleMirror()}> <NavigationMenuItem active={mirror} onClick={() => toggleMirror()}>
Flip Screen Flip Screen
<IoSwapVertical /> <IoSwapVertical />
@@ -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';
@@ -32,11 +32,11 @@
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%;
} }
} }
.empty { .empty {
color: $ui-white; color: $ui-white;
} }
@@ -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;
@@ -4,6 +4,7 @@ import { useSearchParams } from 'react-router';
import { Dialog } from '@base-ui-components/react/dialog'; import { Dialog } from '@base-ui-components/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
@@ -30,6 +30,26 @@ export function useMutateProjectRundowns() {
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data); ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
}, },
}); });
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,
@@ -51,5 +71,5 @@ export function useMutateProjectRundowns() {
}, },
}); });
return { create, remove, load }; return { create, duplicate, remove, load, rename };
} }
@@ -55,7 +55,7 @@ export function useFlatRundown() {
// update data whenever the revision changes // update data whenever the revision changes
useEffect(() => { useEffect(() => {
if (data.revision !== -1 || data.revision !== prevRevision) { if (data.revision !== -1 || data.revision !== prevRevision) {
const flatRundown = data.order.map((id) => data.entries[id]); const flatRundown = data.flatOrder.map((id) => data.entries[id]);
setFlatRundown(flatRundown); setFlatRundown(flatRundown);
setPrevRevision(data.revision); setPrevRevision(data.revision);
} }
@@ -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,
})); }));
@@ -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();
}); });
}); });
+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,14 +151,14 @@ 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) {
/** /**
* isLinkToLoaded is a chain value that we maintain until we * isLinkToLoaded is a chain value that we maintain until we
* 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>
); );
} }
@@ -77,7 +77,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'
@@ -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>
@@ -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={
@@ -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>
@@ -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>
@@ -146,7 +148,7 @@ export default function GeneralSettings() {
<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'
error={errors.timeFormat?.message} error={errors.timeFormat?.message}
/> />
<Select <Select
@@ -70,7 +70,7 @@ 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
@@ -1,11 +1,12 @@
import { memo } from 'react'; import { memo } from 'react';
import { Corner } from '../../../common/components/editor-utils/EditorUtils'; import { CornerExtract } 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 { cx } from '../../../common/utils/styleUtils';
import { getIsNavigationLocked } from '../../../externals';
import MessageControl from './MessageControl'; import MessageControl from './MessageControl';
@@ -19,8 +20,8 @@ function MessageControlExport() {
return ( return (
<ProtectRoute permission='editor'> <ProtectRoute permission='editor'>
<div className={style.messages} data-testid='panel-messages-control'> <div className={style.messages} data-testid='panel-messages-control'>
{!isExtracted && <Corner onClick={(event) => handleLinks('messagecontrol', event)} />} {!isExtracted && <CornerExtract onClick={(event) => handleLinks('messagecontrol', event)} />}
{isExtracted && <ViewNavigationMenu suppressSettings />} {isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
<div className={classes}> <div className={classes}>
<ErrorBoundary> <ErrorBoundary>
@@ -2,12 +2,13 @@ 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 './MessageControl.module.scss';
@@ -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}
@@ -1,10 +1,11 @@
import { memo } from 'react'; import { memo } from 'react';
import { Corner } from '../../../common/components/editor-utils/EditorUtils'; import { CornerExtract } 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 { getIsNavigationLocked } from '../../../externals';
import PlaybackControl from './PlaybackControl'; import PlaybackControl from './PlaybackControl';
@@ -17,8 +18,8 @@ function TimerControlExport() {
return ( return (
<ProtectRoute permission='editor'> <ProtectRoute permission='editor'>
<div className={style.playback} data-testid='panel-timer-control'> <div className={style.playback} data-testid='panel-timer-control'>
{!isExtracted && <Corner onClick={(event) => handleLinks('timercontrol', event)} />} {!isExtracted && <CornerExtract onClick={(event) => handleLinks('timercontrol', event)} />}
{isExtracted && <ViewNavigationMenu suppressSettings />} {isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
<div className={style.content}> <div className={style.content}>
<ErrorBoundary> <ErrorBoundary>
@@ -58,7 +58,6 @@
height: 1.5rem; height: 1.5rem;
display: flex; display: flex;
gap: $section-spacing; gap: $section-spacing;
margin-left: 1.5rem;
} }
.tag { .tag {
@@ -70,6 +69,7 @@
.time { .time {
color: $section-white; color: $section-white;
font-size: $text-body-size; font-size: $text-body-size;
display: inline-block;
} }
.rolltag { .rolltag {
+34 -23
View File
@@ -6,13 +6,11 @@ import ViewParamsEditor from '../../common/components/view-params-editor/ViewPar
import useFollowComponent from '../../common/hooks/useFollowComponent'; import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useSelectedEventId } from '../../common/hooks/useSocket'; import { useSelectedEventId } from '../../common/hooks/useSocket';
import { useWindowTitle } from '../../common/hooks/useWindowTitle'; import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import useProjectData from '../../common/hooks-query/useProjectData';
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../common/utils/styleUtils';
import { throttle } from '../../common/utils/throttle'; import { throttle } from '../../common/utils/throttle';
import { getDefaultFormat } from '../../common/utils/time'; import { getDefaultFormat } from '../../common/utils/time';
import { isTouchDevice } from '../../externals';
import Loader from '../../views/common/loader/Loader';
import EditModal from './edit-modal/EditModal'; import EditModal from './edit-modal/EditModal';
import FollowButton from './follow-button/FollowButton'; import FollowButton from './follow-button/FollowButton';
@@ -22,21 +20,31 @@ import StatusBar from './status-bar/StatusBar';
import { getOperatorOptions, useOperatorOptions } from './operator.options'; import { getOperatorOptions, useOperatorOptions } from './operator.options';
import type { EditEvent } from './operator.types'; import type { EditEvent } from './operator.types';
import { getEventData } from './operator.utils'; import { getEventData } from './operator.utils';
import { OperatorData, useOperatorData } from './useOperatorData';
import style from './Operator.module.scss'; import style from './Operator.module.scss';
const selectedOffset = 50; const selectedOffset = 50;
export default function Operator() { export default function OperatorLoader() {
const { data, rundownMetadata, status } = useRundownWithMetadata(); const { data, status } = useOperatorData();
const { data: customFields, status: customFieldStatus } = useCustomFields();
const { data: projectData, status: projectDataStatus } = useProjectData();
const timeoutId = useRef<NodeJS.Timeout | null>(null); useWindowTitle('Operator');
if (status === 'pending') {
return <Loader />;
}
if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
}
return <Operator {...data} />;
}
function Operator({ rundown, rundownMetadata, customFields, settings }: OperatorData) {
const { selectedEventId } = useSelectedEventId(); const { selectedEventId } = useSelectedEventId();
const { subscribe, mainSource, secondarySource, shouldEdit, hidePast, showStart } = useOperatorOptions(); const { subscribe, mainSource, secondarySource, shouldEdit, hidePast, showStart } = useOperatorOptions();
const { data: settings } = useSettings();
const [showEditPrompt, setShowEditPrompt] = useState(false); const [showEditPrompt, setShowEditPrompt] = useState(false);
const [editEvent, setEditEvent] = useState<EditEvent | null>(null); const [editEvent, setEditEvent] = useState<EditEvent | null>(null);
@@ -52,7 +60,7 @@ export default function Operator() {
followTrigger: selectedEventId, followTrigger: selectedEventId,
}); });
useWindowTitle('Operator'); const timeoutId = useRef<NodeJS.Timeout | null>(null);
// reset scroll if nothing is selected // reset scroll if nothing is selected
useEffect(() => { useEffect(() => {
@@ -101,17 +109,10 @@ export default function Operator() {
setEditEvent({ ...event }); setEditEvent({ ...event });
}, []); }, []);
const missingData = !data || !customFields || !projectData;
const isLoading = status === 'pending' || customFieldStatus === 'pending' || projectDataStatus === 'pending';
// gather option data // gather option data
const defaultFormat = getDefaultFormat(settings?.timeFormat); const defaultFormat = getDefaultFormat(settings?.timeFormat);
const operatorOptions = useMemo(() => getOperatorOptions(customFields, defaultFormat), [customFields, defaultFormat]); const operatorOptions = useMemo(() => getOperatorOptions(customFields, defaultFormat), [customFields, defaultFormat]);
if (missingData || isLoading) {
return <EmptyPage text='Loading...' />;
}
const canEdit = shouldEdit && subscribe.length; const canEdit = shouldEdit && subscribe.length;
return ( return (
@@ -122,12 +123,14 @@ export default function Operator() {
<StatusBar /> <StatusBar />
{canEdit && ( {canEdit && (
<div className={cx([style.editPrompt, showEditPrompt && style.show])}>Press and hold to edit user field</div> <div className={cx([style.editPrompt, showEditPrompt && style.show])}>
{isTouchDevice ? 'Press and hold to edit user field' : 'Right click to edit user field'}
</div>
)} )}
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}> <div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
{data.order.map((entryId) => { {rundown.order.map((entryId) => {
const entry = data.entries[entryId]; const entry = rundown.entries[entryId];
if (isOntimeEvent(entry)) { if (isOntimeEvent(entry)) {
const { isPast, isLinkedToLoaded, isLoaded, totalGap } = rundownMetadata[entryId]; const { isPast, isLinkedToLoaded, isLoaded, totalGap } = rundownMetadata[entryId];
// hide past events (if setting) and skipped events // hide past events (if setting) and skipped events
@@ -168,16 +171,24 @@ export default function Operator() {
} }
if (isOntimeGroup(entry)) { if (isOntimeGroup(entry)) {
const { isPast } = rundownMetadata[entry.id];
const isCurrentParent = selectedEventId ? rundownMetadata[selectedEventId].groupId === entry.id : false;
if (hidePast && isPast && !isCurrentParent) {
return null;
}
return ( return (
<Fragment key={entry.id}> <Fragment key={entry.id}>
<OperatorGroup key={entry.id} title={entry.title} /> <OperatorGroup key={entry.id} title={entry.title} />
{entry.entries.map((nestedEntryId) => { {entry.entries.map((nestedEntryId) => {
const nestedEntry = data.entries[nestedEntryId]; const nestedEntry = rundown.entries[nestedEntryId];
if (!isOntimeEvent(nestedEntry)) { if (!isOntimeEvent(nestedEntry)) {
return null; return null;
} }
const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[entryId]; const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[nestedEntryId];
// hide past events (if setting) and skipped events // hide past events (if setting) and skipped events
if ((hidePast && isPast) || nestedEntry.skip) { if ((hidePast && isPast) || nestedEntry.skip) {
@@ -1,13 +1,10 @@
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 { getIsNavigationLocked } from '../../externals';
import Operator from './Operator'; import Operator from './Operator';
export default function OperatorExport() { export default function OperatorExport() {
return ( return (
<ProtectRoute permission='operator'> <ProtectRoute permission='operator'>
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
<Operator /> <Operator />
</ProtectRoute> </ProtectRoute>
); );
@@ -30,12 +30,12 @@
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
max-height: min(80vh, 600px); max-height: min(60vh, 600px);
overflow-y: auto; overflow-y: auto;
} }
.label { .label {
background-color: var(--user-bg); background-color: var(--user-bg, $gray-900);
width: fit-content; width: fit-content;
padding-inline: 1rem; padding-inline: 1rem;
} }
@@ -13,7 +13,7 @@
column-gap: 0.5rem; column-gap: 0.5rem;
row-gap: 0.5rem; row-gap: 0.5rem;
grid-template-rows: auto auto auto; grid-template-rows: auto auto auto;
grid-template-columns: 1.25rem 1fr auto; grid-template-columns: 1.5rem 1fr auto;
grid-template-areas: grid-template-areas:
'binder main schedule' 'binder main schedule'
'binder secondary running' 'binder secondary running'
@@ -42,6 +42,7 @@
place-content: center; place-content: center;
position: relative; position: relative;
background-color: $gray-1050; // to override inline background-color: $gray-1050; // to override inline
font-weight: 600;
.cue { .cue {
white-space: nowrap; white-space: nowrap;
@@ -71,47 +72,41 @@
white-space: pre-line; white-space: pre-line;
} }
.plannedStart, .timeUntil { .plannedStart,
line-height: 1em; .timeUntil,
background-color: $gray-1000; .runningTime {
border-radius: $component-border-radius-md;
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
border-radius: 1px; line-height: 1;
} }
.plannedStart { .plannedStart {
font-size: 1.5rem; font-size: 1.5rem;
margin-right: 0.5rem; margin-right: 0.5rem;
display: inline;
background-color: $white-20;
letter-spacing: 1px;
} }
.timeUntil { .timeUntil {
font-size: calc(1rem - 2px);
line-height: 1em;
grid-area: schedule; grid-area: schedule;
font-size: calc(1rem - 2px);
justify-self: end; justify-self: end;
background-color: $white-20;
letter-spacing: 1px;
} }
.runningTime { .runningTime {
font-size: 1.25rem;
line-height: 1em;
grid-area: running; grid-area: running;
font-size: 1.25rem;
justify-self: end; justify-self: end;
align-self: flex-start; background-color: $white-7;
display: flex;
align-items: center;
gap: 0.5em;
} }
.fields { .fields {
grid-area: fields; grid-area: fields;
font-size: var(--operator-customfield-font-size-override, 1.25rem); margin-top: 0;
font-weight: 400; border-top: 0;
color: $ui-black;
margin: 0.25rem 0;
display: flex;
flex-wrap: wrap;
gap: 0.5em;
row-gap: 0.25em;
line-height: 1.25em;
.field { .field {
font-weight: 600; font-weight: 600;
@@ -133,8 +128,18 @@
// allow multi-line text but trim before // allow multi-line text but trim before
white-space: pre-line; white-space: pre-line;
} }
}
.fields::after { &.fieldsWithContent {
content: '\200b'; margin-top: 0.25rem;
padding-block: 0.5rem;
border-top: 1px solid $white-13;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
gap: 0.25rem 0.75rem;
line-height: 1.25em;
font-size: var(--operator-customfield-font-size-override, 1.25rem);
font-weight: 400;
color: $ui-black;
}
} }
@@ -1,11 +1,12 @@
import { memo, RefObject, SyntheticEvent } from 'react'; import { CSSProperties, memo, RefObject, SyntheticEvent } from 'react';
import { useLongPress } from '@mantine/hooks'; import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND, millisToString } from 'ontime-utils';
import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator'; import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator';
import { useLongPress } from '../../../common/hooks/useLongPress';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatDuration, useTimeUntilExpectedStart } from '../../../common/utils/time'; import { formatDuration, formatTime, useTimeUntilExpectedStart } from '../../../common/utils/time';
import RunningTime from '../../viewers/common/running-time/RunningTime'; import RunningTime from '../../viewers/common/running-time/RunningTime';
import SuperscriptPeriod from '../../viewers/common/superscript-time/SuperscriptPeriod';
import type { EditEvent, Subscribed } from '../operator.types'; import type { EditEvent, Subscribed } from '../operator.types';
import style from './OperatorEvent.module.scss'; import style from './OperatorEvent.module.scss';
@@ -54,8 +55,10 @@ function OperatorEvent({
* gather behaviour for long press and context menu * gather behaviour for long press and context menu
*/ */
const handleLongPress = (event?: SyntheticEvent) => { const handleLongPress = (event?: SyntheticEvent) => {
// we dont have an event out of useLongPress // prevent default if the event is cancelable to avoid browser intervention warnings
event?.preventDefault(); if (event && event.cancelable) {
event.preventDefault();
}
if (subscribed) { if (subscribed) {
onLongPress({ id, cue, subscriptions: subscribed }); onLongPress({ id, cue, subscriptions: subscribed });
} }
@@ -64,22 +67,33 @@ function OperatorEvent({
const mouseHandlers = useLongPress(handleLongPress); const mouseHandlers = useLongPress(handleLongPress);
const cueColours = colour && getAccessibleColour(colour); const cueColours = colour && getAccessibleColour(colour);
const operatorClasses = cx([ const operatorClasses = cx([style.event, isSelected && style.running, isPast && style.past]);
style.event,
isSelected && style.running, const hasFields = subscribed.some((field) => field.value);
isPast && style.past, const columnCount = subscribed.length ? Math.min(subscribed.length, 4) : 0;
]); const fieldGridStyle =
columnCount > 0
? ({
gridTemplateColumns: `repeat(${columnCount}, minmax(12rem, 1fr))`,
} satisfies CSSProperties)
: undefined;
return ( return (
<div className={operatorClasses} data-testid={cue} ref={selectedRef} onContextMenu={handleLongPress} {...mouseHandlers}> <div
className={operatorClasses}
data-testid={cue}
ref={selectedRef}
onContextMenu={handleLongPress}
{...mouseHandlers}
>
<div className={style.binder} style={{ ...cueColours }}> <div className={style.binder} style={{ ...cueColours }}>
<span className={style.cue}>{cue}</span> <span className={style.cue}>{cue}</span>
</div> </div>
<span className={style.mainField}> <span className={style.mainField}>
{showStart && <span className={style.plannedStart}>{millisToString(timeStart)}</span>} {showStart && <SuperscriptPeriod className={style.plannedStart} time={formatTime(timeStart)} />}
{main} {main}
</span> </span>
<span className={style.secondaryField}>{secondary}</span> <span className={style.secondaryField}>{secondary}</span>
<OperatorEventSchedule <OperatorEventSchedule
timeStart={timeStart} timeStart={timeStart}
@@ -95,22 +109,25 @@ function OperatorEvent({
<RunningTime className={cx([isSelected && style.muted])} value={duration} hideLeadingZero /> <RunningTime className={cx([isSelected && style.muted])} value={duration} hideLeadingZero />
</span> </span>
<div className={style.fields}> <div className={cx([style.fields, hasFields && style.fieldsWithContent])} style={fieldGridStyle}>
{subscribed {subscribed.map((field) => {
.filter((field) => field.value) if (!field.value) {
.map((field) => { return <div key={field.id} />;
const fieldClasses = cx([style.field, !field.colour ? style.noColour : null]); }
return ( return (
<div key={field.id}> <div key={field.id}>
<span className={fieldClasses} style={{ backgroundColor: field.colour }}> <span
{field.label} className={cx([style.field, !field.colour && style.noColour])}
</span> style={{ backgroundColor: field.colour }}
<span className={style.value} style={{ color: field.colour }}> >
{field.value} {field.label}
</span> </span>
</div> <span className={style.value} style={{ color: field.colour }}>
); {field.value}
})} </span>
</div>
);
})}
</div> </div>
</div> </div>
); );
@@ -167,5 +184,9 @@ function TimeUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }:
const isDue = timeUntil < MILLIS_PER_SECOND; const isDue = timeUntil < MILLIS_PER_SECOND;
const timeUntilString = isDue ? 'DUE' : `${formatDuration(Math.abs(timeUntil), timeUntil > 2 * MILLIS_PER_MINUTE)}`; const timeUntilString = isDue ? 'DUE' : `${formatDuration(Math.abs(timeUntil), timeUntil > 2 * MILLIS_PER_MINUTE)}`;
return <span className={style.timeUntil} data-testid='time-until'>{timeUntilString}</span>; return (
<span className={style.timeUntil} data-testid='time-until'>
{timeUntilString}
</span>
);
} }
@@ -1,6 +1,6 @@
import { useMemo } from 'react'; import { use, useMemo } from 'react';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { CustomFields, OntimeEvent } from 'ontime-types'; import { CustomFields, EntryId, OntimeEvent } from 'ontime-types';
import { getTimeOption } from '../../common/components/view-params-editor/common.options'; import { getTimeOption } from '../../common/components/view-params-editor/common.options';
import { OptionTitle } from '../../common/components/view-params-editor/constants'; import { OptionTitle } from '../../common/components/view-params-editor/constants';
@@ -9,6 +9,7 @@ import {
makeCustomFieldSelectOptions, makeCustomFieldSelectOptions,
makeOptionsFromCustomFields, makeOptionsFromCustomFields,
} from '../../common/components/view-params-editor/viewParams.utils'; } from '../../common/components/view-params-editor/viewParams.utils';
import { PresetContext } from '../../common/context/PresetContext';
import { isStringBoolean } from '../viewers/common/viewUtils'; import { isStringBoolean } from '../viewers/common/viewUtils';
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ViewOption[] => { export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ViewOption[] => {
@@ -93,15 +94,26 @@ type OperatorOptions = {
* Utility extract the view options from URL Params * Utility extract the view options from URL Params
* the names and fallback are manually matched with timerOptions * the names and fallback are manually matched with timerOptions
*/ */
function getOptionsFromParams(searchParams: URLSearchParams): OperatorOptions { function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URLSearchParams): OperatorOptions {
// Helper to get value from either source, prioritizing defaultValues
const getValue = (key: string) => defaultValues?.get(key) ?? searchParams.get(key);
// Helper to get array values from either source
const getArrayValues = (key: string): EntryId[] => {
if (defaultValues?.has(key)) {
return defaultValues.getAll(key) as EntryId[];
}
return searchParams.getAll(key) as EntryId[];
};
// we manually make an object that matches the key above // we manually make an object that matches the key above
return { return {
mainSource: searchParams.get('main') as keyof OntimeEvent | null, mainSource: getValue('main') as keyof OntimeEvent | null,
secondarySource: searchParams.get('secondary-src') as keyof OntimeEvent | null, secondarySource: getValue('secondary-src') as keyof OntimeEvent | null,
subscribe: searchParams.getAll('subscribe'), subscribe: getArrayValues('subscribe'),
shouldEdit: isStringBoolean(searchParams.get('shouldEdit')), shouldEdit: isStringBoolean(getValue('shouldEdit')),
hidePast: isStringBoolean(searchParams.get('hidePast')), hidePast: isStringBoolean(getValue('hidePast')),
showStart: isStringBoolean(searchParams.get('showStart')), showStart: isStringBoolean(getValue('showStart')),
}; };
} }
@@ -110,6 +122,12 @@ function getOptionsFromParams(searchParams: URLSearchParams): OperatorOptions {
*/ */
export function useOperatorOptions(): OperatorOptions { export function useOperatorOptions(): OperatorOptions {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]); const maybePreset = use(PresetContext);
const options = useMemo(() => {
const defaultValues = maybePreset ? new URLSearchParams(maybePreset.search) : undefined;
return getOptionsFromParams(searchParams, defaultValues);
}, [maybePreset, searchParams]);
return options; return options;
} }
@@ -40,3 +40,10 @@
height: 1rem; height: 1rem;
--progress-bar-br: 0; --progress-bar-br: 0;
} }
@media (max-width: $min-tablet) {
.timers {
grid-template-columns: 1fr 1fr;
grid-template-areas: 'timers clock';
}
}
@@ -6,7 +6,7 @@ export default function StatusBarTimers() {
return ( return (
<div className={style.timers}> <div className={style.timers}>
<TimerOverview className={style.runningTimer} /> <TimerOverview className={style.runningTimer} />
<ClockOverview className={style.timeNow} /> <ClockOverview className={style.timeNow} shouldFormat />
</div> </div>
); );
} }
@@ -0,0 +1,30 @@
import { CustomFields, Rundown, Settings } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings';
import { RundownMetadataObject } from '../../common/utils/rundownMetadata';
import { aggregateQueryStatus, ViewData } from '../../views/utils/viewLoader.utils';
export interface OperatorData {
rundown: Rundown;
rundownMetadata: RundownMetadataObject;
customFields: CustomFields;
settings: Settings;
}
export function useOperatorData(): ViewData<OperatorData> {
const { data: rundown, rundownMetadata, status: rundownStatus } = useRundownWithMetadata();
const { data: customFields, status: customFieldStatus } = useCustomFields();
const { data: settings, status: settingsStatus } = useSettings();
return {
data: {
rundown,
rundownMetadata,
customFields,
settings,
},
status: aggregateQueryStatus([rundownStatus, customFieldStatus, settingsStatus]),
};
}
@@ -27,11 +27,11 @@ function CuesheetDesktop({ children }: PropsWithChildren) {
return ( return (
<OverviewWrapper navElements={children}> <OverviewWrapper navElements={children}>
<TitleOverview /> <TitleOverview />
<StartTimes /> <StartTimes shouldFormat />
<TimerOverview /> <TimerOverview />
<OffsetOverview /> <OffsetOverview />
<MetadataTimes /> <MetadataTimes />
<ClockOverview /> <ClockOverview shouldFormat />
</OverviewWrapper> </OverviewWrapper>
); );
} }
@@ -34,4 +34,6 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
overflow-x: auto;
overflow-y: hidden;
} }
@@ -9,7 +9,7 @@
.row { .row {
display: grid; display: grid;
grid-template-columns: 3rem 10rem 8rem; grid-template-columns: 3rem 9rem 9rem;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
} }
@@ -29,7 +29,8 @@
.icon { .icon {
font-size: 1rem; font-size: 1rem;
color: $label-gray; color: $secondary-text-gray;
flex-shrink: 0;
} }
.label { .label {
@@ -40,18 +41,24 @@
.time { .time {
font-size: 1.25rem; font-size: 1.25rem;
letter-spacing: 0.5px; letter-spacing: 0.25px;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
line-height: 1.1; line-height: 1.1;
} }
.daySpan { .daySpan {
display: inline-flex;
align-items: center;
margin-left: 0.25rem;
&::after { &::after {
content: "+"attr(data-day-offset); content: "+" attr(data-day-offset);
vertical-align: super; font-size: 0.75rem;
font-size: 0.6em; font-weight: 400;
letter-spacing: 0; color: $ui-white;
color: $info-blue; background: $gray-900; // same as the tag component
padding: 0.125rem 0.25rem;
border-radius: $component-border-radius-sm;
} }
} }
@@ -74,4 +81,7 @@
font-size: 1rem; font-size: 1rem;
letter-spacing: 0; letter-spacing: 0;
color: $playback-over; color: $playback-over;
border-left: 3px solid $playback-over;
padding-left: 0.375rem;
display: inline-block;
} }
@@ -20,6 +20,7 @@ import {
useNextFlag, useNextFlag,
useOffsetOverview, useOffsetOverview,
useProgressOverview, useProgressOverview,
useRundownExpectedEnd,
useStartTimesOverview, useStartTimesOverview,
useTimer, useTimer,
} from '../../../common/hooks/useSocket'; } from '../../../common/hooks/useSocket';
@@ -27,69 +28,136 @@ import { useEntry } from '../../../common/hooks-query/useRundown';
import { getOffsetState, getOffsetText } from '../../../common/utils/offset'; import { getOffsetState, getOffsetText } from '../../../common/utils/offset';
import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils'; import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils';
import { formatTime } from '../../../common/utils/time'; import { formatTime } from '../../../common/utils/time';
import { calculateEndAndDaySpan, formatDueTime, formattedTime } from '../overview.utils'; import SuperscriptPeriod from '../../viewers/common/superscript-time/SuperscriptPeriod';
import { calculateEndAndDaySpan, formatDueTime } from '../overview.utils';
import { OverUnder, TimeColumn } from './TimeLayout'; import { OverUnder, TimeColumn, WrappedInTimeColumn } from './TimeLayout';
import style from './TimeElements.module.scss'; import style from './TimeElements.module.scss';
export function StartTimes() { interface OverviewTimeElementsProps {
const { plannedEnd, plannedStart, actualStart, expectedEnd } = useStartTimesOverview(); shouldFormat?: boolean;
}
const plannedStartText = plannedStart === null ? timerPlaceholder : formatTime(plannedStart); export function StartTimes({ shouldFormat }: OverviewTimeElementsProps) {
const { plannedEnd, plannedStart, actualStart } = useStartTimesOverview();
const formatOptions = { format12: 'hh:mm:ss a', format24: 'HH:mm:ss' };
const plannedStartText = (() => {
if (plannedStart === null) return timerPlaceholder;
if (shouldFormat) return formatTime(plannedStart, formatOptions);
return millisToString(plannedStart, { fallback: timerPlaceholder });
})();
const actualStartText = (() => {
if (actualStart === null) return timerPlaceholder;
if (shouldFormat) return formatTime(actualStart, formatOptions);
return millisToString(actualStart, { fallback: timerPlaceholder });
})();
const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]); const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]);
const [maybeExpectedEnd, maybeExpectedDaySpan] = useMemo(() => calculateEndAndDaySpan(expectedEnd), [expectedEnd]);
const plannedEndText = maybePlannedEnd === null ? timerPlaceholder : formatTime(maybePlannedEnd); const plannedEndText = (() => {
if (maybePlannedEnd === null) return timerPlaceholder;
if (shouldFormat) return formatTime(maybePlannedEnd, formatOptions);
return millisToString(maybePlannedEnd, { fallback: timerPlaceholder });
})();
const multipleDays = maybePlannedDaySpan > 0;
const plannedEndTooltip = multipleDays
? `Planned end time (rundown spans over ${maybePlannedDaySpan + 1} days)`
: 'Planned end time';
return ( return (
<div className={style.column}> <div className={style.column}>
<div className={style.row}> <div className={style.row}>
<span className={style.label}>Start</span> <span className={style.label}>Start</span>
<div className={style.labelledElement}> <Tooltip
<Tooltip text='Planned start time' render={<TbCalendarPin className={style.icon} />} /> text='Planned start time'
<span className={cx([style.time, plannedStart === null && style.muted])}>{plannedStartText}</span> render={
</div> <div className={style.labelledElement}>
<div className={style.labelledElement} data-testid='actual-start-time'> <TbCalendarPin className={style.icon} />
<Tooltip text='Actual start time' render={<TbCalendarClock className={style.icon} />} /> <SuperscriptPeriod
<span className={cx([style.time, actualStart === null && style.muted])}>{formattedTime(actualStart)}</span> className={cx([style.time, plannedStart === null && style.muted])}
</div> time={plannedStartText}
/>
</div>
}
/>
<Tooltip
text='Actual start time'
render={
<div className={style.labelledElement} data-testid='actual-start-time'>
<TbCalendarClock className={style.icon} />
<SuperscriptPeriod
className={cx([style.time, actualStart === null && style.muted])}
time={actualStartText}
/>
</div>
}
/>
</div> </div>
<div className={style.row}> <div className={style.row}>
<span className={style.label}>End</span> <span className={style.label}>End</span>
<div className={style.labelledElement}> <Tooltip
<Tooltip text='Planned end time' render={<TbCalendarPin className={style.icon} />} /> text={plannedEndTooltip}
{maybePlannedDaySpan > 0 ? ( render={
<Tooltip <div className={style.labelledElement}>
text={`Rundown spans over ${maybePlannedDaySpan + 1} days`} <TbCalendarPin className={style.icon} />
render={<span className={cx([style.time, style.daySpan])} data-day-offset={maybePlannedDaySpan} />} <SuperscriptPeriod
> className={cx([style.time, plannedEnd === null && style.muted])}
{plannedEndText} time={plannedEndText}
</Tooltip> />
) : ( {multipleDays && (
<span className={cx([style.time, plannedEnd === null && style.muted])}>{plannedEndText}</span> <span className={cx([style.time, style.daySpan])} data-day-offset={maybePlannedDaySpan} />
)} )}
</div> </div>
<div className={style.labelledElement}> }
<Tooltip text='Expected end time' render={<TbCalendarStar className={style.icon} />} /> />
{maybeExpectedEnd !== null && maybeExpectedDaySpan > 0 ? ( <RundownExpectedEnd shouldFormat={shouldFormat} />
<Tooltip
text={`Rundown spans over ${maybeExpectedDaySpan + 1} days`}
render={<span className={cx([style.time, style.daySpan])} data-day-offset={maybeExpectedDaySpan} />}
>
{formattedTime(maybeExpectedEnd)}
</Tooltip>
) : (
<span className={cx([style.time, maybeExpectedEnd === null && style.muted])}>
{formattedTime(maybeExpectedEnd)}
</span>
)}
</div>
</div> </div>
</div> </div>
); );
} }
/**
* Shows the expected end for the rundown
* Extracted to improve performance as this is a ticking value
*/
function RundownExpectedEnd({ shouldFormat }: OverviewTimeElementsProps) {
const { expectedEnd } = useRundownExpectedEnd();
const [maybeExpectedEnd, maybeExpectedDaySpan] = useMemo(() => calculateEndAndDaySpan(expectedEnd), [expectedEnd]);
const maybeExpectedEndText = (() => {
if (maybeExpectedEnd === null) return timerPlaceholder;
if (shouldFormat) return formatTime(maybeExpectedEnd, { format12: 'hh:mm:ss a', format24: 'HH:mm:ss' });
return millisToString(maybeExpectedEnd, { fallback: timerPlaceholder });
})();
const multipleDays = maybeExpectedEnd !== null && maybeExpectedDaySpan > 0;
const tooltip = multipleDays
? `Expected end time (rundown spans over ${maybeExpectedDaySpan + 1} days)`
: 'Expected end time';
return (
<Tooltip
text={tooltip}
render={
<div className={style.labelledElement}>
<TbCalendarStar className={style.icon} />
<SuperscriptPeriod
className={cx([style.time, maybeExpectedEnd === null && style.muted])}
time={maybeExpectedEndText}
/>
{multipleDays && <span className={cx([style.time, style.daySpan])} data-day-offset={maybeExpectedDaySpan} />}
</div>
}
/>
);
}
export function MetadataTimes() { export function MetadataTimes() {
return ( return (
<div className={style.column}> <div className={style.column}>
@@ -229,11 +297,17 @@ export function OffsetOverview() {
return <OverUnder state={offsetState} value={offsetText} testId='offset' />; return <OverUnder state={offsetState} value={offsetText} testId='offset' />;
} }
export function ClockOverview({ className }: { className?: string }) { export function ClockOverview({ shouldFormat, className }: OverviewTimeElementsProps & { className?: string }) {
const { clock } = useClock(); const { clock } = useClock();
const formattedClock = formatTime(clock); const formattedClock = shouldFormat ? formatTime(clock) : millisToString(clock);
return <TimeColumn label='Time now' value={formattedClock} className={className} />; return (
<WrappedInTimeColumn
label='Time now'
className={className}
render={(clockClasses) => <SuperscriptPeriod className={clockClasses} time={formattedClock} />}
/>
);
} }
export function TimerOverview({ className }: { className?: string }) { export function TimerOverview({ className }: { className?: string }) {
@@ -1,6 +1,6 @@
.label { .label {
color: $label-gray; color: $label-gray;
font-size: calc(1rem - 2px); font-size: calc(1rem - 3px);
} }
.clock { .clock {
@@ -29,14 +29,21 @@
gap: 0.25rem; gap: 0.25rem;
} }
.over,
.under {
transition: color 0.2s ease;
}
&[data-state='waiting'] { &[data-state='waiting'] {
.label { .label {
color: $ontime-roll; color: $ontime-roll;
} }
} }
&[data-state='over'] { &[data-state='over'] {
.label .over { .label .over {
color: $playback-over; color: $playback-over;
font-weight: 600;
} }
.clock { .clock {
color: $playback-over; color: $playback-over;
@@ -46,6 +53,7 @@
&[data-state='under'] { &[data-state='under'] {
.label .under { .label .under {
color: $playback-under; color: $playback-under;
font-weight: 600;
} }
.clock { .clock {
color: $playback-under; color: $playback-under;
@@ -1,3 +1,5 @@
import { ReactNode } from 'react';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import style from './TimeLayout.module.scss'; import style from './TimeLayout.module.scss';
@@ -22,6 +24,21 @@ export function TimeColumn({ label, value, state = 'active', className, testId }
); );
} }
interface WrappedInTimeColumnProps {
label: string;
state?: 'muted' | 'waiting' | 'active';
className?: string;
render: (className: string) => ReactNode;
}
export function WrappedInTimeColumn({ label, state = 'active', className, render }: WrappedInTimeColumnProps) {
return (
<div className={cx([style.column, className])} data-state={state}>
<span className={style.label}>{label}</span>
{render(style.clock)}
</div>
);
}
interface OverUnderProps { interface OverUnderProps {
state: 'over' | 'under' | 'muted' | null; state: 'over' | 'under' | 'muted' | null;
value: string; value: string;
@@ -1,10 +1,14 @@
.title { .title {
font-size: 1.5rem; font-size: 1.75rem;
font-weight: 600;
color: $ui-white;
line-height: 1;
@include ellipsis-overflow; @include ellipsis-overflow;
} }
.description { .description {
font-size: 1rem; font-size: 1rem;
color: $label-gray; color: $secondary-text-gray;
font-weight: 400;
@include ellipsis-overflow; @include ellipsis-overflow;
} }
@@ -1,13 +1,14 @@
import { memo } from 'react'; import { memo } from 'react';
import { useSessionStorage } from '@mantine/hooks'; import { useSessionStorage } from '@mantine/hooks';
import { Corner } from '../../common/components/editor-utils/EditorUtils'; import { CornerExtract } 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 { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice'; import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice';
import { handleLinks } from '../../common/utils/linkUtils'; import { handleLinks } from '../../common/utils/linkUtils';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../common/utils/styleUtils';
import { getIsNavigationLocked } from '../../externals';
import { AppMode, sessionKeys } from '../../ontimeConfig'; import { AppMode, sessionKeys } from '../../ontimeConfig';
import RundownEntryEditor from './entry-editor/RundownEntryEditor'; import RundownEntryEditor from './entry-editor/RundownEntryEditor';
@@ -55,11 +56,11 @@ function RundownExport() {
<ProtectRoute permission='editor'> <ProtectRoute permission='editor'>
<div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'> <div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'>
<FinderPlacement /> <FinderPlacement />
{isExtracted && <ViewNavigationMenu suppressSettings />} {isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
<div className={style.rundown}> <div className={style.rundown}>
<div className={style.list}> <div className={style.list}>
<ErrorBoundary> <ErrorBoundary>
{!isExtracted && <Corner onClick={(event) => handleLinks('rundown', event)} />} {!isExtracted && <CornerExtract onClick={(event) => handleLinks('rundown', event)} />}
<RundownContextMenu> <RundownContextMenu>
<RundownWrapper /> <RundownWrapper />
</RundownContextMenu> </RundownContextMenu>
@@ -59,9 +59,6 @@ export default function GroupEditor({ group }: GroupEditorProps) {
<Editor.Title>Group schedule</Editor.Title> <Editor.Title>Group schedule</Editor.Title>
<div className={style.inline}> <div className={style.inline}>
<div> <div>
{
// TODO: format with user time settings
}
<Editor.Label>First event start</Editor.Label> <Editor.Label>First event start</Editor.Label>
<TextLikeInput className={style.textLikeInput} disabled> <TextLikeInput className={style.textLikeInput} disabled>
{millisToString(group.timeStart, { fallback: timerPlaceholder })} {millisToString(group.timeStart, { fallback: timerPlaceholder })}
@@ -2,16 +2,21 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
height: 0;
position: relative; position: relative;
height: 1px;
background: $blue-500;
z-index: $zindex-floating; z-index: $zindex-floating;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: $blue-500;
}
} }
.addButton { .addButton {
position: absolute; position: relative;
top: 0;
left: 0;
transform: translateY(-50%);
} }
@@ -134,6 +134,13 @@ $skip-opacity: 0.2;
height: 100%; height: 100%;
} }
.eventTimers.editMode:hover {
[class*="hoverLabel"] {
opacity: 1;
transition: opacity 0.15s 0.5s ease;
}
}
.chipSection { .chipSection {
grid-area: chip; grid-area: chip;
} }
@@ -10,10 +10,12 @@ import {
IoTime, IoTime,
} from 'react-icons/io5'; } from 'react-icons/io5';
import { LuArrowDownToLine } from 'react-icons/lu'; import { LuArrowDownToLine } from 'react-icons/lu';
import { useSessionStorage } from '@mantine/hooks';
import { EndAction, Playback, TimerType, TimeStrategy } from 'ontime-types'; import { EndAction, Playback, TimerType, TimeStrategy } from 'ontime-types';
import Tooltip from '../../../common/components/tooltip/Tooltip'; import Tooltip from '../../../common/components/tooltip/Tooltip';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import { AppMode, sessionKeys } from '../../../ontimeConfig';
import TitleEditor from '../common/TitleEditor'; import TitleEditor from '../common/TitleEditor';
import TimeInputFlow from '../time-input-flow/TimeInputFlow'; import TimeInputFlow from '../time-input-flow/TimeInputFlow';
@@ -76,6 +78,11 @@ function RundownEventInner({
}: RundownEventInnerProps) { }: RundownEventInnerProps) {
const [renderInner, setRenderInner] = useState(false); const [renderInner, setRenderInner] = useState(false);
const [editorMode] = useSessionStorage({
key: sessionKeys.editorMode,
defaultValue: AppMode.Edit,
});
useEffect(() => { useEffect(() => {
setRenderInner(true); setRenderInner(true);
}, []); }, []);
@@ -92,7 +99,7 @@ function RundownEventInner({
return !renderInner ? null : ( return !renderInner ? null : (
<> <>
<div className={style.eventTimers}> <div className={cx([style.eventTimers, editorMode === AppMode.Edit && style.editMode])}>
<TimeInputFlow <TimeInputFlow
eventId={eventId} eventId={eventId}
timeStart={timeStart} timeStart={timeStart}
@@ -10,14 +10,14 @@ import {
import { useSortable } from '@dnd-kit/sortable'; import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { EntryId, OntimeGroup } from 'ontime-types'; import { EntryId, OntimeGroup } from 'ontime-types';
import { MILLIS_PER_MINUTE } from 'ontime-utils'; import { MILLIS_PER_MINUTE, millisToString } from 'ontime-utils';
import IconButton from '../../../common/components/buttons/IconButton'; import IconButton from '../../../common/components/buttons/IconButton';
import { useContextMenu } from '../../../common/hooks/useContextMenu'; import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryActions } from '../../../common/hooks/useEntryAction'; import { useEntryActions } from '../../../common/hooks/useEntryAction';
import { getOffsetState } from '../../../common/utils/offset'; import { getOffsetState } from '../../../common/utils/offset';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; import { cx, getAccessibleColour, timerPlaceholder } from '../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../common/utils/time'; import { formatDuration } from '../../../common/utils/time';
import TitleEditor from '../common/TitleEditor'; import TitleEditor from '../common/TitleEditor';
import { canDrop } from '../rundown.utils'; import { canDrop } from '../rundown.utils';
import { useEventSelection } from '../useEventSelection'; import { useEventSelection } from '../useEventSelection';
@@ -149,11 +149,11 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
<div className={style.metaRow}> <div className={style.metaRow}>
<div className={style.metaEntry}> <div className={style.metaEntry}>
<div>Start</div> <div>Start</div>
<div>{formatTime(data.timeStart)}</div> <div>{millisToString(data.timeStart, { fallback: timerPlaceholder })}</div>
</div> </div>
<div className={style.metaEntry}> <div className={style.metaEntry}>
<div>End</div> <div>End</div>
<div>{formatTime(data.timeEnd)}</div> <div>{millisToString(data.timeEnd, { fallback: timerPlaceholder })}</div>
</div> </div>
<div className={style.metaEntry}> <div className={style.metaEntry}>
<div>Duration</div> <div>Duration</div>
@@ -15,3 +15,26 @@
.active { .active {
color: $active-indicator; color: $active-indicator;
} }
.inputWrapper {
position: relative;
}
.hoverLabel {
position: absolute;
top: -1.5rem;
background-color: $ui-white;
padding: 0.125rem 0.5rem;
color: $ui-black;
left: 0;
opacity: 0;
border-radius: $component-border-radius-md;
transition: opacity 0.15s ease; // no delay on fade out
pointer-events: none;
z-index: $zindex-floating;
&.visible {
opacity: 1;
transition: opacity 0.15s 0.5s ease; // small delay before fade in
}
}
@@ -67,8 +67,9 @@ function TimeInputFlow({
return ( return (
<> <>
<div> <div className={style.inputWrapper}>
{showLabels && <Editor.Label className={style.sectionTitle}>Start time</Editor.Label>} {showLabels && <Editor.Label className={style.sectionTitle}>Start time</Editor.Label>}
<Editor.Label className={style.hoverLabel}>Start</Editor.Label>
<TimeInputGroup hasDelay={hasDelay}> <TimeInputGroup hasDelay={hasDelay}>
<TimeInput <TimeInput
name='timeStart' name='timeStart'
@@ -88,8 +89,9 @@ function TimeInputFlow({
</TimeInputGroup> </TimeInputGroup>
</div> </div>
<div> <div className={style.inputWrapper}>
{showLabels && <Editor.Label>End time</Editor.Label>} {showLabels && <Editor.Label>End time</Editor.Label>}
<Editor.Label className={style.hoverLabel}>End</Editor.Label>
<TimeInputGroup hasDelay={hasDelay}> <TimeInputGroup hasDelay={hasDelay}>
<TimeInput <TimeInput
name='timeEnd' name='timeEnd'
@@ -110,8 +112,9 @@ function TimeInputFlow({
</TimeInputGroup> </TimeInputGroup>
</div> </div>
<div> <div className={style.inputWrapper}>
{showLabels && <Editor.Label>Duration</Editor.Label>} {showLabels && <Editor.Label>Duration</Editor.Label>}
<Editor.Label className={style.hoverLabel}>Duration</Editor.Label>
<TimeInputGroup hasDelay={hasDelay}> <TimeInputGroup hasDelay={hasDelay}>
<TimeInput <TimeInput
name='duration' name='duration'
@@ -15,7 +15,6 @@ import Switch from '../../common/components/switch/Switch';
import { useUpdateUrlPreset } from '../../common/hooks-query/useUrlPresets'; import { useUpdateUrlPreset } from '../../common/hooks-query/useUrlPresets';
import copyToClipboard from '../../common/utils/copyToClipboard'; import copyToClipboard from '../../common/utils/copyToClipboard';
import { preventEscape } from '../../common/utils/keyEvent'; import { preventEscape } from '../../common/utils/keyEvent';
import { linkToOtherHost } from '../../common/utils/linkUtils';
import { isUrlSafe } from '../../common/utils/regex'; import { isUrlSafe } from '../../common/utils/regex';
import { isOntimeCloud, serverURL } from '../../externals'; import { isOntimeCloud, serverURL } from '../../externals';
import * as Panel from '../app-settings/panel-utils/PanelUtils'; import * as Panel from '../app-settings/panel-utils/PanelUtils';
@@ -136,7 +135,7 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
} }
const url = await generateUrl({ const url = await generateUrl({
baseUrl: linkToOtherHost(options.baseUrl), baseUrl: options.baseUrl,
path, path,
authenticate: options.authenticate, authenticate: options.authenticate,
lockConfig: options.lockConfig, lockConfig: options.lockConfig,
@@ -14,12 +14,18 @@ export default function GenerateLinkFormExport({ lockedPath }: GenerateLinkFormE
const { data: infoData } = useInfo(); const { data: infoData } = useInfo();
const { data: urlPresetData } = useUrlPresets({ skip: lockedPath === undefined }); const { data: urlPresetData } = useUrlPresets({ skip: lockedPath === undefined });
/**
* hostOptions are only used for local networks
* the NIF address is a local IP address: 192.168.x.x or 10.x.x.x
* We need to inject the port and protocol to create a valid URL
* eg: http://192.168.x.x:port
*/
const hostOptions = useMemo(() => { const hostOptions = useMemo(() => {
return infoData.networkInterfaces.map((nif) => ({ return infoData.networkInterfaces.map((nif) => ({
value: nif.address, value: `http://${nif.address}:${infoData.serverPort}`,
label: `${nif.name} - ${nif.address}`, label: `${nif.name} - ${nif.address}`,
})); }));
}, [infoData.networkInterfaces]); }, [infoData.networkInterfaces, infoData.serverPort]);
const pathOptions = useMemo(() => { const pathOptions = useMemo(() => {
if (lockedPath) { if (lockedPath) {
@@ -0,0 +1,23 @@
import './SuperscriptTime.scss';
interface SuperscriptPeriodProps {
time: string;
className?: string;
}
/**
* Receives a time string and formats periods (am/pm) as superscript
* @example 12:00 AM -> AM becomes a superscript
* @example 12:00:10 -> no formatting changes applied
*/
export default function SuperscriptPeriod({ time, className }: SuperscriptPeriodProps) {
// we assume anything after space is a period tag
const [timeString, period] = time.split(' ');
return (
<div className={className}>
{timeString}
{period && <sup className='period'>{period}</sup>}
</div>
);
}
+1 -2
View File
@@ -1,6 +1,5 @@
import React from 'react'; import React from 'react';
import { createRoutesFromChildren, matchRoutes, useLocation, useNavigationType } from 'react-router'; import { createRoutesFromChildren, matchRoutes, Routes, useLocation, useNavigationType } from 'react-router';
import { Routes } from 'react-router';
import * as Sentry from '@sentry/react'; import * as Sentry from '@sentry/react';
import { ONTIME_VERSION } from './ONTIME_VERSION'; import { ONTIME_VERSION } from './ONTIME_VERSION';
@@ -1,6 +1,7 @@
// from https://www.genomecolor.space/ // from https://www.genomecolor.space/
$white-1: rgba(255, 255, 255, 0.01); $white-1: rgba(255, 255, 255, 0.01);
$white-2: rgba(255, 255, 255, 0.02);
$white-3: rgba(255, 255, 255, 0.03); $white-3: rgba(255, 255, 255, 0.03);
$white-7: rgba(255, 255, 255, 0.07); $white-7: rgba(255, 255, 255, 0.07);
$white-9: rgba(255, 255, 255, 0.09); $white-9: rgba(255, 255, 255, 0.09);
+2 -2
View File
@@ -35,7 +35,7 @@ $ontime-paused: #c05621;
$ontime-stop: #E4281E; $ontime-stop: #E4281E;
$playback-negative: $red-500; $playback-negative: $red-500;
$playback-ahead: $green-500; $playback-ahead: $green-500;
$active-indicator: #8bb33d; $active-indicator: $green-400;
$text-black: $gray-1350; $text-black: $gray-1350;
$playback-over: #F57C13; $playback-over: #F57C13;
@@ -65,7 +65,7 @@ $main-spacing: 2rem;
// interface text // interface text
$ontime-font-family: "Open Sans", "Segoe UI", sans-serif; $ontime-font-family: "Open Sans", "Segoe UI", sans-serif;
$title-gray: $gray-200; $title-gray: $gray-200;
$label-gray: $gray-400; $label-gray: $gray-600;
$secondary-text-gray: $gray-400; $secondary-text-gray: $gray-400;
$muted-gray: $gray-600; $muted-gray: $gray-600;
$section-white: $ui-white; $section-white: $ui-white;
@@ -76,13 +76,13 @@ function Backstage({ events, customFields, projectData, isMirrored, settings }:
const scheduledStart = (() => { const scheduledStart = (() => {
if (showNow) return undefined; if (showNow) return undefined;
if (!hasEvents) return undefined; if (!hasEvents) return undefined;
return formatTime(rundown.plannedStart, { format12: 'hh:mm a', format24: 'HH:mm' }); return formatTime(rundown.plannedStart, { format12: 'h:mm a', format24: 'HH:mm' });
})(); })();
const scheduledEnd = (() => { const scheduledEnd = (() => {
if (showNow) return undefined; if (showNow) return undefined;
if (!hasEvents) return undefined; if (!hasEvents) return undefined;
return formatTime(rundown.plannedEnd, { format12: 'hh:mm a', format24: 'HH:mm' }); return formatTime(rundown.plannedEnd, { format12: 'h:mm a', format24: 'HH:mm' });
})(); })();
let displayTimer = millisToString(time.current, { fallback: timerPlaceholderMin }); let displayTimer = millisToString(time.current, { fallback: timerPlaceholderMin });

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