diff --git a/.dockerignore b/.dockerignore
index fcac172a2..f91d1539a 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -16,7 +16,8 @@
# Ignore build folders
node_modules
-dist
+**/node_modules
+**/dist
# Ignore default volumes created by running docker compose up
ontime-db
diff --git a/.eslintignore b/.eslintignore
index cfdd63c69..877ad032b 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -1 +1,2 @@
- "ONTIME_VERSION.js"
+ "ONTIME_VERSION.js"
+dist/
diff --git a/.github/workflows/build_cli.yml b/.github/workflows/build_cli.yml
index 96d35215f..effc09658 100644
--- a/.github/workflows/build_cli.yml
+++ b/.github/workflows/build_cli.yml
@@ -13,20 +13,20 @@ jobs:
steps:
- uses: actions/checkout@v4
-
- - name: Set up QEMU
- uses: docker/setup-qemu-action@v3
-
- - name: Use Node.js
- uses: actions/setup-node@v4
- with:
- node-version: 22
- registry-url: 'https://registry.npmjs.org'
-
+
- name: Setup pnpm
- uses: pnpm/action-setup@v3
+ 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:
- version: 10
+ 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
diff --git a/.github/workflows/build.yml b/.github/workflows/build_electron.yml
similarity index 84%
rename from .github/workflows/build.yml
rename to .github/workflows/build_electron.yml
index 1052d6cb7..29b253124 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build_electron.yml
@@ -11,15 +11,20 @@ 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
+ 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:
- version: 10
+ 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
@@ -53,15 +58,9 @@ 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
@@ -86,15 +85,9 @@ 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
diff --git a/.github/workflows/build_resolver.yml b/.github/workflows/build_resolver.yml
new file mode 100644
index 000000000..8f763fd4d
--- /dev/null
+++ b/.github/workflows/build_resolver.yml
@@ -0,0 +1,42 @@
+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: 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/resolver
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 7a39d94a2..66cdd12e4 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -14,55 +14,35 @@ 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
+ 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:
- version: 10
+ 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
- # Run code quality per package
- - name: React - Run linter + TypeScript checks
- if: always()
- run: pnpm lint && tsc --noEmit
- working-directory: ./apps/client
-
- - 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 code quality
+ - name: Run linter
run: pnpm lint
- working-directory: ./packages/types
+
+ - name: Run TypeScript checks
+ run: pnpm typecheck
# We choose to run tests separately
- - name: React - Run unit tests
+ - name: Run unit tests
if: always()
- 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
+ run: pnpm test
e2e-test:
runs-on: ubuntu-latest
@@ -70,21 +50,36 @@ 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
+ 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:
- version: 10
+ 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 client
- run: pnpm build:local
+ - 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: Install Playwright Browsers
run: npx playwright install --with-deps
diff --git a/.gitignore b/.gitignore
index 4a5888eb0..251deeb6c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -34,6 +34,10 @@ e2e/tests/fixtures/tmp/*
build/
dist/
+# bundled assets
+translations.json
+override.css
+
# working stuff
**/TODO.md
diff --git a/.idea/.gitignore b/.idea/.gitignore
deleted file mode 100644
index b58b603fe..000000000
--- a/.idea/.gitignore
+++ /dev/null
@@ -1,5 +0,0 @@
-# Default ignored files
-/shelf/
-/workspace.xml
-# Editor-based HTTP Client requests
-/httpRequests/
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
deleted file mode 100644
index 1ff6325df..000000000
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ /dev/null
@@ -1,51 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.prettierignore b/.prettierignore
index a0139e551..46cb21864 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -7,4 +7,7 @@ playwright-report
**/*.toml
**/*.yml
-**/*.json
\ No newline at end of file
+**/*.json
+!tsconfig.common.json
+!turbo.json
+!package.json
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
index 5ddd0f382..48f93b23a 100644
--- a/DEVELOPMENT.md
+++ b/DEVELOPMENT.md
@@ -20,19 +20,20 @@ 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`
-- __Run dev mode__ by running `turbo dev`
+- __Run dev mode__ by running `pnpm dev` or `pnpm dev:electron` to get the electron window
+
### Debugging backend
-To debug backend code in Node.js:
+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.
-- 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`.
+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`.
## TESTING
@@ -45,7 +46,7 @@ Generally we have 2 types of tests.
Unit tests are contained in mostly all the apps and packages (client, server and utils)
-You can run unit tests by running turbo `turbo test:pipeline` from the project root.
+You can run unit tests by running `pnpm 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
@@ -65,7 +66,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)
@@ -76,13 +77,13 @@ 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 `turbo build:electron`
-- __Create the package__ by running `turbo dist-win`, `turbo dist-mac` or `turbo dist-linux`
+- __Build the UI and server__ by running `pnpm build`
+- __Create the package__ by running `pnpm dist-win`, `pnpm dist-mac` or `pnpm 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 dist-mac:local` command to build a MacOS distribution locally.
+Use the `pnpm dist-mac:local` command to build a MacOS distribution locally and skip the notary process.
## DOCKER
@@ -99,9 +100,15 @@ Other useful commands
- __List running processes__ by running `docker ps`
- __Kill running process__ by running `docker kill `
-## General Info
+## CONTRIBUTION GUIDELINES
-# APP Building
+If you want to propose changes to the codebase, please reach out before opening a Pull Request.
-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.
+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
diff --git a/Dockerfile b/Dockerfile
index 726130551..58480312b 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,37 +1,36 @@
-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
+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 --filter=ontime-types install --config.dedupe-peer-dependents=false --frozen-lockfile
+RUN pnpm 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
+# 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
diff --git a/README.md b/README.md
index afe691e57..375d89a6e 100644
--- a/README.md
+++ b/README.md
@@ -43,12 +43,12 @@ Ontime is made by entertainment and broadcast engineers and used by
- Houses of worship
## Main features
-- [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)
+- **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)
... and a lot more ...
@@ -79,84 +79,29 @@ Ontime is made by video engineers and entertainment technicians.
## Using Ontime
-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)
+### Getting started
-Once installed and running, any device in the network has access to 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.
-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.
+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)
-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/minimal > Simple timer view
-IP.ADDRESS:4001/clock > Simple clock 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 the public views
--------------------------------------------------------------
-IP.ADDRESS:4001/public > Public / Foyer view
-IP.ADDRESS:4001/lower > Lower Thirds
-```
-
-```
-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/operator > automated views for operators
-```
+Once installed and running, any device that shares the same network as Ontime will have access to Ontime.
More information is available [in our docs](https://docs.getontime.no)
-## Roadmap
-
-### Continued development
+## 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.
@@ -168,6 +113,7 @@ 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
diff --git a/apps/cli/package.json b/apps/cli/package.json
index 6f5297d63..53d05de53 100644
--- a/apps/cli/package.json
+++ b/apps/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
- "version": "3.16.0",
+ "version": "4.0.0-beta.5",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
diff --git a/apps/client/.eslintrc b/apps/client/.eslintrc
index c600c987f..c1ac8d56d 100644
--- a/apps/client/.eslintrc
+++ b/apps/client/.eslintrc
@@ -15,10 +15,12 @@
],
"plugins": [
"react",
+ "react-compiler",
"simple-import-sort",
"@tanstack/query"
],
"rules": {
+ "react-compiler/react-compiler": "warn",
"react/jsx-no-bind": [
"error",
{
diff --git a/apps/client/index.html b/apps/client/index.html
index 7332b74e4..c46ec4f7f 100644
--- a/apps/client/index.html
+++ b/apps/client/index.html
@@ -13,12 +13,6 @@
ontime
-
diff --git a/apps/client/package.json b/apps/client/package.json
index 175359ec4..40258b8a4 100644
--- a/apps/client/package.json
+++ b/apps/client/package.json
@@ -1,54 +1,48 @@
{
"name": "ontime-ui",
- "version": "3.16.0",
+ "version": "4.0.0-beta.5",
"private": true,
"type": "module",
"dependencies": {
- "@chakra-ui/react": "^2.7.0",
- "@dnd-kit/core": "^6.1.0",
- "@dnd-kit/sortable": "^8.0.0",
+ "@base-ui-components/react": "1.0.0-beta.4",
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
- "@emotion/is-prop-valid": "^1.3.1",
- "@emotion/react": "^11.10.6",
- "@emotion/styled": "^11.10.6",
- "@fontsource/open-sans": "^5.0.28",
- "@mantine/hooks": "^7.13.3",
- "@sentry/react": "^8.43.0",
+ "@fontsource/open-sans": "^5.2.6",
+ "@mantine/hooks": "^8.2.8",
+ "@sentry/react": "^10.2.0",
"@table-nav/react": "^0.0.7",
- "@tanstack/react-query": "^5.62.7",
- "@tanstack/react-query-devtools": "^5.62.7",
+ "@tanstack/react-query": "^5.85.9",
+ "@tanstack/react-query-devtools": "^5.85.9",
"@tanstack/react-table": "^8.21.3",
"autosize": "^6.0.1",
- "axios": "^1.2.0",
- "color": "^4.2.3",
- "csv-stringify": "^6.4.5",
- "framer-motion": "^10.10.0",
- "prismjs": "^1.29.0",
- "react": "^18.3.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",
"react-colorful": "^5.6.1",
- "react-dom": "^18.3.1",
+ "react-dom": "^19.1.1",
"react-fast-compare": "^3.2.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-hook-form": "^7.62.0",
+ "react-icons": "5.5.0",
+ "react-qr-code": "^2.0.18",
+ "react-router": "^7.8.2",
"react-simple-code-editor": "^0.14.1",
- "web-vitals": "^3.1.1",
- "zustand": "^5.0.3"
+ "react-virtuoso": "^4.14.0",
+ "web-vitals": "^5.1.0",
+ "zustand": "^5.0.8"
},
"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",
- "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",
+ "dev:electron": "pnpm dev",
"lint": "eslint . --quiet",
+ "typecheck": "tsc --noEmit",
+ "build": "vite build",
"test": "vitest",
"test:pipeline": "vitest run",
- "cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build",
"analyse": "npx vite-bundle-visualizer"
},
"browserslist": {
@@ -66,10 +60,9 @@
"devDependencies": {
"@sentry/vite-plugin": "^2.16.1",
"@tanstack/eslint-plugin-query": "^5.8.4",
- "@types/color": "^3.0.3",
"@types/prismjs": "^1.26.5",
- "@types/react": "^18.0.26",
- "@types/react-dom": "^18.0.10",
+ "@types/react": "^19.1.12",
+ "@types/react-dom": "^19.1.9",
"@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:",
"@vitejs/plugin-react": "4.5.1",
@@ -78,16 +71,16 @@
"eslint-plugin-jest": "^28.6.0",
"eslint-plugin-prettier": "catalog:",
"eslint-plugin-react": "^7.32.0",
- "eslint-plugin-react-hooks": "^4.6.0",
+ "eslint-plugin-react-compiler": "19.1.0-rc.2",
+ "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-simple-import-sort": "^8.0.0",
- "happy-dom": "^16.7.2",
"ontime-types": "workspace:*",
"ontime-utils": "workspace:*",
"prettier": "catalog:",
"sass": "^1.57.1",
"typescript": "catalog:",
"vite": "6.3.1",
- "vite-plugin-compression2": "1.4.0",
+ "vite-plugin-compression2": "2.2.0",
"vite-plugin-svgr": "4.3.0",
"vite-tsconfig-paths": "5.1.4",
"vitest": "catalog:"
diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx
index c07768b94..369e9a857 100644
--- a/apps/client/src/App.tsx
+++ b/apps/client/src/App.tsx
@@ -1,5 +1,5 @@
-import { BrowserRouter } from 'react-router-dom';
-import { ChakraProvider } from '@chakra-ui/react';
+import { BrowserRouter } from 'react-router';
+import { Tooltip } from '@base-ui-components/react/tooltip';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
@@ -8,7 +8,7 @@ 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 theme from './theme/theme';
+import KeepAwake from './features/keep-awake/KeepAwake';
import { TranslationProvider } from './translation/TranslationProvider';
import AppRouter from './AppRouter';
import { baseURI } from './externals';
@@ -17,14 +17,15 @@ connectSocket();
function App() {
return (
-
-
-
+
+
+
+
@@ -34,9 +35,9 @@ function App() {
-
-
-
+
+
+
);
}
diff --git a/apps/client/src/AppRouter.tsx b/apps/client/src/AppRouter.tsx
index 63cb9099d..711b5e13c 100644
--- a/apps/client/src/AppRouter.tsx
+++ b/apps/client/src/AppRouter.tsx
@@ -1,175 +1,114 @@
-import React from 'react';
-import {
- createRoutesFromChildren,
- matchRoutes,
- Navigate,
- Route,
- Routes,
- useLocation,
- useNavigationType,
-} from 'react-router-dom';
-import * as Sentry from '@sentry/react';
+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 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 withPreset from './features/PresetWrapper';
-import withData from './features/viewers/ViewWrapper';
+import Loader from './views/common/loader/Loader';
+import NotFound from './views/common/not-found/NotFound';
import ViewLoader from './views/ViewLoader';
-import { ONTIME_VERSION } from './ONTIME_VERSION';
-import { sentryDsn, sentryRecommendedIgnore } from './sentry.config';
+import { getIsNavigationLocked, sessionScope } from './externals';
+import { initializeSentry } from './sentry.config';
-const Editor = React.lazy(() => import('./features/editors/ProtectedEditor'));
-const Cuesheet = React.lazy(() => import('./views/cuesheet/ProtectedCuesheet'));
-const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
+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 TimerView = React.lazy(() => import('./views/timer/Timer'));
-const MinimalTimerView = React.lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
-const ClockView = React.lazy(() => import('./features/viewers/clock/Clock'));
-const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown'));
+const Editor = lazy(() => import('./views/editor/ProtectedEditor'));
+const Cuesheet = lazy(() => import('./views/cuesheet/ProtectedCuesheet'));
+const Operator = lazy(() => import('./features/operator/OperatorExport'));
-const Backstage = React.lazy(() => import('./views/backstage/Backstage'));
-const Timeline = React.lazy(() => import('./views/timeline/TimelinePage'));
-const Public = React.lazy(() => import('./views/public/Public'));
-const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
-const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
-const ProjectInfo = React.lazy(() => import('./views/project-info/ProjectInfo'));
+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 STimer = withPreset(withData(TimerView));
-const SMinimalTimer = withPreset(withData(MinimalTimerView));
-const SClock = withPreset(withData(ClockView));
-const SCountdown = withPreset(withData(Countdown));
-const SBackstage = withPreset(withData(Backstage));
-const SProjectInfo = withPreset(withData(ProjectInfo));
-const SPublic = withPreset(withData(Public));
-const SLowerThird = withPreset(withData(Lower));
-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'));
-
-Sentry.init({
- dsn: sentryDsn,
- integrations: [
- Sentry.reactRouterV6BrowserTracingIntegration({
- useEffect: React.useEffect,
- useLocation,
- useNavigationType,
- createRoutesFromChildren,
- matchRoutes,
- }),
- ],
- tracesSampleRate: 0.3,
- release: ONTIME_VERSION,
- enabled: import.meta.env.PROD,
- ignoreErrors: [...sentryRecommendedIgnore, /Unable to preload CSS/i, /dynamically imported module/i],
- denyUrls: [/extensions\//i, /^chrome:\/\//i, /^chrome-extension:\/\//i],
-});
-
-const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes);
+// Initialize Sentry with our configuration
+const SentryRouter = initializeSentry();
export default function AppRouter() {
// handle client path changes
useClientPath();
return (
-
-
+ }>
+ } />
-
+
+
}
/>
-
+
+
}
/>
-
+
+
}
/>
-
+
+
}
/>
-
+
+
}
/>
-
+
+
}
/>
-
-
-
- }
- />
- {/*/!* Lower third cannot have a loading screen *!/*/}
- } />
-
-
-
- }
- />
-
-
-
- }
- />
-
{/*/!* Protected Routes *!/*/}
- } />
- } />
+ } />
+ } />
-
+
}
/>
-
{/*/!* Protected Routes - Elements *!/*/}
@@ -177,7 +116,7 @@ export default function AppRouter() {
}
/>
@@ -185,7 +124,7 @@ export default function AppRouter() {
}
/>
@@ -193,16 +132,119 @@ export default function AppRouter() {
}
/>
}
/>
- {/*/!* Send to default if nothing found *!/*/}
- } />
-
-
+ {/**
+ * If the views are prefixed with the "preset" path, we are in a locked preset
+ * Locked presets do not expose their parameters
+ */}
+ } />
+
+ {/**
+ * 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
+ */}
+ } />
+
+
+ );
+}
+
+const PresetViewMap: Record = {
+ [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 ;
+ }
+
+ /**
+ * 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 (
+ <>
+
+
+ >
+ );
+ }
+
+ /**
+ * Locked presets do not allow configuration changes
+ * Whether the user can navigate is determined by the locked param
+ */
+ const Component = PresetViewMap[preset.target as OntimeViewPresettable];
+ return (
+
+ {preset.target !== OntimeView.Cuesheet && (
+
+ )}
+ {Component ? : }
+
+ );
+}
+
+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 ;
+ }
+
+ return (
+ <>
+
+
+ >
);
}
diff --git a/apps/client/src/common/api/assets.ts b/apps/client/src/common/api/assets.ts
index 32203e66c..65be05f78 100644
--- a/apps/client/src/common/api/assets.ts
+++ b/apps/client/src/common/api/assets.ts
@@ -1,6 +1,9 @@
import axios from 'axios';
+import { TranslationObject } from 'ontime-types';
-import { apiEntryUrl } from './constants';
+import { ontimeQueryClient } from '../../common/queryClient';
+
+import { apiEntryUrl, customTranslationsURL, TRANSLATION } from './constants';
const assetsPath = `${apiEntryUrl}/assets`;
@@ -28,3 +31,21 @@ export async function restoreCSSContents(): Promise {
const res = await axios.post(`${assetsPath}/css/restore`);
return res.data;
}
+
+/**
+ * HTTP request to get user translation
+ */
+export async function getUserTranslation(): Promise {
+ const res = await axios.get(customTranslationsURL);
+ return res.data;
+}
+
+/**
+ * HTTP request to post user translation
+ */
+export async function postUserTranslation(translation: TranslationObject): Promise {
+ await axios.post(`${assetsPath}/translations`, {
+ translation,
+ });
+ await ontimeQueryClient.invalidateQueries({ queryKey: TRANSLATION });
+}
diff --git a/apps/client/src/common/api/axios.config.ts b/apps/client/src/common/api/axios.config.ts
new file mode 100644
index 000000000..9d993bc85
--- /dev/null
+++ b/apps/client/src/common/api/axios.config.ts
@@ -0,0 +1,5 @@
+import axios from 'axios';
+
+axios.defaults.validateStatus = (status) => {
+ return (status >= 200 && status < 300) || status === 304;
+};
diff --git a/apps/client/src/common/api/constants.ts b/apps/client/src/common/api/constants.ts
index 52f281211..aad81975f 100644
--- a/apps/client/src/common/api/constants.ts
+++ b/apps/client/src/common/api/constants.ts
@@ -8,22 +8,22 @@ export const AUTOMATION = ['automation'];
export const CUSTOM_FIELDS = ['customFields'];
export const PROJECT_DATA = ['project'];
export const PROJECT_LIST = ['projectList'];
+export const PROJECT_RUNDOWNS = ['projectRundowns'];
export const RUNDOWN = ['rundown'];
export const RUNTIME = ['runtimeStore'];
-export const SHEET_STATE = ['sheetState'];
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`;
-export const projectDataURL = `${serverURL}/project`;
-export const rundownURL = `${serverURL}/events`;
-export const ontimeURL = `${serverURL}/ontime`;
+const userAssetsPath = 'user';
+const cssOverridePath = 'styles/override.css';
+const customTranslationsPath = 'translations/translations.json';
-export const userAssetsPath = 'user';
-export const cssOverridePath = 'styles/override.css';
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`;
+export const customTranslationsURL = `${serverURL}/${userAssetsPath}/${customTranslationsPath}`;
diff --git a/apps/client/src/common/api/customFields.ts b/apps/client/src/common/api/customFields.ts
index ff60290ed..02ce4d15e 100644
--- a/apps/client/src/common/api/customFields.ts
+++ b/apps/client/src/common/api/customFields.ts
@@ -1,5 +1,5 @@
import axios from 'axios';
-import { CustomField, CustomFieldLabel, CustomFields } from 'ontime-types';
+import { CustomField, CustomFieldKey, CustomFields } from 'ontime-types';
import { apiEntryUrl } from './constants';
@@ -24,15 +24,15 @@ export async function postCustomField(newField: CustomField): Promise {
- const res = await axios.put(`${customFieldsPath}/${label}`, { ...newField });
+export async function editCustomField(key: CustomFieldKey, newField: CustomField): Promise {
+ const res = await axios.put(`${customFieldsPath}/${key}`, { ...newField });
return res.data;
}
/**
* Deletes single custom field
*/
-export async function deleteCustomField(label: CustomFieldLabel): Promise {
- const res = await axios.delete(`${customFieldsPath}/${label}`);
+export async function deleteCustomField(key: CustomFieldKey): Promise {
+ const res = await axios.delete(`${customFieldsPath}/${key}`);
return res.data;
}
diff --git a/apps/client/src/common/api/db.ts b/apps/client/src/common/api/db.ts
index 9edf73eb5..28f3b9b33 100644
--- a/apps/client/src/common/api/db.ts
+++ b/apps/client/src/common/api/db.ts
@@ -1,9 +1,6 @@
import axios, { AxiosResponse } from 'axios';
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
-import { makeTable } from '../../views/cuesheet/cuesheet.utils';
-import { makeCSVFromArrayOfArrays } from '../utils/csv';
-
import { apiEntryUrl } from './constants';
import { createBlob, downloadBlob } from './utils';
@@ -33,25 +30,6 @@ 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, rundown, customFields } = data;
-
- const sheetData = makeTable(project, rundown, 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
*/
diff --git a/apps/client/src/common/api/excel.ts b/apps/client/src/common/api/excel.ts
index b73aa585f..5c21c987a 100644
--- a/apps/client/src/common/api/excel.ts
+++ b/apps/client/src/common/api/excel.ts
@@ -1,16 +1,11 @@
import axios, { AxiosResponse } from 'axios';
-import { CustomFields, OntimeRundown } from 'ontime-types';
+import { CustomFields, Rundown } from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants';
const excelPath = `${apiEntryUrl}/excel`;
-type PreviewSpreadsheetResponse = {
- rundown: OntimeRundown;
- customFields: CustomFields;
-};
-
/**
* upload Excel file to server
* @return string - file ID op the uploaded file
@@ -34,6 +29,10 @@ export async function getWorksheetNames(): Promise {
return response.data;
}
+type PreviewSpreadsheetResponse = {
+ rundown: Rundown;
+ customFields: CustomFields;
+};
export async function importRundownPreview(options: ImportMap): Promise {
const response: AxiosResponse = await axios.post(`${excelPath}/preview`, {
options,
diff --git a/apps/client/src/common/api/external.ts b/apps/client/src/common/api/external.ts
index e84e49a15..9c2c0ba53 100644
--- a/apps/client/src/common/api/external.ts
+++ b/apps/client/src/common/api/external.ts
@@ -11,7 +11,7 @@ export type HasUpdate = {
* HTTP request to get the latest version and url from github
*/
export async function getLatestVersion(): Promise {
- const res = await axios.get(`${apiRepoLatest}`);
+ const res = await axios.get(apiRepoLatest);
return {
url: res.data.html_url as string,
version: res.data.tag_name as string,
diff --git a/apps/client/src/common/api/report.ts b/apps/client/src/common/api/report.ts
index 2ea67e94a..619d946e6 100644
--- a/apps/client/src/common/api/report.ts
+++ b/apps/client/src/common/api/report.ts
@@ -11,7 +11,7 @@ export const reportUrl = `${apiEntryUrl}/report`;
* HTTP request to fetch all reports
*/
export async function fetchReport(): Promise {
- const res = await axios.get(`${reportUrl}/`);
+ const res = await axios.get(reportUrl);
return res.data;
}
diff --git a/apps/client/src/common/api/rundown.ts b/apps/client/src/common/api/rundown.ts
index 9d854eee1..5d6c758ec 100644
--- a/apps/client/src/common/api/rundown.ts
+++ b/apps/client/src/common/api/rundown.ts
@@ -1,86 +1,141 @@
import axios, { AxiosResponse } from 'axios';
-import { MessageResponse, OntimeEvent, OntimeRundownEntry, RundownCached, TransientEventPayload } from 'ontime-types';
+import { EntryId, OntimeEntry, OntimeEvent, ProjectRundownsList, Rundown, TransientEventPayload } from 'ontime-types';
import { apiEntryUrl } from './constants';
-const rundownPath = `${apiEntryUrl}/rundown`;
+const rundownPath = `${apiEntryUrl}/rundowns`;
+
+// #region operations on project rundowns =========================
/**
- * HTTP request to fetch all events
+ * HTTP request to fetch a list of existing rundowns
*/
-export async function fetchNormalisedRundown(): Promise {
- const res = await axios.get(`${rundownPath}/normalised`);
+export async function fetchProjectRundownList(): Promise {
+ const res = await axios.get(rundownPath);
return res.data;
}
/**
- * HTTP request to post new event
+ * HTTP request to fetch all entries in the currently loaded rundown
*/
-export async function requestPostEvent(data: TransientEventPayload): Promise> {
- return axios.post(rundownPath, data);
+export async function fetchCurrentRundown(): Promise {
+ const res = await axios.get(`${rundownPath}/current`);
+ return res.data;
}
/**
- * HTTP request to put new event
+ * HTTP request to switch the currently loaded rundown
*/
-export async function requestPutEvent(data: Partial): Promise> {
- return axios.put(rundownPath, data);
+export async function loadRundown(id: string): Promise> {
+ return axios.post(`${rundownPath}/${id}/load`);
}
-type BatchEditEntry = {
+/**
+ * HTTP request to create a new rundown
+ */
+export async function createRundown(title: string): Promise> {
+ return axios.post(rundownPath, { title });
+}
+
+/**
+ * HTTP request to delete a rundown
+ */
+export async function deleteRundown(id: string): Promise> {
+ 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> {
+ return axios.post(`${rundownPath}/${rundownId}/entry`, data);
+}
+
+/**
+ * HTTP request to edit an entry
+ */
+export async function putEditEntry(rundownId: string, data: Partial): Promise> {
+ return axios.put(`${rundownPath}/${rundownId}/entry`, data);
+}
+
+export type BatchEditEntry = {
data: Partial;
- ids: string[];
+ ids: EntryId[];
};
/**
- * HTTP request to put multiple events
+ * HTTP request to edit multiple events
*/
-export async function requestBatchPutEvents(data: BatchEditEntry): Promise> {
- return axios.put(`${rundownPath}/batch`, data);
+export async function putBatchEditEvents(rundownId: string, data: BatchEditEntry): Promise> {
+ return axios.put(`${rundownPath}/${rundownId}/batch`, data);
}
export type ReorderEntry = {
- eventId: string;
- from: number;
- to: number;
+ entryId: EntryId;
+ destinationId: EntryId;
+ order: 'before' | 'after' | 'insert';
};
/**
- * HTTP request to reorder events
+ * HTTP request to reorder an entry
*/
-export async function requestReorderEvent(data: ReorderEntry): Promise> {
- return axios.patch(`${rundownPath}/reorder`, data);
+export async function patchReorderEntry(rundownId: string, data: ReorderEntry): Promise> {
+ return axios.patch(`${rundownPath}/${rundownId}/reorder`, data);
}
-export type SwapEntry = {
- from: string;
- to: string;
-};
-
/**
* HTTP request to swap two events
*/
-export async function requestEventSwap(data: SwapEntry): Promise> {
- return axios.patch(`${rundownPath}/swap`, data);
+export async function requestEventSwap(rundownId: string, from: EntryId, to: EntryId): Promise> {
+ return axios.patch(`${rundownPath}/${rundownId}/swap`, { from, to });
}
/**
* HTTP request to request application of delay
*/
-export async function requestApplyDelay(eventId: string): Promise> {
- return axios.patch(`${rundownPath}/applydelay/${eventId}`);
+export async function requestApplyDelay(rundownId: string, delayId: EntryId): Promise> {
+ return axios.patch(`${rundownPath}/${rundownId}/applydelay/${delayId}`);
}
/**
- * HTTP request to delete given event
+ * HTTP request for cloning an entry
*/
-export async function requestDelete(eventIds: string[]): Promise> {
- return axios.delete(rundownPath, { data: { ids: eventIds } });
+export async function postCloneEntry(rundownId: string, entryId: EntryId): Promise> {
+ return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`);
}
/**
- * HTTP request to delete all events
+ * HTTP request for grouping a list of entries into a group
*/
-export async function requestDeleteAll(): Promise> {
- return axios.delete(`${rundownPath}/all`);
+export async function requestGroupEntries(rundownId: string, entryIds: EntryId[]): Promise> {
+ return axios.post(`${rundownPath}/${rundownId}/group`, { ids: entryIds });
}
+
+/**
+ * HTTP request for dissolving of a group
+ */
+export async function requestUngroup(rundownId: string, groupId: EntryId): Promise> {
+ return axios.post(`${rundownPath}/${rundownId}/ungroup/${groupId}`);
+}
+
+/**
+ * HTTP request to delete entries of a given rundown
+ */
+export async function deleteEntries(rundownId: string, entryIds: EntryId[]): Promise> {
+ return axios.delete(`${rundownPath}/${rundownId}/entries`, { data: { ids: entryIds } });
+}
+
+/**
+ * HTTP request to delete all entries of a given rundown
+ */
+export async function requestDeleteAll(rundownId: string): Promise> {
+ return axios.delete(`${rundownPath}/${rundownId}/all`);
+}
+
+// #endregion operations on rundown entries =======================
diff --git a/apps/client/src/common/api/session.ts b/apps/client/src/common/api/session.ts
index 2449cf18e..ae71fa512 100644
--- a/apps/client/src/common/api/session.ts
+++ b/apps/client/src/common/api/session.ts
@@ -1,5 +1,5 @@
import axios from 'axios';
-import { GetInfo } from 'ontime-types';
+import { GetInfo, LinkOptions } from 'ontime-types';
import { apiEntryUrl } from './constants';
@@ -16,12 +16,7 @@ export async function getInfo(): Promise {
/**
* HTTP request to get a pre-authenticated URL
*/
-export async function generateUrl(
- baseUrl: string,
- path: string,
- lock: boolean,
- authenticate: boolean,
-): Promise {
- const res = await axios.post(`${sessionPath}/url`, { baseUrl, path, lock, authenticate });
+export async function generateUrl(options: LinkOptions & { baseUrl: string; path: string }): Promise {
+ const res = await axios.post(`${sessionPath}/url`, options);
return res.data.url;
}
diff --git a/apps/client/src/common/api/sheets.ts b/apps/client/src/common/api/sheets.ts
index ebeb9fdce..231f543db 100644
--- a/apps/client/src/common/api/sheets.ts
+++ b/apps/client/src/common/api/sheets.ts
@@ -1,5 +1,5 @@
import axios, { AxiosResponse } from 'axios';
-import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types';
+import { AuthenticationStatus, CustomFields, Rundown } from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants';
@@ -54,7 +54,7 @@ export const previewRundown = async (
sheetId: string,
options: ImportMap,
): Promise<{
- rundown: OntimeRundown;
+ rundown: Rundown;
customFields: CustomFields;
}> => {
const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options });
diff --git a/apps/client/src/common/api/urlPresets.ts b/apps/client/src/common/api/urlPresets.ts
index 68820a155..454a80c67 100644
--- a/apps/client/src/common/api/urlPresets.ts
+++ b/apps/client/src/common/api/urlPresets.ts
@@ -6,7 +6,7 @@ import { apiEntryUrl } from './constants';
const urlPresetsPath = `${apiEntryUrl}/url-presets`;
/**
- * HTTP request to retrieve aliases
+ * HTTP request to retrieve all presets
*/
export async function getUrlPresets(): Promise {
const res = await axios.get(urlPresetsPath);
@@ -14,8 +14,22 @@ export async function getUrlPresets(): Promise {
}
/**
- * HTTP request to mutate aliases
+ * HTTP request to add a preset
*/
-export async function postUrlPresets(data: URLPreset[]): Promise {
- return axios.post(urlPresetsPath, data);
+export async function postUrlPreset(data: URLPreset): Promise {
+ return (await axios.post(urlPresetsPath, data)).data;
+}
+
+/**
+ * HTTP request to edit a preset
+ */
+export async function putUrlPreset(alias: string, data: URLPreset): Promise {
+ return (await axios.put(`${urlPresetsPath}/${alias}`, data)).data;
+}
+
+/**
+ * HTTP request to delete a preset
+ */
+export async function deleteUrlPreset(alias: string): Promise {
+ return (await axios.delete(`${urlPresetsPath}/${alias}`)).data;
}
diff --git a/apps/client/src/common/api/utils.ts b/apps/client/src/common/api/utils.ts
index 9f2f35ce2..1f8701f24 100644
--- a/apps/client/src/common/api/utils.ts
+++ b/apps/client/src/common/api/utils.ts
@@ -7,7 +7,7 @@ import { addLog } from '../stores/logger';
import { nowInMillis } from '../utils/time';
/**
- * Utility unrwap a potential axios error
+ * Utility unwrap a potential axios error
* @param error
* @returns
*/
@@ -34,7 +34,21 @@ export function maybeAxiosError(error: unknown) {
}
/**
- * Utility unrwaps a potential axios error and sends to logger
+ * 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
* @param error
*/
diff --git a/apps/client/src/common/api/viewSettings.ts b/apps/client/src/common/api/viewSettings.ts
index f835f29e7..9eb87505f 100644
--- a/apps/client/src/common/api/viewSettings.ts
+++ b/apps/client/src/common/api/viewSettings.ts
@@ -1,21 +1,24 @@
import axios from 'axios';
-import { ViewSettings } from 'ontime-types';
+import type { ViewSettings } from 'ontime-types';
import { apiEntryUrl } from './constants';
const viewSettingsPath = `${apiEntryUrl}/view-settings`;
/**
- * HTTP request to retrieve view settings
+ * HTTP request to fetch view settings
+ * @returns
*/
-export async function getView(): Promise {
+export async function getViewSettings(): Promise {
const res = await axios.get(viewSettingsPath);
return res.data;
}
/**
- * HTTP request to mutate view settings
+ * HTTP request to update view settings
+ * needs to update entire objects, not just a patch
*/
-export async function postViewSettings(data: ViewSettings) {
- return axios.post(viewSettingsPath, data);
+export async function postViewSettings(data: ViewSettings): Promise {
+ const res = await axios.post(viewSettingsPath, data);
+ return res.data;
}
diff --git a/apps/client/src/common/components/buttons/BaseButtonStyles.module.scss b/apps/client/src/common/components/buttons/BaseButtonStyles.module.scss
new file mode 100644
index 000000000..0466b499c
--- /dev/null
+++ b/apps/client/src/common/components/buttons/BaseButtonStyles.module.scss
@@ -0,0 +1,140 @@
+.subtle {
+ background: $gray-1050;
+ color: $blue-400;
+ line-height: 1em;
+
+ &:hover:not(:disabled):not(:active) {
+ background: $gray-1000;
+ color: $blue-500;
+ }
+
+ &:active:not(:disabled) {
+ background: $gray-1100;
+ border-color: $gray-1250;
+ }
+}
+
+.subtle-white {
+ background: $gray-1050;
+ color: $ui-white;
+
+ &:hover:not(:disabled):not(:active) {
+ background: $gray-1000;
+ color: $ui-white;
+ }
+
+ &:active:not(:disabled) {
+ background: $gray-1100;
+ border-color: $gray-1250;
+ }
+
+ &:disabled {
+ background: $gray-1050;
+ }
+}
+
+.primary {
+ background: $blue-700;
+ color: $ui-white;
+
+ &:hover:not(:disabled):not(:active) {
+ background: $blue-600;
+ }
+
+ &:active:not(:disabled) {
+ background: $blue-800;
+ border-color: $blue-900;
+ }
+
+ &:disabled {
+ opacity: $viewer-opacity-disabled;
+ }
+}
+
+.destructive {
+ background: $red-700;
+ color: $ui-white;
+
+ &:hover:not(:disabled):not(:active) {
+ background: $red-600;
+ color: $ui-white;
+ }
+
+ &:active:not(:disabled) {
+ background: $red-800;
+ border-color: $red-900;
+ }
+}
+
+.subtle-destructive {
+ background: $gray-1050;
+ color: $red-400;
+
+ &:hover:not(:disabled):not(:active) {
+ background: $gray-1000;
+ color: $red-500;
+ }
+
+ &:active:not(:disabled) {
+ background: $gray-1100;
+ border-color: $gray-1250;
+ }
+}
+
+.ghosted {
+ background: transparent;
+ color: $blue-500;
+
+ &:hover:not(:disabled):not(:active) {
+ background: $gray-1000;
+ color: $blue-500;
+ }
+
+ &:active:not(:disabled) {
+ background: $gray-1100;
+ border-color: $gray-1250;
+ }
+
+ &:disabled {
+ opacity: $opacity-disabled;
+ }
+}
+
+.ghosted-white {
+ background: transparent;
+ color: $ui-white;
+
+ &:hover:not(:disabled):not(:active) {
+ background: $gray-1000;
+ color: $ui-white;
+ }
+
+ &:active:not(:disabled) {
+ background: $gray-1100;
+ border-color: $gray-1250;
+ }
+
+ &:disabled {
+ opacity: $opacity-disabled;
+ }
+}
+
+
+.ghosted-destructive {
+ background: transparent;
+ color: $red-500;
+
+ &:hover:not(:disabled):not(:active) {
+ background: $gray-1000;
+ color: $red-500;
+ }
+
+ &:active:not(:disabled) {
+ background: $gray-1100;
+ border-color: $gray-1250;
+ }
+
+ &:disabled {
+ opacity: $opacity-disabled;
+ }
+}
diff --git a/apps/client/src/common/components/buttons/Button.module.scss b/apps/client/src/common/components/buttons/Button.module.scss
new file mode 100644
index 000000000..cc6dd04b7
--- /dev/null
+++ b/apps/client/src/common/components/buttons/Button.module.scss
@@ -0,0 +1,84 @@
+@use '@/theme/viewerDefs' as *;
+@import './BaseButtonStyles.module.scss';
+
+.baseButton {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5em;
+
+ width: fit-content;
+ padding-inline: 1em;
+ white-space: nowrap;
+
+ border: 1px solid transparent;
+ border-radius: $component-border-radius-md;
+
+ cursor: pointer;
+
+ &:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+ }
+
+ &:focus-visible {
+ outline: 2px solid $blue-500;
+ outline-offset: 2px;
+ }
+
+ &.loading {
+ cursor: wait;
+ .content {
+ opacity: 0;
+ }
+ }
+}
+
+.content {
+ display: flex;
+ align-items: center;
+ gap: inherit;
+}
+
+.loadingOverlay {
+ position: absolute;
+ display: grid;
+ place-content: center;
+}
+
+.spinner {
+ animation: spin 1s linear infinite;
+ stroke-dasharray: 8;
+}
+
+@keyframes spin {
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+.small {
+ height: 1.5rem;
+ font-size: calc(1rem - 3px);
+}
+
+.medium {
+ height: 2rem;
+ font-size: calc(1rem - 2px);
+}
+
+.large {
+ height: 2.5rem;
+ font-weight: 600;
+}
+
+.xlarge {
+ height: 3rem;
+ font-weight: 600;
+}
+
+.fluid {
+ width: 100%;
+}
+
diff --git a/apps/client/src/common/components/buttons/Button.tsx b/apps/client/src/common/components/buttons/Button.tsx
new file mode 100644
index 000000000..9457e74f4
--- /dev/null
+++ b/apps/client/src/common/components/buttons/Button.tsx
@@ -0,0 +1,53 @@
+import { ButtonHTMLAttributes, forwardRef } from 'react';
+import { IoEllipseOutline } from 'react-icons/io5';
+
+import { cx } from '../../utils/styleUtils';
+
+import style from './Button.module.scss';
+
+interface ButtonProps extends ButtonHTMLAttributes {
+ variant?:
+ | 'primary'
+ | 'subtle'
+ | 'subtle-white'
+ | 'destructive'
+ | 'subtle-destructive'
+ | 'ghosted'
+ | 'ghosted-white'
+ | 'ghosted-destructive';
+ size?: 'small' | 'medium' | 'large' | 'xlarge';
+ fluid?: boolean;
+ loading?: boolean;
+}
+
+const Button = forwardRef(
+ ({ className, children, variant = 'subtle', size = 'medium', fluid, loading, ...buttonProps }, ref) => {
+ return (
+
+ );
+ },
+);
+
+Button.displayName = 'Button';
+
+export default Button;
diff --git a/apps/client/src/common/components/buttons/IconButton.module.scss b/apps/client/src/common/components/buttons/IconButton.module.scss
index 4fcf16539..a1e9b9981 100644
--- a/apps/client/src/common/components/buttons/IconButton.module.scss
+++ b/apps/client/src/common/components/buttons/IconButton.module.scss
@@ -1,26 +1,44 @@
-.subtle {
+@use '@/theme/viewerDefs' as *;
+@import './BaseButtonStyles.module.scss';
+
+.baseIconButton {
aspect-ratio: 1;
- height: 2rem;
- height: 2rem;
display: grid;
place-content: center;
- background: $gray-1050;
- color: $blue-400;
border: 1px solid transparent;
- border-radius: 3px;
+ border-radius: $component-border-radius-md;
- &:hover:not(:disabled):not(:active) {
- background: $gray-1000;
- color: $blue-500;
- }
+ cursor: pointer;
- &:active {
- background: $gray-1100;
- border-color: $gray-1250;
+ &:focus-visible {
+ outline: 2px solid $blue-500;
+ outline-offset: 2px;
}
&:disabled {
- background: $gray-1050;
+ opacity: 0.4;
+ cursor: not-allowed;
}
}
+
+.small {
+ height: 1.5rem;
+ font-size: calc(1rem - 3px);
+}
+
+.medium {
+ height: 2rem;
+ width: 2rem;
+}
+
+.large {
+ height: 2.5rem;
+ width: 2.5rem;
+}
+
+.xlarge {
+ height: 3rem;
+ height: 3rem;
+ font-size: 1.5rem;
+}
diff --git a/apps/client/src/common/components/buttons/IconButton.tsx b/apps/client/src/common/components/buttons/IconButton.tsx
index 172780f51..1160ea78f 100644
--- a/apps/client/src/common/components/buttons/IconButton.tsx
+++ b/apps/client/src/common/components/buttons/IconButton.tsx
@@ -1,14 +1,37 @@
-import { ButtonHTMLAttributes } from 'react';
+import { ButtonHTMLAttributes, forwardRef } from 'react';
import { cx } from '../../utils/styleUtils';
import style from './IconButton.module.scss';
-export default function IconButton(props: ButtonHTMLAttributes) {
- const { className, children, ...buttonProps } = props;
- return (
-
- );
+interface IconButtonProps extends ButtonHTMLAttributes {
+ variant?:
+ | 'primary'
+ | 'subtle'
+ | 'subtle-white'
+ | 'destructive'
+ | 'subtle-destructive'
+ | 'ghosted'
+ | 'ghosted-white'
+ | 'ghosted-destructive';
+ size?: 'small' | 'medium' | 'large' | 'xlarge';
}
+
+const IconButton = forwardRef(
+ ({ className, children, variant = 'subtle', size = 'medium', ...buttonProps }, ref) => {
+ return (
+
+ );
+ },
+);
+
+IconButton.displayName = 'IconButton';
+
+export default IconButton;
diff --git a/apps/client/src/common/components/buttons/TooltipActionBtn.tsx b/apps/client/src/common/components/buttons/TooltipActionBtn.tsx
deleted file mode 100644
index 81f4de703..000000000
--- a/apps/client/src/common/components/buttons/TooltipActionBtn.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import { MouseEvent } from 'react';
-import { IconButton, IconButtonProps, Tooltip } from '@chakra-ui/react';
-
-interface TooltipActionBtnProps extends IconButtonProps {
- clickHandler: (event: MouseEvent) => void | Promise;
- tooltip: string;
- openDelay?: number;
-}
-
-export default function TooltipActionBtn(props: TooltipActionBtnProps) {
- const { clickHandler, icon, size = 'xs', tooltip, openDelay = 0, className, ...rest } = props;
- return (
-
-
-
- );
-}
diff --git a/apps/client/src/common/components/checkbox/Checkbox.module.scss b/apps/client/src/common/components/checkbox/Checkbox.module.scss
new file mode 100644
index 000000000..6fce73d69
--- /dev/null
+++ b/apps/client/src/common/components/checkbox/Checkbox.module.scss
@@ -0,0 +1,50 @@
+.checkbox {
+ box-sizing: border-box;
+ display: flex;
+ width: 1rem;
+ height: 1rem;
+ align-items: center;
+ justify-content: center;
+ border-radius: $component-border-radius-sm;
+
+ &[data-unchecked] {
+ background-color: $gray-1100;
+
+ &:hover:not(:disabled) {
+ background: $gray-1000;
+ }
+ &:active:not(:disabled) {
+ background: $gray-1200;
+ }
+ }
+
+ &[data-checked] {
+ background-color: $blue-500;
+
+ &:hover:not(:disabled) {
+ background: $blue-400;
+ }
+ &:active:not(:disabled) {
+ background: $blue-600;
+ }
+ }
+
+ &:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+ }
+
+ &:focus-visible {
+ outline: 2px solid $blue-500;
+ outline-offset: 2px;
+ }
+}
+
+.indicator {
+ display: flex;
+ color: $ui-white;
+
+ &[data-unchecked] {
+ display: none;
+ }
+}
diff --git a/apps/client/src/common/components/checkbox/Checkbox.tsx b/apps/client/src/common/components/checkbox/Checkbox.tsx
new file mode 100644
index 000000000..8345563c0
--- /dev/null
+++ b/apps/client/src/common/components/checkbox/Checkbox.tsx
@@ -0,0 +1,14 @@
+import { IoCheckmark } from 'react-icons/io5';
+import { Checkbox as BaseCheckbox } from '@base-ui-components/react/checkbox';
+
+import style from './Checkbox.module.scss';
+
+export default function Checkbox(checkboxProps: BaseCheckbox.Root.Props) {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/apps/client/src/common/components/client-modal/RedirectClientModal.tsx b/apps/client/src/common/components/client-modal/RedirectClientModal.tsx
index 50b67ee10..e57ff6c8f 100644
--- a/apps/client/src/common/components/client-modal/RedirectClientModal.tsx
+++ b/apps/client/src/common/components/client-modal/RedirectClientModal.tsx
@@ -1,22 +1,15 @@
import { useState } from 'react';
import { IoArrowForward } from 'react-icons/io5';
-import {
- IconButton,
- Input,
- Modal,
- ModalBody,
- ModalCloseButton,
- ModalContent,
- ModalHeader,
- ModalOverlay,
- Select,
-} from '@chakra-ui/react';
import { navigatorConstants } from '../../../viewerConfig';
import { setClientRemote } from '../../hooks/useSocket';
import useUrlPresets from '../../hooks-query/useUrlPresets';
+import Button from '../buttons/Button';
import Info from '../info/Info';
+import Input from '../input/input/Input';
import AppLink from '../link/app-link/AppLink';
+import Modal from '../modal/Modal';
+import Select from '../select/Select';
import style from './RedirectClientModal.module.scss';
@@ -29,8 +22,7 @@ interface RedirectClientModalProps {
onClose: () => void;
}
-export function RedirectClientModal(props: RedirectClientModalProps) {
- const { id, isOpen, name, currentPath, origin, onClose } = props;
+export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onClose }: RedirectClientModalProps) {
const { data } = useUrlPresets();
const [path, setPath] = useState(currentPath);
const [selected, setSelected] = useState('/');
@@ -41,86 +33,88 @@ export function RedirectClientModal(props: RedirectClientModalProps) {
if (newPath === '/' || newPath === currentPath) {
return;
}
- setRedirect({ target: id, redirect: newPath });
+
+ if (newPath.startsWith('preset-')) {
+ setRedirect({ target: id, redirect: newPath.slice(7) });
+ } else {
+ setRedirect({ target: id, redirect: newPath });
+ }
+
onClose();
};
const enabledPresets = data.filter((preset) => preset.enabled);
+ const viewOptions = [
+ ...navigatorConstants.map((view) => ({
+ value: `/${view.url}`,
+ label: view.label,
+ })),
+ ...enabledPresets.map((preset) => ({
+ value: `preset-${preset.alias}`,
+ label: `URL Preset: ${preset.alias}`,
+ })),
+ ];
+
return (
-
-
-
- Redirect: {name}
-
-
+
Remotely redirect the client to a different URL.
- Either by selecting a URL Preset or entering a custom path.
+ Either by entering a custom path or selecting a URL Preset.