Compare commits

..

17 Commits

Author SHA1 Message Date
arc-alex 6397d33eaa feat: option to select all events for countdown 2025-09-16 16:59:29 +02:00
arc-alex 9288566a7f chore: remove unused code 2025-09-16 16:38:11 +02:00
Alex Christoffer Rasmussen 7fd9f83fb0 fix docker ignore (#1780) 2025-09-16 16:27:00 +02:00
Carlos Valente e621f1386e fix(cuesheet): infinite render loop on resizing columns 2025-09-16 06:43:19 +02:00
Carlos Valente b51e7cbd2d fix: maintain multiline in cuesheet cells 2025-09-14 14:19:18 +02:00
Carlos Valente 7cd92ce5f7 docs: add contribution guidelines 2025-09-14 14:19:18 +02:00
Carlos Valente f6a02abf36 fix: skip nested events 2025-09-14 14:19:18 +02:00
Carlos Valente e4b0df42cf feat: allow finding milestones 2025-09-14 14:19:18 +02:00
Carlos Valente ded8bddb2d refactor: improve automated following 2025-09-14 14:19:18 +02:00
Carlos Valente 8d3ce46c56 fix: allow moving a group after another 2025-09-14 14:19:18 +02:00
Carlos Valente 578cb2b244 refactor: improve pin styling 2025-09-14 14:19:18 +02:00
Carlos Valente c79ee193c9 fix: prevent layout reflow on different entry types 2025-09-14 14:19:18 +02:00
Carlos Valente 5810ae0d54 refactor: add default value to secondary source 2025-09-14 14:19:18 +02:00
Carlos Valente bfbf8574e3 fix: phase style overrides in timer 2025-09-14 14:19:18 +02:00
Alex Christoffer Rasmussen 490e429f44 cleanup (#1776)
* chore: cleanup leftover console log

* chore: prevent error in client tsconfig
2025-09-13 16:31:56 +02:00
Alex Christoffer Rasmussen 2e950965e0 fix: sheet default import map (#1775)
* fix: default import map

* fix: update test
2025-09-08 09:49:01 +02:00
arc-alex cc34db775a chore: bump version 2025-09-08 06:51:07 +02:00
384 changed files with 3306 additions and 7391 deletions
+1
View File
@@ -17,6 +17,7 @@
# 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 -2
View File
@@ -1,2 +1 @@
"ONTIME_VERSION.js" "ONTIME_VERSION.js"
dist/
+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"], "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier", "eslint-config-prettier"],
"plugins": ["@typescript-eslint", "prettier"], "plugins": ["@typescript-eslint", "prettier"],
"overrides": [ "overrides": [
{ {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 336 KiB

@@ -2,7 +2,7 @@ name: Ontime build
on: on:
push: push:
tags: ['*'] tags: [ "*" ]
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@@ -11,20 +11,15 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v3
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with: with:
path: ${{ env.PNPM_STORE_PATH }} version: 10
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -35,7 +30,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 }}
@@ -58,9 +53,15 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with:
version: 10
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -85,9 +86,15 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with:
version: 10
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -103,9 +110,6 @@ jobs:
- name: Release - name: Release
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v1
with: with:
files: | files: './apps/electron/dist/ontime-linux.AppImage'
./apps/electron/dist/ontime-linux-x86_64.AppImage
./apps/electron/dist/ontime-linux-arm64.AppImage
./apps/electron/dist/ontime-linux-armv7l.AppImage
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+12 -20
View File
@@ -14,24 +14,19 @@ 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 - name: Set up QEMU
- uses: actions/setup-node@v6 uses: docker/setup-qemu-action@v3
- name: Use Node.js
uses: actions/setup-node@v4
with: with:
node-version-file: '.nvmrc' node-version: 22
registry-url: 'https://registry.npmjs.org'
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v3
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with: with:
path: ${{ env.PNPM_STORE_PATH }} version: 10
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -43,19 +38,16 @@ 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
+29 -49
View File
@@ -6,63 +6,43 @@ 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: Setup pnpm - name: Docker Login
uses: pnpm/action-setup@v4 uses: docker/login-action@v2.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get pnpm store directory - name: Docker Setup Buildx
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV uses: docker/setup-buildx-action@v2.5.0
- name: Setup pnpm cache - name: Build and push stable release
uses: actions/cache@v4 if: github.event.release.prerelease == false
with: uses: docker/build-push-action@v4.0.0
path: ${{ env.PNPM_STORE_PATH }} with:
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }} context: .
restore-keys: | file: ./Dockerfile
${{ runner.os }}-pnpm-store- 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: Install dependencies - name: Build and push pre-release
run: pnpm install --frozen-lockfile if: github.event.release.prerelease == true
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
- name: 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
-48
View File
@@ -1,48 +0,0 @@
name: Ontime Resolver build
on:
workflow_dispatch:
jobs:
build_resolver:
permissions:
id-token: write
runs-on: ubuntu-latest
steps:
- 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
uses: pnpm/action-setup@v4
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
run: pnpm build:resolver
# 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
run: pnpm publish --access public --no-git-checks
env:
NPM_CONFIG_PROVENANCE: true
working-directory: ./apps/resolver
+46 -41
View File
@@ -14,35 +14,55 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v3
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with: with:
path: ${{ env.PNPM_STORE_PATH }} version: 10
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
# Run code quality # Run code quality per package
- name: Run linter - name: React - Run linter + TypeScript checks
run: pnpm lint if: always()
run: pnpm lint && tsc --noEmit
working-directory: ./apps/client
- name: Run TypeScript checks - name: Server - Run linter + TypeScript checks
run: pnpm typecheck if: always()
run: pnpm lint && tsc --noEmit
working-directory: ./apps/server
- name: Utils - Run linter + TypeScript checks
if: always()
run: pnpm lint && tsc --noEmit
working-directory: ./packages/utils
- name: Types - Run linter
if: always()
run: pnpm lint
working-directory: ./packages/types
# We choose to run tests separately # We choose to run tests separately
- name: Run unit tests - name: React - Run unit tests
if: always() if: always()
run: pnpm test run: pnpm test:pipeline
working-directory: ./apps/client
- name: Server - Run unit tests
if: always()
run: pnpm test:pipeline
working-directory: ./apps/server
- name: Utils - Run unit tests
if: always()
run: pnpm test:pipeline
working-directory: ./packages/utils
e2e-test: e2e-test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -50,36 +70,21 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v3
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with: with:
path: ${{ env.PNPM_STORE_PATH }} version: 10
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: Get installed Playwright version - name: Build client
run: echo "PLAYWRIGHT_VERSION=$(pnpm ls @playwright/test --parseable | cut -s -d '@' -f3 | cut -d '/' -f1)" >> $GITHUB_ENV run: pnpm build:local
- name: Cache playwright binaries
uses: actions/cache@v4
with:
path: |
~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
restore-keys: |
${{ runner.os }}-playwright-
- run: npx playwright install --with-deps
if: steps.playwright-cache.outputs.cache-hit != 'true'
- name: Install Playwright Browsers - name: Install Playwright Browsers
run: npx playwright install --with-deps run: npx playwright install --with-deps
-1
View File
@@ -40,7 +40,6 @@ override.css
# working stuff # working stuff
**/TODO.md **/TODO.md
**.local.**
# docker utils # docker utils
ontime-db ontime-db
+2 -6
View File
@@ -5,10 +5,6 @@ node_modules
playwright-report playwright-report
pnpm-lock.yaml
**/*.toml **/*.toml
**/*.json **/*.yml
!tsconfig.common.json **/*.json
!turbo.json
!package.json
+11 -9
View File
@@ -21,8 +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 turbo dev`
- __Run dev mode__ by running `pnpm dev` or `pnpm dev:electron` to get the electron window
### Debugging backend ### Debugging backend
@@ -31,10 +30,10 @@ The previous command will start the development servers for both the client, ser
Typically in dev mode we prefer to start these in separate terminals to help with error tracking and debugging. Typically in dev mode we prefer to start these in separate terminals to help with error tracking and debugging.
We do that by creating two terminals an running We do that by creating two terminals an running
- __Run the React UI__ by running `pnpm dev --filter=ontime-ui` - __Run the React UI__ by running `pnpm turbo dev --filter=ontime-ui`
- __Run the nodejs server__ by running `pnpm dev --filter=ontime-server` - __Run the nodejs server__ by running `pnpm turbo dev --filter=ontime-server`
- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by running `pnpm dev:inspect --filter=ontime-server`. - If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by running `pnpm turbo dev:inspect --filter=ontime-server`.
## TESTING ## TESTING
@@ -47,7 +46,7 @@ Generally we have 2 types of tests.
Unit tests are contained in mostly all the apps and packages (client, server and utils) Unit tests are contained in mostly all the apps and packages (client, server and utils)
You can run unit tests by running `pnpm test:pipeline` from the project root. You can run unit tests by running `pnpm turbo test:pipeline` from the project root.
This will run all tests and close test runner. This will run all tests and close test runner.
Alternatively you can navigate to an app or project and run `pnpm test` to run those tests in watch mode Alternatively you can navigate to an app or project and run `pnpm test` to run those tests in watch mode
@@ -67,7 +66,7 @@ start the webserver with `pnpm dev:server`
Some other useful commands Some other useful commands
- `pnpm e2e:ui` open playwright UI - `pnpm e2e --ui` open playwright UI
- `pnpm e2e --headed` run tests with a visible browser window - `pnpm e2e --headed` run tests with a visible browser window
## CREATE AN INSTALLABLE FILE (Windows | MacOS | Linux) ## CREATE AN INSTALLABLE FILE (Windows | MacOS | Linux)
@@ -78,11 +77,14 @@ You can generate a distribution for your OS by running the following steps.
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`
- __Build the UI and server__ by running `pnpm build` - __Build the UI and server__ by running `pnpm turbo run build:electron`
- __Create the package__ by running `pnpm dist-win`, `pnpm dist-mac` or `pnpm dist-linux` - __Create the package__ by running `pnpm turbo run dist-win`, `pnpm turbo run dist-mac` or `pnpm turbo run dist-linux`
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 turbo run 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.
+37 -30
View File
@@ -1,30 +1,37 @@
ARG NODE_VERSION=22.15.1 FROM node:22-bullseye AS builder
FROM node:${NODE_VERSION}-alpine ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
# Set environment variables RUN npm install -g pnpm@10.11.0
# Environment Variable to signal that we are running production COPY . /app
ENV NODE_ENV=docker WORKDIR /app
# Ontime Data path RUN pnpm --filter=ontime-ui --filter=ontime-server --filter=ontime-utils install --config.dedupe-peer-dependents=false --frozen-lockfile
ENV ONTIME_DATA=/data/ RUN pnpm --filter=ontime-ui --filter=ontime-server run build:docker
RUN mkdir /app FROM node:22-alpine
WORKDIR /app/
# Set environment variables
# Prepare UI # Environment Variable to signal that we are running production
COPY apps/client/build/ ./client/ ENV NODE_ENV=docker
# Ontime Data path
# Prepare Backend ENV ONTIME_DATA=/data/
COPY apps/server/dist/ ./server/
COPY apps/server/src/external/ ./external/ WORKDIR /app/
COPY apps/server/src/user/ ./user/
COPY apps/server/src/html/ ./html/ # Prepare UI
COPY --from=builder /app/apps/client/build ./client/
# Export default ports
EXPOSE 4001/tcp 8888/udp 9999/udp # Prepare Backend
COPY --from=builder /app/apps/server/dist/ ./server/
CMD ["node", "server/docker.cjs"] COPY --from=builder /app/apps/server/src/external/ ./external/
COPY --from=builder /app/apps/server/src/user/ ./user/
# Build and run commands COPY --from=builder /app/apps/server/src/html/ ./html/
# pnpm build:docker
# docker buildx build . -t getontime/ontime # Export default ports
# docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/data/ getontime/ontime EXPOSE 4001/tcp 8888/udp 9999/udp
CMD ["node", "server/docker.cjs"]
# Build and run commands
# !!! Note that this command needs pre-build versions of the UI and server apps
# docker buildx build . -t getontime/ontime
# docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/data/ getontime/ontime
+2
View File
@@ -26,6 +26,8 @@ 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.3.1", "version": "4.0.0-beta.3",
"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",
+6
View File
@@ -13,6 +13,12 @@
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<meta name="robots" content="noindex" /> <meta name="robots" content="noindex" />
<title>ontime</title> <title>ontime</title>
<style>
body,
html {
background-color: #101010 !important;
}
</style>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
+16 -13
View File
@@ -1,47 +1,51 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "4.3.1", "version": "4.0.0-beta.3",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@base-ui/react": "1.0.0", "@base-ui-components/react": "1.0.0-beta.3",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@fontsource/open-sans": "^5.2.6", "@fontsource/open-sans": "^5.2.6",
"@mantine/hooks": "^8.3.7", "@mantine/hooks": "^8.2.8",
"@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",
"@tanstack/react-query-devtools": "^5.85.9", "@tanstack/react-query-devtools": "^5.85.9",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"axios": "^1.12.2", "axios": "^1.11.0",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"csv-stringify": "^6.6.0", "csv-stringify": "^6.6.0",
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
"react": "^19.2.3", "react": "^19.1.1",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-dom": "^19.2.3", "react-dom": "^19.1.1",
"react-fast-compare": "^3.2.2", "react-fast-compare": "^3.2.2",
"react-hook-form": "^7.62.0", "react-hook-form": "^7.62.0",
"react-icons": "5.5.0", "react-icons": "5.5.0",
"react-qr-code": "^2.0.18", "react-qr-code": "^2.0.18",
"react-router": "^7.11.0", "react-router": "^7.8.2",
"react-simple-code-editor": "^0.14.1", "react-simple-code-editor": "^0.14.1",
"react-virtuoso": "^4.17.0", "react-virtuoso": "^4.14.0",
"web-vitals": "^5.1.0", "web-vitals": "^5.1.0",
"zustand": "^5.0.9" "zustand": "^5.0.8"
}, },
"scripts": { "scripts": {
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js", "addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
"postinstall": "pnpm addversion", "postinstall": "pnpm addversion",
"dev": "cross-env BROWSER=none vite", "dev": "cross-env BROWSER=none vite",
"dev:electron": "pnpm dev",
"lint": "eslint . --quiet",
"typecheck": "tsc --noEmit",
"build": "vite build", "build": "vite build",
"build:local": "cross-env NODE_ENV=local vite build",
"build:electron": "cross-env NODE_ENV=local vite build",
"build:docker": "cross-env VITE_IS_DOCKER=true vite build",
"build:localdocker": "cross-env NODE_ENV=local vite build",
"lint": "eslint . --quiet",
"test": "vitest", "test": "vitest",
"test:pipeline": "vitest run", "test:pipeline": "vitest run",
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build",
"analyse": "npx vite-bundle-visualizer" "analyse": "npx vite-bundle-visualizer"
}, },
"browserslist": { "browserslist": {
@@ -65,7 +69,6 @@
"@typescript-eslint/eslint-plugin": "catalog:", "@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:", "@typescript-eslint/parser": "catalog:",
"@vitejs/plugin-react": "4.5.1", "@vitejs/plugin-react": "4.5.1",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "catalog:", "eslint": "catalog:",
"eslint-config-prettier": "catalog:", "eslint-config-prettier": "catalog:",
"eslint-plugin-jest": "^28.6.0", "eslint-plugin-jest": "^28.6.0",
+1 -3
View File
@@ -1,5 +1,5 @@
import { BrowserRouter } from 'react-router'; import { BrowserRouter } from 'react-router';
import { Tooltip } from '@base-ui/react/tooltip'; import { Tooltip } from '@base-ui-components/react/tooltip';
import { QueryClientProvider } from '@tanstack/react-query'; import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
@@ -8,7 +8,6 @@ import IdentifyOverlay from './common/components/identify-overlay/IdentifyOverla
import { AppContextProvider } from './common/context/AppContext'; import { AppContextProvider } from './common/context/AppContext';
import { ontimeQueryClient } from './common/queryClient'; import { ontimeQueryClient } from './common/queryClient';
import { connectSocket } from './common/utils/socket'; import { connectSocket } from './common/utils/socket';
import KeepAwake from './features/keep-awake/KeepAwake';
import { TranslationProvider } from './translation/TranslationProvider'; import { TranslationProvider } from './translation/TranslationProvider';
import AppRouter from './AppRouter'; import AppRouter from './AppRouter';
import { baseURI } from './externals'; import { baseURI } from './externals';
@@ -25,7 +24,6 @@ function App() {
<ErrorBoundary> <ErrorBoundary>
<TranslationProvider> <TranslationProvider>
<IdentifyOverlay /> <IdentifyOverlay />
<KeepAwake />
<AppRouter /> <AppRouter />
</TranslationProvider> </TranslationProvider>
</ErrorBoundary> </ErrorBoundary>
+3 -4
View File
@@ -102,7 +102,6 @@ export default function AppRouter() {
path='op' path='op'
element={ element={
<ViewLoader> <ViewLoader>
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
<Operator /> <Operator />
</ViewLoader> </ViewLoader>
} }
@@ -208,13 +207,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}>
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSettings /> {preset.target !== OntimeView.Cuesheet && (
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSettings />
)}
{Component ? <Component /> : <NotFound />} {Component ? <Component /> : <NotFound />}
</PresetContext> </PresetContext>
); );
+24
View File
@@ -1,6 +1,9 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types'; import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
import { makeTable } from '../../views/cuesheet/cuesheet.utils';
import { aggregateRundowns, makeCSVFromArrayOfArrays } from '../utils/csv';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import { createBlob, downloadBlob } from './utils'; import { createBlob, downloadBlob } from './utils';
@@ -30,6 +33,27 @@ export async function downloadProject(fileName: string) {
} }
} }
/**
* Request download of the current rundown as a CSV file
* @param fileName
*/
export async function downloadCSV(fileName: string = 'rundown') {
try {
const { data, name } = await fileDownload(fileName);
const { project, rundowns, customFields } = data;
const flatRundowns = aggregateRundowns(rundowns);
const sheetData = makeTable(project, flatRundowns, customFields);
const fileContent = makeCSVFromArrayOfArrays(sheetData);
const blob = createBlob(fileContent, 'text/csv;charset=utf-8;');
downloadBlob(blob, `${name}.csv`);
} catch (error) {
console.error(error);
}
}
/** /**
* HTTP request to upload project file * HTTP request to upload project file
*/ */
+11 -20
View File
@@ -1,9 +1,8 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { CustomFields, Rundown, RundownSummary } from 'ontime-types'; import { CustomFields, Rundown } 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`;
@@ -11,21 +10,28 @@ 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): Promise<string[]> { export async function upload(file: File) {
const formData = new FormData(); const formData = new FormData();
formData.append('excel', file); formData.append('excel', file);
const response = await axios.post(`${excelPath}/upload`, formData, { 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`, {
@@ -33,18 +39,3 @@ 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);
}
}
+16 -58
View File
@@ -3,7 +3,6 @@ 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 =========================
@@ -21,30 +20,14 @@ export async function fetchProjectRundownList(): Promise<ProjectRundownsList> {
*/ */
export async function fetchCurrentRundown(): Promise<Rundown> { export async function fetchCurrentRundown(): Promise<Rundown> {
const res = await axios.get(`${rundownPath}/current`); const res = await axios.get(`${rundownPath}/current`);
if (!isValidRundown(res.data)) {
throw new Error('Invalid rundown payload');
}
return res.data; return res.data;
function isValidRundown(x: any): x is Rundown {
return (
x &&
typeof x === 'object' &&
typeof x.id === 'string' &&
Array.isArray(x.order) &&
Array.isArray(x.flatOrder) &&
x.entries &&
typeof x.entries === 'object' &&
typeof x.revision === 'number'
);
}
} }
/** /**
* HTTP request to switch the currently loaded rundown * HTTP request to switch the currently loaded rundown
*/ */
export async function loadRundown(rundownId: RundownId): Promise<AxiosResponse<ProjectRundownsList>> { export async function loadRundown(id: string): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(`${rundownPath}/${rundownId}/load`); return axios.post(`${rundownPath}/${id}/load`);
} }
/** /**
@@ -54,25 +37,11 @@ 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(rundownId: RundownId): Promise<AxiosResponse<ProjectRundownsList>> { export async function deleteRundown(id: string): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.delete(`${rundownPath}/${rundownId}`); return axios.delete(`${rundownPath}/${id}`);
} }
// #endregion operations on project rundowns ====================== // #endregion operations on project rundowns ======================
@@ -82,7 +51,7 @@ export async function deleteRundown(rundownId: RundownId): Promise<AxiosResponse
* HTTP request to post new entry * HTTP request to post new entry
*/ */
export async function postAddEntry( export async function postAddEntry(
rundownId: RundownId, rundownId: string,
data: TransientEventPayload, data: TransientEventPayload,
): Promise<AxiosResponse<OntimeEntry>> { ): Promise<AxiosResponse<OntimeEntry>> {
return axios.post(`${rundownPath}/${rundownId}/entry`, data); return axios.post(`${rundownPath}/${rundownId}/entry`, data);
@@ -91,10 +60,7 @@ export async function postAddEntry(
/** /**
* HTTP request to edit an entry * HTTP request to edit an entry
*/ */
export async function putEditEntry( export async function putEditEntry(rundownId: string, data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> {
rundownId: RundownId,
data: Partial<OntimeEntry>,
): Promise<AxiosResponse<OntimeEntry>> {
return axios.put(`${rundownPath}/${rundownId}/entry`, data); return axios.put(`${rundownPath}/${rundownId}/entry`, data);
} }
@@ -106,7 +72,7 @@ export type BatchEditEntry = {
/** /**
* HTTP request to edit multiple events * HTTP request to edit multiple events
*/ */
export async function putBatchEditEvents(rundownId: RundownId, data: BatchEditEntry): Promise<AxiosResponse<Rundown>> { export async function putBatchEditEvents(rundownId: string, data: BatchEditEntry): Promise<AxiosResponse<Rundown>> {
return axios.put(`${rundownPath}/${rundownId}/batch`, data); return axios.put(`${rundownPath}/${rundownId}/batch`, data);
} }
@@ -119,64 +85,56 @@ export type ReorderEntry = {
/** /**
* HTTP request to reorder an entry * HTTP request to reorder an entry
*/ */
export async function patchReorderEntry(rundownId: RundownId, data: ReorderEntry): Promise<AxiosResponse<Rundown>> { export async function patchReorderEntry(rundownId: string, 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( export async function requestEventSwap(rundownId: string, from: EntryId, to: EntryId): Promise<AxiosResponse<Rundown>> {
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: RundownId, delayId: EntryId): Promise<AxiosResponse<Rundown>> { export async function requestApplyDelay(rundownId: string, 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( export async function postCloneEntry(rundownId: string, entryId: EntryId): Promise<AxiosResponse<Rundown>> {
rundownId: RundownId, return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`);
entryId: EntryId,
options?: { before?: EntryId; after?: EntryId },
): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`, options);
} }
/** /**
* HTTP request for grouping a list of entries into a group * HTTP request for grouping a list of entries into a group
*/ */
export async function requestGroupEntries(rundownId: RundownId, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> { export async function requestGroupEntries(rundownId: string, 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: RundownId, groupId: EntryId): Promise<AxiosResponse<Rundown>> { export async function requestUngroup(rundownId: string, 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: RundownId, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> { export async function deleteEntries(rundownId: string, 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: RundownId): Promise<AxiosResponse<Rundown>> { export async function requestDeleteAll(rundownId: string): Promise<AxiosResponse<Rundown>> {
return axios.delete(`${rundownPath}/${rundownId}/all`); return axios.delete(`${rundownPath}/${rundownId}/all`);
} }
+1 -2
View File
@@ -1,5 +1,5 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { AuthenticationStatus, CustomFields, Rundown, RundownSummary } from 'ontime-types'; import { AuthenticationStatus, CustomFields, Rundown } from 'ontime-types';
import { ImportMap } from 'ontime-utils'; import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
@@ -56,7 +56,6 @@ 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;
@@ -0,0 +1,140 @@
.subtle {
background: $gray-1050;
color: $blue-400;
line-height: 1em;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $blue-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
}
.subtle-white {
background: $gray-1050;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $ui-white;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
background: $gray-1050;
}
}
.primary {
background: $blue-700;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $blue-600;
}
&:active:not(:disabled) {
background: $blue-800;
border-color: $blue-900;
}
&:disabled {
opacity: $viewer-opacity-disabled;
}
}
.destructive {
background: $red-700;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $red-600;
color: $ui-white;
}
&:active:not(:disabled) {
background: $red-800;
border-color: $red-900;
}
}
.subtle-destructive {
background: $gray-1050;
color: $red-400;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $red-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
}
.ghosted {
background: transparent;
color: $blue-500;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $blue-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
.ghosted-white {
background: transparent;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $ui-white;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
.ghosted-destructive {
background: transparent;
color: $red-500;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $red-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
@@ -1,6 +1,5 @@
@use './buttonVariants' as base; @use '@/theme/viewerDefs' as *;
@import './BaseButtonStyles.module.scss';
@include base.button-variants;
.baseButton { .baseButton {
position: relative; position: relative;
@@ -82,3 +81,4 @@
.fluid { .fluid {
width: 100%; width: 100%;
} }
@@ -1,6 +1,5 @@
@use './buttonVariants' as base; @use '@/theme/viewerDefs' as *;
@import './BaseButtonStyles.module.scss';
@include base.button-variants;
.baseIconButton { .baseIconButton {
aspect-ratio: 1; aspect-ratio: 1;
@@ -1,144 +0,0 @@
@use '@/theme/themeTokens' as *;
@use '@/theme/viewerDefs' as *;
@mixin button-variants {
.subtle {
background: $gray-1050;
color: $blue-400;
line-height: 1em;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $blue-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
}
.subtle-white {
background: $gray-1050;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $ui-white;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
background: $gray-1050;
}
}
.primary {
background: $blue-700;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $blue-600;
}
&:active:not(:disabled) {
background: $blue-800;
border-color: $blue-900;
}
&:disabled {
opacity: $viewer-opacity-disabled;
}
}
.destructive {
background: $red-700;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $red-600;
color: $ui-white;
}
&:active:not(:disabled) {
background: $red-800;
border-color: $red-900;
}
}
.subtle-destructive {
background: $gray-1050;
color: $red-400;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $red-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
}
.ghosted {
background: transparent;
color: $blue-500;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $blue-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
.ghosted-white {
background: transparent;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $ui-white;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
.ghosted-destructive {
background: transparent;
color: $red-500;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $red-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
}
@@ -1,5 +1,5 @@
import { IoCheckmark } from 'react-icons/io5'; import { IoCheckmark } from 'react-icons/io5';
import { Checkbox as BaseCheckbox } from '@base-ui/react/checkbox'; import { Checkbox as BaseCheckbox } from '@base-ui-components/react/checkbox';
import style from './Checkbox.module.scss'; import style from './Checkbox.module.scss';
@@ -5,10 +5,10 @@ import { navigatorConstants } from '../../../viewerConfig';
import { setClientRemote } from '../../hooks/useSocket'; import { setClientRemote } from '../../hooks/useSocket';
import useUrlPresets from '../../hooks-query/useUrlPresets'; import useUrlPresets from '../../hooks-query/useUrlPresets';
import Button from '../buttons/Button'; import Button from '../buttons/Button';
import Dialog from '../dialog/Dialog';
import Info from '../info/Info'; import Info from '../info/Info';
import Input from '../input/input/Input'; import Input from '../input/input/Input';
import AppLink from '../link/app-link/AppLink'; import AppLink from '../link/app-link/AppLink';
import Modal from '../modal/Modal';
import Select from '../select/Select'; import Select from '../select/Select';
import style from './RedirectClientModal.module.scss'; import style from './RedirectClientModal.module.scss';
@@ -57,7 +57,7 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
]; ];
return ( return (
<Dialog <Modal
isOpen={isOpen} isOpen={isOpen}
onClose={onClose} onClose={onClose}
showCloseButton showCloseButton
@@ -98,10 +98,7 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
fluid fluid
options={viewOptions} options={viewOptions}
defaultValue={viewOptions[0].value} defaultValue={viewOptions[0].value}
onValueChange={(value) => { onValueChange={(value) => setSelected(value)}
if (value === null) return;
setSelected(value);
}}
disabled={enabledPresets.length === 0} disabled={enabledPresets.length === 0}
/> />
</label> </label>
@@ -1,7 +1,8 @@
import { PropsWithChildren, useRef, useState } from 'react'; import { PropsWithChildren, useRef, useState } from 'react';
import { IoCheckmark, IoCopy } from 'react-icons/io5'; import { IoCheckmark } from 'react-icons/io5';
import { IoCopy } from 'react-icons/io5';
import { copyToClipboard } from '../../utils/copyToClipboard'; import copyToClipboard from '../../utils/copyToClipboard';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
import Button from '../buttons/Button'; import Button from '../buttons/Button';
import IconButton from '../buttons/IconButton'; import IconButton from '../buttons/IconButton';
@@ -25,19 +26,15 @@ export default function CopyTag({
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null); const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleClick = async () => { const handleClick = () => {
try { copyToClipboard(copyValue);
await copyToClipboard(copyValue); setCopied(true);
setCopied(true);
// reset copied state // reset copied state
if (timeoutRef.current) { if (timeoutRef.current) {
clearTimeout(timeoutRef.current); clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => setCopied(false), 2000);
} catch {
// ignore errors
} }
timeoutRef.current = setTimeout(() => setCopied(false), 2000);
}; };
return ( return (
@@ -6,7 +6,7 @@
transform: translateX(-50%); transform: translateX(-50%);
padding-inline: 1rem; padding-inline: 1rem;
min-width: min(600px, 90vw); min-width: min(420px, 90vw);
background-color: $gray-1250; background-color: $gray-1250;
color: $ui-white; color: $ui-white;
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { IoClose } from 'react-icons/io5'; import { IoClose } from 'react-icons/io5';
import { Dialog as BaseDialog } from '@base-ui/react/dialog'; import { Dialog as BaseDialog } from '@base-ui-components/react/dialog';
import IconButton from '../buttons/IconButton'; import IconButton from '../buttons/IconButton';
@@ -12,7 +12,7 @@ interface DialogProps {
showCloseButton?: boolean; showCloseButton?: boolean;
showBackdrop?: boolean; showBackdrop?: boolean;
bodyElements: ReactNode; bodyElements: ReactNode;
footerElements?: ReactNode; footerElements: ReactNode;
onClose: () => void; onClose: () => void;
} }
@@ -27,6 +27,7 @@
} }
} }
.item { .item {
outline: 0; outline: 0;
cursor: default; cursor: default;
@@ -60,13 +61,6 @@
border-radius: 3px; border-radius: 3px;
background-color: $gray-1000; background-color: $gray-1000;
} }
&[data-type='destructive'] {
color: $red-500;
svg {
color: $red-500;
}
}
} }
.separator { .separator {
@@ -1,12 +1,12 @@
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { IconType } from 'react-icons'; import { IconType } from 'react-icons';
import { Menu as BaseMenu } from '@base-ui/react/menu'; import { Menu as BaseMenu } from '@base-ui-components/react/menu';
import style from './DropdownMenu.module.scss'; import style from './DropdownMenu.module.scss';
type DropdownMenuItemDivider = { type: 'divider' }; type DropdownMenuItemDivider = { type: 'divider' };
type DropdownMenuItem = { type DropdownMenuItem = {
type: 'item' | 'destructive'; type: 'item';
label: string; label: string;
icon?: IconType; icon?: IconType;
disabled?: boolean; disabled?: boolean;
@@ -31,13 +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 <BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled}>
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,7 +1,6 @@
.arrow {
transform: rotate(45deg);
}
.corner { .corner {
transform: rotate(45deg);
position: absolute; position: absolute;
top: 0.5rem; top: 0.5rem;
right: 0.5rem; right: 0.5rem;
@@ -22,10 +21,6 @@
} }
} }
.offsetCorner {
right: 2rem;
}
.header { .header {
font-size: 1.5rem; font-size: 1.5rem;
} }
@@ -56,13 +51,6 @@
&.vertical { &.vertical {
width: 1px; width: 1px;
height: 0.75em; height: 0.75em;
} }
} }
.panel {
position: relative;
border-radius: $panel-border-radius;
background-color: $bg-container-l2;
padding: 1rem;
}
@@ -1,33 +1,13 @@
import type { HTMLAttributes, JSX, LabelHTMLAttributes, MouseEventHandler } from 'react'; import type { HTMLAttributes, LabelHTMLAttributes } 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 CornerExtract({ className, ...elementProps }: IconBaseProps) { export function Corner({ className, ...elementProps }: IconBaseProps) {
return <IoArrowUp className={cx([style.corner, style.arrow, className])} {...elementProps} />; return <IoArrowUp className={cx([style.corner, 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>) {
@@ -55,7 +35,3 @@ interface SeparatorProps extends HTMLAttributes<HTMLDivElement> {
export function Separator({ className, orientation = 'vertical', ...elementProps }: SeparatorProps) { export function Separator({ className, orientation = 'vertical', ...elementProps }: SeparatorProps) {
return <div className={cx([style.separator, style[orientation], className])} role='separator' {...elementProps} />; return <div className={cx([style.separator, style[orientation], className])} role='separator' {...elementProps} />;
} }
export function Panel({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cx([style.panel, className])} {...props} />;
}
@@ -49,14 +49,15 @@ class ErrorBoundary extends React.Component {
return ( return (
<div className={style.errorContainer} data-testid='error-container'> <div className={style.errorContainer} data-testid='error-container'>
<div> <div>
<p className={style.error}>: /</p> <p className={style.error}>:/</p>
<p>Something went wrong.</p> <p>Something went wrong</p>
<a <div
role='button'
className={style.report} className={style.report}
href={`mailto:mail@getontime.no?subject=Error%20Report&body=${encodeURIComponent(this.reportContent)}`} onClick={() => Sentry.showReportDialog({ eventId: this.state.eventId })}
> >
Report error Report error
</a> </div>
<div <div
role='button' role='button'
className={style.report} className={style.report}
@@ -3,21 +3,24 @@
height: 100%; height: 100%;
display: grid; display: grid;
place-content: center; place-content: center;
background-color: $ui-black; background-color: #121212;
color: $ui-white; color: white;
}
.error { .error {
color: $error-red; color: $error-red;
} font-weight: 600;
}
.report { .report {
color: $blue-500; text-decoration: underline $error-red;
text-decoration: underline; cursor: pointer;
text-underline-offset: 2px; }
cursor: pointer;
&:hover { .report:hover {
color: $ontime-color; color: $error-red;
}
.report:active {
color: white;
} }
} }
@@ -1,4 +1,3 @@
import { MouseEvent } from 'react';
import { IoBan } from 'react-icons/io5'; import { IoBan } from 'react-icons/io5';
import { cx } from '../../../utils/styleUtils'; import { cx } from '../../../utils/styleUtils';
@@ -11,13 +10,12 @@ interface SwatchProps {
isSelected?: boolean; isSelected?: boolean;
} }
export default function Swatch({ color, isSelected, onClick }: SwatchProps) { export default function Swatch(props: SwatchProps) {
const handleClick = (event: MouseEvent) => { const { color, isSelected, onClick } = props;
onClick?.(color);
event.preventDefault();
event.stopPropagation();
};
const handleClick = () => {
onClick?.(color);
};
const classes = cx([style.swatch, isSelected && style.selected, onClick && style.selectable]); const classes = cx([style.swatch, isSelected && style.selected, onClick && style.selectable]);
if (!color) { if (!color) {
@@ -1,5 +1,5 @@
import { Radio } from '@base-ui/react/radio'; import { Radio } from '@base-ui-components/react/radio';
import { RadioGroup as BaseRadioGroup } from '@base-ui/react/radio-group'; import { RadioGroup as BaseRadioGroup } from '@base-ui-components/react/radio-group';
import style from './BlockRadio.module.scss'; import style from './BlockRadio.module.scss';
@@ -20,7 +20,7 @@ export default function DelayInput(props: DelayInputProps) {
const [value, setValue] = useState<string>(''); const [value, setValue] = useState<string>('');
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
// avoid wrong submit on cancel // avoid wrong submit on cancel
const ignoreChangeRef = useRef(false); let ignoreChange = false;
// set internal value on duration change // set internal value on duration change
useEffect(() => { useEffect(() => {
@@ -35,8 +35,8 @@ export default function DelayInput(props: DelayInputProps) {
* @param {string} newValue string to be parsed * @param {string} newValue string to be parsed
*/ */
const validateAndSubmit = (newValue: string) => { const validateAndSubmit = (newValue: string) => {
if (ignoreChangeRef.current) { if (ignoreChange) {
ignoreChangeRef.current = false; ignoreChange = false;
return; return;
} }
@@ -78,7 +78,7 @@ export default function DelayInput(props: DelayInputProps) {
} else if (event.key === 'Tab') { } else if (event.key === 'Tab') {
validateAndSubmit((event.target as HTMLInputElement).value); validateAndSubmit((event.target as HTMLInputElement).value);
} else if (event.key === 'Escape') { } else if (event.key === 'Escape') {
ignoreChangeRef.current = true; ignoreChange = true;
setValue(millisToString(duration)); setValue(millisToString(duration));
inputRef.current?.blur(); inputRef.current?.blur();
} }
@@ -11,7 +11,7 @@
outline: none; outline: none;
&::placeholder { &::placeholder {
color: $gray-600; color: $gray-500;
letter-spacing: 0; letter-spacing: 0;
} }
@@ -1,6 +1,6 @@
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { HexAlphaColorPicker, HexColorInput } from 'react-colorful'; import { HexAlphaColorPicker, HexColorInput } from 'react-colorful';
import { Popover } from '@base-ui/react/popover'; import { Popover } from '@base-ui-components/react/popover';
import PopoverContents from '../../popover/Popover'; import PopoverContents from '../../popover/Popover';
@@ -11,7 +11,8 @@ interface AppLinkProps {
* Component used to navigate to an editor link inside the same window * Component used to navigate to an editor link inside the same window
* Handles the path to respect Ontime Clouds base URL * Handles the path to respect Ontime Clouds base URL
*/ */
export default function AppLink({ search, children }: PropsWithChildren<AppLinkProps>) { export default function AppLink(props: PropsWithChildren<AppLinkProps>) {
const { search, children } = props;
const navigate = useNavigate(); const navigate = useNavigate();
const handleClick = () => navigate({ search }); const handleClick = () => navigate({ search });
@@ -12,7 +12,9 @@ interface ExternalLinkProps {
inline?: boolean; inline?: boolean;
} }
export default function ExternalLink({ href, inline, children }: ExternalLinkProps) { export default function ExternalLink(props: ExternalLinkProps) {
const { href, inline, children } = props;
const handleClick = (event: MouseEvent) => { const handleClick = (event: MouseEvent) => {
event.preventDefault(); event.preventDefault();
openLink(href); openLink(href);
@@ -5,9 +5,9 @@
transform: translateX(-50%); transform: translateX(-50%);
padding-inline: 1rem; padding-inline: 1rem;
min-width: min(880px, 90vw); min-width: min(680px, 90vw);
min-height: min(200px, 10vh); min-height: min(200px, 10vh);
max-width: min(1200px, 90vw); max-width: min(900px, 90vw);
background-color: $gray-1250; background-color: $gray-1250;
color: $ui-white; color: $ui-white;
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { IoClose } from 'react-icons/io5'; import { IoClose } from 'react-icons/io5';
import { Dialog as BaseDialog } from '@base-ui/react/dialog'; import { Dialog as BaseDialog } from '@base-ui-components/react/dialog';
import IconButton from '../buttons/IconButton'; import IconButton from '../buttons/IconButton';
@@ -31,7 +31,7 @@ export default function Modal({
onOpenChange={(isOpen) => { onOpenChange={(isOpen) => {
if (!isOpen) onClose(); if (!isOpen) onClose();
}} }}
disablePointerDismissal dismissible={false}
> >
<BaseDialog.Portal> <BaseDialog.Portal>
{showBackdrop && <BaseDialog.Backdrop className={style.backdrop} />} {showBackdrop && <BaseDialog.Backdrop className={style.backdrop} />}
@@ -1,14 +1,11 @@
import { memo } from 'react'; import { memo } from 'react';
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5'; import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
import { LuCoffee } from 'react-icons/lu';
import { useLocation } from 'react-router'; import { useLocation } from 'react-router';
import { Dialog } from '@base-ui/react/dialog'; import { Dialog } from '@base-ui-components/react/dialog';
import { useDisclosure, useFullscreen } from '@mantine/hooks'; import { useDisclosure, useFullscreen } from '@mantine/hooks';
import { isLocalhost, supportsFullscreen } from '../../../externals'; import { isLocalhost } from '../../../externals';
import { canUseWakeLock, useKeepAwakeOptions } from '../../../features/keep-awake/useWakeLock';
import { navigatorConstants } from '../../../viewerConfig'; import { navigatorConstants } from '../../../viewerConfig';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import { useClientStore } from '../../stores/clientStore'; import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions'; import { useViewOptionsStore } from '../../stores/viewOptions';
import IconButton from '../buttons/IconButton'; import IconButton from '../buttons/IconButton';
@@ -30,12 +27,10 @@ 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();
const { mirror, toggleMirror } = useViewOptionsStore(); const { mirror, toggleMirror } = useViewOptionsStore();
const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions();
const location = useLocation(); const location = useLocation();
return ( return (
@@ -58,38 +53,25 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
</IconButton> </IconButton>
</div> </div>
<div className={style.body}> <div className={style.body}>
{supportsFullscreen && ( <NavigationMenuItem active={fullscreen} onClick={toggle}>
<NavigationMenuItem active={fullscreen} onClick={toggle}> Toggle Fullscreen
Toggle Fullscreen {fullscreen ? <IoContract /> : <IoExpand />}
{fullscreen ? <IoContract /> : <IoExpand />} </NavigationMenuItem>
</NavigationMenuItem>
)}
<NavigationMenuItem active={mirror} onClick={() => toggleMirror()}> <NavigationMenuItem active={mirror} onClick={() => toggleMirror()}>
Flip Screen Flip Screen
<IoSwapVertical /> <IoSwapVertical />
{mirror && <span className={style.note}>Active</span>} {mirror && <span className={style.note}>Active</span>}
</NavigationMenuItem> </NavigationMenuItem>
{canUseWakeLock && (
<NavigationMenuItem active={keepAwake} onClick={toggleKeepAwake}>
Keep Awake
<LuCoffee />
{keepAwake && <span className={style.note}>Active</span>}
</NavigationMenuItem>
)}
<NavigationMenuItem onClick={handlers.open}>Rename Client</NavigationMenuItem> <NavigationMenuItem onClick={handlers.open}>Rename Client</NavigationMenuItem>
<hr className={style.separator} /> <hr className={style.separator} />
<EditorNavigation /> <EditorNavigation />
<ClientLink <ClientLink to='cuesheet' current={location.pathname === '/cuesheet'}>
to='cuesheet'
current={location.pathname === '/cuesheet'}
postAction={isSmallScreen ? onClose : undefined}
>
<IoLockClosedOutline /> <IoLockClosedOutline />
Cuesheet Cuesheet
</ClientLink> </ClientLink>
<ClientLink to='op' current={location.pathname === '/op'} postAction={isSmallScreen ? onClose : undefined}> <ClientLink to='op' current={location.pathname === '/op'}>
<IoLockClosedOutline /> <IoLockClosedOutline />
Operator Operator
</ClientLink> </ClientLink>
@@ -97,12 +79,7 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
<hr className={style.separator} /> <hr className={style.separator} />
{navigatorConstants.map((route) => ( {navigatorConstants.map((route) => (
<ClientLink <ClientLink key={route.url} to={route.url} current={location.pathname === `/${route.url}`}>
key={route.url}
to={route.url}
current={location.pathname === `/${route.url}`}
postAction={isSmallScreen ? onClose : undefined}
>
{route.label} {route.label}
</ClientLink> </ClientLink>
))} ))}
@@ -11,22 +11,15 @@ 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, postAction, children }: PropsWithChildren<ClientLinkProps>) { export default function ClientLink({ current, to, children }: PropsWithChildren<ClientLinkProps>) {
const { isElectron } = useElectronEvent(); const { isElectron } = useElectronEvent();
const navigate = useNavigate(); const navigate = useNavigate();
if (isElectron) { if (isElectron) {
return ( return (
<NavigationMenuItem <NavigationMenuItem active={current} onClick={() => handleLinks(to)}>
active={current}
onClick={() => {
handleLinks(to);
postAction?.();
}}
>
{children} {children}
<IoArrowUp className={style.linkIcon} /> <IoArrowUp className={style.linkIcon} />
</NavigationMenuItem> </NavigationMenuItem>
@@ -34,13 +27,7 @@ export default function ClientLink({ current, to, postAction, children }: PropsW
} }
return ( return (
<NavigationMenuItem <NavigationMenuItem active={current} onClick={() => navigate(`/${to}`)}>
active={current}
onClick={() => {
navigate(`/${to}`);
postAction?.();
}}
>
{children} {children}
</NavigationMenuItem> </NavigationMenuItem>
); );
@@ -1,4 +1,5 @@
import { IoApps, IoSettingsOutline } from 'react-icons/io5'; import { IoApps } from 'react-icons/io5';
import { IoSettingsOutline } from 'react-icons/io5';
import { useFadeOutOnInactivity } from '../../../hooks/useFadeOutOnInactivity'; import { useFadeOutOnInactivity } from '../../../hooks/useFadeOutOnInactivity';
import { cx } from '../../../utils/styleUtils'; import { cx } from '../../../utils/styleUtils';
@@ -1,5 +1,5 @@
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { Popover } from '@base-ui/react/popover'; import { Popover } from '@base-ui-components/react/popover';
import style from './Popover.module.scss'; import style from './Popover.module.scss';
@@ -1,5 +1,5 @@
import { Radio } from '@base-ui/react/radio'; import { Radio } from '@base-ui-components/react/radio';
import { RadioGroup as BaseRadioGroup } from '@base-ui/react/radio-group'; import { RadioGroup as BaseRadioGroup } from '@base-ui-components/react/radio-group';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
@@ -62,6 +62,8 @@
padding: 2px; padding: 2px;
border-radius: $component-border-radius-md; border-radius: $component-border-radius-md;
color: $ui-white; color: $ui-white;
overflow-y: auto;
max-height: 20rem;
border: 1px solid $gray-1000; border: 1px solid $gray-1000;
&[data-side='start'] { &[data-side='start'] {
@@ -71,16 +73,6 @@
} }
} }
.list {
box-sizing: border-box;
position: relative;
padding-block: 0.25rem;
overflow-y: auto;
max-height: 20rem;
max-height: var(--available-height);
scroll-padding-block: 1.5rem;
}
.item { .item {
box-sizing: border-box; box-sizing: border-box;
outline: 0; outline: 0;
@@ -1,6 +1,6 @@
import { IoCheckmark } from 'react-icons/io5'; import { IoCheckmark } from 'react-icons/io5';
import { LuChevronsUpDown } from 'react-icons/lu'; import { LuChevronsUpDown } from 'react-icons/lu';
import { Select as BaseSelect } from '@base-ui/react/select'; import { Select as BaseSelect } from '@base-ui-components/react/select';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
@@ -31,17 +31,14 @@ export default function Select<T>({ options, fluid, size = 'medium', ...selectRo
<BaseSelect.Positioner side='bottom' align='start'> <BaseSelect.Positioner side='bottom' align='start'>
<BaseSelect.ScrollUpArrow className={styles.scrollArrow} /> <BaseSelect.ScrollUpArrow className={styles.scrollArrow} />
<BaseSelect.Popup className={styles.popup}> <BaseSelect.Popup className={styles.popup}>
<BaseSelect.Arrow /> {options.map(({ disabled, label, value }) => (
<BaseSelect.List className={styles.list}> <BaseSelect.Item key={String(value)} className={styles.item} value={value} disabled={disabled}>
{options.map(({ disabled, label, value }) => ( <BaseSelect.ItemIndicator className={styles.itemIndicator}>
<BaseSelect.Item key={String(value)} className={styles.item} value={value} disabled={disabled}> <IoCheckmark className={styles.itemIndicatorIcon} />
<BaseSelect.ItemIndicator className={styles.itemIndicator}> </BaseSelect.ItemIndicator>
<IoCheckmark className={styles.itemIndicatorIcon} /> <BaseSelect.ItemText className={styles.itemLabel}>{label}</BaseSelect.ItemText>
</BaseSelect.ItemIndicator> </BaseSelect.Item>
<BaseSelect.ItemText className={styles.itemLabel}>{label}</BaseSelect.ItemText> ))}
</BaseSelect.Item>
))}
</BaseSelect.List>
</BaseSelect.Popup> </BaseSelect.Popup>
<BaseSelect.ScrollDownArrow className={styles.scrollArrow} /> <BaseSelect.ScrollDownArrow className={styles.scrollArrow} />
</BaseSelect.Positioner> </BaseSelect.Positioner>
@@ -1,4 +1,4 @@
import { Switch as BaseSwitch } from '@base-ui/react/switch'; import { Switch as BaseSwitch } from '@base-ui-components/react/switch';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
@@ -1,5 +1,5 @@
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { Tooltip as BaseTooltip } from '@base-ui/react/tooltip'; import { Tooltip as BaseTooltip } from '@base-ui-components/react/tooltip';
import style from './Tooltip.module.scss'; import style from './Tooltip.module.scss';
@@ -1,5 +1,3 @@
import { useRef } from 'react';
import { projectLogoPath } from '../../api/constants'; import { projectLogoPath } from '../../api/constants';
import './ViewLogo.scss'; import './ViewLogo.scss';
@@ -9,19 +7,13 @@ interface ViewLogoProps {
className: string; className: string;
} }
export default function ViewLogo({ name, className }: ViewLogoProps) { export default function ViewLogo(props: ViewLogoProps) {
const imageRef = useRef<HTMLImageElement>(null); const { name, className } = props;
const hideImage = () => {
if (!imageRef.current) return;
imageRef.current.style.display = 'none';
};
// we wrap the image in a div to help maintain the aspect ratio // we wrap the image in a div to help maintain the aspect ratio
return ( return (
<div className={className}> <div className={className}>
<img ref={imageRef} alt='' src={`${projectLogoPath}/${name}`} className='viewLogo' onError={hideImage} /> <img alt='' src={`${projectLogoPath}/${name}`} className='viewLogo' />
</div> </div>
); );
} }
@@ -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, $gray-900); background: var(--user-bg);
border-radius: 50%; border-radius: 50%;
} }
} }
.empty { .empty {
color: $ui-white; color: $ui-white;
} }
@@ -1,7 +1,7 @@
import { ComponentProps, useEffect, useState } from 'react'; import { ComponentProps, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { isStringBoolean } from '../../../views/common/viewUtils'; import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
import Checkbox from '../checkbox/Checkbox'; import Checkbox from '../checkbox/Checkbox';
import Input from '../input/input/Input'; import Input from '../input/input/Input';
import Select, { SelectOption } from '../select/Select'; import Select, { SelectOption } from '../select/Select';
@@ -160,16 +160,7 @@ function ControlledSelect({ id, initialValue, options }: ControlledSelectProps)
}, [initialValue]); }, [initialValue]);
return ( return (
<Select <Select size='large' name={id} options={options} value={selected} onValueChange={(value) => setSelected(value)} />
size='large'
name={id}
options={options}
value={selected}
onValueChange={(value) => {
if (value === null) return;
setSelected(value);
}}
/>
); );
} }
@@ -28,9 +28,7 @@
bottom: 0; bottom: 0;
width: 40rem; width: 40rem;
max-width: 100vw;
height: 100vh; height: 100vh;
height: 100dvh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1,10 +1,9 @@
import { FormEvent, memo } from 'react'; import { FormEvent, memo } from 'react';
import { IoClose } from 'react-icons/io5'; import { IoClose } from 'react-icons/io5';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { Dialog } from '@base-ui/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';
@@ -28,7 +27,6 @@ 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();
@@ -44,10 +42,6 @@ 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, colour: value.colour || 'transparent',
}); });
} }
@@ -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 { ProjectFile, ProjectFileList, ProjectFileListResponse } from 'ontime-types'; import { 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,33 +24,22 @@ function useProjectList() {
return { data: data ?? placeholderProjectList, status, refetch }; return { data: data ?? placeholderProjectList, status, refetch };
} }
export type ProjectSortMode = 'alphabetical-asc' | 'alphabetical-desc' | 'modified-asc' | 'modified-desc'; export function useOrderedProjectList() {
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: ProjectFileList = useMemo(() => { const reorderedProjectFiles = useMemo(() => {
if (!files.length) return []; if (!files.length) return [];
const sorted = [...files].sort(sortComparators[sort]); const currentlyLoadedIndex = files.findIndex((project) => project.filename === lastLoadedProject);
// keep loaded always on top if (currentlyLoadedIndex === -1) return files;
const currentlyLoadedIndex = sorted.findIndex((project) => project.filename === lastLoadedProject);
if (currentlyLoadedIndex > 0) {
const [loaded] = sorted.splice(currentlyLoadedIndex, 1);
sorted.unshift(loaded);
}
return sorted; const projectFiles = [...files];
}, [files, lastLoadedProject, sort]); const current = projectFiles.splice(currentlyLoadedIndex, 1)[0];
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, duplicateRundown, fetchProjectRundownList, loadRundown, renameRundown } from '../api/rundown'; import { createRundown, deleteRundown, fetchProjectRundownList, loadRundown } from '../api/rundown';
/** /**
* Project rundowns * Project rundowns
@@ -30,26 +30,6 @@ 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,
@@ -71,5 +51,5 @@ export function useMutateProjectRundowns() {
}, },
}); });
return { create, duplicate, remove, load, rename }; return { create, remove, load };
} }
@@ -1,4 +1,4 @@
import { useMemo } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { EntryId, OntimeEntry, Rundown } from 'ontime-types'; import { EntryId, OntimeEntry, Rundown } from 'ontime-types';
@@ -6,7 +6,9 @@ import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { RUNDOWN } from '../api/constants'; import { RUNDOWN } from '../api/constants';
import { fetchCurrentRundown } from '../api/rundown'; import { fetchCurrentRundown } from '../api/rundown';
import { useSelectedEventId } from '../hooks/useSocket'; import { useSelectedEventId } from '../hooks/useSocket';
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata'; import { getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import useProjectData from './useProjectData';
// revision is -1 so that the remote revision is higher // revision is -1 so that the remote revision is higher
const cachedRundownPlaceholder: Rundown = { const cachedRundownPlaceholder: Rundown = {
@@ -25,9 +27,9 @@ export default function useRundown() {
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({ const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: RUNDOWN, queryKey: RUNDOWN,
queryFn: fetchCurrentRundown, queryFn: fetchCurrentRundown,
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
}); });
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching }; return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
} }
@@ -44,13 +46,30 @@ export function useRundownWithMetadata() {
*/ */
export function useFlatRundown() { export function useFlatRundown() {
const { data, status } = useRundown(); const { data, status } = useRundown();
const { data: projectData } = useProjectData();
const flatRundown = useMemo(() => { const loadedProject = useRef<string>('');
if (data.revision === -1) { const [prevRevision, setPrevRevision] = useState<number>(-1);
return []; const [flatRundown, setFlatRundown] = useState<OntimeEntry[]>([]);
// update data whenever the revision changes
useEffect(() => {
if (data.revision !== -1 && data.revision !== prevRevision) {
const flatRundown = data.flatOrder.map((id) => data.entries[id]);
setFlatRundown(flatRundown);
setPrevRevision(data.revision);
} }
return data.flatOrder.map((id) => data.entries[id]).filter((entry): entry is OntimeEntry => entry !== undefined); }, [data.entries, data.flatOrder, data.revision, prevRevision]);
}, [data]);
// TODO: should we have a project id field?
// TODO(v4): cleanup as part of load multiple rundowns
// invalidate current version if project changes
useEffect(() => {
if (projectData?.title !== loadedProject.current) {
setPrevRevision(-1);
loadedProject.current = projectData?.title ?? '';
}
}, [projectData]);
return { data: flatRundown, rundownId: data.id, status }; return { data: flatRundown, rundownId: data.id, status };
} }
@@ -65,13 +84,9 @@ export function useFlatRundownWithMetadata() {
/** /**
* Provides access to a partial rundown based on a filter callback * Provides access to a partial rundown based on a filter callback
*
* Callers MUST memoize the callback with useCallback to prevent
* re-filtering on every render.
*
*/ */
export function usePartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boolean) { export function usePartialRundown(cb: (event: OntimeEntry) => boolean) {
const { data, status } = useFlatRundownWithMetadata(); const { data, status } = useFlatRundown();
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
return data.filter(cb); return data.filter(cb);
}, [data, cb]); }, [data, cb]);
@@ -85,6 +100,11 @@ export function usePartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boo
export function useEntry(entryId: EntryId | null): OntimeEntry | null { export function useEntry(entryId: EntryId | null): OntimeEntry | null {
const { data: rundown } = useRundown(); const { data: rundown } = useRundown();
if (entryId === null) return null; // track the specific entry we care about
return rundown.entries[entryId] ?? null; const entry = useMemo(() => {
if (entryId === null) return null;
return rundown.entries[entryId];
}, [entryId, rundown.entries]);
return entry;
} }
@@ -2,7 +2,6 @@ import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { import {
EntryId, EntryId,
InsertOptions,
isOntimeEvent, isOntimeEvent,
isOntimeGroup, isOntimeGroup,
MaybeString, MaybeString,
@@ -174,8 +173,7 @@ export const useEntryActions = () => {
* @private * @private
*/ */
const { mutateAsync: cloneEntryMutation } = useMutation({ const { mutateAsync: cloneEntryMutation } = useMutation({
mutationFn: ([rundownId, entryId, options]: Parameters<typeof postCloneEntry>) => mutationFn: ([rundownId, entryId]: Parameters<typeof postCloneEntry>) => postCloneEntry(rundownId, entryId),
postCloneEntry(rundownId, entryId, options),
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }), onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }), onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
}); });
@@ -184,14 +182,14 @@ export const useEntryActions = () => {
* Clone an entry * Clone an entry
*/ */
const clone = useCallback( const clone = useCallback(
async (entryId: EntryId, options?: InsertOptions) => { async (entryId: EntryId) => {
try { try {
const rundownId = getCurrentRundownData()?.id; const rundownId = getCurrentRundownData()?.id;
if (!rundownId) { if (!rundownId) {
throw new Error('Rundown not initialised'); throw new Error('Rundown not initialised');
} }
await cloneEntryMutation([rundownId, entryId, options]); await cloneEntryMutation([rundownId, entryId]);
} catch (error) { } catch (error) {
logAxiosError('Error cloning entry', error); logAxiosError('Error cloning entry', error);
} }
@@ -1,95 +0,0 @@
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;
}
+38 -45
View File
@@ -164,15 +164,46 @@ export const useProgressData = createSelector((state: RuntimeStore) => ({
timeDanger: state.eventNow?.timeDanger ?? null, timeDanger: state.eventNow?.timeDanger ?? null,
})); }));
export const useRundownOverview = createSelector((state: RuntimeStore) => ({
plannedStart: state.rundown.plannedStart,
actualStart: state.rundown.actualStart,
plannedEnd: state.rundown.plannedEnd,
expectedEnd: state.offset.expectedRundownEnd,
}));
export const useRuntimePlaybackOverview = createSelector((state: RuntimeStore) => ({
playback: state.timer.playback,
clock: state.clock,
numEvents: state.rundown.numEvents,
selectedEventIndex: state.rundown.selectedEventIndex,
offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative,
groupExpectedEnd: state.offset.expectedGroupEnd,
}));
export const useTimelineStatus = createSelector((state: RuntimeStore) => ({
clock: state.clock,
offset: state.offset.absolute,
}));
export const useExpectedStartData = createSelector((state: RuntimeStore) => ({ export const useExpectedStartData = createSelector((state: RuntimeStore) => ({
offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative, offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative,
mode: state.offset.mode, mode: state.offset.mode,
currentDay: state.rundown.currentDay ?? 0, currentDay: state.eventNow?.dayOffset ?? 0,
actualStart: state.rundown.actualStart, actualStart: state.rundown.actualStart,
plannedStart: state.rundown.plannedStart, plannedStart: state.rundown.plannedStart,
clock: state.clock, clock: state.clock,
})); }));
export const useCurrentDay = createSelector((state: RuntimeStore) => ({
currentDay: state.eventNow?.dayOffset ?? 0,
}));
export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({
offset: state.offset.absolute,
}));
export const usePing = createSelector((state: RuntimeStore) => ({ export const usePing = createSelector((state: RuntimeStore) => ({
ping: state.ping, ping: state.ping,
})); }));
@@ -196,48 +227,6 @@ export const usePlayback = () => {
return useRuntimeStore(featureSelector); return useRuntimeStore(featureSelector);
}; };
/* ======================= Overview data subscriptions ======================= */
export const useStartTimesOverview = createSelector((state: RuntimeStore) => ({
plannedStart: state.rundown.plannedStart,
actualStart: state.rundown.actualStart,
plannedEnd: state.rundown.plannedEnd,
}));
export const useRundownExpectedEnd = createSelector((state: RuntimeStore) => ({
expectedEnd: state.offset.expectedRundownEnd,
}));
export const useProgressOverview = createSelector((state: RuntimeStore) => ({
numEvents: state.rundown.numEvents,
selectedEventIndex: state.rundown.selectedEventIndex,
}));
export const useOffsetOverview = createSelector((state: RuntimeStore) => ({
offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative,
playback: state.timer.playback,
}));
export const useGroupTimerOverView = createSelector((state: RuntimeStore) => ({
clock: state.clock,
mode: state.offset.mode,
groupExpectedEnd: state.offset.expectedGroupEnd,
// we can force these numbers to 0 fo this use case to avoid null checks
actualGroupStart: state.rundown.actualGroupStart ?? 0,
currentDay: state.eventNow?.dayOffset ?? 0,
playback: state.timer.playback,
}));
export const useFlagTimerOverView = createSelector((state: RuntimeStore) => ({
clock: state.clock,
mode: state.offset.mode,
// we can force these numbers to 0 fo this use case to avoid null checks
actualStart: state.rundown.actualStart ?? 0,
plannedStart: state.rundown.plannedStart ?? 0,
currentDay: state.eventNow?.dayOffset ?? 0,
playback: state.timer.playback,
}));
/* ======================= View specific subscriptions ======================= */ /* ======================= View specific subscriptions ======================= */
export const useTimerSocket = createSelector((state: RuntimeStore) => ({ export const useTimerSocket = createSelector((state: RuntimeStore) => ({
@@ -279,7 +268,11 @@ export const useStudioTimersSocket = createSelector((state: RuntimeStore) => ({
eventNow: state.eventNow, eventNow: state.eventNow,
message: state.message, message: state.message,
time: state.timer, time: state.timer,
offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative, offset: state.offset,
rundown: state.rundown, rundown: state.rundown,
expectedRundownEnd: state.offset.expectedRundownEnd, }));
export const useTimelineSocket = createSelector((state: RuntimeStore) => ({
clock: state.clock,
offset: state.offset.absolute,
})); }));
@@ -0,0 +1 @@
export type Size = 'xs' | 'sm' | 'md' | 'lg';
@@ -0,0 +1,89 @@
import { create } from 'zustand';
type Target = 'cuesheet' | 'timer' | 'clock' | 'countdown' | 'backstage' | 'studio';
interface SelectionState {
[key: string]: boolean;
}
interface ColumnPermissions {
read: string[];
write: string[];
}
interface CuesheetLinksState {
target: Target | null;
readSelected: SelectionState;
writeSelected: SelectionState;
setTarget: (target: Target | null) => void;
setField: (field: 'read' | 'write', key: string, value: boolean) => void;
toggleField: (field: 'read' | 'write', key: string) => void;
selectAll: (field: 'read' | 'write', keys: string[]) => void;
clearAll: (field: 'read' | 'write', keys: string[]) => void;
// Returns arrays of column keys that have read/write permissions if target is 'cuesheet'
getSelections: () => ColumnPermissions | null;
}
export const useCuesheetLinksStore = create<CuesheetLinksState>((set, get) => ({
target: null,
readSelected: {},
writeSelected: {},
setTarget: (target) => set({ target }),
setField: (field, key, value) =>
set((state) => ({
...(field === 'read'
? { readSelected: { ...state.readSelected, [key]: value } }
: { writeSelected: { ...state.writeSelected, [key]: value } }),
})),
toggleField: (field, key) =>
set((state) => ({
...(field === 'read'
? { readSelected: { ...state.readSelected, [key]: !state.readSelected[key] } }
: { writeSelected: { ...state.writeSelected, [key]: !state.writeSelected[key] } }),
})),
selectAll: (field, keys) =>
set((_state) => ({
...(field === 'read'
? {
readSelected: keys.reduce((acc, key) => {
acc[key] = true;
return acc;
}, {} as SelectionState),
}
: {
writeSelected: keys.reduce((acc, key) => {
acc[key] = true;
return acc;
}, {} as SelectionState),
}),
})),
clearAll: (field, keys) =>
set((_state) => ({
...(field === 'read'
? {
readSelected: keys.reduce((acc, key) => {
acc[key] = false;
return acc;
}, {} as SelectionState),
}
: {
writeSelected: keys.reduce((acc, key) => {
acc[key] = false;
return acc;
}, {} as SelectionState),
}),
})),
getSelections: () => {
const state = get();
if (state.target !== 'cuesheet') return null;
return {
read: Object.entries(state.readSelected)
.filter(([_, selected]) => selected)
.map(([key]) => key),
write: Object.entries(state.writeSelected)
.filter(([_, selected]) => selected)
.map(([key]) => key),
};
},
}));
@@ -0,0 +1,68 @@
import { EndAction, EntryCustomFields, OntimeEvent, SupportedEntry, TimerType, TimeStrategy } from 'ontime-types';
import { cloneEvent } from '../clone';
describe('cloneEvent()', () => {
it('creates a stem from a given event', () => {
const original: OntimeEvent = {
id: 'unique',
type: SupportedEntry.Event,
flag: false,
title: 'title',
cue: 'cue',
note: 'note',
timeStart: 0,
duration: 10,
timeEnd: 10,
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockEnd,
parent: 'test',
linkStart: false,
countToEnd: false,
endAction: EndAction.None,
skip: false,
colour: 'F00',
revision: 10,
timeWarning: 120000,
timeDanger: 60000,
delay: 0,
dayOffset: 0,
gap: 0,
triggers: [],
custom: {
lighting: '3',
} as EntryCustomFields,
};
const cloned = cloneEvent(original);
expect(cloned).not.toBe(original);
expect(cloned.custom).not.toBe(original.custom);
expect(cloned.triggers).not.toBe(original.triggers);
expect(cloned).toMatchObject({
type: SupportedEntry.Event,
flag: original.flag,
title: original.title,
note: original.note,
timeStart: original.timeStart,
duration: original.duration,
timeEnd: original.timeEnd,
timerType: original.timerType,
timeStrategy: original.timeStrategy,
parent: 'test',
countToEnd: original.countToEnd,
linkStart: original.linkStart,
endAction: original.endAction,
skip: original.skip,
colour: original.colour,
revision: 0,
delay: original.delay,
dayOffset: original.dayOffset,
gap: 0,
timeWarning: original.timeWarning,
timeDanger: original.timeDanger,
triggers: original.triggers,
custom: original.custom,
});
});
});
@@ -1,4 +1,6 @@
import { makeCSVFromArrayOfArrays } from '../csv'; import { OntimeEntry, ProjectRundowns, Rundown } from 'ontime-types';
import { aggregateRundowns, makeCSVFromArrayOfArrays } from '../csv';
describe('makeCSVFromArrayOfArrays()', () => { describe('makeCSVFromArrayOfArrays()', () => {
it('joins an array of arrays with commas and newlines', () => { it('joins an array of arrays with commas and newlines', () => {
@@ -11,3 +13,34 @@ after newline,after comma
`); `);
}); });
}); });
describe('aggregateRundowns()', () => {
it('flattens an object of rundowns into a single array', () => {
const rundowns = {
first: {
id: '',
title: '',
revision: 0,
order: ['1', '2'],
flatOrder: ['1', '2'],
entries: {
'1': { id: '1' } as OntimeEntry,
'2': { id: '2' } as OntimeEntry,
},
},
second: {
id: '',
title: '',
revision: 0,
order: ['3', '4'],
flatOrder: ['3', '4'],
entries: {
'3': { id: '3' } as OntimeEntry,
'4': { id: '4' } as OntimeEntry,
},
} as Rundown,
} as ProjectRundowns;
expect(aggregateRundowns(rundowns)).toStrictEqual([{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }]);
});
});
@@ -14,11 +14,4 @@ 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: 0, totalGap: 10,
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: 0, totalGap: 10,
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: 0, totalGap: 10,
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: 0, totalGap: 10,
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: 7, totalGap: 17,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
groupId: null, groupId: null,
@@ -1,6 +1,4 @@
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils'; import { formatTime, nowInMillis } from '../time';
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', () => {
@@ -40,18 +38,3 @@ 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 invalid route', () => { it('throws on on invalid route', () => {
expect(() => generateUrlPresetOptions('test', 'www.getontime.no/somethingelse/')).toThrow(); expect(() => generateUrlPresetOptions('test', 'www.getontime.no/somethingelse/')).toThrow();
}); });
}); });
+36
View File
@@ -0,0 +1,36 @@
import { OntimeEvent, SupportedEntry } from 'ontime-types';
/**
* @description Creates a safe duplicate of an event
* @param {OntimeEvent} event
* @param {string} [after]
* @return {OntimeEvent} clean event
*/
type ClonedEvent = Omit<OntimeEvent, 'id' | 'cue'>;
export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
return {
type: SupportedEntry.Event,
flag: event.flag,
title: event.title,
note: event.note,
timeStart: event.timeStart,
duration: event.duration,
timeEnd: event.timeEnd,
timerType: event.timerType,
timeStrategy: event.timeStrategy,
countToEnd: event.countToEnd,
linkStart: event.linkStart,
endAction: event.endAction,
skip: event.skip,
colour: event.colour,
parent: event.parent,
revision: 0,
delay: event.delay, // the events will be collocated, so having the same metadata is a good start
dayOffset: event.dayOffset,
gap: 0,
timeWarning: event.timeWarning,
timeDanger: event.timeDanger,
triggers: structuredClone(event.triggers),
custom: structuredClone(event.custom),
};
};
@@ -1,18 +1,4 @@
/** // we need to this as a promise because safari
* copy text to clipboard export default async function copyToClipboard(text: string) {
* @throws if not supported or permission denied setTimeout(async () => await navigator.clipboard?.writeText(text));
*/
export async function copyToClipboard(text: string) {
await navigator.clipboard?.writeText(text);
}
/**
* Copy to clipboard but safely ignore errors
*/
export async function safeCopyToClipboard(text: string): Promise<void> {
try {
await copyToClipboard(text);
} catch {
// Silently ignore errors
}
} }
+23
View File
@@ -1,4 +1,5 @@
import { stringify } from 'csv-stringify/browser/esm/sync'; import { stringify } from 'csv-stringify/browser/esm/sync';
import { OntimeEntry, ProjectRundowns } from 'ontime-types';
/** /**
* Converts an array of arrays to a CSV file * Converts an array of arrays to a CSV file
@@ -6,3 +7,25 @@ import { stringify } from 'csv-stringify/browser/esm/sync';
export function makeCSVFromArrayOfArrays(arrayOfArrays: string[][]): string { export function makeCSVFromArrayOfArrays(arrayOfArrays: string[][]): string {
return stringify(arrayOfArrays); return stringify(arrayOfArrays);
} }
/**
* Receives an object of rundowns, and flattens them into a single, linear rundown
* Used for CSV export
*/
export function aggregateRundowns(rundowns: ProjectRundowns): OntimeEntry[] {
const rundownKeys = Object.keys(rundowns);
if (rundownKeys.length === 0) return [];
const flatRundown: OntimeEntry[] = [];
for (const key of rundownKeys) {
const { order, entries } = rundowns[key];
for (let i = 0; i < order.length; i++) {
const entryId = order[i];
const entry = entries[entryId];
flatRundown.push(entry);
}
}
return flatRundown;
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { MaybeNumber } from 'ontime-types'; import { MaybeNumber } from 'ontime-types';
import { millisToString, removeLeadingZero } from 'ontime-utils'; import { millisToString } 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 += removeLeadingZero(millisToString(Math.abs(offset))); offsetText += 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;
} }
@@ -0,0 +1,8 @@
import { MaybeString } from 'ontime-types';
export default function safeParseNumber(value: MaybeString, defaultValue: number = 0): number {
if (!value) return defaultValue;
const number = Number(value);
if (isNaN(number)) return defaultValue;
return number;
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { import {
ApiActionTag, ApiAction,
Log, Log,
MessageTag, MessageTag,
RefetchKey, RefetchKey,
@@ -199,7 +199,7 @@ export const connectSocket = () => {
}; };
}; };
export function sendSocket<T extends MessageTag | ApiActionTag>( export function sendSocket<T extends MessageTag | ApiAction>(
tag: T, tag: T,
payload: T extends MessageTag ? Pick<WsPacketToServer & { tag: T }, 'payload'>['payload'] : unknown, payload: T extends MessageTag ? Pick<WsPacketToServer & { tag: T }, 'payload'>['payload'] : unknown,
): void { ): void {
+2 -40
View File
@@ -12,8 +12,6 @@ import { APP_SETTINGS } from '../api/constants';
import { useExpectedStartData } from '../hooks/useSocket'; import { useExpectedStartData } from '../hooks/useSocket';
import { ontimeQueryClient } from '../queryClient'; import { ontimeQueryClient } from '../queryClient';
import { ExtendedEntry } from './rundownMetadata';
/** /**
* Returns current time in milliseconds from midnight * Returns current time in milliseconds from midnight
* @returns {number} * @returns {number}
@@ -112,12 +110,11 @@ 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 '0m'; return '0h 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`;
@@ -127,16 +124,11 @@ export function formatDuration(duration: number, hideSeconds = true): string {
} }
if (!hideSeconds) { if (!hideSeconds) {
const remainingMs = duration % MILLIS_PER_MINUTE; const seconds = Math.ceil((duration % MILLIS_PER_MINUTE) / MILLIS_PER_SECOND);
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;
} }
@@ -162,33 +154,3 @@ export function useTimeUntilExpectedStart(
); );
return expectedStart - clock; return expectedStart - clock;
} }
export function getExpectedTimesFromExtendedEvent(
event: Pick<
ExtendedEntry<OntimeEvent>,
'timeStart' | 'dayOffset' | 'delay' | 'totalGap' | 'isLinkedToLoaded' | 'countToEnd' | 'duration'
> | null,
state: ReturnType<typeof useExpectedStartData>,
) {
if (event === null) return { expectedStart: 0, timeToStart: 0, expectedEnd: 0, plannedEnd: 0 };
const expectedStart = getExpectedStart(
{ timeStart: event.timeStart, delay: event.delay, dayOffset: event.dayOffset },
{
totalGap: event.totalGap,
isLinkedToLoaded: event.isLinkedToLoaded,
...state,
},
);
const plannedEnd = event.timeStart + event.duration + event.delay;
return {
expectedStart,
timeToStart: expectedStart - state.clock,
expectedEnd: event.countToEnd
? Math.max(expectedStart + event.duration, plannedEnd)
: expectedStart + event.duration,
plannedEnd,
};
}
+5 -5
View File
@@ -33,8 +33,8 @@ export function validateProjectFile(file: File) {
} }
// Limit file size of a project file to around 1MB // Limit file size of a project file to around 1MB
if (file.size > 2_000_000) { if (file.size > 1_000_000) {
throw new Error('File size limit (2MB) exceeded'); throw new Error('File size limit (1MB) exceeded');
} }
} }
@@ -56,8 +56,8 @@ export function validateLogo(file: File) {
throw new Error('File is empty'); throw new Error('File is empty');
} }
// Limit file size of a project file to around 1.5MB // Limit file size of a project file to around 1MB
if (file.size > 1_500_000) { if (file.size > 1_000_000) {
throw new Error('File size limit (1.5MB) exceeded'); throw new Error('File size limit (1MB) exceeded');
} }
} }
-5
View File
@@ -19,11 +19,6 @@ declare global {
process: { process: {
type: string; type: string;
}; };
// Experimental browser feature
documentPictureInPicture: {
requestWindow: () => Promise<Window>;
window: Window;
};
} }
} }
+4 -8
View File
@@ -8,7 +8,6 @@ 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/';
@@ -18,15 +17,12 @@ export const buyMeACoffeeUrl = 'https://buymeacoffee.com/cpvalente';
// resolve environment // resolve environment
export const appVersion = version; export const appVersion = version;
export const isDocker = import.meta.env.IS_DOCKER; // this env is made available by the vite.config.js define function export const isProduction = import.meta.env.MODE === 'production';
export const isProduction = import.meta.env.PROD; export const isDev = !isProduction;
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 = document.querySelector('base')?.hasAttribute('data-is-cloud') export const isDockerImage = Boolean(import.meta.env.VITE_IS_DOCKER);
export const isOntimeCloud = currentHostName.includes('cloud.getontime.no');
export const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
export const supportsFullscreen = document.fullscreenEnabled;
// resolve entrypoint URLs // resolve entrypoint URLs
@@ -0,0 +1,29 @@
/* eslint-disable react/display-name */
import { ComponentType, useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router';
import useUrlPresets from '../common/hooks-query/useUrlPresets';
import { getRouteFromPreset } from '../common/utils/urlPresets';
const withPreset = <P extends object>(Component: ComponentType<P>) => {
return (props: Partial<P>) => {
const { data } = useUrlPresets();
const navigate = useNavigate();
const location = useLocation();
// navigate if is alias route
useEffect(() => {
if (!data) return;
const destination = getRouteFromPreset(location, data);
// navigate to this destination if its not null
if (destination) {
navigate(destination);
}
}, [data, navigate, location]);
return <Component {...(props as P)} />;
};
};
export default withPreset;
@@ -196,7 +196,6 @@ $inner-padding: 1rem;
.inlineElements { .inlineElements {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap;
&.inner { &.inner {
gap: 0.5rem; gap: 0.5rem;
@@ -5,7 +5,6 @@ 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';
@@ -39,7 +38,6 @@ 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</Panel.Error> <Panel.Error>{`Could not fetch version information: ${isError}`}</Panel.Error>
</Panel.Paragraph> </Panel.Paragraph>
); );
} }
@@ -230,18 +230,10 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
<div key={key} className={style.filterSection}> <div key={key} className={style.filterSection}>
<label> <label>
Runtime data source Runtime data source
<Select<string | null> <Select
// need to normalize '' to null for the Select to show the placeholder value={watch(`filters.${index}.field`)}
value={watch(`filters.${index}.field`) || null} onValueChange={(value) => setValue(`filters.${index}.field`, value, { shouldDirty: true })}
onValueChange={(value) => { options={fieldList.map(({ value, label }) => ({ value, label }))}
if (value === null) return;
setValue(`filters.${index}.field`, value, { shouldDirty: true });
}}
options={fieldList.map(({ value, label }) => ({
value,
label,
disabled: value === null,
}))}
aria-label='Event field' aria-label='Event field'
/> />
<Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error> <Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error>
@@ -250,14 +242,11 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
Matching condition Matching condition
<Select <Select
value={watch(`filters.${index}.operator`)} value={watch(`filters.${index}.operator`)}
onValueChange={(value: string | null) => { onValueChange={(value) =>
if (value === null) return; setValue(`filters.${index}.operator`, value as 'equals' | 'not_equals' | 'contains', {
setValue( shouldDirty: true,
`filters.${index}.operator`, })
value as 'equals' | 'not_equals' | 'greater_than' | 'less_than' | 'contains' | 'not_contains', }
{ shouldDirty: true },
);
}}
options={[ options={[
{ value: 'equals', label: 'equals' }, { value: 'equals', label: 'equals' },
{ value: 'not_equals', label: 'not equals' }, { value: 'not_equals', label: 'not equals' },
@@ -293,8 +282,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
<div className={style.innerColumn}> <div className={style.innerColumn}>
<h3>Outputs</h3> <h3>Outputs</h3>
<Info> <Info>
Automation outputs can be used to send data from Ontime to external software <br /> Automation outputs can be used to send data from Ontime to external software.
or to change properties of Ontime itself.
<ExternalLink href={integrationsDocsUrl}>See the documentation for templates</ExternalLink> <ExternalLink href={integrationsDocsUrl}>See the documentation for templates</ExternalLink>
</Info> </Info>
@@ -107,16 +107,16 @@ export default function AutomationsList(props: AutomationsListProps) {
</IconButton> </IconButton>
</Panel.InlineElements> </Panel.InlineElements>
</tr> </tr>
{deleteError && (
<tr>
<td colSpan={5}>
<Panel.Error>{deleteError}</Panel.Error>
</td>
</tr>
)}
</Fragment> </Fragment>
); );
})} })}
{deleteError && (
<tr>
<td colSpan={5}>
<Panel.Error>{deleteError}</Panel.Error>
</td>
</tr>
)}
</tbody> </tbody>
</Panel.Table> </Panel.Table>
</Panel.Card> </Panel.Card>
@@ -44,9 +44,8 @@ export default function OntimeActionForm({
<label> <label>
Action Action
<Select <Select
onValueChange={(value: OntimeActionKey | null) => { onValueChange={(value) => {
if (value === null) return; handleSetAction(value as OntimeActionKey);
handleSetAction(value);
}} }}
value={watch(`outputs.${index}.action`)} value={watch(`outputs.${index}.action`)}
options={[ options={[
@@ -78,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' }, 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
})} })}
fluid fluid
placeholder='eg: 10m5s' placeholder='eg: 10m5s'
@@ -98,13 +97,13 @@ export default function OntimeActionForm({
Visibility Visibility
<Select <Select
onValueChange={(value) => { onValueChange={(value) => {
// we need to translate the null to undefined so it becomes 'untouched' // we need to translate the undefined value to 'untouched'
const translatedValue = value === null ? undefined : value; const translatedValue = value === 'untouched' ? undefined : (value as boolean | undefined);
setValue(`outputs.${index}.visible`, translatedValue, { shouldDirty: true }); setValue(`outputs.${index}.visible`, translatedValue, { shouldDirty: true });
}} }}
value={watch(`outputs.${index}.visible`)} value={watch(`outputs.${index}.visible`) === undefined ? 'untouched' : watch(`outputs.${index}.visible`)}
options={[ options={[
{ value: null, label: 'Untouched' }, { value: 'untouched', label: 'Untouched' },
{ value: true, label: 'Show' }, { value: true, label: 'Show' },
{ value: false, label: 'Hide' }, { value: false, label: 'Hide' },
]} ]}
@@ -117,16 +116,9 @@ export default function OntimeActionForm({
{selectedAction === 'message-secondary' && ( {selectedAction === 'message-secondary' && (
<label> <label>
Timer secondary source Timer secondary source
<Select<SecondarySource | 'null' | null> <Select
onValueChange={(value) => { onValueChange={(value) => {
// null -> no selection setValue(`outputs.${index}.secondarySource`, value as SecondarySource, { shouldDirty: true });
if (value === null) return;
// 'null' -> clear the secondary source
if (value === 'null') {
setValue(`outputs.${index}.secondarySource`, null, { shouldDirty: true });
return;
}
setValue(`outputs.${index}.secondarySource`, value, { shouldDirty: true });
}} }}
value={watch(`outputs.${index}.secondarySource`)} value={watch(`outputs.${index}.secondarySource`)}
options={[ options={[
@@ -134,14 +126,13 @@ export default function OntimeActionForm({
{ value: 'aux1', label: 'Auxiliary timer 1' }, { value: 'aux1', label: 'Auxiliary timer 1' },
{ value: 'aux2', label: 'Auxiliary timer 2' }, { value: 'aux2', label: 'Auxiliary timer 2' },
{ value: 'aux3', label: 'Auxiliary timer 3' }, { value: 'aux3', label: 'Auxiliary timer 3' },
{ value: 'secondary', label: 'Secondary' }, { value: 'external', label: 'External' },
{ value: 'null', label: 'None' }, // allow the user to clear the secondary source { value: 'null', label: 'None' },
]} ]}
/> />
<Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error> <Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error>
</label> </label>
)} )}
<div className={style.test}>{children}</div> <div className={style.test}>{children}</div>
</div> </div>
); );
@@ -106,10 +106,7 @@ export default function TriggerForm({
Lifecycle trigger Lifecycle trigger
<Select <Select
value={watch('trigger')} value={watch('trigger')}
onValueChange={(value) => { onValueChange={(value) => setValue('trigger', value as TimerLifeCycle, { shouldDirty: true })}
if (value === null) return;
setValue('trigger', value as TimerLifeCycle, { shouldDirty: true });
}}
options={cycles.map((cycle) => ({ value: cycle.value, label: cycle.label }))} options={cycles.map((cycle) => ({ value: cycle.value, label: cycle.label }))}
aria-label='Lifecycle trigger' aria-label='Lifecycle trigger'
/> />
@@ -119,10 +116,7 @@ export default function TriggerForm({
Automation title Automation title
<Select <Select
value={watch('automationId')} value={watch('automationId')}
onValueChange={(value: string | null) => { onValueChange={(value) => setValue('automationId', value, { shouldDirty: true })}
if (value === null) return;
setValue('automationId', value, { shouldDirty: true });
}}
options={automationSelect} options={automationSelect}
aria-label='Automation title' aria-label='Automation title'
/> />

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