Compare commits

..

4 Commits

Author SHA1 Message Date
google-labs-jules[bot] 137db18897 feat: dynamically set PWA start_url
This commit fixes an issue where the PWA would always start at the
root of the application, regardless of the page you were on when
you installed it.

This is fixed by:
- Consolidating the two manifest files into a single file.
- Adding a new route to the server that dynamically generates the
  `start_url` in the manifest based on the current route.
- Adding an e2e test to verify the changes.
2025-07-26 14:23:17 +00:00
google-labs-jules[bot] 65e8894cf5 feat: dynamically set PWA start_url
This commit fixes an issue where the PWA would always start at the
root of the application, regardless of the page you were on when
you installed it.

This is fixed by:
- Consolidating the two manifest files into a single file.
- Adding a new route to the server that dynamically generates the
  `start_url` in the manifest based on the current route.
- Adding an e2e test to verify the changes.
2025-07-26 14:22:49 +00:00
google-labs-jules[bot] 2683f1a1e0 feat: dynamically set PWA start_url
This commit fixes an issue where the PWA would always start at the
root of the application, regardless of the page you were on when
you installed it.

This is fixed by:
- Consolidating the two manifest files into a single file.
- Adding a new route to the server that dynamically generates the
  `start_url` in the manifest based on the current route.
- Adding an e2e test to verify the changes.
2025-07-26 14:22:21 +00:00
google-labs-jules[bot] 84459b06e2 feat: dynamically set PWA start_url
This commit fixes an issue where the PWA would always start at the
root of the application, regardless of the page you were on when
you installed it.

This is fixed by:
- Consolidating the two manifest files into a single file.
- Adding a new route to the server that dynamically generates the
  `start_url` in the manifest based on the current route.
- Adding an e2e test to verify the changes.
2025-07-24 17:27:02 +00:00
510 changed files with 9743 additions and 16294 deletions
+1 -1
View File
@@ -16,7 +16,7 @@
# Ignore build folders
node_modules
**/node_modules
dist
# Ignore default volumes created by running docker compose up
ontime-db
+1 -2
View File
@@ -1,2 +1 @@
"ONTIME_VERSION.js"
dist/
"ONTIME_VERSION.js"
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

@@ -2,7 +2,7 @@ name: Ontime build
on:
push:
tags: ['*']
tags: [ "*" ]
workflow_dispatch:
jobs:
@@ -11,20 +11,15 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@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
uses: pnpm/action-setup@v3
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
version: 10
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -35,7 +30,7 @@ jobs:
run: pnpm build
- name: Electron - Build app
env:
env:
APPLE_ID: ${{ secrets.APPLEID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLEIDPASS }}
APPLE_TEAM_ID: ${{ secrets.TEAMID }}
@@ -58,9 +53,15 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -85,9 +86,15 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
- name: Install dependencies
run: pnpm install --frozen-lockfile
+14 -18
View File
@@ -14,19 +14,19 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@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
- name: Use Node.js
uses: actions/setup-node@v4
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
node-version: 22
registry-url: 'https://registry.npmjs.org'
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -38,20 +38,16 @@ jobs:
- name: Copy server
run: mkdir -p apps/cli/server && cp apps/server/dist/index.cjs apps/cli/server/index.cjs
- name: Copy client
run: cp -R apps/client/build apps/cli/client
- name: Copy external
run: cp -R apps/server/src/external apps/cli/external
- name: Setup pnpm auth config
run: pnpm config set '//registry.npmjs.org/:_authToken' "${NODE_AUTH_TOKEN}"
env:
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
- name: Publish to NPM
run: pnpm publish --access public --no-git-checks
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_CONFIG_PROVENANCE: true
working-directory: ./apps/cli
+29 -49
View File
@@ -6,63 +6,43 @@ on:
workflow_dispatch:
jobs:
publish_docker:
runs-on: ubuntu-latest
env:
CI: ''
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Docker Login
uses: docker/login-action@v2.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Docker Setup Buildx
uses: docker/setup-buildx-action@v2.5.0
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Build and push 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: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build and push pre-release
if: github.event.release.prerelease == true
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
- name: 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
-46
View File
@@ -1,46 +0,0 @@
name: Ontime Resolver build
on:
release:
types: [published]
workflow_dispatch:
jobs:
build_resolver:
permissions:
id-token: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
run: pnpm build:resolver
- name: Setup pnpm auth config
run: pnpm config set '//registry.npmjs.org/:_authToken' "${NODE_AUTH_TOKEN}"
env:
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
- name: Publish to NPM
run: pnpm publish --access public --no-git-checks
env:
NPM_CONFIG_PROVENANCE: true
working-directory: ./apps/resolver
+46 -41
View File
@@ -14,35 +14,55 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@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
uses: pnpm/action-setup@v3
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
version: 10
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Run code quality
- name: Run linter
run: pnpm lint
# Run code quality per package
- name: React - Run linter + TypeScript checks
if: always()
run: pnpm lint && tsc --noEmit
working-directory: ./apps/client
- name: Run TypeScript checks
run: pnpm typecheck
- name: Server - Run linter + TypeScript checks
if: always()
run: pnpm lint && tsc --noEmit
working-directory: ./apps/server
- name: Utils - Run linter + TypeScript checks
if: always()
run: pnpm lint && tsc --noEmit
working-directory: ./packages/utils
- name: Types - Run linter
if: always()
run: pnpm lint
working-directory: ./packages/types
# We choose to run tests separately
- name: Run unit tests
- name: React - Run unit tests
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:
runs-on: ubuntu-latest
@@ -50,36 +70,21 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@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
uses: pnpm/action-setup@v3
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
version: 10
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Get installed Playwright version
run: echo "PLAYWRIGHT_VERSION=$(pnpm ls @playwright/test --parseable | cut -s -d '@' -f3 | cut -d '/' -f1)" >> $GITHUB_ENV
- name: Cache playwright binaries
uses: actions/cache@v4
with:
path: |
~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
restore-keys: |
${{ runner.os }}-playwright-
- run: npx playwright install --with-deps
if: steps.playwright-cache.outputs.cache-hit != 'true'
- name: Build client
run: pnpm build:local
- name: Install Playwright Browsers
run: npx playwright install --with-deps
-4
View File
@@ -34,10 +34,6 @@ e2e/tests/fixtures/tmp/*
build/
dist/
# bundled assets
translations.json
override.css
# working stuff
**/TODO.md
+2 -6
View File
@@ -5,10 +5,6 @@ node_modules
playwright-report
pnpm-lock.yaml
**/*.toml
**/*.json
!tsconfig.common.json
!turbo.json
!package.json
**/*.yml
**/*.json
+19 -24
View File
@@ -20,21 +20,19 @@ development.
Locally, we would need to run both the React client and the node.js server in development mode
From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i`
- __Create a local build__ by running `pnpm build`, this will populate local dependencies
- __Run dev mode__ by running `pnpm dev` or `pnpm dev:electron` to get the electron window
- __Install the project dependencies__ by running `pnpm i`
- __Run dev mode__ by running `turbo dev`
### Debugging backend
The previous command will start the development servers for both the client, server and electron applications.
Typically in dev mode we prefer to start these in separate terminals to help with error tracking and debugging.
To debug backend code in Node.js:
We do that by creating two terminals an running
- __Run the React UI__ by running `pnpm dev --filter=ontime-ui`
- __Run the nodejs server__ by running `pnpm dev --filter=ontime-server`
- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by running `pnpm dev:inspect --filter=ontime-server`.
- Open two separate terminals and navigate to the `apps/client` and `apps/server` directories.
- In each terminal, run the command `pnpm dev` to start the development servers for both the client and server
applications.
- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by
running `pnpm dev:inspect`.
## TESTING
@@ -47,7 +45,7 @@ Generally we have 2 types of tests.
Unit tests are contained in mostly all the apps and packages (client, server and utils)
You can run unit tests by running `pnpm test:pipeline` from the project root.
You can run unit tests by running `turbo run test:pipeline` from the project root.
This will run all tests and close test runner.
Alternatively you can navigate to an app or project and run `pnpm test` to run those tests in watch mode
@@ -67,7 +65,7 @@ start the webserver with `pnpm dev:server`
Some other useful commands
- `pnpm e2e:ui` open playwright UI
- `pnpm e2e --ui` open playwright UI
- `pnpm e2e --headed` run tests with a visible browser window
## CREATE AN INSTALLABLE FILE (Windows | MacOS | Linux)
@@ -78,11 +76,14 @@ You can generate a distribution for your OS by running the following steps.
From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i`
- __Build the UI and server__ by running `pnpm build`
- __Create the package__ by running `pnpm dist-win`, `pnpm dist-mac` or `pnpm dist-linux`
- __Build the UI and server__ by running `turbo run build:electron`
- __Create the package__ by running `turbo run dist-win`, `turbo run dist-mac` or `turbo run dist-linux`
The build distribution assets will be at `.apps/electron/dist`
Note: The MacOS build will only work in CI, locally it will fail due to notarisation issues.
Use the `turbo run dist-mac:local` command to build a MacOS distribution locally.
## DOCKER
Ontime provides a docker-compose file to aid with building and running docker images.
@@ -98,15 +99,9 @@ Other useful commands
- __List running processes__ by running `docker ps`
- __Kill running process__ by running `docker kill <process-id>`
## CONTRIBUTION GUIDELINES
## General Info
If you want to propose changes to the codebase, please reach out before opening a Pull Request.
# APP Building
For new PRs, please follow the following checklist:
* [ ] You have updated and ran unit locally and they are passing. Unit tests are generally created for all utility functions and business logic
* [ ] You have ran code formatting and linting in all your changes
* [ ] The branch is clean and the commits are meaningfully separated and contain descriptive messages
* [ ] The PR body contains description and motivation for the changes
After this checklist is complete, you can request a review from one of the maintainers to get feedback and approval on the changes. \
We will review as soon as possible
We build the app from app.js for almost all applications. The output file will still be named index.cjs. This is because of Electron.
Building the app from index.ts only applies for applications that don't use electron. index.ts will take over the initialization of the server and UI when electron isn't present.
+37 -30
View File
@@ -1,30 +1,37 @@
ARG NODE_VERSION=22.15.1
FROM node:${NODE_VERSION}-alpine
# Set environment variables
# Environment Variable to signal that we are running production
ENV NODE_ENV=docker
# Ontime Data path
ENV ONTIME_DATA=/data/
RUN mkdir /app
WORKDIR /app/
# Prepare UI
COPY apps/client/build/ ./client/
# Prepare Backend
COPY apps/server/dist/ ./server/
COPY apps/server/src/external/ ./external/
COPY apps/server/src/user/ ./user/
COPY apps/server/src/html/ ./html/
# Export default ports
EXPOSE 4001/tcp 8888/udp 9999/udp
CMD ["node", "server/docker.cjs"]
# Build and run commands
# pnpm build:docker
# docker buildx build . -t getontime/ontime
# docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/data/ getontime/ontime
FROM node:22-bullseye AS builder
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN npm install -g pnpm@10.11.0
COPY . /app
WORKDIR /app
RUN pnpm --filter=ontime-ui --filter=ontime-server --filter=ontime-utils install --config.dedupe-peer-dependents=false --frozen-lockfile
RUN pnpm --filter=ontime-ui --filter=ontime-server run build:docker
FROM node:22-alpine
# Set environment variables
# Environment Variable to signal that we are running production
ENV NODE_ENV=docker
# Ontime Data path
ENV ONTIME_DATA=/data/
WORKDIR /app/
# Prepare UI
COPY --from=builder /app/apps/client/build ./client/
# Prepare Backend
COPY --from=builder /app/apps/server/dist/ ./server/
COPY --from=builder /app/apps/server/src/external/ ./external/
COPY --from=builder /app/apps/server/src/user/ ./user/
COPY --from=builder /app/apps/server/src/html/ ./html/
# Export default ports
EXPOSE 4001/tcp 8888/udp 9999/udp
CMD ["node", "server/docker.cjs"]
# Build and run commands
# !!! Note that this command needs pre-build versions of the UI and server apps
# docker buildx build . -t getontime/ontime
# docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/data/ getontime/ontime
+59 -14
View File
@@ -43,12 +43,12 @@ Ontime is made by entertainment and broadcast engineers and used by
- Houses of worship
## Main features
- **Multiplatform**: Available as a Cloud service and for Windows, macOS, Linux, or self-hosted via Docker.
- **In any device**: Ontime is available to any device with a browser, eg: tablets, mobile phones, laptops, signage, media servers...
- **Team Collaboration**: Dedicated views for directors, operators, backstage, and signage.
- **Real-Time Updates**: Manage and communicate runtime delays effortlessly.
- **Automatable**: Ontime can be fully or partially controlled by an operator, or run standalone with the system clock
- **Flexible Integrations**: Use one of the APIs provided (OSC, HTTP, Websocket) or the available [Companion module](https://bitfocus.io/connections/getontime-ontime) to integrate into your workflow (vMix, disguise, Qlab, OBS)
- [x] **Multiplatform**: Available for Windows / MacOS, Linux. You can also self host with the docker image
- [x] **In any device**: Ontime is available in the local network to any device with a browser, eg: tablets, mobile phones, laptops, signage, media servers...
- [x] **Made for teams**: Ontime caters to different roles in your production team: directors, operators, backstage and front of house signage...
- [x] **Delay workflows**: Manage and communicate runtime delays in real-time to your team
- [x] **Automatable**: Ontime can be fully or partially controlled by an operator, or run standalone with the system clock
- [x] **Focus on integrations**: Use one of the APIs provided (OSC, HTTP, Websocket) or the available [Companion module](https://bitfocus.io/connections/getontime-ontime) to integrate into your workflow (vMix, disguise, Qlab, OBS)
... and a lot more ...
@@ -79,29 +79,75 @@ Ontime is made by video engineers and entertainment technicians.
## Using Ontime
### Getting started
Ontime can be started by downloading the latest release for your platform. \
Alternatively you can also use the docker image, available at [Docker Hub](https://hub.docker.com/r/getontime/ontime)
The easiest way to start with Ontime is by leveraging our [Cloud service](https://getontime.no). \
This will give you immediate access to running instances of Ontime which are available to share with anyone with an internet connection.
Once installed and running, any device in the network has access to Ontime.
Alternatively, you can run Ontime locally for free by downloading the latest release for your platform or using the docker image, available at [Docker Hub](https://hub.docker.com/r/getontime/ontime)
Ontime provides different screens which allow for different types of interactions with the data. These are called
views. \
Each view in Ontime focuses on empowering a specific role or achieving a particular task.
Once installed and running, any device that shares the same network as Ontime will have access to Ontime.
You can access the different views by reaching the ontime server, in your browser, at (_default port
4001_) `http://localhost:4001` or `http://192.168.1.3:4001`
```
For the backstage views
-------------------------------------------------------------
IP.ADDRESS:4001/timer > Presenter / Stage timer view
IP.ADDRESS:4001/backstage > Stage Manager / Backstage view
IP.ADDRESS:4001/countdown > Countdown to anything
IP.ADDRESS:4001/studio > Studio Clock
IP.ADDRESS:4001/timeline > Timeline
```
```
For production views
-------------------------------------------------------------
IP.ADDRESS:4001/editor > the control interface, same as the app
IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
IP.ADDRESS:4001/op > automated views for operators
```
More information is available [in our docs](https://docs.getontime.no)
## Continued development
## Roadmap
### Continued development
Ontime is under active development. We continue adding and improving features in collaboration with users.
Have an idea? Reach out via [email](mail@getontime.no)
or [open an issue](https://github.com/cpvalente/ontime/issues/new)
## Issues
### Issues
We use Github's issue tracking for bug reporting and feature requests. \
Found a bug? [Open an issue](https://github.com/cpvalente/ontime/issues/new).
#### Unsigned App
When installing the app you would see warning screens from the Operating System like:
in Windows
`Microsoft Defender SmartScreen prevented an unrecognised app from starting. Running this app might put your PC at risk.`
or in Linux
`Could Not Display "ontime-linux.AppImage`
We currently only sign MacOS releases. \
Purchasing the certificates for both Mac and Windows would mean a recurrent expense which we are not able to cover.
You can circumvent this by allowing the execution of the app manually.
- In Windows: click `more` -> `Run Anyway`
- In Linux: right-click the AppImage file: `Properties` -> `Permissions` -> `Allow Executing File as a Program`
If you have tips on how to improve this or would like to sponsor the code signing,
please [open an issue](https://github.com/cpvalente/ontime/issues/new)
## Contributing
Looking to contribute? All types of help are appreciated, from coding to testing and feature specification.
@@ -113,7 +159,6 @@ Information about the project setup can be found in the [development documentati
## Links
- [Ontime website](https://getontime.no)
- [Documentation](https://docs.getontime.no)
- [Video tutorials](https://www.youtube.com/@ontimeapp)
- [Ontime discord server](https://discord.com/invite/eje3CSUEXm)
## License
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "4.0.0",
"version": "4.0.0-alpha.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+7 -2
View File
@@ -9,10 +9,15 @@
<meta name="ontime" content="ontime - time keeping for live events" />
<link rel="apple-touch-icon" href="/ontime-logo.png" />
<link rel="icon" type="image/png" href="/ontime-logo.png" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="manifest" href="/manifest.json" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="robots" content="noindex" />
<title>ontime</title>
<style>
body,
html {
background-color: #101010 !important;
}
</style>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
+28 -25
View File
@@ -1,48 +1,51 @@
{
"name": "ontime-ui",
"version": "4.0.0",
"version": "4.0.0-alpha.0",
"private": true,
"type": "module",
"dependencies": {
"@base-ui-components/react": "1.0.0-beta.4",
"@base-ui-components/react": "1.0.0-beta.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource/open-sans": "^5.2.6",
"@mantine/hooks": "^8.2.8",
"@sentry/react": "^10.2.0",
"@emotion/is-prop-valid": "^1.3.1",
"@fontsource/open-sans": "^5.0.28",
"@mantine/hooks": "^8.1.2",
"@sentry/react": "^8.43.0",
"@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.85.9",
"@tanstack/react-query-devtools": "^5.85.9",
"@tanstack/react-query": "^5.62.7",
"@tanstack/react-query-devtools": "^5.62.7",
"@tanstack/react-table": "^8.21.3",
"autosize": "^6.0.1",
"axios": "^1.12.2",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"csv-stringify": "^6.6.0",
"prismjs": "^1.30.0",
"react": "^19.1.1",
"axios": "^1.9.0",
"babel-plugin-react-compiler": "19.1.0-rc.2",
"csv-stringify": "^6.4.5",
"prismjs": "^1.29.0",
"react": "^19.1.0",
"react-colorful": "^5.6.1",
"react-dom": "^19.1.1",
"react-dom": "^19.1.0",
"react-fast-compare": "^3.2.2",
"react-hook-form": "^7.62.0",
"react-icons": "5.5.0",
"react-qr-code": "^2.0.18",
"react-router": "^7.8.2",
"react-hook-form": "^7.53.1",
"react-icons": "5.4.0",
"react-qr-code": "^2.0.12",
"react-router-dom": "^6.3.0",
"react-simple-code-editor": "^0.14.1",
"react-virtuoso": "^4.14.0",
"web-vitals": "^5.1.0",
"zustand": "^5.0.8"
"web-vitals": "^3.1.1",
"zustand": "^5.0.3"
},
"scripts": {
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
"postinstall": "pnpm addversion",
"dev": "cross-env BROWSER=none vite",
"dev:electron": "pnpm dev",
"lint": "eslint . --quiet",
"typecheck": "tsc --noEmit",
"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:pipeline": "vitest run",
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build",
"analyse": "npx vite-bundle-visualizer"
},
"browserslist": {
@@ -61,8 +64,8 @@
"@sentry/vite-plugin": "^2.16.1",
"@tanstack/eslint-plugin-query": "^5.8.4",
"@types/prismjs": "^1.26.5",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:",
"@vitejs/plugin-react": "4.5.1",
-19
View File
@@ -1,19 +0,0 @@
{
"name": "ontime",
"short_name": "ontime",
"icons": [
{
"src": "favicon.ico",
"type": "image/x-icon"
},
{
"src": "ontime-logo.png",
"type": "image/png"
}
],
"scope": "/",
"start_url": "/",
"display": "",
"theme_color": "#121212",
"background_color": "#ffffff"
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "ontime",
"short_name": "ontime",
"icons": [
{
"src": "/ontime-logo.png",
"sizes": "295x295",
"type": "image/png"
}
],
"scope": "/",
"display": "standalone",
"theme_color": "#2B5ABC",
"background_color": "#101010"
}
-10
View File
@@ -1,10 +0,0 @@
{
"name": "",
"short_name": "",
"icons": [
{ "src": "/ontime-logo.png", "sizes": "295x295", "type": "image/png" }
],
"theme_color": "#2B5ABC",
"background_color": "#101010",
"display": "standalone"
}
+1 -3
View File
@@ -1,4 +1,4 @@
import { BrowserRouter } from 'react-router';
import { BrowserRouter } from 'react-router-dom';
import { Tooltip } from '@base-ui-components/react/tooltip';
import { QueryClientProvider } from '@tanstack/react-query';
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 { ontimeQueryClient } from './common/queryClient';
import { connectSocket } from './common/utils/socket';
import KeepAwake from './features/keep-awake/KeepAwake';
import { TranslationProvider } from './translation/TranslationProvider';
import AppRouter from './AppRouter';
import { baseURI } from './externals';
@@ -25,7 +24,6 @@ function App() {
<ErrorBoundary>
<TranslationProvider>
<IdentifyOverlay />
<KeepAwake />
<AppRouter />
</TranslationProvider>
</ErrorBoundary>
+53 -157
View File
@@ -1,34 +1,38 @@
import { ComponentType, lazy, Suspense, useEffect, useMemo } from 'react';
import { Navigate, Route, useLocation, useNavigate, useParams } from 'react-router';
import { OntimeView, OntimeViewPresettable, URLPreset } from 'ontime-types';
import React from 'react';
import { Navigate, Route } from 'react-router-dom';
import ViewNavigationMenu from './common/components/navigation-menu/ViewNavigationMenu';
import { PresetContext } from './common/context/PresetContext';
import { useClientPath } from './common/hooks/useClientPath';
import useUrlPresets from './common/hooks-query/useUrlPresets';
import { getRouteFromPreset } from './common/utils/urlPresets';
import Log from './features/log/Log';
import Loader from './views/common/loader/Loader';
import NotFound from './views/common/not-found/NotFound';
import withPreset from './features/PresetWrapper';
import withData from './features/viewers/ViewWrapper';
import ViewLoader from './views/ViewLoader';
import { getIsNavigationLocked, sessionScope } from './externals';
import { initializeSentry } from './sentry.config';
const Timer = lazy(() => import('./views/timer/Timer'));
const Countdown = lazy(() => import('./views/countdown/Countdown'));
const Backstage = lazy(() => import('./views/backstage/Backstage'));
const StudioClock = lazy(() => import('./views/studio/Studio'));
const Timeline = lazy(() => import('./views/timeline/TimelinePage'));
const ProjectInfo = lazy(() => import('./views/project-info/ProjectInfo'));
const Editor = React.lazy(() => import('./views/editor/ProtectedEditor'));
const Cuesheet = React.lazy(() => import('./views/cuesheet/ProtectedCuesheet'));
const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
const Editor = lazy(() => import('./views/editor/ProtectedEditor'));
const Cuesheet = lazy(() => import('./views/cuesheet/ProtectedCuesheet'));
const Operator = lazy(() => import('./features/operator/OperatorExport'));
const TimerView = React.lazy(() => import('./views/timer/Timer'));
const Countdown = React.lazy(() => import('./views/countdown/Countdown'));
const EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper'));
const RundownPanel = lazy(() => import('./features/rundown/RundownExport'));
const TimerControl = lazy(() => import('./features/control/playback/TimerControlExport'));
const MessageControl = lazy(() => import('./features/control/message/MessageControlExport'));
const Backstage = React.lazy(() => import('./views/backstage/Backstage'));
const Timeline = React.lazy(() => import('./views/timeline/TimelinePage'));
const StudioClock = React.lazy(() => import('./views/studio/Studio'));
const ProjectInfo = React.lazy(() => import('./views/project-info/ProjectInfo'));
const STimer = withPreset(withData(TimerView));
const SCountdown = withPreset(withData(Countdown));
const SBackstage = withPreset(withData(Backstage));
const SProjectInfo = withPreset(ProjectInfo); // NOTE: ProjectInfo does not use the viewWrapper since it has no options
const SStudio = withPreset(withData(StudioClock));
const STimeline = withPreset(withData(Timeline));
const PCuesheet = withPreset(Cuesheet);
const POperator = withPreset(Operator);
const EditorFeatureWrapper = React.lazy(() => import('./features/EditorFeatureWrapper'));
const RundownPanel = React.lazy(() => import('./features/rundown/RundownExport'));
const TimerControl = React.lazy(() => import('./features/control/playback/TimerControlExport'));
const MessageControl = React.lazy(() => import('./features/control/message/MessageControlExport'));
// Initialize Sentry with our configuration
const SentryRouter = initializeSentry();
@@ -38,78 +42,73 @@ export default function AppRouter() {
useClientPath();
return (
<Suspense fallback={<Loader />}>
<React.Suspense fallback={null}>
<SentryRouter>
<Route path='/' element={<Navigate to='/timer' />} />
<Route
path='timer'
path='/timer'
element={
<ViewLoader>
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
<Timer />
<STimer />
</ViewLoader>
}
/>
<Route
path='countdown'
path='/countdown'
element={
<ViewLoader>
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
<Countdown />
<SCountdown />
</ViewLoader>
}
/>
<Route
path='backstage'
path='/backstage'
element={
<ViewLoader>
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
<Backstage />
<SBackstage />
</ViewLoader>
}
/>
<Route
path='studio'
path='/studio'
element={
<ViewLoader>
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
<StudioClock />
<SStudio />
</ViewLoader>
}
/>
<Route
path='timeline'
path='/timeline'
element={
<ViewLoader>
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
<Timeline />
<STimeline />
</ViewLoader>
}
/>
<Route
path='info'
path='/info'
element={
<ViewLoader>
<ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />
<ProjectInfo />
<SProjectInfo />
</ViewLoader>
}
/>
{/*/!* Protected Routes *!/*/}
<Route path='editor' element={<Editor />} />
<Route path='cuesheet' element={<Cuesheet />} />
<Route path='/editor' element={<Editor />} />
<Route path='/cuesheet' element={<PCuesheet />} />
<Route
path='op'
path='/op'
element={
<ViewLoader>
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
<Operator />
<POperator />
</ViewLoader>
}
/>
{/*/!* Protected Routes - Elements *!/*/}
<Route
path='rundown'
path='/rundown'
element={
<EditorFeatureWrapper>
<RundownPanel />
@@ -117,7 +116,7 @@ export default function AppRouter() {
}
/>
<Route
path='timercontrol'
path='/timercontrol'
element={
<EditorFeatureWrapper>
<TimerControl />
@@ -125,7 +124,7 @@ export default function AppRouter() {
}
/>
<Route
path='messagecontrol'
path='/messagecontrol'
element={
<EditorFeatureWrapper>
<MessageControl />
@@ -133,119 +132,16 @@ export default function AppRouter() {
}
/>
<Route
path='log'
path='/log'
element={
<EditorFeatureWrapper>
<Log />
</EditorFeatureWrapper>
}
/>
{/**
* If the views are prefixed with the "preset" path, we are in a locked preset
* Locked presets do not expose their parameters
*/}
<Route path='preset/:alias' element={<PresetView />} />
{/**
* If we havent matched any views or presets, we may be in an unlocked preset
* Unlocked presets are unwrapped to expose their target and parameters
*/}
<Route path='*' element={<RedirectPreset />} />
{/*/!* Send to default if nothing found *!/*/}
<Route path='*' element={<STimer />} />
</SentryRouter>
</Suspense>
);
}
const PresetViewMap: Record<OntimeViewPresettable, ComponentType> = {
[OntimeView.Cuesheet]: Cuesheet,
[OntimeView.Operator]: Operator,
[OntimeView.Timer]: Timer,
[OntimeView.Backstage]: Backstage,
[OntimeView.Timeline]: Timeline,
[OntimeView.StudioClock]: StudioClock,
[OntimeView.Countdown]: Countdown,
[OntimeView.ProjectInfo]: ProjectInfo,
};
/**
* This view will mask a configured canonical route
* and inject the preset search parameters to context
* User are not able to configure the parameters locked presets
*/
function PresetView() {
const { data, status } = useUrlPresets();
const { alias } = useParams();
const preset: URLPreset | undefined = useMemo(() => {
if (status === 'pending' || !alias) return;
return data.find((p) => p.alias === alias && p.enabled);
}, [data, status, alias]);
if (status === 'pending') {
return <Loader />;
}
/**
* We need to check the session scope to determine if the user can navigate
* If the user has a global scope, they can navigate freely
* Otherwise, they are locked to the preset view
*/
const showNav = sessionScope === 'rw';
/**
* If we are in a preset path but cannot find a preset, we will need to show a not found page
* This can happen if the preset was deleted or disabled
*/
if (!preset) {
return (
<>
<ViewNavigationMenu isNavigationLocked={!showNav} suppressSettings />
<NotFound />
</>
);
}
/**
* Locked presets do not allow configuration changes
* 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];
return (
<PresetContext value={preset}>
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSettings />
{Component ? <Component /> : <NotFound />}
</PresetContext>
);
}
function RedirectPreset() {
const { data, status } = useUrlPresets();
const navigate = useNavigate();
const location = useLocation();
// checks if we are in a preset path and resolves a destination URL
const destination = useMemo(() => {
if (status === 'pending') return null;
return getRouteFromPreset(location, data);
}, [data, location, status]);
// if we have a destination, we will navigate to it
useEffect(() => {
if (destination) {
navigate(`/${destination}`, { replace: true });
}
}, [destination, navigate]);
if (status === 'pending') {
return <Loader />;
}
return (
<>
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSettings />
<NotFound />
</>
</React.Suspense>
);
}
+1 -22
View File
@@ -1,9 +1,6 @@
import axios from 'axios';
import { TranslationObject } from 'ontime-types';
import { ontimeQueryClient } from '../../common/queryClient';
import { apiEntryUrl, customTranslationsURL, TRANSLATION } from './constants';
import { apiEntryUrl } from './constants';
const assetsPath = `${apiEntryUrl}/assets`;
@@ -31,21 +28,3 @@ export async function restoreCSSContents(): Promise<string> {
const res = await axios.post(`${assetsPath}/css/restore`);
return res.data;
}
/**
* HTTP request to get user translation
*/
export async function getUserTranslation(): Promise<TranslationObject> {
const res = await axios.get(customTranslationsURL);
return res.data;
}
/**
* HTTP request to post user translation
*/
export async function postUserTranslation(translation: TranslationObject): Promise<void> {
await axios.post(`${assetsPath}/translations`, {
translation,
});
await ontimeQueryClient.invalidateQueries({ queryKey: TRANSLATION });
}
-3
View File
@@ -15,15 +15,12 @@ export const URL_PRESETS = ['urlpresets'];
export const VIEW_SETTINGS = ['viewSettings'];
export const CLIENT_LIST = ['clientList'];
export const REPORT = ['report'];
export const TRANSLATION = ['translation'];
// API URLs
export const apiEntryUrl = `${serverURL}/data`;
const userAssetsPath = 'user';
const cssOverridePath = 'styles/override.css';
const customTranslationsPath = 'translations/translations.json';
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`;
export const customTranslationsURL = `${serverURL}/${userAssetsPath}/${customTranslationsPath}`;
+24
View File
@@ -1,6 +1,9 @@
import axios, { AxiosResponse } from 'axios';
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
import { makeTable } from '../../views/cuesheet/cuesheet.utils';
import { aggregateRundowns, makeCSVFromArrayOfArrays } from '../utils/csv';
import { apiEntryUrl } from './constants';
import { createBlob, downloadBlob } from './utils';
@@ -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
*/
-16
View File
@@ -3,7 +3,6 @@ import { CustomFields, Rundown } from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants';
import { downloadBlob } from './utils';
const excelPath = `${apiEntryUrl}/excel`;
@@ -40,18 +39,3 @@ export async function importRundownPreview(options: ImportMap): Promise<PreviewS
});
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);
}
}
+44 -62
View File
@@ -1,11 +1,17 @@
import axios, { AxiosResponse } from 'axios';
import { EntryId, OntimeEntry, OntimeEvent, ProjectRundownsList, Rundown, TransientEventPayload } from 'ontime-types';
import {
EntryId,
MessageResponse,
OntimeEntry,
OntimeEvent,
ProjectRundownsList,
Rundown,
TransientEventPayload,
} from 'ontime-types';
import { apiEntryUrl } from './constants';
const rundownPath = `${apiEntryUrl}/rundowns`;
// #region operations on project rundowns =========================
const rundownPath = `${apiEntryUrl}/rundown`;
/**
* HTTP request to fetch a list of existing rundowns
@@ -16,64 +22,37 @@ export async function fetchProjectRundownList(): Promise<ProjectRundownsList> {
}
/**
* HTTP request to fetch all entries in the currently loaded rundown
* HTTP request to fetch all events
*/
export async function fetchCurrentRundown(): Promise<Rundown> {
const res = await axios.get(`${rundownPath}/current`);
return res.data;
}
/**
* HTTP request to switch the currently loaded rundown
*/
export async function loadRundown(id: string): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(`${rundownPath}/${id}/load`);
}
/**
* HTTP request to create a new rundown
*/
export async function createRundown(title: string): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(rundownPath, { title });
}
/**
* HTTP request to delete a rundown
*/
export async function deleteRundown(id: string): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.delete(`${rundownPath}/${id}`);
}
// #endregion operations on project rundowns ======================
// #region operations on rundown entries ==========================
/**
* HTTP request to post new entry
*/
export async function postAddEntry(
rundownId: string,
data: TransientEventPayload,
): Promise<AxiosResponse<OntimeEntry>> {
return axios.post(`${rundownPath}/${rundownId}/entry`, data);
export async function postAddEntry(data: TransientEventPayload): Promise<AxiosResponse<OntimeEntry>> {
return axios.post(rundownPath, data);
}
/**
* HTTP request to edit an entry
*/
export async function putEditEntry(rundownId: string, data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> {
return axios.put(`${rundownPath}/${rundownId}/entry`, data);
export async function putEditEntry(data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> {
return axios.put(rundownPath, data);
}
export type BatchEditEntry = {
type BatchEditEntry = {
data: Partial<OntimeEvent>;
ids: EntryId[];
ids: string[];
};
/**
* HTTP request to edit multiple events
*/
export async function putBatchEditEvents(rundownId: string, data: BatchEditEntry): Promise<AxiosResponse<Rundown>> {
return axios.put(`${rundownPath}/${rundownId}/batch`, data);
export async function putBatchEditEvents(data: BatchEditEntry): Promise<AxiosResponse<Rundown>> {
return axios.put(`${rundownPath}/batch`, data);
}
export type ReorderEntry = {
@@ -85,57 +64,60 @@ export type ReorderEntry = {
/**
* HTTP request to reorder an entry
*/
export async function patchReorderEntry(rundownId: string, data: ReorderEntry): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/${rundownId}/reorder`, data);
export async function patchReorderEntry(data: ReorderEntry): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/reorder`, data);
}
export type SwapEntry = {
from: string;
to: string;
};
/**
* HTTP request to swap two events
*/
export async function requestEventSwap(rundownId: string, from: EntryId, to: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/${rundownId}/swap`, { from, to });
export async function requestEventSwap(data: SwapEntry): Promise<AxiosResponse<MessageResponse>> {
return axios.patch(`${rundownPath}/swap`, data);
}
/**
* HTTP request to request application of delay
*/
export async function requestApplyDelay(rundownId: string, delayId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/${rundownId}/applydelay/${delayId}`);
export async function requestApplyDelay(delayId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/applydelay/${delayId}`);
}
/**
* HTTP request for cloning an entry
*/
export async function postCloneEntry(rundownId: string, entryId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`);
export async function postCloneEntry(entryId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/clone/${entryId}`);
}
/**
* HTTP request for grouping a list of entries into a group
* HTTP request for dissolving of a block
*/
export async function requestGroupEntries(rundownId: string, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/group`, { ids: entryIds });
export async function requestUngroup(blockId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/ungroup/${blockId}`);
}
/**
* HTTP request for dissolving of a group
* HTTP request for grouping a list of entries into a block
*/
export async function requestUngroup(rundownId: string, groupId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/ungroup/${groupId}`);
export async function requestGroupEntries(entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/group`, { ids: entryIds });
}
/**
* HTTP request to delete entries of a given rundown
* HTTP request to delete entries
*/
export async function deleteEntries(rundownId: string, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
return axios.delete(`${rundownPath}/${rundownId}/entries`, { data: { ids: entryIds } });
export async function deleteEntries(entryIds: EntryId[]): Promise<AxiosResponse<MessageResponse>> {
return axios.delete(rundownPath, { data: { ids: entryIds } });
}
/**
* HTTP request to delete all entries of a given rundown
* HTTP request to delete all events
*/
export async function requestDeleteAll(rundownId: string): Promise<AxiosResponse<Rundown>> {
return axios.delete(`${rundownPath}/${rundownId}/all`);
export async function requestDeleteAll(): Promise<AxiosResponse<MessageResponse>> {
return axios.delete(`${rundownPath}/all`);
}
// #endregion operations on rundown entries =======================
+8 -3
View File
@@ -1,5 +1,5 @@
import axios from 'axios';
import { GetInfo, LinkOptions } from 'ontime-types';
import { GetInfo } from 'ontime-types';
import { apiEntryUrl } from './constants';
@@ -16,7 +16,12 @@ export async function getInfo(): Promise<GetInfo> {
/**
* HTTP request to get a pre-authenticated URL
*/
export async function generateUrl(options: LinkOptions & { baseUrl: string; path: string }): Promise<string> {
const res = await axios.post(`${sessionPath}/url`, options);
export async function generateUrl(
baseUrl: string,
path: string,
lock: boolean,
authenticate: boolean,
): Promise<string> {
const res = await axios.post(`${sessionPath}/url`, { baseUrl, path, lock, authenticate });
return res.data.url;
}
+4 -18
View File
@@ -6,7 +6,7 @@ import { apiEntryUrl } from './constants';
const urlPresetsPath = `${apiEntryUrl}/url-presets`;
/**
* HTTP request to retrieve all presets
* HTTP request to retrieve aliases
*/
export async function getUrlPresets(): Promise<URLPreset[]> {
const res = await axios.get(urlPresetsPath);
@@ -14,22 +14,8 @@ export async function getUrlPresets(): Promise<URLPreset[]> {
}
/**
* HTTP request to add a preset
* HTTP request to mutate aliases
*/
export async function postUrlPreset(data: URLPreset): Promise<URLPreset[]> {
return (await axios.post(urlPresetsPath, data)).data;
}
/**
* HTTP request to edit a preset
*/
export async function putUrlPreset(alias: string, data: URLPreset): Promise<URLPreset[]> {
return (await axios.put(`${urlPresetsPath}/${alias}`, data)).data;
}
/**
* HTTP request to delete a preset
*/
export async function deleteUrlPreset(alias: string): Promise<URLPreset[]> {
return (await axios.delete(`${urlPresetsPath}/${alias}`)).data;
export async function postUrlPresets(data: URLPreset[]): Promise<URLPreset[]> {
return axios.post(urlPresetsPath, data);
}
-14
View File
@@ -33,20 +33,6 @@ export function maybeAxiosError(error: unknown) {
}
}
/**
* Utility unwrap a an instance of Error
*/
export function unwrapError(error: unknown) {
if (error instanceof Error) {
return error.message;
} else {
if (typeof error !== 'string') {
return JSON.stringify(error);
}
return error;
}
}
/**
* Utility unwraps a potential axios error and sends to logger
* @param prepend
@@ -33,13 +33,7 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
if (newPath === '/' || newPath === currentPath) {
return;
}
if (newPath.startsWith('preset-')) {
setRedirect({ target: id, redirect: newPath.slice(7) });
} else {
setRedirect({ target: id, redirect: newPath });
}
setRedirect({ target: id, redirect: newPath });
onClose();
};
@@ -51,8 +45,8 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
label: view.label,
})),
...enabledPresets.map((preset) => ({
value: `preset-${preset.alias}`,
label: `URL Preset: ${preset.alias}`,
value: preset.pathAndParams,
label: `Preset: ${preset.alias}`,
})),
];
@@ -67,11 +61,32 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
<>
<Info>
Remotely redirect the client to a different URL. <br />
Either by entering a custom path or selecting a URL Preset.
Either by selecting a URL Preset or entering a custom path.
<br />
<br />
<AppLink search='settings=sharing__presets'>Manage URL Presets</AppLink>
</Info>
<div>
<span className={style.label}>Select View or URL Preset</span>
<div className={style.textEntry}>
<Select
fluid
options={viewOptions}
defaultValue={viewOptions[0].value}
onValueChange={(value) => setSelected(value)}
disabled={enabledPresets.length === 0}
/>
<Button
variant='primary'
aria-label='Redirect to preset'
className={style.redirect}
disabled={enabledPresets.length === 0 || selected === '/'}
onClick={() => handleRedirect(selected)}
>
Redirect <IoArrowForward />
</Button>
</div>
</div>
<div className={style.inlineEntry}>
<span className={style.label}>Enter custom path</span>
<label className={style.textEntry}>
@@ -85,34 +100,9 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
className={style.redirect}
onClick={() => handleRedirect(path)}
>
Redirect
<IoArrowForward />
Redirect <IoArrowForward />
</Button>
</div>
<div>
<span className={style.label}>Select View or URL Preset</span>
<div className={style.inlineEntry}>
<label className={style.textEntry}>
{origin}
<Select
fluid
options={viewOptions}
defaultValue={viewOptions[0].value}
onValueChange={(value) => setSelected(value)}
disabled={enabledPresets.length === 0}
/>
</label>
<Button
variant='primary'
aria-label='Redirect to preset'
className={style.redirect}
disabled={enabledPresets.length === 0 || selected === '/'}
onClick={() => handleRedirect(selected)}
>
Redirect <IoArrowForward />
</Button>
</div>
</div>
</>
}
/>
@@ -1,5 +1,6 @@
import { PropsWithChildren, useRef, useState } from 'react';
import { IoCheckmark, IoCopy } from 'react-icons/io5';
import { PropsWithChildren, useState } from 'react';
import { IoCheckmark } from 'react-icons/io5';
import { IoCopy } from 'react-icons/io5';
import copyToClipboard from '../../utils/copyToClipboard';
import { cx } from '../../utils/styleUtils';
@@ -23,29 +24,32 @@ export default function CopyTag({
onClick,
}: PropsWithChildren<CopyTagProps>) {
const [copied, setCopied] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleClick = () => {
copyToClipboard(copyValue);
setCopied(true);
// reset copied state
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => setCopied(false), 2000);
setTimeout(() => setCopied(false), 1000);
};
return (
<div className={style.copytag}>
{onClick !== undefined ? (
<Button className={style.action} size={size} onClick={onClick} disabled={disabled}>
<Button className={style.action} size={size} tabIndex={-1} onClick={onClick} disabled={disabled}>
{children}
</Button>
) : (
<div className={cx([style.label, style[size]])}>{children}</div>
)}
<IconButton className={style.copy} variant='primary' size={size} onClick={handleClick} disabled={disabled}>
<IconButton
className={style.copy}
variant='primary'
size={size}
tabIndex={-1}
onClick={handleClick}
disabled={disabled}
>
{copied ? <IoCheckmark /> : <IoCopy />}
</IconButton>
</div>
@@ -1,7 +1,5 @@
.delaySymbol {
svg {
display: inline;
vertical-align: middle;
font-size: 1.5rem;
color: $ontime-delay;
margin: 0 auto;
@@ -13,7 +13,6 @@
border-radius: 3px;
box-shadow: $box-shadow-l1;
border: 1px solid $gray-1100;
outline: none;
}
.backdrop {
@@ -49,14 +49,15 @@ class ErrorBoundary extends React.Component {
return (
<div className={style.errorContainer} data-testid='error-container'>
<div>
<p className={style.error}>: /</p>
<p>Something went wrong.</p>
<a
<p className={style.error}>:/</p>
<p>Something went wrong</p>
<div
role='button'
className={style.report}
href={`mailto:mail@getontime.no?subject=Error%20Report&body=${encodeURIComponent(this.reportContent)}`}
onClick={() => Sentry.showReportDialog({ eventId: this.state.eventId })}
>
Report error
</a>
</div>
<div
role='button'
className={style.report}
@@ -3,21 +3,24 @@
height: 100%;
display: grid;
place-content: center;
background-color: $ui-black;
color: $ui-white;
}
background-color: #121212;
color: white;
.error {
color: $error-red;
}
.error {
color: $error-red;
font-weight: 600;
}
.report {
color: $blue-500;
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
.report {
text-decoration: underline $error-red;
cursor: pointer;
}
&:hover {
color: $ontime-color;
.report:hover {
color: $error-red;
}
.report:active {
color: white;
}
}
@@ -1,4 +1,3 @@
import { MouseEvent } from 'react';
import { IoBan } from 'react-icons/io5';
import { cx } from '../../../utils/styleUtils';
@@ -11,13 +10,12 @@ interface SwatchProps {
isSelected?: boolean;
}
export default function Swatch({ color, isSelected, onClick }: SwatchProps) {
const handleClick = (event: MouseEvent) => {
onClick?.(color);
event.preventDefault();
event.stopPropagation();
};
export default function Swatch(props: SwatchProps) {
const { color, isSelected, onClick } = props;
const handleClick = () => {
onClick?.(color);
};
const classes = cx([style.swatch, isSelected && style.selected, onClick && style.selectable]);
if (!color) {
@@ -1,13 +1,12 @@
.list {
display: flex;
flex-wrap: nowrap;
flex-wrap: wrap;
gap: 0.5rem;
}
.swatch {
width: 2rem;
height: 2rem;
aspect-ratio: 1;
border-radius: 99px;
border: 2px solid $gray-1200;
color: $ui-white;
@@ -31,7 +31,11 @@
}
&[data-checked] {
background-color: $gray-700;
background-color: $gray-1200;
&:hover {
border-color: $gray-1000;
}
}
&:focus-visible {
@@ -53,6 +57,6 @@
border-radius: 100%;
width: 0.5em;
height: 0.5em;
background-color: $ui-white;
background-color: $blue-500;
}
}
@@ -20,7 +20,7 @@ export default function DelayInput(props: DelayInputProps) {
const [value, setValue] = useState<string>('');
const inputRef = useRef<HTMLInputElement | null>(null);
// avoid wrong submit on cancel
const ignoreChangeRef = useRef(false);
let ignoreChange = false;
// set internal value on duration change
useEffect(() => {
@@ -35,8 +35,8 @@ export default function DelayInput(props: DelayInputProps) {
* @param {string} newValue string to be parsed
*/
const validateAndSubmit = (newValue: string) => {
if (ignoreChangeRef.current) {
ignoreChangeRef.current = false;
if (ignoreChange) {
ignoreChange = false;
return;
}
@@ -78,7 +78,7 @@ export default function DelayInput(props: DelayInputProps) {
} else if (event.key === 'Tab') {
validateAndSubmit((event.target as HTMLInputElement).value);
} else if (event.key === 'Escape') {
ignoreChangeRef.current = true;
ignoreChange = true;
setValue(millisToString(duration));
inputRef.current?.blur();
}
@@ -10,19 +10,13 @@
padding-inline: 0.5em;
outline: none;
&::placeholder {
color: $gray-500;
letter-spacing: 0;
}
&:hover:not(:disabled):not(:read-only) {
&:hover:not(:disabled) {
background-color: $gray-1100;
}
&:focus:not(:read-only) {
background-color: $gray-1000;
outline: 2px solid $blue-500;
outline-offset: 2px;
border: 1px solid $blue-500;
}
&:disabled {
@@ -30,8 +24,9 @@
cursor: not-allowed;
}
&:read-only::placeholder {
opacity: 0;
&::placeholder {
color: $gray-500;
letter-spacing: 0;
}
}
@@ -9,13 +9,9 @@
border-radius: $component-border-radius-md;
border: 1px solid transparent;
padding-inline: 0.5em;
outline: none;
&::placeholder {
color: $gray-500;
letter-spacing: 0;
}
&:hover:not(:disabled) {
background-color: $gray-1100;
}
@@ -30,21 +26,21 @@
cursor: not-allowed;
}
&:read-only::placeholder {
opacity: 0;
&::placeholder {
color: $gray-500;
letter-spacing: 0;
}
}
.subtle {
background-color: $gray-1200;
padding-inline: 0.5em;
padding-top: 0.25em;
padding-top: 0.5em;
}
.ghosted {
background-color: transparent;
padding: 0;
padding-top: 0.25em;
padding-top: 0.5em;
}
.fluid {
@@ -9,7 +9,7 @@ import style from './TimeInput.module.scss';
interface NullableTimeInputProps<T extends string> {
id?: T;
name: T;
submitHandler: (field: T, value: number) => void;
submitHandler: (field: T, value: string) => void;
time?: number | null;
emptyDisplay: string;
placeholder?: string;
@@ -71,7 +71,7 @@ export default function NullableTimeInput<T extends string>({
return false;
}
submitHandler(name, valueInMillis);
submitHandler(name, newValue);
return true;
},
[name, submitHandler, time],
@@ -1,5 +1,5 @@
import { type PropsWithChildren } from 'react';
import { useNavigate } from 'react-router';
import { useNavigate } from 'react-router-dom';
import style from './AppLink.module.scss';
@@ -11,7 +11,8 @@ interface AppLinkProps {
* Component used to navigate to an editor link inside the same window
* Handles the path to respect Ontime Clouds base URL
*/
export default function AppLink({ search, children }: PropsWithChildren<AppLinkProps>) {
export default function AppLink(props: PropsWithChildren<AppLinkProps>) {
const { search, children } = props;
const navigate = useNavigate();
const handleClick = () => navigate({ search });
@@ -12,7 +12,9 @@ interface ExternalLinkProps {
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) => {
event.preventDefault();
openLink(href);
@@ -5,9 +5,9 @@
transform: translateX(-50%);
padding-inline: 1rem;
min-width: min(880px, 90vw);
min-width: min(680px, 90vw);
min-height: min(200px, 10vh);
max-width: min(1200px, 90vw);
max-width: min(800px, 90vw);
background-color: $gray-1250;
color: $ui-white;
@@ -64,8 +64,3 @@
flex: 1;
overflow-y: auto;
}
.note {
margin-left: auto;
color: $white-20;
}
@@ -1,12 +1,10 @@
import { memo } from 'react';
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
import { LuCoffee } from 'react-icons/lu';
import { useLocation } from 'react-router';
import { useLocation } from 'react-router-dom';
import { Dialog } from '@base-ui-components/react/dialog';
import { useDisclosure, useFullscreen } from '@mantine/hooks';
import { isLocalhost } from '../../../externals';
import { useKeepAwakeOptions } from '../../../features/keep-awake/KeepAwake';
import { navigatorConstants } from '../../../viewerConfig';
import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions';
@@ -33,7 +31,6 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
const [isRenameOpen, handlers] = useDisclosure(false);
const { fullscreen, toggle } = useFullscreen();
const { mirror, toggleMirror } = useViewOptionsStore();
const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions();
const location = useLocation();
return (
@@ -60,18 +57,10 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
Toggle Fullscreen
{fullscreen ? <IoContract /> : <IoExpand />}
</NavigationMenuItem>
<NavigationMenuItem active={mirror} onClick={() => toggleMirror()}>
<NavigationMenuItem active={mirror} onClick={toggleMirror}>
Flip Screen
<IoSwapVertical />
{mirror && <span className={style.note}>Active</span>}
</NavigationMenuItem>
{window.isSecureContext && (
<NavigationMenuItem active={keepAwake} onClick={toggleKeepAwake}>
Keep Awake
<LuCoffee />
{keepAwake && <span className={style.note}>Active</span>}
</NavigationMenuItem>
)}
<NavigationMenuItem onClick={handlers.open}>Rename Client</NavigationMenuItem>
<hr className={style.separator} />
@@ -1,29 +1,26 @@
import { memo } from 'react';
import { useDisclosure, useHotkeys } from '@mantine/hooks';
import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store';
import FloatingNavigation from './floating-navigation/FloatingNavigation';
import ViewLockedIcon from './view-locked-icon/ViewLockedIcon';
import NavigationMenu from './NavigationMenu';
import useViewEditor from './useViewEditor';
interface ViewNavigationMenuProps {
/** prevent navigation */
isNavigationLocked?: boolean;
/** prevent showing settings */
isLockable?: boolean;
suppressSettings?: boolean;
}
export default memo(ViewNavigationMenu);
function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) {
function ViewNavigationMenu({ isLockable, suppressSettings }: ViewNavigationMenuProps) {
const [isMenuOpen, menuHandler] = useDisclosure();
const { open: showEditFormDrawer } = useViewParamsEditorStore();
const { showEditFormDrawer, isViewLocked } = useViewEditor({ isLockable });
useHotkeys([
[
'Space',
() => {
if (isNavigationLocked) return;
if (isViewLocked) return;
menuHandler.toggle();
},
{ preventDefault: true },
@@ -31,24 +28,24 @@ function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNaviga
[
'mod + ,',
() => {
if (suppressSettings) return;
if (isViewLocked || suppressSettings) return;
showEditFormDrawer();
},
{ preventDefault: true },
],
]);
if (isNavigationLocked && suppressSettings) {
if (isViewLocked) {
return <ViewLockedIcon />;
}
return (
<>
<FloatingNavigation
toggleMenu={isNavigationLocked ? undefined : menuHandler.toggle}
toggleSettings={suppressSettings ? undefined : showEditFormDrawer}
toggleMenu={menuHandler.toggle}
toggleSettings={suppressSettings ? undefined : () => showEditFormDrawer()}
/>
{!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />}
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
</>
);
}
@@ -1,6 +1,6 @@
import { PropsWithChildren } from 'react';
import { IoArrowUp } from 'react-icons/io5';
import { useNavigate } from 'react-router';
import { useNavigate } from 'react-router-dom';
import { useElectronEvent } from '../../../hooks/useElectronEvent';
import { handleLinks } from '../../../utils/linkUtils';
@@ -1,5 +1,5 @@
import { IoLockClosedOutline } from 'react-icons/io5';
import { useNavigate } from 'react-router';
import { useNavigate } from 'react-router-dom';
import { useIsSmallDevice } from '../../../hooks/useIsSmallDevice';
import NavigationMenuItem from '../navigation-menu-item/NavigationMenuItem';
@@ -14,8 +14,7 @@
display: flex;
flex-direction: column;
row-gap: 1rem;
padding-top: 0.75rem;
padding-left: 0.5em;
padding: 0.5em;
position: fixed;
left: 0;
@@ -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 { cx } from '../../../utils/styleUtils';
@@ -7,7 +8,7 @@ import IconButton from '../../buttons/IconButton';
import style from './FloatingNavigation.module.scss';
interface FloatingNavigationProps {
toggleMenu?: () => void;
toggleMenu: () => void;
toggleSettings?: () => void;
}
@@ -19,17 +20,15 @@ export default function FloatingNavigation({ toggleMenu, toggleSettings }: Float
id='fadeable-navigation'
className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])}
>
{toggleMenu && (
<IconButton
variant='subtle-white'
size='xlarge'
onClick={toggleMenu}
aria-label='toggle menu'
data-testid='navigation__toggle-menu'
>
<IoApps />
</IconButton>
)}
<IconButton
variant='subtle-white'
size='xlarge'
onClick={toggleMenu}
aria-label='toggle menu'
data-testid='navigation__toggle-menu'
>
<IoApps />
</IconButton>
{toggleSettings && (
<IconButton
variant='subtle-white'
@@ -5,17 +5,12 @@
}
.interfaces {
padding: 0.5rem 0.5rem;
padding: 0.5rem 1rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.interfaceCopy {
display: flex;
align-items: center;
}
.goIcon {
@include rotate-fourty-five;
margin-left: 0.25rem;
@@ -30,10 +30,8 @@ export default function OtherAddresses({ currentLocation }: OtherAddressesProps)
const address = linkToOtherHost(nif.address, currentLocation);
return (
<CopyTag key={nif.name} copyValue={address} onClick={() => openLink(address)} size='small'>
<span className={style.interfaceCopy}>
{nif.address} <IoArrowUp className={style.goIcon} />
</span>
<CopyTag key={nif.name} copyValue={address} onClick={() => openLink(address)}>
{nif.address} <IoArrowUp className={style.goIcon} />
</CopyTag>
);
})}
@@ -0,0 +1,23 @@
import { useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store';
interface EditorVisibilityOptions {
isLockable?: boolean;
}
export default function useViewEditor({ isLockable }: EditorVisibilityOptions) {
const [searchParams] = useSearchParams();
const { open: showEditFormDrawer } = useViewParamsEditorStore();
const isViewLocked = useMemo(() => {
if (!isLockable) {
return false;
}
return isStringBoolean(searchParams.get('locked'));
}, [isLockable, searchParams]);
return { showEditFormDrawer, isViewLocked };
}
@@ -11,11 +11,9 @@
}
.pin {
margin-top: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
input {
font-size: 4rem;
@@ -1,15 +1,9 @@
.radioGroup {
display: flex;
gap: 0.25rem;
color: $gray-900;
font-size: calc(1rem - 2px);
color: $ui-white;
&[data-disabled] {
.item {
opacity: $opacity-disabled;
cursor: not-allowed;
}
}
}
.horizontal {
@@ -38,8 +32,8 @@
.radio {
box-sizing: border-box;
display: flex;
width: 16px; // use absolute units to avoid subpixel rendering issues
height: 16px;
width: 1rem;
height: 1rem;
align-items: center;
justify-content: center;
border-radius: 100%;
@@ -52,7 +46,10 @@
}
&[data-checked] {
background-color: $blue-700;
background-color: $ui-white;
&:hover {
border-color: $gray-1000;
}
}
&:focus-visible {
@@ -72,9 +69,8 @@
&::before {
content: '';
border-radius: 100%;
width: 6px; // half of the radio size
height: 6px;
aspect-ratio: 1;
background-color: $ui-white;
width: 0.5rem;
height: 0.5rem;
background-color: $gray-1200;
}
}
@@ -14,6 +14,7 @@
border: 1px solid transparent;
padding-inline: 0.5rem;
font-size: calc(1rem - 2px);
white-space: nowrap;
&:hover:not([data-disabled]) {
@@ -56,12 +57,14 @@
}
.popup {
font-size: 1rem;
font-size: calc(1rem - 2px);
background-color: $gray-1200;
box-sizing: border-box;
padding: 2px;
border-radius: $component-border-radius-md;
color: $ui-white;
overflow-y: auto;
max-height: 20rem;
border: 1px solid $gray-1000;
&[data-side='start'] {
@@ -71,16 +74,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 {
box-sizing: border-box;
outline: 0;
@@ -31,17 +31,14 @@ export default function Select<T>({ options, fluid, size = 'medium', ...selectRo
<BaseSelect.Positioner side='bottom' align='start'>
<BaseSelect.ScrollUpArrow className={styles.scrollArrow} />
<BaseSelect.Popup className={styles.popup}>
<BaseSelect.Arrow />
<BaseSelect.List className={styles.list}>
{options.map(({ disabled, label, value }) => (
<BaseSelect.Item key={String(value)} className={styles.item} value={value} disabled={disabled}>
<BaseSelect.ItemIndicator className={styles.itemIndicator}>
<IoCheckmark className={styles.itemIndicatorIcon} />
</BaseSelect.ItemIndicator>
<BaseSelect.ItemText className={styles.itemLabel}>{label}</BaseSelect.ItemText>
</BaseSelect.Item>
))}
</BaseSelect.List>
{options.map(({ disabled, label, value }) => (
<BaseSelect.Item key={String(value)} className={styles.item} value={value} disabled={disabled}>
<BaseSelect.ItemIndicator className={styles.itemIndicator}>
<IoCheckmark className={styles.itemIndicatorIcon} />
</BaseSelect.ItemIndicator>
<BaseSelect.ItemText className={styles.itemLabel}>{label}</BaseSelect.ItemText>
</BaseSelect.Item>
))}
</BaseSelect.Popup>
<BaseSelect.ScrollDownArrow className={styles.scrollArrow} />
</BaseSelect.Positioner>
@@ -9,10 +9,7 @@
}
.text {
display: block;
margin-inline: auto;;
font-weight: 600;
font-size: 2em;
max-width: 600px;
}
}
@@ -7,13 +7,14 @@ import style from './Empty.module.scss';
interface EmptyProps {
text?: string;
injectedStyles?: CSSProperties;
style?: CSSProperties;
className?: string;
}
export default function Empty({ text, className, injectedStyles }: EmptyProps) {
export default function Empty(props: EmptyProps) {
const { text, className, ...rest } = props;
return (
<div className={cx([style.emptyContainer, className])} style={injectedStyles}>
<div className={cx([style.emptyContainer, className])} {...rest}>
<EmptyImage className={style.empty} />
{text && <span className={style.text}>{text}</span>}
</div>
@@ -6,13 +6,15 @@ import style from './EmptyPage.module.scss';
interface EmptyPageProps {
text?: string;
injectedStyles?: CSSProperties;
style?: CSSProperties;
}
export default function EmptyPage({ text, injectedStyles }: EmptyPageProps) {
export default function EmptyPage(props: EmptyPageProps) {
const { text, ...rest } = props;
return (
<div className={style.page}>
<Empty text={text} injectedStyles={injectedStyles} />
<Empty text={text} {...rest} />
</div>
);
}
@@ -1,20 +0,0 @@
.emptyContainer {
width: 100%;
color: $white-10;
.emptyCell {
margin-inline: auto;
text-align: center;
padding-top: 10vh;
}
.empty {
width: 100%;
opacity: 0.8;
}
.text {
font-weight: 600;
font-size: 2em;
}
}
@@ -1,20 +0,0 @@
import EmptyImage from '../../../assets/images/empty.svg?react';
import style from './EmptyTableBody.module.scss';
interface EmptyTableBodyProps {
text: string;
}
export default function EmptyTableBody({ text }: EmptyTableBodyProps) {
return (
<tbody className={style.emptyContainer}>
<tr>
<td colSpan={99} className={style.emptyCell}>
<EmptyImage className={style.empty} />
{text && <span className={style.text}>{text}</span>}
</td>
</tr>
</tbody>
);
}
@@ -22,11 +22,6 @@
outline: 2px solid $blue-500;
outline-offset: 2px;
}
&[data-disabled] {
opacity: $opacity-disabled;
cursor: not-allowed;
}
}
.medium {
@@ -46,7 +41,7 @@
}
.thumb {
aspect-ratio: 1;
aspect-ratio: 1 / 1;
height: 100%;
border-radius: 99px;
background-color: $ui-white;
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
import SwatchPicker from '../input/colour-input/SwatchPicker';
@@ -16,13 +16,10 @@ const ensureHex = (value: string) => {
return value;
};
export default function InlineColourPicker({ name, value }: InlineColourPickerProps) {
export default function InlineColourPicker(props: InlineColourPickerProps) {
const { name, value } = props;
const [colour, setColour] = useState(() => ensureHex(value));
useEffect(() => {
setColour(ensureHex(value));
}, [value]);
return (
<div className={style.inline}>
<SwatchPicker color={colour} onChange={setColour} alwaysDisplayColor />
@@ -1,10 +1,10 @@
import { ComponentProps, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router';
import { useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
import Checkbox from '../checkbox/Checkbox';
import Input from '../input/input/Input';
import Select, { SelectOption } from '../select/Select';
import Select from '../select/Select';
import Switch from '../switch/Switch';
import InlineColourPicker from './InlineColourPicker';
@@ -35,22 +35,15 @@ export default function ParamInput({ paramField }: ParamInputProps) {
return <span className={style.empty}>No options available</span>;
}
return <ControlledSelect id={id} initialValue={defaultOptionValue} options={paramField.values} />;
return <Select size='large' name={id} defaultValue={defaultOptionValue} options={paramField.values} />;
}
if (type === 'multi-option') {
const optionFromParams = searchParams.getAll(id);
return (
<MultiOption
paramField={paramField}
options={optionFromParams.length ? optionFromParams : paramField.defaultValue ?? ['']}
/>
);
return <MultiOption paramField={paramField} />;
}
if (type === 'boolean') {
return <ControlledSwitch id={id} initialValue={isStringBoolean(searchParams.get(id)) ?? defaultValue} />;
return <ControlledSwitch id={id} initialValue={isStringBoolean(searchParams.get(id)) || defaultValue} />;
}
if (type === 'number') {
@@ -70,28 +63,27 @@ export default function ParamInput({ paramField }: ParamInputProps) {
}
if (type === 'colour') {
return <InlineColourPicker name={id} value={searchParams.get(id) ?? defaultValue} />;
const currentvalue = `#${searchParams.get(id) ?? defaultValue}`;
return <InlineColourPicker name={id} value={currentvalue} />;
}
const defaultStringValue = searchParams.get(id) ?? defaultValue ?? '';
const defaultStringValue = searchParams.get(id) ?? defaultValue;
const { placeholder } = paramField;
return <ControlledInput id={id} initialValue={defaultStringValue} placeholder={placeholder} />;
return <Input height='large' name={id} defaultValue={defaultStringValue} placeholder={placeholder} />;
}
interface EditFormMultiOptionProps {
paramField: ParamField & { type: 'multi-option' };
options: string[];
}
function MultiOption({ paramField, options }: EditFormMultiOptionProps) {
const { id, values } = paramField;
const [paramState, setParamState] = useState<string[]>(options);
function MultiOption({ paramField }: EditFormMultiOptionProps) {
const [searchParams] = useSearchParams();
const { id, values, defaultValue = [''] } = paramField;
// synchronise options
useEffect(() => {
setParamState(options);
}, [options]);
const optionFromParams = searchParams.getAll(id);
const [paramState, setParamState] = useState<string[]>(optionFromParams || defaultValue);
const toggleValue = (value: string, checked: boolean) => {
if (checked) {
@@ -137,52 +129,5 @@ interface ControlledSwitchProps {
}
function ControlledSwitch({ id, initialValue }: ControlledSwitchProps) {
const [checked, setChecked] = useState(initialValue);
// synchronise checked state
useEffect(() => {
setChecked(initialValue);
}, [initialValue]);
return <Switch size='large' name={id} checked={checked} onCheckedChange={setChecked} />;
}
interface ControlledSelectProps {
id: string;
initialValue?: string;
options: SelectOption[];
}
function ControlledSelect({ id, initialValue, options }: ControlledSelectProps) {
const [selected, setSelected] = useState(initialValue);
// synchronise selected state
useEffect(() => {
setSelected(initialValue);
}, [initialValue]);
return (
<Select size='large' name={id} options={options} value={selected} onValueChange={(value) => setSelected(value)} />
);
}
interface ControlledInputProps<T extends number | string> extends ComponentProps<typeof Input> {
id: string;
initialValue: T;
}
function ControlledInput<T extends number | string>({ id, initialValue, ...inputProps }: ControlledInputProps<T>) {
const [value, setValue] = useState(initialValue);
// synchronise selected state
useEffect(() => {
setValue(initialValue);
}, [initialValue]);
return (
<Input
height='large'
name={id}
value={value}
onChange={(event) => setValue(event.target.value as T)}
{...inputProps}
/>
);
}
@@ -28,9 +28,7 @@
bottom: 0;
width: 40rem;
max-width: 100vw;
height: 100vh;
height: 100dvh;
display: flex;
flex-direction: column;
@@ -1,8 +1,7 @@
import { FormEvent, memo } from 'react';
import { IoClose } from 'react-icons/io5';
import { useSearchParams } from 'react-router';
import { useSearchParams } from 'react-router-dom';
import { Dialog } from '@base-ui-components/react/dialog';
import { OntimeView } from 'ontime-types';
import useViewSettings from '../../hooks-query/useViewSettings';
import Button from '../buttons/Button';
@@ -12,18 +11,17 @@ import Info from '../info/Info';
import { ViewOption } from './viewParams.types';
import { getURLSearchParamsFromObj } from './viewParams.utils';
import { useViewParamsEditorStore } from './viewParamsEditor.store';
import { ViewParamsPresets } from './ViewParamsPresets';
import ViewParamsSection from './ViewParamsSection';
import style from './ViewParamsEditor.module.scss';
interface EditFormDrawerProps {
target: OntimeView;
viewOptions: ViewOption[];
}
export default memo(ViewParamsEditor);
function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
const [_, setSearchParams] = useSearchParams();
const { data: viewSettings } = useViewSettings();
const { isOpen, close } = useViewParamsEditorStore();
@@ -58,7 +56,7 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
<Dialog.Popup className={style.drawer}>
<div className={style.header}>
<Dialog.Title>Customise</Dialog.Title>
<IconButton variant='subtle-white' size='large' data-testid='close-view-params' onClick={handleClose}>
<IconButton variant='subtle-white' size='large' onClick={handleClose}>
<IoClose />
</IconButton>
</div>
@@ -66,7 +64,6 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
{viewSettings.overrideStyles && (
<Info className={style.info}>This view style is being modified by a custom CSS file.</Info>
)}
<ViewParamsPresets target={target} />
<form id='edit-params-form' onSubmit={onParamsFormSubmit} className={style.sectionList}>
{viewOptions.map((section) => (
<ViewParamsSection
@@ -82,14 +79,8 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
<Button variant='subtle' size='large' onClick={resetParams} type='reset'>
Reset to default
</Button>
<Button
variant='primary'
size='large'
form='edit-params-form'
type='submit'
data-testid='apply-view-params'
>
Apply
<Button variant='primary' size='large' form='edit-params-form' type='submit'>
Save
</Button>
</div>
</Dialog.Popup>
@@ -1,25 +0,0 @@
.presetSection {
background-color: $gray-1350;
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 1rem 0.5rem;
margin-bottom: 1rem;
max-height: 10rem;
overflow-y: auto;
scrollbar-gutter: stable;
}
.preset {
display: flex;
align-items: center;
gap: 0.5rem;
&.active {
color: $blue-500;
}
}
.presetActions {
margin-left: auto;
}
@@ -1,47 +0,0 @@
import { useSearchParams } from 'react-router';
import { OntimeView, URLPreset } from 'ontime-types';
import { useViewUrlPresets } from '../../hooks-query/useUrlPresets';
import { cx } from '../../utils/styleUtils';
import Button from '../buttons/Button';
import style from './ViewParamsPresets.module.scss';
/**
* Shows a list of presets for the current view
*/
export function ViewParamsPresets({ target }: { target: OntimeView }) {
const { viewPresets } = useViewUrlPresets(target);
const [searchParams, setSearchParams] = useSearchParams();
const handleRecall = (preset: URLPreset) => {
const newSearch = new URLSearchParams(preset.search);
newSearch.set('alias', preset.alias);
setSearchParams(newSearch);
};
if (viewPresets.length === 0) {
return null;
}
return (
<div className={style.presetSection}>
{viewPresets.map((preset) => {
const active = searchParams.get('alias') === preset.alias;
return (
<div key={preset.alias} className={cx([style.preset, active && style.active])}>
<div>{preset.alias}</div>
<Button
variant={active ? 'ghosted' : 'subtle-white'}
onClick={() => handleRecall(preset)}
disabled={active}
className={style.presetActions}
>
{active ? 'Applied' : 'Apply'}
</Button>
</div>
);
})}
</div>
);
}
@@ -36,15 +36,11 @@
}
.closed {
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
transition: rotate $transition-time-feedback;
rotate: 0deg;
}
.open {
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
transition: rotate $transition-time-feedback;
rotate: 180deg;
}
.hidden {
display: none;
}
@@ -1,3 +1,4 @@
import { memo } from 'react';
import { IoChevronDown } from 'react-icons/io5';
import { useLocalStorage } from '@mantine/hooks';
@@ -15,7 +16,8 @@ interface ViewParamsSectionProps {
options: ParamField[];
}
export default function ViewParamsSection({ title, collapsible, options }: ViewParamsSectionProps) {
export default memo(ViewParamsSection);
function ViewParamsSection({ title, collapsible, options }: ViewParamsSectionProps) {
const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false });
const handleCollapse = () => {
@@ -47,11 +49,15 @@ interface SectionContentsProps {
}
function SectionContents({ options, collapsed }: SectionContentsProps) {
if (collapsed) {
return null;
}
return (
<>
{options.map((option) => {
return (
<label key={option.title} className={cx([style.label, collapsed && style.hidden])}>
<label key={option.title} className={style.label}>
<span className={style.title}>{option.title}</span>
<span className={style.description}>{option.description}</span>
<ParamInput paramField={option} />
@@ -232,6 +232,7 @@ describe('getURLSearchParamsFromObj()', () => {
bool2: 'on',
};
const result = getURLSearchParamsFromObj(params, mockOptionsWithBooleans);
console.log('Result:', result.toString());
expect(result.get('bool1')).toBe('false');
expect(result.get('bool2')).toBe('true');
});
@@ -16,7 +16,7 @@ export type MultiselectOption = { value: string; label: string; colour: string }
type MultiOptionsField = {
type: 'multi-option';
values: MultiselectOption[];
defaultValue?: string[];
defaultValue?: string;
};
type StringField = { type: 'string'; defaultValue?: string; placeholder?: string };
@@ -1,4 +1,4 @@
import type { CustomFields, ProjectData } from 'ontime-types';
import type { CustomFields } from 'ontime-types';
import type { SelectOption } from '../select/Select';
@@ -53,23 +53,6 @@ export function makeCustomFieldSelectOptions(customFields: CustomFields, filterI
return options;
}
/**
* Creates data for a select element that displays project custom data
*/
export function makeProjectDataOptions(
projectData: ProjectData,
additionalOptions: SelectOption[] = [],
): SelectOption[] {
const generatedOptions = projectData.custom.map((entry, index) => {
return {
value: `${index}-${entry.title}`,
label: entry.title,
};
});
return [...additionalOptions, ...generatedOptions];
}
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
/**
@@ -1,4 +0,0 @@
import { createContext } from 'react';
import { URLPreset } from 'ontime-types';
export const PresetContext = createContext<URLPreset | undefined>(undefined);
@@ -1,16 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { langEn } from 'ontime-types';
import { getUserTranslation } from '../../common/api/assets';
import { TRANSLATION } from '../../common/api/constants';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
export function useCustomTranslation() {
const { data, status, refetch } = useQuery({
queryKey: TRANSLATION,
queryFn: getUserTranslation,
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
return { data: data ?? langEn, status, refetch };
}
@@ -1,8 +1,8 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_DATA } from '../api/constants';
import { getProjectData, postProjectData } from '../api/project';
import { getProjectData } from '../api/project';
import { projectDataPlaceholder } from '../models/ProjectData';
export default function useProjectData() {
@@ -10,25 +10,11 @@ export default function useProjectData() {
queryKey: PROJECT_DATA,
queryFn: getProjectData,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? projectDataPlaceholder, status, isFetching, isError, refetch };
}
export function useUpdateProjectData() {
const queryClient = useQueryClient();
const updateFn = useMutation({
mutationFn: postProjectData,
onSuccess: (newProjectData) => {
queryClient.setQueryData(PROJECT_DATA, newProjectData);
},
});
return {
updateProjectData: updateFn.mutateAsync,
isMutating: updateFn.isPending,
isMutatingError: updateFn.isError,
};
}
@@ -1,9 +1,9 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import { ProjectRundownsList } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_RUNDOWNS } from '../api/constants';
import { createRundown, deleteRundown, fetchProjectRundownList, loadRundown } from '../api/rundown';
import { fetchProjectRundownList } from '../api/rundown';
/**
* Project rundowns
@@ -13,43 +13,10 @@ export function useProjectRundowns() {
queryKey: PROJECT_RUNDOWNS,
queryFn: fetchProjectRundownList,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? { loaded: '', rundowns: [] }, status, isError, refetch, isFetching };
}
export function useMutateProjectRundowns() {
const ontimeQueryClient = useQueryClient();
const { mutateAsync: create } = useMutation({
mutationFn: createRundown,
onMutate: () => {
ontimeQueryClient.cancelQueries({ queryKey: PROJECT_RUNDOWNS });
},
onSuccess: (response) => {
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
},
});
const { mutateAsync: remove } = useMutation({
mutationFn: deleteRundown,
onMutate: () => {
ontimeQueryClient.cancelQueries({ queryKey: PROJECT_RUNDOWNS });
},
onSuccess: (response) => {
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
},
});
const { mutateAsync: load } = useMutation({
mutationFn: loadRundown,
onMutate: () => {
ontimeQueryClient.cancelQueries({ queryKey: PROJECT_RUNDOWNS });
},
onSuccess: (response) => {
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
},
});
return { create, remove, load };
}
@@ -5,8 +5,6 @@ import { EntryId, OntimeEntry, Rundown } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { RUNDOWN } from '../api/constants';
import { fetchCurrentRundown } from '../api/rundown';
import { useSelectedEventId } from '../hooks/useSocket';
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import useProjectData from './useProjectData';
@@ -28,18 +26,14 @@ export default function useRundown() {
queryKey: RUNDOWN,
queryFn: fetchCurrentRundown,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
}
export function useRundownWithMetadata() {
const { data, status } = useRundown();
const { selectedEventId } = useSelectedEventId();
const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data, status, rundownMetadata };
}
/**
* Provides access to a flat rundown
* built from the order and rundown fields
@@ -54,15 +48,14 @@ export function useFlatRundown() {
// update data whenever the revision changes
useEffect(() => {
if (data.revision !== -1 || data.revision !== prevRevision) {
const flatRundown = data.order.map((id) => data.entries[id]);
if (data.revision !== -1 && data.revision !== prevRevision) {
const flatRundown = data.flatOrder.map((id) => data.entries[id]);
setFlatRundown(flatRundown);
setPrevRevision(data.revision);
}
}, [data.entries, data.flatOrder, data.revision, prevRevision]);
// 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) {
@@ -71,22 +64,14 @@ export function useFlatRundown() {
}
}, [projectData]);
return { data: flatRundown, rundownId: data.id, status };
}
export function useFlatRundownWithMetadata() {
const { data, status } = useRundown();
const { selectedEventId } = useSelectedEventId();
const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data: rundownWithMetadata, status };
return { data: flatRundown, status };
}
/**
* Provides access to a partial rundown based on a filter callback
*/
export function usePartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boolean) {
const { data, status } = useFlatRundownWithMetadata();
export function usePartialRundown(cb: (event: OntimeEntry) => boolean) {
const { data, status } = useFlatRundown();
const filteredData = useMemo(() => {
return data.filter(cb);
}, [data, cb]);
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { unobfuscate } from 'ontime-utils';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { APP_SETTINGS } from '../api/constants';
import { getSettings } from '../api/settings';
import { ontimePlaceholderSettings } from '../models/OntimeSettings';
@@ -10,6 +11,10 @@ export default function useSettings() {
queryKey: APP_SETTINGS,
queryFn: getSettings,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
select: (data) => {
const unobfuscated = { ...data };
if (data.editorKey) {
@@ -1,10 +1,8 @@
import { useMemo } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { OntimeView, URLPreset } from 'ontime-types';
import { useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { URL_PRESETS } from '../api/constants';
import { deleteUrlPreset, getUrlPresets, postUrlPreset, putUrlPreset } from '../api/urlPresets';
import { getUrlPresets } from '../api/urlPresets';
interface FetchProps {
skip?: boolean;
@@ -15,50 +13,12 @@ export default function useUrlPresets({ skip = false }: FetchProps = {}) {
queryKey: URL_PRESETS,
queryFn: getUrlPresets,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
enabled: !skip,
});
return { data: data ?? [], status, isError, refetch };
}
export function useViewUrlPresets(view: OntimeView) {
const { data } = useUrlPresets();
const viewPresets = useMemo(() => data.filter((preset) => preset.target === view), [data, view]);
return { viewPresets };
}
export function useUpdateUrlPreset() {
const queryClient = useQueryClient();
const addFn = useMutation({
mutationFn: postUrlPreset,
onSuccess: (newPresets) => {
queryClient.setQueryData(URL_PRESETS, newPresets);
},
});
const updateFn = useMutation({
mutationFn: ({ alias, data }: { alias: string; data: URLPreset }) => putUrlPreset(alias, data),
onSuccess: (newPresets) => {
queryClient.setQueryData(URL_PRESETS, newPresets);
},
});
const deleteFn = useMutation({
mutationFn: deleteUrlPreset,
onSuccess: (newPresets) => {
queryClient.setQueryData(URL_PRESETS, newPresets);
},
});
return {
addPreset: addFn.mutateAsync,
updatePreset: (alias: string, data: URLPreset) => updateFn.mutateAsync({ alias, data }),
deletePreset: deleteFn.mutateAsync,
isMutating: addFn.isPending || updateFn.isPending || deleteFn.isPending,
isMutationError: addFn.isError || updateFn.isError || deleteFn.isError,
};
}
@@ -7,7 +7,7 @@ import { VIEW_SETTINGS } from '../api/constants';
import { viewsSettingsPlaceholder } from '../models/ViewSettings.type';
export default function useViewSettings() {
const { data, status } = useQuery({
const { data, isPending } = useQuery({
queryKey: VIEW_SETTINGS,
queryFn: getViewSettings,
placeholderData: (previousData, _previousQuery) => previousData,
@@ -24,5 +24,5 @@ export default function useViewSettings() {
},
});
return { data: data ?? viewsSettingsPlaceholder, status, mutateAsync };
return { data: data ?? viewsSettingsPlaceholder, mutateAsync, isPending };
}
@@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router';
import { useLocation, useNavigate } from 'react-router-dom';
import { MessageTag } from 'ontime-types';
import { useShallow } from 'zustand/shallow';
@@ -1,5 +1,5 @@
import { useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router';
import { useNavigate } from 'react-router-dom';
import { addDialog } from '../stores/dialogStore';
+118 -181
View File
@@ -2,13 +2,13 @@ import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
EntryId,
isOntimeBlock,
isOntimeEvent,
isOntimeGroup,
MaybeString,
OntimeBlock,
OntimeEntry,
OntimeEvent,
Rundown,
SupportedEntry,
TimeField,
TimeStrategy,
TransientEventPayload,
@@ -30,12 +30,13 @@ import {
requestEventSwap,
requestGroupEntries,
requestUngroup,
SwapEntry,
} from '../api/rundown';
import { logAxiosError } from '../api/utils';
import { useEditorSettings } from '../stores/editorSettings';
export type EventOptions = Partial<{
// options of any new entries (event / delay / group)
// options of any new entries (event / delay / block)
after: MaybeString;
before: MaybeString;
// options of entries of type OntimeEvent
@@ -58,25 +59,15 @@ export const useEntryActions = () => {
defaultEndAction,
} = useEditorSettings();
/**
* Returns the currently loaded rundown
*/
const getCurrentRundownData = useCallback(() => {
return queryClient.getQueryData<Rundown>(RUNDOWN);
}, [queryClient]);
/**
* Looks for an entry with a given ID in the currently loaded rundown
*/
const getEntryById = useCallback(
(eventId: EntryId): OntimeEntry | undefined => {
const cachedRundown = getCurrentRundownData();
(eventId: string): OntimeEntry | undefined => {
const cachedRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
if (!cachedRundown?.entries) {
return;
}
return cachedRundown.entries[eventId];
},
[getCurrentRundownData],
[queryClient],
);
/**
@@ -84,8 +75,8 @@ export const useEntryActions = () => {
* @private
*/
const { mutateAsync: addEntryMutation } = useMutation({
mutationFn: ([rundownId, entry]: Parameters<typeof postAddEntry>) => postAddEntry(rundownId, entry),
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
// TODO(v4): optimistic create entry
mutationFn: postAddEntry,
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
});
@@ -94,27 +85,29 @@ export const useEntryActions = () => {
*/
const addEntry = useCallback(
async (entry: Partial<OntimeEntry>, options?: EventOptions) => {
const rundownData = getCurrentRundownData();
const rundownId = rundownData?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
const newEntry: TransientEventPayload = { ...entry, id: generateId() };
// ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEntry)) {
if (options?.lastEventId) {
// merge creation time options with event settings
const applicationOptions = {
after: options?.after,
before: options?.before,
lastEventId: options?.lastEventId,
linkPrevious: options?.linkPrevious ?? linkPrevious,
};
if (applicationOptions?.lastEventId) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
const previousEvent = rundownData.entries[options?.lastEventId];
const rundownData = queryClient.getQueryData<Rundown>(RUNDOWN)!;
const previousEvent = rundownData.entries[applicationOptions.lastEventId];
if (isOntimeEvent(previousEvent)) {
newEntry.timeStart = previousEvent.timeEnd;
}
}
// Override event with options from editor settings
newEntry.linkStart = options?.linkPrevious ?? linkPrevious;
newEntry.linkStart = applicationOptions.linkPrevious;
if (newEntry.duration === undefined && newEntry.timeEnd === undefined) {
newEntry.duration = parseUserTime(defaultDuration);
@@ -150,21 +143,21 @@ export const useEntryActions = () => {
}
try {
await addEntryMutation([rundownId, newEntry]);
await addEntryMutation(newEntry);
} catch (error) {
logAxiosError('Failed adding event', error);
}
},
[
getCurrentRundownData,
linkPrevious,
defaultDuration,
defaultDangerTime,
defaultWarnTime,
defaultTimerType,
defaultEndAction,
defaultTimeStrategy,
addEntryMutation,
defaultDangerTime,
defaultDuration,
defaultEndAction,
defaultTimerType,
defaultTimeStrategy,
defaultWarnTime,
linkPrevious,
queryClient,
],
);
@@ -173,28 +166,22 @@ export const useEntryActions = () => {
* @private
*/
const { mutateAsync: cloneEntryMutation } = useMutation({
mutationFn: ([rundownId, entryId]: Parameters<typeof postCloneEntry>) => postCloneEntry(rundownId, entryId),
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
mutationFn: postCloneEntry,
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
});
/**
* Clone an entry
* Clone a selection
*/
const clone = useCallback(
async (entryId: EntryId) => {
try {
const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
await cloneEntryMutation([rundownId, entryId]);
await cloneEntryMutation(entryId);
} catch (error) {
logAxiosError('Error cloning entry', error);
}
},
[cloneEntryMutation, getCurrentRundownData],
[cloneEntryMutation],
);
/**
@@ -202,9 +189,9 @@ export const useEntryActions = () => {
* @private
*/
const { mutateAsync: updateEntryMutation } = useMutation({
mutationFn: ([rundownId, newEvent]: Parameters<typeof putEditEntry>) => putEditEntry(rundownId, newEvent),
mutationFn: putEditEntry,
// we optimistically update here
onMutate: async ([_rundownId, newEvent]) => {
onMutate: async (newEvent) => {
// cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN });
@@ -232,9 +219,7 @@ export const useEntryActions = () => {
},
// Mutation fails, rollback undoes optimist update
onError: (_error, _newEvent, context) => {
if (context?.previousData) {
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
}
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
@@ -249,17 +234,19 @@ export const useEntryActions = () => {
const updateEntry = useCallback(
async (entry: Partial<OntimeEntry>) => {
try {
const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
await updateEntryMutation([rundownId, entry]);
await updateEntryMutation(entry);
} catch (error) {
logAxiosError('Error updating event', error);
}
},
[getCurrentRundownData, updateEntryMutation],
[updateEntryMutation],
);
const updateCustomField = useCallback(
async (entryId: EntryId, field: string, value: string) => {
updateEntry({ id: entryId, custom: { [field]: value } });
},
[updateEntry],
);
/**
@@ -271,11 +258,6 @@ export const useEntryActions = () => {
*/
const updateTimer = useCallback(
async (eventId: EntryId, field: TimeField, value: string, lockOnUpdate?: boolean) => {
const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
// an empty value with no lock has no domain validity
if (!lockOnUpdate && value === '') {
return;
@@ -305,7 +287,7 @@ export const useEntryActions = () => {
}
try {
await updateEntryMutation([rundownId, newEvent]);
await updateEntryMutation(newEvent);
} catch (error) {
logAxiosError('Error updating event', error);
}
@@ -357,7 +339,7 @@ export const useEntryActions = () => {
return previousEnd;
}
},
[getCurrentRundownData, updateEntryMutation, queryClient],
[updateEntryMutation, queryClient],
);
/**
@@ -365,8 +347,8 @@ export const useEntryActions = () => {
* @private
*/
const { mutateAsync: batchUpdateEventsMutation } = useMutation({
mutationFn: ([rundownId, data]: Parameters<typeof putBatchEditEvents>) => putBatchEditEvents(rundownId, data),
onMutate: async ([_rundownId, data]) => {
mutationFn: putBatchEditEvents,
onMutate: async ({ ids, data }) => {
// cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN });
@@ -374,7 +356,7 @@ export const useEntryActions = () => {
const previousRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
if (previousRundown) {
const eventIds = new Set(data.ids);
const eventIds = new Set(ids);
const newRundown = { ...previousRundown.entries };
eventIds.forEach((eventId) => {
@@ -421,19 +403,14 @@ export const useEntryActions = () => {
});
const batchUpdateEvents = useCallback(
async (data: Partial<OntimeEvent>, eventIds: EntryId[]) => {
async (data: Partial<OntimeEvent>, eventIds: string[]) => {
try {
const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
await batchUpdateEventsMutation([rundownId, { data, ids: eventIds }]);
await batchUpdateEventsMutation({ ids: eventIds, data });
} catch (error) {
logAxiosError('Error updating events', error);
}
},
[batchUpdateEventsMutation, getCurrentRundownData],
[batchUpdateEventsMutation],
);
/**
@@ -441,9 +418,9 @@ export const useEntryActions = () => {
* @private
*/
const { mutateAsync: deleteEntryMutation } = useMutation({
mutationFn: ([rundownId, entryIds]: Parameters<typeof deleteEntries>) => deleteEntries(rundownId, entryIds),
mutationFn: deleteEntries,
// we optimistically update here
onMutate: async ([_rundownId, entryIds]) => {
onMutate: async (entryIds: EntryId[]) => {
// cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN });
@@ -485,17 +462,12 @@ export const useEntryActions = () => {
const deleteEntry = useCallback(
async (entryIds: EntryId[]) => {
try {
const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
await deleteEntryMutation([rundownId, entryIds]);
await deleteEntryMutation(entryIds);
} catch (error) {
logAxiosError('Error deleting event', error);
}
},
[deleteEntryMutation, getCurrentRundownData],
[deleteEntryMutation],
);
/**
@@ -503,7 +475,7 @@ export const useEntryActions = () => {
* @private
*/
const { mutateAsync: deleteAllEntriesMutation } = useMutation({
mutationFn: ([rundownId]: Parameters<typeof requestDeleteAll>) => requestDeleteAll(rundownId),
mutationFn: requestDeleteAll,
// we optimistically update here
onMutate: async () => {
// cancel ongoing queries
@@ -542,24 +514,18 @@ export const useEntryActions = () => {
*/
const deleteAllEntries = useCallback(async () => {
try {
const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
await deleteAllEntriesMutation([rundownId]);
await deleteAllEntriesMutation();
} catch (error) {
logAxiosError('Error deleting events', error);
}
}, [deleteAllEntriesMutation, getCurrentRundownData]);
}, [deleteAllEntriesMutation]);
/**
* Calls mutation to apply a delay
* @private
*/
const { mutateAsync: applyDelayMutation } = useMutation({
mutationFn: ([rundownId, delayId]: Parameters<typeof requestApplyDelay>) => requestApplyDelay(rundownId, delayId),
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
mutationFn: requestApplyDelay,
onSuccess: (response) => {
if (!response.data) return;
@@ -585,26 +551,20 @@ export const useEntryActions = () => {
const applyDelay = useCallback(
async (delayEventId: EntryId) => {
try {
const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
await applyDelayMutation([rundownId, delayEventId]);
await applyDelayMutation(delayEventId);
} catch (error) {
logAxiosError('Error applying delay', error);
}
},
[applyDelayMutation, getCurrentRundownData],
[applyDelayMutation],
);
/**
* Calls mutation to dissolve a group
* Calls mutation to dissolve a block
* @private
*/
const { mutateAsync: ungroupMutation } = useMutation({
mutationFn: ([rundownId, groupId]: Parameters<typeof requestUngroup>) => requestUngroup(rundownId, groupId),
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
mutationFn: requestUngroup,
onSuccess: (response) => {
if (!response.data) return;
@@ -622,32 +582,25 @@ export const useEntryActions = () => {
});
/**
* Deletes a group and moves its events to the top level
* Deletes a block and moves its events to the top level
*/
const ungroup = useCallback(
async (groupId: EntryId) => {
async (blockId: EntryId) => {
try {
const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
await ungroupMutation([rundownId, groupId]);
await ungroupMutation(blockId);
} catch (error) {
logAxiosError('Error dissolving group', error);
}
},
[getCurrentRundownData, ungroupMutation],
[ungroupMutation],
);
/**
* Calls mutation to create a group with a selection
* Calls mutation to create a block with a selection
* @private
*/
const { mutateAsync: groupEntriesMutation } = useMutation({
mutationFn: ([rundownId, entryIds]: Parameters<typeof requestGroupEntries>) =>
requestGroupEntries(rundownId, entryIds),
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
mutationFn: requestGroupEntries,
onSuccess: (response) => {
if (!response.data) return;
@@ -665,31 +618,26 @@ export const useEntryActions = () => {
});
/**
* Create a group with a selection
* Create a block with a selection
*/
const groupEntries = useCallback(
async (entryIds: EntryId[]) => {
if (entryIds.length === 0) return;
try {
const rundownData = getCurrentRundownData();
const rundownId = rundownData?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
if (entryIds.length === 1) {
await groupEntriesMutation([rundownId, entryIds]);
await groupEntriesMutation(entryIds);
} else {
// the user selection may be out of order
const orderedIds = orderEntries(entryIds, rundownData.flatOrder);
await groupEntriesMutation([rundownId, orderedIds]);
const rundown = queryClient.getQueryData<Rundown>(RUNDOWN);
if (!rundown) return;
const orderedIds = orderEntries(entryIds, rundown.flatOrder);
await groupEntriesMutation(orderedIds);
}
} catch (error) {
logAxiosError('Error grouping entries', error);
}
},
[getCurrentRundownData, groupEntriesMutation],
[groupEntriesMutation, queryClient],
);
/**
@@ -697,8 +645,9 @@ export const useEntryActions = () => {
* @private
*/
const { mutateAsync: reorderEntryMutation } = useMutation({
mutationFn: ([rundownId, data]: Parameters<typeof patchReorderEntry>) => patchReorderEntry(rundownId, data),
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
mutationFn: patchReorderEntry,
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
@@ -709,36 +658,34 @@ export const useEntryActions = () => {
*/
const move = useCallback(
async (entryId: EntryId, direction: 'up' | 'down') => {
const rundown = queryClient.getQueryData<Rundown>(RUNDOWN);
if (!rundown) {
return;
}
const { destinationId, order } =
direction === 'up'
? moveUp(entryId, rundown.flatOrder, rundown.entries)
: moveDown(entryId, rundown.flatOrder, rundown.entries);
if (!destinationId) {
return; // noop
}
try {
const rundownData = getCurrentRundownData();
const rundownId = rundownData?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
const { destinationId, order } =
direction === 'up'
? moveUp(entryId, rundownData.flatOrder, rundownData.entries)
: moveDown(entryId, rundownData.flatOrder, rundownData.entries);
if (!destinationId) {
return; // noop
}
const reorderObject: ReorderEntry = {
entryId,
destinationId,
order,
};
await reorderEntryMutation([rundownId, reorderObject]);
// the rundown needs to know whether we moved into a group
return rundownData.entries[destinationId]?.type === SupportedEntry.Group ? destinationId : undefined;
await reorderEntryMutation(reorderObject);
} catch (error) {
logAxiosError('Error re-ordering event', error);
}
return undefined;
// the rundown needs to know whether we moved into a block
return rundown.entries[destinationId]?.type === 'block' ? destinationId : undefined;
},
[getCurrentRundownData, reorderEntryMutation],
[queryClient, reorderEntryMutation],
);
/**
* Reorders a given entry
@@ -746,25 +693,17 @@ export const useEntryActions = () => {
const reorderEntry = useCallback(
async (entryId: EntryId, destinationId: EntryId, order: 'before' | 'after' | 'insert') => {
try {
const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
await reorderEntryMutation([
rundownId,
{
entryId,
destinationId,
order,
},
]);
const reorderObject: ReorderEntry = {
entryId,
destinationId,
order,
};
await reorderEntryMutation(reorderObject);
} catch (error) {
logAxiosError('Error re-ordering event', error);
throw error; // rethrow to handle in the component
}
},
[getCurrentRundownData, reorderEntryMutation],
[reorderEntryMutation],
);
/**
@@ -772,9 +711,9 @@ export const useEntryActions = () => {
* @private
*/
const { mutateAsync: swapEventsMutation } = useMutation({
mutationFn: ([rundownId, from, to]: Parameters<typeof requestEventSwap>) => requestEventSwap(rundownId, from, to),
mutationFn: requestEventSwap,
// we optimistically update here
onMutate: async ([_rundownId, from, to]) => {
onMutate: async ({ from, to }) => {
// cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN });
@@ -823,19 +762,14 @@ export const useEntryActions = () => {
* Swaps the schedule of two events
*/
const swapEvents = useCallback(
async (from: EntryId, to: EntryId) => {
async ({ from, to }: SwapEntry) => {
try {
const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
await swapEventsMutation([rundownId, from, to]);
await swapEventsMutation({ from, to });
} catch (error) {
logAxiosError('Error re-ordering event', error);
}
},
[getCurrentRundownData, swapEventsMutation],
[swapEventsMutation],
);
return {
@@ -853,6 +787,7 @@ export const useEntryActions = () => {
swapEvents,
updateEntry,
updateTimer,
updateCustomField,
};
};
@@ -870,12 +805,14 @@ function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) {
}
function deleteEntry(entry: OntimeEntry) {
if (isOntimeGroup(entry) || !entry.parent) {
if (isOntimeBlock(entry) || !entry.parent) {
order = order.filter((id) => id !== entry.id);
} else {
const parent = entries[entry.parent];
if (parent && isOntimeGroup(parent)) {
parent.entries = parent.entries.filter((parentEntry) => parentEntry !== entry.id);
if ('parent' in entries) {
(parent as OntimeBlock).entries = (parent as OntimeBlock).entries.filter(
(parentEntry) => parentEntry !== entry.id,
);
}
}
@@ -23,9 +23,6 @@ export const useFadeOutOnInactivity = (initialState = false) => {
const throttledShowMenu = throttle(setShowMenuTrue, 1000);
// we call the function on mount, to make sure the menu is hidden
throttledShowMenu();
document.addEventListener('mousemove', throttledShowMenu);
document.addEventListener('keydown', throttledShowMenu);
@@ -1,5 +1,6 @@
import { RefObject, useCallback, useEffect } from 'react';
import { MaybeString } from 'ontime-types';
import { RefObject, useCallback, useEffect, useRef } from 'react';
import { useSelectedEventId } from './useSocket';
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
componentRef: RefObject<ComponentRef>,
@@ -17,26 +18,37 @@ function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends H
scrollRef.current.scrollTo({ top, behavior: 'smooth' });
}
function snapToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
componentRef: RefObject<ComponentRef>,
scrollRef: RefObject<ScrollRef>,
topOffset: number,
) {
if (!componentRef.current || !scrollRef.current) {
return;
}
const componentRect = componentRef.current.getBoundingClientRect();
const scrollRect = scrollRef.current.getBoundingClientRect();
const top = componentRect.top - scrollRect.top + scrollRef.current.scrollTop - topOffset;
// maintain current x scroll position
scrollRef.current.scrollTo(scrollRef.current.scrollLeft, top);
}
interface UseFollowComponentProps {
followRef: RefObject<HTMLElement | null>;
scrollRef: RefObject<HTMLElement | null>;
doFollow: boolean;
topOffset?: number;
setScrollFlag?: (newValue: boolean) => void;
followTrigger?: MaybeString; // this would be an entry id or null
}
export default function useFollowComponent({
followRef,
scrollRef,
doFollow,
topOffset = 100,
setScrollFlag,
followTrigger,
}: UseFollowComponentProps) {
// when trigger moves, view should follow
export default function useFollowComponent(props: UseFollowComponentProps) {
const { followRef, scrollRef, doFollow, topOffset = 100, setScrollFlag } = props;
// when cursor moves, view should follow
useEffect(() => {
if (!doFollow || !followTrigger) {
if (!doFollow) {
return;
}
@@ -48,14 +60,16 @@ export default function useFollowComponent({
setScrollFlag?.(false);
});
}
}, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset]);
// eslint-disable-next-line -- the prompt seems incorrect
}, [followRef?.current, scrollRef?.current]);
const scrollToRefComponent = useCallback(
(componentRef = followRef, containerRef = scrollRef, offset = topOffset) => {
if (componentRef && containerRef) {
if (componentRef.current && containerRef.current) {
// @ts-expect-error -- we know this are not null
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
scrollToComponent(componentRef!, containerRef!, offset);
scrollToComponent(componentRef!, scrollRef!, offset);
}
},
[followRef, scrollRef, topOffset],
@@ -63,3 +77,32 @@ export default function useFollowComponent({
return scrollToRefComponent;
}
export function useFollowSelected(doFollow: boolean, topOffset = 100) {
const selectedEvenId = useSelectedEventId();
const selectedRef = useRef<HTMLTableRowElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!doFollow) {
return;
}
if (selectedEvenId && selectedRef.current && scrollRef.current) {
// Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => {
snapToComponent(
{ current: selectedRef.current } as RefObject<HTMLElement>,
{ current: scrollRef.current } as RefObject<HTMLElement>,
topOffset,
);
});
}
}, [doFollow, selectedEvenId, topOffset]);
return {
selectedRef,
scrollRef,
};
}
+47 -102
View File
@@ -1,4 +1,4 @@
import { OffsetMode, RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage, TimerType } from 'ontime-types';
import { OffsetMode, RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types';
import { useRuntimeStore } from '../stores/runtime';
import { sendSocket } from '../utils/socket';
@@ -10,15 +10,14 @@ const createSelector =
export const setClientRemote = {
setIdentify: (payload: { target: string; identify: boolean }) => sendSocket('client', payload),
setRedirect: (payload: { target: string; redirect: string }) => {
sendSocket('client', payload);
},
setRedirect: (payload: { target: string; redirect: string }) => sendSocket('client', payload),
setClientName: (payload: { target: string; rename: string }) => sendSocket('client', payload),
};
export const useRundownEditor = createSelector((state: RuntimeStore) => ({
playback: state.timer.playback,
selectedEventId: state.eventNow?.id ?? null,
selectedBlockId: state.blockNow?.id ?? null,
nextEventId: state.eventNext?.id ?? null,
}));
@@ -60,8 +59,8 @@ export const setMessage = {
export const usePlaybackControl = createSelector((state: RuntimeStore) => ({
playback: state.timer.playback,
selectedEventIndex: state.rundown.selectedEventIndex,
numEvents: state.rundown.numEvents,
selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents,
timerPhase: state.timer.phase,
}));
@@ -132,8 +131,8 @@ export const useSelectedEventId = createSelector((state: RuntimeStore) => ({
selectedEventId: state.eventNow?.id ?? null,
}));
export const useCurrentGroupId = createSelector((state: RuntimeStore) => ({
currentGroupId: state.groupNow?.id ?? null,
export const useCurrentBlockId = createSelector((state: RuntimeStore) => ({
currentBlockId: state.blockNow?.id ?? null,
}));
export const setEventPlayback = {
@@ -152,8 +151,7 @@ export const useClock = createSelector((state: RuntimeStore) => ({
}));
export const useNextFlag = createSelector((state: RuntimeStore) => ({
id: state.eventFlag?.id ?? null,
expectedStart: state.offset.expectedFlagStart,
nextFlag: state.nextFlag,
}));
/** Used by the progress bar components */
@@ -164,13 +162,45 @@ export const useProgressData = createSelector((state: RuntimeStore) => ({
timeDanger: state.eventNow?.timeDanger ?? null,
}));
export const useExpectedStartData = createSelector((state: RuntimeStore) => ({
offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative,
mode: state.offset.mode,
currentDay: state.rundown.currentDay ?? 0,
actualStart: state.rundown.actualStart,
plannedStart: state.rundown.plannedStart,
export const useRuntimeOverview = createSelector((state: RuntimeStore) => ({
plannedStart: state.runtime.plannedStart,
actualStart: state.runtime.actualStart,
plannedEnd: state.runtime.plannedEnd,
expectedEnd: state.runtime.expectedEnd,
}));
export const useRuntimePlaybackOverview = createSelector((state: RuntimeStore) => ({
playback: state.timer.playback,
clock: state.clock,
numEvents: state.runtime.numEvents,
selectedEventIndex: state.runtime.selectedEventIndex,
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
blockStartedAt: state.blockNow?.startedAt ?? null,
blockExpectedEnd: state.blockNow?.expectedEnd ?? null,
}));
export const useTimelineStatus = createSelector((state: RuntimeStore) => ({
clock: state.clock,
offset: state.runtime.offset,
}));
export const useTimeUntilData = createSelector((state: RuntimeStore) => ({
clock: state.clock,
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
offsetMode: state.runtime.offsetMode,
currentDay: state.eventNow?.dayOffset ?? 0,
actualStart: state.runtime.actualStart,
plannedStart: state.runtime.plannedStart,
}));
export const useCurrentDay = createSelector((state: RuntimeStore) => ({
currentDay: state.eventNow?.dayOffset ?? 0,
}));
export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({
offset: state.runtime.offset,
}));
export const usePing = createSelector((state: RuntimeStore) => ({
@@ -183,7 +213,7 @@ export const useIsOnline = createSelector((state: RuntimeStore) => ({
}));
export const useOffsetMode = createSelector((state: RuntimeStore) => ({
offsetMode: state.offset.mode,
offsetMode: state.runtime.offsetMode,
}));
export const setOffsetMode = (payload: OffsetMode) => sendSocket('offsetmode', payload);
@@ -195,88 +225,3 @@ export const usePlayback = () => {
return useRuntimeStore(featureSelector);
};
/* ======================= Overview data subscriptions ======================= */
export const useStartTimesOverview = createSelector((state: RuntimeStore) => ({
plannedStart: state.rundown.plannedStart,
actualStart: state.rundown.actualStart,
plannedEnd: state.rundown.plannedEnd,
expectedEnd: state.offset.expectedRundownEnd,
}));
export const useProgressOverview = createSelector((state: RuntimeStore) => ({
numEvents: state.rundown.numEvents,
selectedEventIndex: state.rundown.selectedEventIndex,
}));
export const useOffsetOverview = createSelector((state: RuntimeStore) => ({
offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative,
playback: state.timer.playback,
}));
export const useGroupTimerOverView = createSelector((state: RuntimeStore) => ({
clock: state.clock,
mode: state.offset.mode,
groupExpectedEnd: state.offset.expectedGroupEnd,
// we can force these numbers to 0 fo this use case to avoid null checks
actualGroupStart: state.rundown.actualGroupStart ?? 0,
currentDay: state.eventNow?.dayOffset ?? 0,
playback: state.timer.playback,
}));
export const useFlagTimerOverView = createSelector((state: RuntimeStore) => ({
clock: state.clock,
mode: state.offset.mode,
// we can force these numbers to 0 fo this use case to avoid null checks
actualStart: state.rundown.actualStart ?? 0,
plannedStart: state.rundown.plannedStart ?? 0,
currentDay: state.eventNow?.dayOffset ?? 0,
playback: state.timer.playback,
}));
/* ======================= View specific subscriptions ======================= */
export const useTimerSocket = createSelector((state: RuntimeStore) => ({
eventNext: state.eventNext,
eventNow: state.eventNow,
message: state.message,
time: state.timer,
clock: state.clock,
timerTypeNow: state.eventNow?.timerType ?? TimerType.CountDown,
countToEndNow: state.eventNow?.countToEnd ?? false,
auxTimer: {
aux1: state.auxtimer1.current,
aux2: state.auxtimer2.current,
aux3: state.auxtimer3.current,
},
}));
export const useCountdownSocket = createSelector((state: RuntimeStore) => ({
playback: state.timer.playback,
current: state.timer.current,
clock: state.clock,
}));
export const useBackstageSocket = createSelector((state: RuntimeStore) => ({
eventNext: state.eventNext,
eventNow: state.eventNow,
rundown: state.rundown,
selectedEventId: state.eventNow?.id ?? null,
time: state.timer,
}));
export const useStudioClockSocket = createSelector((state: RuntimeStore) => ({
clock: state.clock,
playback: state.timer.playback,
}));
export const useStudioTimersSocket = createSelector((state: RuntimeStore) => ({
eventNext: state.eventNext,
eventNow: state.eventNow,
message: state.message,
time: state.timer,
offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative,
rundown: state.rundown,
expectedRundownEnd: state.offset.expectedRundownEnd,
}));
@@ -0,0 +1,19 @@
import { MaybeNumber, Playback, TimerPhase, TimerType } from 'ontime-types';
// first set extends TimerState
export type ViewExtendedTimer = {
addedTime: number;
current: MaybeNumber;
duration: MaybeNumber;
elapsed: MaybeNumber;
expectedFinish: MaybeNumber;
finishedAt: MaybeNumber;
phase: TimerPhase;
playback: Playback;
secondaryTimer: MaybeNumber;
startedAt: MaybeNumber;
clock: number;
timerType: TimerType;
countToEnd: boolean;
};
@@ -0,0 +1 @@
export type Size = 'xs' | 'sm' | 'md' | 'lg';
@@ -0,0 +1,19 @@
export type OverridableOptions = {
keyColour?: string;
textColour?: string;
textBackground?: string;
font?: string;
size?: number;
justifyContent?: 'start' | 'center' | 'end';
alignItems?: 'start' | 'center' | 'end';
left?: string;
top?: string;
hideNav?: boolean;
hideOvertime?: boolean;
hideMessagesOverlay?: boolean;
hideEndMessage?: boolean;
language?: string;
showProgressBar?: boolean;
hideTimerSeconds?: boolean;
removeLeadingZeros?: boolean;
};
+1 -3
View File
@@ -1,8 +1,6 @@
import { ClientList } from 'ontime-types';
import { create } from 'zustand';
import { makeStageKey } from '../utils/localStorage';
interface ClientStore {
name?: string;
setName: (newValue: string) => void;
@@ -17,7 +15,7 @@ interface ClientStore {
setClients: (clients: ClientList) => void;
}
const clientNameKey = makeStageKey('client-name');
const clientNameKey = 'ontime-client-name';
function persistNameInStorage(newValue: string) {
localStorage.setItem(clientNameKey, newValue);
+1 -1
View File
@@ -3,7 +3,7 @@ import { create } from 'zustand';
import { booleanFromLocalStorage } from '../utils/localStorage';
enum LocalEventKeys {
Mirror = 'view-mirror',
Mirror = 'ontime-view-mirror',
}
type ViewOptionsStore = {
@@ -1,4 +1,6 @@
import { makeCSVFromArrayOfArrays } from '../csv';
import { OntimeEntry, ProjectRundowns, Rundown } from 'ontime-types';
import { aggregateRundowns, makeCSVFromArrayOfArrays } from '../csv';
describe('makeCSVFromArrayOfArrays()', () => {
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' }]);
});
});
@@ -1,302 +0,0 @@
import { OntimeDelay, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types';
import { initRundownMetadata } from '../rundownMetadata';
describe('initRundownMetadata()', () => {
it('processes nested rundown data', () => {
const selectedEventId = '12';
const demoEvents = {
'1': {
id: '1',
type: SupportedEntry.Event,
parent: null,
timeStart: 0,
timeEnd: 1,
duration: 1,
dayOffset: 0,
gap: 0,
skip: false,
linkStart: false,
} as OntimeEvent,
group: {
id: 'group',
type: SupportedEntry.Group,
entries: ['11', 'delay', '12', '13'],
colour: 'red',
} as OntimeGroup,
'11': {
id: '11',
type: SupportedEntry.Event,
parent: 'group',
timeStart: 10,
timeEnd: 11,
duration: 1,
dayOffset: 0,
gap: 10,
skip: false,
linkStart: false,
} as OntimeEvent,
delay: {
id: 'delay',
type: SupportedEntry.Delay,
parent: 'group',
duration: 0,
} as OntimeDelay,
'12': {
id: '12',
type: SupportedEntry.Event,
parent: 'group',
timeStart: 11,
timeEnd: 12,
duration: 1,
dayOffset: 0,
gap: 0,
skip: false,
linkStart: true,
} as OntimeEvent,
'13': {
id: '13',
type: SupportedEntry.Event,
parent: 'group',
timeStart: 12,
timeEnd: 13,
duration: 1,
dayOffset: 0,
gap: 0,
skip: false,
linkStart: true,
} as OntimeEvent,
'2': {
id: '2',
type: SupportedEntry.Event,
parent: null,
timeStart: 20,
timeEnd: 21,
duration: 1,
dayOffset: 0,
gap: 7,
skip: false,
linkStart: false,
} as OntimeEvent,
};
const { metadata, process } = initRundownMetadata(selectedEventId);
expect(metadata).toStrictEqual({
previousEvent: null,
latestEvent: null,
previousEntryId: null,
thisId: null,
eventIndex: 0,
isPast: true,
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: null,
groupColour: undefined,
groupEntries: undefined,
isFirstAfterGroup: false,
});
expect(process(demoEvents['1'])).toStrictEqual({
previousEvent: null,
latestEvent: demoEvents['1'],
previousEntryId: null,
thisId: demoEvents['1'].id,
eventIndex: 1, // UI indexes are 1 based
isPast: true,
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: null,
groupColour: undefined,
groupEntries: undefined,
isFirstAfterGroup: false,
});
expect(process(demoEvents['group'])).toMatchObject({
previousEvent: demoEvents['1'],
latestEvent: demoEvents['1'],
previousEntryId: demoEvents['1'].id,
thisId: demoEvents['group'].id,
eventIndex: 1,
isPast: true,
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: 'group',
groupColour: 'red',
isFirstAfterGroup: false,
});
expect(process(demoEvents['11'])).toMatchObject({
previousEvent: demoEvents['1'],
latestEvent: demoEvents['11'],
previousEntryId: demoEvents['group'].id,
thisId: demoEvents['11'].id,
eventIndex: 2,
isPast: true,
isNextDay: false,
totalGap: 10,
isLinkedToLoaded: false,
isLoaded: false,
groupId: 'group',
groupColour: 'red',
isFirstAfterGroup: false,
});
expect(process(demoEvents['delay'])).toMatchObject({
previousEvent: demoEvents['11'],
latestEvent: demoEvents['11'],
previousEntryId: demoEvents['11'].id,
thisId: demoEvents['delay'].id,
eventIndex: 2,
isPast: true,
isNextDay: false,
totalGap: 10,
isLinkedToLoaded: false,
isLoaded: false,
groupId: 'group',
groupColour: 'red',
isFirstAfterGroup: false,
});
expect(process(demoEvents['12'])).toMatchObject({
previousEvent: demoEvents['11'],
latestEvent: demoEvents['12'],
previousEntryId: demoEvents['delay'].id,
thisId: demoEvents['12'].id,
eventIndex: 3,
isPast: false,
isNextDay: false,
totalGap: 10,
isLinkedToLoaded: true,
isLoaded: true,
groupId: 'group',
groupColour: 'red',
isFirstAfterGroup: false,
});
expect(process(demoEvents['13'])).toMatchObject({
previousEvent: demoEvents['12'],
latestEvent: demoEvents['13'],
previousEntryId: demoEvents['12'].id,
thisId: demoEvents['13'].id,
eventIndex: 4,
isPast: false,
isNextDay: false,
totalGap: 10,
isLinkedToLoaded: true,
isLoaded: false,
groupId: 'group',
groupColour: 'red',
isFirstAfterGroup: false,
});
expect(process(demoEvents['2'])).toMatchObject({
previousEvent: demoEvents['13'],
latestEvent: demoEvents['2'],
previousEntryId: demoEvents['13'].id,
thisId: demoEvents['2'].id,
eventIndex: 5,
isPast: false,
isNextDay: false,
totalGap: 17,
isLinkedToLoaded: false,
isLoaded: false,
groupId: null,
groupColour: undefined,
isFirstAfterGroup: true,
});
});
it('populates previousEntries in groups', () => {
const rundownStartsWithGroup = {
group: {
id: 'group',
type: SupportedEntry.Group,
colour: 'red',
entries: ['1', '2'],
} as OntimeGroup,
'1': {
id: '1',
type: SupportedEntry.Event,
parent: 'group',
timeStart: 1,
timeEnd: 2,
duration: 1,
dayOffset: 0,
gap: 0,
skip: false,
linkStart: false,
} as OntimeEvent,
'2': {
id: '2',
type: SupportedEntry.Event,
parent: 'group',
timeStart: 2,
timeEnd: 3,
duration: 1,
dayOffset: 0,
gap: 0,
skip: false,
linkStart: false,
} as OntimeEvent,
};
const { process } = initRundownMetadata(null);
expect(process(rundownStartsWithGroup.group)).toStrictEqual({
previousEvent: null,
latestEvent: null,
previousEntryId: null,
thisId: rundownStartsWithGroup.group.id,
eventIndex: 0,
isPast: false,
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: rundownStartsWithGroup.group.id,
groupColour: 'red',
groupEntries: 2,
isFirstAfterGroup: false,
});
expect(process(rundownStartsWithGroup['1'])).toStrictEqual({
previousEvent: null,
latestEvent: rundownStartsWithGroup['1'],
previousEntryId: rundownStartsWithGroup.group.id,
thisId: rundownStartsWithGroup['1'].id,
eventIndex: 1,
isPast: false,
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: rundownStartsWithGroup.group.id,
groupColour: 'red',
groupEntries: 2,
isFirstAfterGroup: false,
});
expect(process(rundownStartsWithGroup['2'])).toStrictEqual({
previousEvent: rundownStartsWithGroup['1'],
latestEvent: rundownStartsWithGroup['2'],
previousEntryId: rundownStartsWithGroup['1'].id,
thisId: rundownStartsWithGroup['2'].id,
eventIndex: 2,
isPast: false,
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: rundownStartsWithGroup.group.id,
groupColour: 'red',
groupEntries: 2,
isFirstAfterGroup: false,
});
});
});
@@ -1,14 +1,6 @@
import { Path, resolvePath } from 'react-router';
import { OntimeView, URLPreset } from 'ontime-types';
import { resolvePath } from 'react-router-dom';
import {
arePathsEquivalent,
generatePathFromPreset,
generateUrlPresetOptions,
getCurrentPath,
getRouteFromPreset,
validateUrlPresetPath,
} from '../urlPresets';
import { arePathsEquivalent, generatePathFromPreset, getRouteFromPreset, validateUrlPresetPath } from '../urlPresets';
describe('validateUrlPresetPaths()', () => {
test.each([
@@ -31,13 +23,11 @@ describe('validateUrlPresetPaths()', () => {
});
describe('getRouteFromPreset()', () => {
const presets: URLPreset[] = [
const presets = [
{
enabled: true,
alias: 'demopage',
target: OntimeView.Timer,
search: 'user=guest',
options: {},
pathAndParams: '/timer?user=guest',
},
];
@@ -73,28 +63,28 @@ describe('getRouteFromPreset()', () => {
describe('handle url sharing edge cases', () => {
it('finds the correct preset when the url contains extra arguments', () => {
const location = resolvePath('/demopage?n=1&token=123');
const location = resolvePath('/demopage?locked=true&token=123');
expect(getRouteFromPreset(location, presets)?.startsWith('timer?user=guest&alias=demopage')).toBeTruthy();
});
it('appends the feature params to the alias', () => {
const location = resolvePath('/demopage?n=1&token=123');
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&n=1&token=123');
const location = resolvePath('/demopage?locked=true&token=123');
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&locked=true&token=123');
});
});
});
describe('generatePathFromPreset()', () => {
test.each([
['timer', 'user=guest', 'demopage', false, 'timer?user=guest&alias=demopage'],
['timer', 'user=admin', 'demopage', false, 'timer?user=admin&alias=demopage'],
])('generates a path from a preset: %s', (target, search, alias, locked, expected) => {
expect(generatePathFromPreset(target, search, alias, locked, null)).toEqual(expected);
['timer?user=guest', 'demopage', 'timer?user=guest&alias=demopage'],
['timer?user=admin', 'demopage', 'timer?user=admin&alias=demopage'],
])('generates a path from a preset: %s', (path, alias, expected) => {
expect(generatePathFromPreset(path, alias, null, null)).toEqual(expected);
});
test('appends the feature params to the alias', () => {
expect(generatePathFromPreset('timer', 'user=guest', 'demopage', true, '123')).toBe(
'timer?user=guest&alias=demopage&n=1&token=123',
expect(generatePathFromPreset('timer?user=guest', 'demopage', 'true', '123')).toBe(
'timer?user=guest&alias=demopage&locked=true&token=123',
);
});
});
@@ -111,84 +101,8 @@ describe('arePathsEquivalent()', () => {
expect(arePathsEquivalent('timer?test=a', 'timer?test=a')).toBeTruthy();
});
it('checks whether we are in a locked preset', () => {
expect(arePathsEquivalent('preset/minimal', 'preset/minimal?test=b')).toBeTruthy();
});
it('considers edge cases for the url sharing feature', () => {
expect(arePathsEquivalent('timer?test=a&n=1=token=123', 'timer?test=b')).toBeFalsy();
expect(arePathsEquivalent('timer?test=a&n=1=token=123', 'timer?test=a')).toBeTruthy();
});
});
describe('generateUrlPresetOptions', () => {
it.each([
[
'cloud URL without protocol',
'test',
'www.getontime.no/timer?param1=value1&param2=value2',
{
alias: 'test',
target: 'timer',
search: 'param1=value1&param2=value2',
enabled: true,
},
],
[
'cloud URL',
'test',
'https://cloud.getontime.no/timer?param1=value1&param2=value2',
{
alias: 'test',
target: 'timer',
search: 'param1=value1&param2=value2',
enabled: true,
},
],
[
'local URL',
'test',
'http://localhost:4001/timer?param1=value1&param2=value2',
{
alias: 'test',
target: 'timer',
search: 'param1=value1&param2=value2',
enabled: true,
},
],
[
'IP-based URL',
'test',
'http://192.168.0.1:4001/timer?param1=value1&param2=value2',
{
alias: 'test',
target: 'timer',
search: 'param1=value1&param2=value2',
enabled: true,
},
],
])('should generate URL preset options for %s', (_description, alias, url, expected) => {
expect(generateUrlPresetOptions(alias, url)).toStrictEqual(expected);
});
it('throws on invalid URL', () => {
expect(() => generateUrlPresetOptions('test', 'invalid-url')).toThrow();
});
it('throws on on invalid route', () => {
expect(() => generateUrlPresetOptions('test', 'www.getontime.no/somethingelse/')).toThrow();
});
});
describe('getCurrentPath()', () => {
test.each([
[resolvePath('http://localhost:4001/timer'), 'timer'],
[resolvePath('http://192.168.0.1:654321/minimal'), 'minimal'],
[resolvePath('https://user-hosted.io/cuesheet'), 'cuesheet'],
[resolvePath('https://cloud.getontime.no/team-hash/op'), 'op'],
[resolvePath('https://cloud.getontime.no/team-hash/backstage/?params-with-slash=true'), 'backstage'],
[resolvePath('https://cloud.getontime.no/team-hash/timeline?params-are-ignored=true'), 'timeline'],
])('resolves the current: %s', (location, expected) => {
expect(getCurrentPath(location as Path)).toEqual(expected);
expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=b')).toBeFalsy();
expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=a')).toBeTruthy();
});
});

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