Compare commits

..

2 Commits

Author SHA1 Message Date
Fabian Posenau 409bc65427 Add arm platforms to docker build (#297)
* add arm platforms to docker build

---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>
2023-02-25 15:46:37 +01:00
Carlos Valente 0ff6556e4d fix: fetch in offline environments (#295) 2023-02-23 21:41:05 +01:00
636 changed files with 30713 additions and 27148 deletions
+1
View File
@@ -3,6 +3,7 @@ version = 1
test_patterns = [
"__mocks__/**",
"__tests__/**",
"cypress/**",
"*.test.*",
"*.mock.*",
"*.spec.*"
+22 -7
View File
@@ -9,11 +9,26 @@
"extends": [
"eslint:recommended"
],
"overrides": [
{
"files": ["e2e/**/**.spec.ts", "e2e/**/**.test.ts"],
"extends": ["plugin:playwright/playwright-test"]
}
],
"rules": {}
"rules": {
// disallow certain object properties
// https://eslint.org/docs/rules/no-restricted-properties
"no-restricted-properties": [
"error",
{
"object": "global",
"property": "isNaN",
"message": "Please use Number.isNaN instead"
},
{
"object": "self",
"property": "isNaN",
"message": "Please use Number.isNaN instead"
},
{
"object": "window",
"property": "isNaN",
"message": "Please use Number.isNaN instead"
}
]
}
}

Before

Width:  |  Height:  |  Size: 285 KiB

After

Width:  |  Height:  |  Size: 285 KiB

Before

Width:  |  Height:  |  Size: 241 KiB

After

Width:  |  Height:  |  Size: 241 KiB

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Before

Width:  |  Height:  |  Size: 250 KiB

After

Width:  |  Height:  |  Size: 250 KiB

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

+56 -3
View File
@@ -2,12 +2,14 @@ name: Ontime build
on:
push:
tags: [ "v1.*.*" ]
# run when a tag is created
tags:
- '*'
workflow_dispatch:
jobs:
build_mac:
runs-on: macos-latest
runs-on: macOS-latest
timeout-minutes: 20
env:
CI: ''
@@ -136,4 +138,55 @@ jobs:
with:
files: ./server/dist/ontime-linux.AppImage
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
publish_docker:
runs-on: ubuntu-latest
timeout-minutes: 20
env:
CI: ''
steps:
- uses: actions/checkout@v2
- name: Setup env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
- name: Use Node.js
uses: actions/setup-node@v1
with:
node-version: '14.x'
# React
- name: React - Install dependencies
run: yarn install --network-timeout 300000
working-directory: ./client
- name: React - Build project
run: yarn build
working-directory: ./client
# Node server
- name: Server - Install dependencies
run: yarn install --frozen-lockfile --production --network-timeout 300000
working-directory: ./server/src
# Login to docker
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# Prepare builder
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
with:
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6
# Build and push
- name: Build and push
uses: docker/build-push-action@v2
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ env.RELEASE_VERSION }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
-55
View File
@@ -1,55 +0,0 @@
name: Docker Image CI Ontime V2
on:
push:
tags: [ "v2.*.*" ]
workflow_dispatch:
jobs:
publish_docker:
runs-on: ubuntu-latest
env:
CI: ''
steps:
- uses: actions/checkout@v3
- name: Setup env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
- name: Setup Node.js environment
uses: actions/setup-node@v3.6.0
with:
version: 16.16.0
- name: Setup pnpm
with:
version: 7.26.3
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm turbo 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 Docker images
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:beta_${{ env.RELEASE_VERSION }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:beta_v2
-106
View File
@@ -1,106 +0,0 @@
name: Ontime build v2
on:
push:
tags: [ "v2.*.*" ]
workflow_dispatch:
jobs:
build_macos:
runs-on: macOS-latest
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 16
- name: Setup pnpm
uses: pnpm/action-setup@v2.2.4
with:
version: 7.26.3
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm build
- name: Electron - Build app
run: pnpm dist-mac
- name: Release
uses: softprops/action-gh-release@v1
with:
files: './apps/electron/dist/ontime-macOS.dmg'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build_windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 16
- name: Setup pnpm
uses: pnpm/action-setup@v2.2.4
with:
version: 7.26.3
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm build
- name: Electron - Build app
run: pnpm dist-win
- name: Release
uses: softprops/action-gh-release@v1
with:
files: './apps/electron/dist/ontime-win64.exe'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build_ubuntu:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 16
- name: Setup pnpm
uses: pnpm/action-setup@v2.2.4
with:
version: 7.26.3
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm build
- name: Electron - Build app
run: pnpm dist-linux
- name: Release
uses: softprops/action-gh-release@v1
with:
files: './apps/electron/dist/ontime-linux.AppImage'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+70
View File
@@ -0,0 +1,70 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ master ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ master ]
schedule:
- cron: '33 02 * * 5'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'javascript' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Learn more about CodeQL language support at https://git.io/codeql-language-support
steps:
- name: Checkout repository
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1
+11 -23
View File
@@ -3,10 +3,7 @@
name: ontime_test_CI
on:
pull_request:
branches: [ master ]
workflow_dispatch:
on: [push, pull_request]
jobs:
build:
@@ -17,11 +14,11 @@ jobs:
CI: ''
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v2
- name: Use Node.js
uses: actions/setup-node@v3
uses: actions/setup-node@v2
with:
node-version: 16
node-version: '14.x'
# React
- name: React - Install dependencies
@@ -43,24 +40,15 @@ jobs:
# App
- name: Electron - Install dependencies
run: yarn setup
run: yarn install && yarn setdb
working-directory: ./server
- name: Server - run tests
- name: Electron - Run tests
run: yarn test
working-directory: ./server
# - name: Install Playwright Browsers
# run: npx playwright install --with-deps
# working-directory: ./server
#
# - name: Run Playwright tests
# run: yarn e2e
# working-directory: ./server
#
# - uses: actions/upload-artifact@v3
# if: always()
# with:
# name: playwright-report
# path: playwright-report/
# retention-days: 7
- name: Cypress run
uses: cypress-io/github-action@v2
with:
working-directory: ./server
start: yarn cypress
-76
View File
@@ -1,76 +0,0 @@
name: Ontime test v2
on:
pull_request:
branches: [ v2 ]
workflow_dispatch:
jobs:
unit-test:
runs-on: ubuntu-latest
timeout-minutes: 20
env:
CI: ''
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 16
- name: Setup pnpm
uses: pnpm/action-setup@v2.2.4
with:
version: 7.26.3
- name: Install dependencies
run: pnpm install --frozen-lockfile
# We choose to run tests separately
- name: React - Run unit tests
run: pnpm test:pipeline
working-directory: ./apps/client
- name: Server - Run unit tests
run: pnpm test:pipeline
working-directory: ./apps/server
- name: Utils - Run unit tests
run: pnpm test:pipeline
working-directory: ./packages/utils
e2e-test:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 16
- name: Setup pnpm
uses: pnpm/action-setup@v2.2.4
with:
version: 7.26.3
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build client
run: pnpm build:local
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: pnpm e2e
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
+23 -27
View File
@@ -5,42 +5,38 @@ node_modules/
/.pnp
.pnp.js
# testing
coverage/
*.mp4
# production
build/
dist/
# misc
.DS_Store
*.local
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# working stuff
_SS/
db backup.json
server/src/preloaded-db/db.json
server/src/models/db.json
TODO.md
ontime-db/
ontime-external/
# vscode stuff
.vscode/*
ontime.code-workspace
# webstorm stuff
.idea/*
# turborepo stuff
.turbo
# testing
test-results
playwright-report
/playwright/.cache/
# production
build/
dist/
# working stuff
**/TODO.md
# docker utils
ontime-db
ontime-external/
# working database
apps/server/src/preloaded-db/db.json
# versioning file
**/ONTIME_VERSION.js
.idea/*
BIN
View File
Binary file not shown.
+3 -3
View File
@@ -1,8 +1,8 @@
{
"trailingComma": "all",
"trailingComma": "es5",
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"jsxSingleQuote": true,
"printWidth": 120
}
"printWidth": 100
}
-47
View File
@@ -1,47 +0,0 @@
# GETTING STARTED
Ontime consists of 3 distinct parts
- __client__: A React app for Ontime's UI and web clients
- __electron__: An electron app which facilitates the cross-platform distribution of Ontime
- __server__: A node application which handles the domains services and integrations
The steps below will assume you have locally installed the necessary dependencies.
Other dependencies will be installed as part of the setup
- __node__ (>=16.16)
- __pnpm__ (>=7)
- __docker__ (only necessary to run and build docker images)
## LOCAL DEVELOPMENT
The electron app is only necessary to distribute an installable version of the app and is not required for local 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`
## CREATE AN INSTALLABLE FILE (Windows | MacOS | Linux)
Ontime uses Electron to distribute the application.
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`
- __Create the package__ by running `turbo dist-win`, `turbo dist-mac` or `turbo dist-linux`
The build distribution assets will be at `.apps/electron/dist`
## DOCKER
Ontime provides a docker-compose file to aid with building and running docker images.
While it should allow for a generic setup, it might need to be modified to fit your infrastructure.
From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i`
- __Build docker image from__ by running `docker build -t getontime/ontime`
- __Run docker image from compose__ by running `docker-compose up -d`
Other useful commands
- __List running processes__ by running `docker ps`
- __Kill running process__ by running `docker kill <process-id>`
+12 -19
View File
@@ -1,27 +1,20 @@
FROM node:16-alpine
FROM node:14-alpine
# Set environment variables
# Environment Variable to signal that we are running production
ENV NODE_ENV=docker
# Ontime Data path
ENV ONTIME_DATA=/external/
WORKDIR /app/
WORKDIR /app/server
# Prepare UI
COPY /apps/client/build ./client/
COPY /client/build ../client/build
# Prepare Backend
COPY /apps/server/dist/ ./server/
COPY /demo-db/ ./preloaded-db/
COPY /apps/server/src/external/ ./external/
COPY /server/src ./
# Export default ports
EXPOSE 4001/tcp 8888/udp 9999/udp
# Export default ports Main - OSC IN
EXPOSE 4001/tcp 8888/udp
ENV NODE_ENV=production
ENV ONTIME_DATA=/server/
CMD ["node", "server/docker.cjs"]
CMD ["yarn", "start:headless"]
# 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:/external/db/ -v ./ontime-styles:/external/styles/ getontime/ontime
# Build an run commandsN
# docker build -t getontime/ontime .
# docker run -p 4001:4001 -p 10.0.0.12:8888:8888/udp --mount type=bind,source="$(pwd)/ontime-db",target=/server/preloaded-db getontime/ontime
+71 -66
View File
@@ -14,114 +14,120 @@
Ontime is an application for managing event rundowns and running stage timers.
A single, locally hosted central application distributes your event information over the local network.
This enables the distribution of the data to a series of viewers and allows integration into video and control workflows, including OBS and d3.
It allows a center application to be able to distribute event information in the local network. This
minimises needs for using Media Server outputs or expensive video distribution while allowing easy
integration in workflows including OBS and d3.
![App Window](https://github.com/cpvalente/ontime/blob/master/.github/aux-images/app.jpg)
![App Window](https://github.com/cpvalente/ontime/blob/master/.github/app.jpg)
![Views](https://github.com/cpvalente/ontime/blob/master/.github/aux-images/02_screentypes.png)
![Views](https://github.com/cpvalente/ontime/blob/master/.github/02_screentypes.png)
## Using Ontime
Once installed and running, Ontime starts a background server that is the heart of all processes.
From the app, you can add / edit your running order and control the timer playback.
Once installed and running, ontime starts a background server that is the heart of all processes.
The app, is used to add / edit your running order in the event list, and running the timers using
the Playback Control function.
Any device with a browser in the same network can choose one of the supported views to render the available data.
From here, any device in the same network with a browser is able to render the views as described.
This is done by reaching the ontime server at the _default port 4001_ eg: `localhost:4001`
or `192.168.1.3:4001`
<br />
You can then use the Ontime logo in the top left corner to select the desired view.
The logo will be initially hidden until there is mouse interaction.
You can then use the ontime logo in the top right corner to select the desired view (event in the
lower thirds view, where it is hidden).
In the case of unattended machines or automation, it is possible to use different URL to recall
individual views and extend view settings using the URL aliases feature
In case of unattended machines or automations, it is possible to use different URL to recall
individual views and extend with using the URL aliases feature
```
For the presentation views
For the presentation views...
-------------------------------------------------------------
IP.ADDRESS:4001 > Web server default to presenter timer view
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/sm > Stage Manager / Backstage view
IP.ADDRESS:4001/public > Public / Foyer view
IP.ADDRESS:4001/pip > Picture in Picture view
IP.ADDRESS:4001/lower > Lower Thirds
IP.ADDRESS:4001/studio > Studio Clock
```
```
For management views
IP.ADDRESS:4001/cuesheet > Cue Sheet
...and for the editor (the control interface, same as the app)
-------------------------------------------------------------
IP.ADDRESS:4001/editor > the control interface, same as the app
IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
IP.ADDRESS:4001/editor
```
More documentation is available [in our docs](https://cpvalente.gitbook.io/ontime/)
More documentation available [here](https://cpvalente.gitbook.io/ontime/)
## Feature List (in no specific order)
- [x] Distribute data over network and render it in the browser
- [x] Distribute Data over network and render in the browser
- [x] Different screen types
- Stage Timer
- Backstage Info
- Public Info
- Picture in Picture
- Studio Clock
- [Make your own?](#make-your-own-viewer)
- [x] Configurable Lower Thirds
- [x] Cuesheets with user definable fields
- [x] Configurable realtime Lower Thirds
- [x] Cuesheets with additional custom fields
- [x] Send live messages to different screen types
- [x] Differentiate between backstage and public data
- [x] Workflow for managing delays
- [x] Protocol integrations for Control and Feedback
- OSC (Open Sound Control)
- HTTP
- WebSockets
- [x] Ability to differentiate between backstage and public data
- [x] Manage delays workflow
- [x] Open Sound Control (OSC) Control and Feedback
- [x] Integrate with hardware using Companion or one of the APIs
- [x] Roll mode: run independently using the system clock
- [x] [Headless run](#headless-run): run server in a separate machine, configure from a browser locally
- [x] [Countdown to anything!](https://cpvalente.gitbook.io/ontime/views/countdown): have
- [x] Import event list from Excel
- [x] URL Aliases (define configurable aliases to ease onsite setup)
- [x] Logging view
- [x] Edit anywhere: run ontime in your local network and use any machine to reach the editor page (
same as app)
- [x] Multi platform (available on Windows, MacOS and Linux)
- [x] [Headless run](#headless-run) (run server only, configure from a browser locally)
- [x] [Countdown to anything!](https://cpvalente.gitbook.io/ontime/views/countdown): ability to have
a countdown to any scheduled event
- [x] Multi-platform (available on Windows, MacOS and Linux)
- [x] [Companion integration](https://bitfocus.io/connections/getontime-ontime)
## Unopinionated
We are not interested in forcing workflows and have made Ontime, so it is flexible to whichever way
We are not interested in forcing workflows and have made ontime, so it is flexible to whichever way
you would like to work.
- [x] If you want just the info screens, there is no need to use the timer!
- [x] You do not need an order list to use the timer. Create an empty event and the OSC API works
just the same
- [x] If you want just the info screens, no need to use the timer!
- [x] Don't have or care for a schedule?
- [x] a single event with no data is enough to use one of the APIs and use a dynamic timer
- [x] a single event with no data is enough to use the OSC API and get going
- [x] use the order list to create a set of quick timers by setting the beginning and start
times to 00:00 and 00:10 (**BAM**! 10 minute timer). You can quickly recall this with OSC or any of the other available integrations
times to 00:00 and 00:10 (**BAM**! 10 minute timer). You can quickly recall this with OSC as
always
## Rich APIs for workflow integrations
The app is currently being developed for a broad user base, from broadcast to entertainment and
The app is being currently developed to a wide user base, from broadcast to entertainment and
conference halls.
Taking advantage of the integrations, we currently use Ontime with:
Taking advantage of the integrations in Ontime, we currently use Ontime with:
- `disguise`: trigger Ontime from d3's timeline using the **OSC API**, and **render views** using d3's
- `disguise`: trigger ontime from d3's timeline using the **OSC API**, **render views** using d3's
webmodule
- `OBS`: **render views** using the Browser Module
- `QLab`: trigger Ontime using **OSC API**
- `QLab`: trigger ontime using **OSC API**
- `Companion`: Ontime has a **companion module**. Issue report and feature requests should be done
in the [repository getontime/ontime](https://github.com/bitfocus/companion-module-getontime-ontime)
in
the [repository getontime/ontime](https://github.com/bitfocus/companion-module-getontime-ontime)
### Make your own viewer
Ontime broadcasts its data over WebSockets. This allows you to consume its data outside of the application.
Ontime broadcasts its data over websockets. This allows you to build your own viewers by leveranging
basic knowledge of HTML + CSS + Javascript (or any other language that can run in the browser).
Writing a new view for the browser can be done with basic knowledge of HTML + CSS + Javascript (or any other language that can run in the browser).
<br />
See [this repository](https://github.com/cpvalente/ontime-viewer-template) with a small template on
how to get you started and read the docs about
the [Websocket API](https://app.gitbook.com/s/-Mc0giSOToAhq0ROd0CR/control-and-feedback/websocket-api)
### Headless run
You can self-host and run Ontime in a docker image. The run command will:
You can self host and run ontime in a docker image, the run command should:
- expose the necessary ports (listed in the Dockerfile)
- expose the necessary ports (listen in Dockerfile)
- mount a local file to persist your data (in the example: ````$(pwd)/local-data````)
- the image name __getontime/ontime__
@@ -148,14 +154,19 @@ docker-compose up
### Continued development
Several features are planned in the roadmap, and we continuously adjust this to match how users interact with the app.
<br />
Have an idea? Reach out via [email](mail@getontime.no) or [open an issue](https://github.com/cpvalente/ontime/issues/new)
There are several features planned in the roadmap. These will be implemented in a development
friendly order unless there is user demand to bump any of them.
- [ ] HTTP Server (vMix integration)
- [ ] Improvements in event interface
- [ ] Moderator view
- [ ] New playback mode for [cumulative time keeping](https://github.com/cpvalente/ontime/issues/100)
- [ ] Lower Third Manager
- [ ] Reach Schedule: way to speedup timer to meet a deadline
### Issues
We use Github's issue tracking for bug reporting and feature requests. <br />
Found a bug? [Open an issue](https://github.com/cpvalente/ontime/issues/new).
If you come across any bugs, [please open an issue]((https://github.com/cpvalente/ontime/issues/new)). Usually bugs get fixed pretty quickly when reported
#### Unsigned App
@@ -181,28 +192,22 @@ You can circumvent this by allowing the execution of the app manually.
Long story short: Ontime app is unsigned. </br>Purchasing the certificates for both Mac and Windows
would mean a recurrent expense and is not a priority. This is unlikely to change in future. 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)
have tips on how to improve this, or would like to sponsor the code signing,
please [open an issue, so we can discuss it](https://github.com/cpvalente/ontime/issues/new)
#### Safari
There are known issues with Safari versions lower than 13:
There are some issues with Safari versions lower than 13:
- Spacing and text styles might have small inconsistencies
- Table view does not work
There is no plan for any further work on this.
# Contributing
Looking to contribute? All types of help are appreciated, from coding to testing and feature specification.
<br /><br />
If you are a developer and would like to contribute with some code, please open an issue to discuss before opening a Pull Request.
<br />
Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md)
There is no plan for any further work on this since the breaking code belongs to third party
libraries.
# Help
Help is underway! ... and can be found [here](https://cpvalente.gitbook.io/ontime/)
Help is underway! ... and can be viewed [here](https://cpvalente.gitbook.io/ontime/)
# License
-8
View File
@@ -1,8 +0,0 @@
{
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"jsxSingleQuote": true,
"printWidth": 120
}
-98
View File
@@ -1,98 +0,0 @@
{
"name": "ontime-ui",
"version": "2.0.0-beta1",
"private": true,
"dependencies": {
"@chakra-ui/react": "^2.5.1",
"@dnd-kit/core": "^6.0.8",
"@dnd-kit/sortable": "^7.0.2",
"@dnd-kit/utilities": "^3.2.1",
"@emotion/react": "^11.10.5",
"@emotion/styled": "^11.10.5",
"@react-icons/all-files": "^4.1.0",
"@sentry/react": "^7.28.1",
"@sentry/tracing": "^7.24.1",
"@tanstack/react-query": "^4.26.1",
"@tanstack/react-query-devtools": "^4.26.1",
"autosize": "^5.0.2",
"axios": "^1.2.0",
"color": "^4.2.3",
"csv-stringify": "^6.2.3",
"deepmerge": "^4.3.0",
"framer-motion": "^8.0.2",
"luxon": "^3.3.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-fast-compare": "^3.2.0",
"react-hook-form": "^7.43.5",
"react-qr-code": "^2.0.11",
"react-router-dom": "^6.3.0",
"react-table": "^7.7.0",
"typeface-open-sans": "^1.1.13",
"web-vitals": "^3.1.1",
"zustand": "^4.3.6"
},
"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:docker": "vite build",
"lint": "eslint .",
"stylelint": "npx stylelint \"**/*.scss\"\n",
"test": "vitest",
"test:pipeline": "vitest run",
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@sentry/vite-plugin": "^0.3.0",
"@tanstack/eslint-plugin-query": "^4.26.2",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.1.1",
"@testing-library/user-event": "^14.1.1",
"@types/color": "^3.0.3",
"@types/luxon": "^3.2.0",
"@types/prop-types": "^15.7.5",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@types/testing-library__jest-dom": "^5.14.5",
"@typescript-eslint/eslint-plugin": "^5.48.1",
"@typescript-eslint/parser": "^5.48.1",
"@vitejs/plugin-react": "^3.0.1",
"eslint": "^8.31.0",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-jest": "^27.1.7",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.32.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-simple-import-sort": "^8.0.0",
"eslint-plugin-testing-library": "^5.9.1",
"jsdom": "^21.1.0",
"ontime-types": "workspace:*",
"ontime-utils": "workspace:*",
"prettier": "^2.8.3",
"prop-types": "^15.8.1",
"sass": "^1.57.1",
"stylelint": "^14.16.1",
"stylelint-config-prettier": "^9.0.4",
"stylelint-config-standard-scss": "^6.1.0",
"typescript": "^4.9.4",
"vite": "^4.0.4",
"vite-plugin-svgr": "^2.4.0",
"vite-tsconfig-paths": "^4.0.3",
"vitest": "^0.29.8"
}
}
-67
View File
@@ -1,67 +0,0 @@
import { Suspense, useEffect } from 'react';
import { BrowserRouter } from 'react-router-dom';
import { ChakraProvider } from '@chakra-ui/react';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import ErrorBoundary from './common/components/error-boundary/ErrorBoundary';
import { AppContextProvider } from './common/context/AppContext';
import useElectronEvent from './common/hooks/useElectronEvent';
import { ontimeQueryClient } from './common/queryClient';
import { connectSocket } from './common/utils/socket';
import theme from './theme/theme';
import AppRouter from './AppRouter';
// Load Open Sans typeface
// @ts-expect-error no types from font import
import('typeface-open-sans');
connectSocket();
function App() {
const { isElectron, sendToElectron } = useElectronEvent();
const handleKeyPress = (event: KeyboardEvent) => {
// handle held key
if (event.repeat) return;
// check if the alt key is pressed
if (event.altKey) {
if (event.code === 'KeyT') {
// ask to see debug
sendToElectron('set-window', 'show-dev');
}
}
};
useEffect(() => {
if (isElectron) {
document.addEventListener('keydown', handleKeyPress);
}
return () => {
if (isElectron) {
document.removeEventListener('keydown', handleKeyPress);
}
};
}, []);
return (
<ChakraProvider resetCSS theme={theme}>
<QueryClientProvider client={ontimeQueryClient}>
<AppContextProvider>
<BrowserRouter>
<div className='App'>
<ErrorBoundary>
<Suspense fallback={null}>
<AppRouter />
</Suspense>
</ErrorBoundary>
<ReactQueryDevtools initialIsOpen={false} />
</div>
</BrowserRouter>
</AppContextProvider>
</QueryClientProvider>
</ChakraProvider>
);
}
export default App;
-24
View File
@@ -1,24 +0,0 @@
// Exported viewer link location
const minimalLocation = 'minimal';
const speakerLocation = 'speaker';
const smLocation = 'sm';
const publicLocation = 'public';
const pipLocation = 'pip';
const studioLocation = 'studio';
const cuesheetLocation = 'cuesheet';
const countdownLocation = 'countdown';
const clockLocation = 'clock';
const lowerLocation = 'lower';
export const viewerLocations = [
{ link: speakerLocation, label: 'Stage timer' },
{ link: clockLocation, label: 'Clock' },
{ link: minimalLocation, label: 'Minimal timer' },
{ link: smLocation, label: 'Backstage screen' },
{ link: publicLocation, label: 'Public screen' },
{ link: lowerLocation, label: 'Lower thirds' },
{ link: pipLocation, label: 'Picture in Picture' },
{ link: studioLocation, label: 'Studio clock' },
{ link: countdownLocation, label: 'Countdown' },
{ link: cuesheetLocation, label: 'Cuesheet' },
];
@@ -1,32 +0,0 @@
export const STATIC_PORT = 4001;
// REST stuff
export const EVENTDATA_TABLE = ['eventdata'];
export const ALIASES = ['aliases'];
export const USERFIELDS = ['userFields'];
export const RUNDOWN_TABLE_KEY = 'rundown';
export const RUNDOWN_TABLE = [RUNDOWN_TABLE_KEY];
export const APP_INFO = ['appinfo'];
export const OSC_SETTINGS = ['oscSettings'];
export const APP_SETTINGS = ['appSettings'];
export const VIEW_SETTINGS = ['viewSettings'];
export const RUNTIME = ['runtimeStore'];
// external stuff
export const githubURL = 'https://api.github.com/repos/cpvalente/ontime/releases/latest';
/**
* @description finds server path given the current location, it
* @return {*}
*/
export const calculateServer = () => (import.meta.env.DEV ? `http://localhost:${STATIC_PORT}` : window.location.origin);
export const serverURL = calculateServer();
export const websocketUrl = `ws://${window.location.hostname}:${STATIC_PORT}/ws`;
export const eventURL = `${serverURL}/eventdata`;
export const rundownURL = `${serverURL}/eventlist`;
export const ontimeURL = `${serverURL}/ontime`;
export const stylesPath = 'external/styles/override.css';
export const overrideStylesURL = `${serverURL}/${stylesPath}`;
-75
View File
@@ -1,75 +0,0 @@
import axios from 'axios';
import { OntimeRundown, OntimeRundownEntry } from 'ontime-types';
import { rundownURL } from './apiConstants';
/**
* @description HTTP request to fetch all events
* @return {Promise}
*/
export async function fetchRundown(): Promise<OntimeRundown> {
const res = await axios.get(rundownURL);
return res.data;
}
/**
* @description HTTP request to post new event
* @return {Promise}
*/
export async function requestPostEvent(data: OntimeRundownEntry) {
return axios.post(rundownURL, data);
}
/**
* @description HTTP request to put new event
* @return {Promise}
*/
export async function requestPutEvent(data: Partial<OntimeRundownEntry>) {
return axios.put(rundownURL, data);
}
/**
* @description HTTP request to modify event
* @return {Promise}
*/
export async function requestPatchEvent(data: OntimeRundownEntry) {
return axios.patch(rundownURL, data);
}
export type ReorderEntry = {
eventId: string;
from: number;
to: number;
};
/**
* @description HTTP request to reorder events
* @return {Promise}
*/
export async function requestReorderEvent(data: ReorderEntry) {
return axios.patch(`${rundownURL}/reorder`, data);
}
/**
* @description HTTP request to request application of delay
* @return {Promise}
*/
export async function requestApplyDelay(eventId: string) {
return axios.patch(`${rundownURL}/applydelay/${eventId}`);
}
/**
* @description HTTP request to delete given event
* @return {Promise}
*/
export async function requestDelete(eventId: string) {
return axios.delete(`${rundownURL}/${eventId}`);
}
/**
* @description HTTP request to delete all events
* @return {Promise}
*/
export async function requestDeleteAll() {
return axios.delete(`${rundownURL}/all`);
}
-162
View File
@@ -1,162 +0,0 @@
import axios from 'axios';
import { Alias, OSCSettings, Settings, UserFields, ViewSettings } from 'ontime-types';
import { InfoType } from '../models/Info';
import { githubURL, ontimeURL } from './apiConstants';
/**
* @description HTTP request to retrieve application settings
* @return {Promise}
*/
export async function getSettings(): Promise<Settings> {
const res = await axios.get(`${ontimeURL}/settings`);
return res.data;
}
/**
* @description HTTP request to mutate application settings
* @return {Promise}
*/
export async function postSettings(data: Settings) {
return axios.post(`${ontimeURL}/settings`, data);
}
/**
* @description HTTP request to retrieve application info
* @return {Promise}
*/
export async function getInfo(): Promise<InfoType> {
const res = await axios.get(`${ontimeURL}/info`);
return res.data;
}
/**
* @description HTTP request to retrieve view settings
* @return {Promise}
*/
export async function getView(): Promise<ViewSettings> {
const res = await axios.get(`${ontimeURL}/views`);
return res.data;
}
/**
* @description HTTP request to mutate view settings
* @return {Promise}
*/
export async function postView(data: ViewSettings) {
return axios.post(`${ontimeURL}/views`, data);
}
/**
* @description HTTP request to retrieve aliases
* @return {Promise}
*/
export async function getAliases(): Promise<Alias[]> {
const res = await axios.get(`${ontimeURL}/aliases`);
return res.data;
}
/**
* @description HTTP request to mutate aliases
* @return {Promise}
*/
export async function postAliases(data: Alias[]) {
return axios.post(`${ontimeURL}/aliases`, data);
}
/**
* @description HTTP request to retrieve user fields
* @return {Promise}
*/
export async function getUserFields(): Promise<UserFields> {
const res = await axios.get(`${ontimeURL}/userfields`);
return res.data;
}
/**
* @description HTTP request to mutate user fields
* @return {Promise}
*/
export async function postUserFields(data: UserFields) {
return axios.post(`${ontimeURL}/userfields`, data);
}
/**
* @description HTTP request to retrieve osc settings
* @return {Promise}
*/
export async function getOSC(): Promise<OSCSettings> {
const res = await axios.get(`${ontimeURL}/osc`);
return res.data;
}
/**
* @description HTTP request to mutate osc settings
* @return {Promise}
*/
export async function postOSC(data: OSCSettings) {
return axios.post(`${ontimeURL}/osc`, data);
}
/**
* @description HTTP request to download db
* @return {Promise}
*/
export const downloadRundown = async () => {
await axios({
url: `${ontimeURL}/db`,
method: 'GET',
responseType: 'blob', // important
}).then((response) => {
const headerLine = response.headers['Content-Disposition'];
let filename = 'rundown.json';
// try and get the filename from the response
if (headerLine != null) {
const startFileNameIndex = headerLine.indexOf('"') + 1;
const endFileNameIndex = headerLine.lastIndexOf('"');
filename = headerLine.substring(startFileNameIndex, endFileNameIndex);
}
const url = window.URL.createObjectURL(new Blob([response.data], { type: 'application/json' }));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
});
};
/**
* @description HTTP request to upload events db
* @return {Promise}
*/
type UploadDataOptions = {
onlyRundown?: boolean;
};
export const uploadData = async (file: string, setProgress: (value: number) => void, options?: UploadDataOptions) => {
const formData = new FormData();
formData.append('userFile', file);
const onlyRundown = options?.onlyRundown || 'false';
await axios
.post(`${ontimeURL}/db?onlyRundown=${onlyRundown}`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
const complete = progressEvent?.total ? Math.round((progressEvent.loaded * 100) / progressEvent.total) : 0;
setProgress(complete);
},
})
.then((response) => response.data.id);
};
/**
* @description HTTP request to get the latest version and url from github
* @return {Promise}
*/
export async function getLatestVersion(): Promise<object> {
const res = await axios.get(`${githubURL}`);
return { url: res.data.html_url, version: res.data.tag_name };
}
@@ -1,23 +0,0 @@
@use '../../../theme/v2Styles' as *;
.header {
font-size: $inner-section-text-size;
font-weight: 600;
display: flex;
justify-content: space-between;
color: $section-white;
border-bottom: 1px solid $border-color-ondark;
padding-bottom: $element-inner-spacing;
margin-bottom: $element-spacing;
cursor: pointer;
}
.moreExpanded {
transform: scaleY(-1);
transition: transform $transition-time-feedback;
}
.moreCollapsed {
transform: scaleY(1);
transition: transform $transition-time-feedback;
}
@@ -1,20 +0,0 @@
import { FiChevronUp } from '@react-icons/all-files/fi/FiChevronUp';
import style from './CollapseBar.module.scss';
interface CollapseBarProps {
title: string;
isCollapsed: boolean;
onClick: () => void;
}
export default function CollapseBar(props: CollapseBarProps) {
const { title = 'Collapse bar', isCollapsed, onClick } = props;
return (
<div className={style.header} onClick={onClick}>
{title}
<FiChevronUp className={isCollapsed ? style.moreCollapsed : style.moreExpanded} />
</div>
);
}
@@ -1,35 +0,0 @@
import { PropsWithChildren } from 'react';
import { Button, ButtonGroup, IconButton, Tooltip } from '@chakra-ui/react';
import { IoCopy } from '@react-icons/all-files/io5/IoCopy';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { Size } from '../../models/Util.type';
interface CopyTagProps {
label: string;
className?: string;
size?: Size;
}
export default function CopyTag(props: PropsWithChildren<CopyTagProps>) {
const { label, className, size = 'xs', children } = props;
return (
<Tooltip label={label} openDelay={tooltipDelayFast}>
<ButtonGroup
size={size}
isAttached
className={className}
>
<Button variant='ontime-subtle' tabIndex={-1}>{children}</Button>
<IconButton
aria-label={label}
icon={<IoCopy />}
variant='ontime-filled'
tabIndex={-1}
onClick={() => navigator.clipboard.writeText(children as string)}
/>
</ButtonGroup>
</Tooltip>
);
}
@@ -1,34 +0,0 @@
import { useEffect, useRef } from 'react';
import { Textarea, TextareaProps } from '@chakra-ui/react';
// @ts-expect-error no types from library
import autosize from 'autosize/dist/autosize';
interface AutoTextAreaProps extends TextareaProps {
isDark?: boolean;
}
export const AutoTextArea = (props: AutoTextAreaProps) => {
const { isDark, ...rest } = props;
const ref = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
const node = ref.current;
autosize(ref.current);
return () => {
autosize.destroy(node);
};
}, []);
return (
<Textarea
overflow='hidden'
w='100%'
resize='none'
ref={ref}
transition='height none'
variant={isDark ? 'ontime-filled' : 'ontime-filled-onlight'}
{...rest}
/>
);
};
@@ -1,7 +0,0 @@
input[type="color"] {
appearance: none;
cursor: pointer;
height: 32px;
width: 32px;
padding: 0;
}
@@ -1,25 +0,0 @@
import { Input } from '@chakra-ui/react';
import { EventEditorSubmitActions } from '../../../../features/event-editor/EventEditor';
import style from './ColourInput.module.scss';
interface ColourInputProps {
value: string;
name: EventEditorSubmitActions;
handleChange: (newValue: EventEditorSubmitActions, name: string) => void;
}
export default function ColourInput(props: ColourInputProps) {
const { value, name, handleChange } = props;
return (
<Input
size='sm'
variant='ontime-filled'
className={style.colourInput}
type='color'
value={value}
onChange={(event) => handleChange(name, event.target.value)}
/>
);
}
@@ -1,13 +0,0 @@
@use '../../../../theme/v2Styles' as *;
.delayInput {
display: flex;
gap: $element-spacing;
align-items: center;
color: $ontime-delay-text;
font-size: $text-body-size;
}
.inputField {
text-align: center;
}
@@ -1,82 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Input } from '@chakra-ui/react';
import { clamp } from '../../../utils/math';
import style from './DelayInput.module.scss';
const inputStyleProps = {
width: 20,
placeholder: '-',
size: 'sm',
color: '#E69056',
variant: 'ontime-filled',
};
interface DelayInputProps {
submitHandler: (value: number) => void;
value?: number;
}
export default function DelayInput(props: DelayInputProps) {
const { submitHandler, value = 0 } = props;
const [_value, setValue] = useState(value);
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (value == null) return;
setValue(value);
}, [value]);
/**
* @description Prepare delay value for update
* @param {string} value string to be parsed
*/
const validate = useCallback(
(newValue?: string) => {
if (newValue === '') setValue(0);
const delayValue = clamp(Number(newValue), -60, 60);
if (delayValue === value) return;
setValue(delayValue);
submitHandler(delayValue);
},
[submitHandler, value],
);
/**
* @description Handles common keys for submit and cancel
* @param {KeyboardEvent} event
*/
const onKeyDownHandler = useCallback((key: string) => {
if (key === 'Enter') {
inputRef.current?.blur();
validate(inputRef.current?.value);
} else if (key === 'Escape') {
inputRef.current?.blur();
setValue(value);
}
}, [validate, value]);
const labelText = `${Math.abs(value) !== 1 ? 'minutes' : 'minute'} ${
value !== undefined && value >= 0 ? 'delayed' : 'ahead'
}`;
return (
<label className={style.delayInput}>
<Input
{...inputStyleProps}
ref={inputRef}
data-testid='delay-input'
className={style.inputField}
value={_value}
onChange={(event) => setValue(Number(event.target.value))}
onBlur={(event) => validate(event.target.value)}
onKeyDown={(event) => onKeyDownHandler(event.key)}
type='number'
/>
{labelText}
</label>
);
}
@@ -1,47 +0,0 @@
import { useCallback, useRef } from 'react';
import { Input, Textarea } from '@chakra-ui/react';
import { EventEditorSubmitActions } from '../../../../features/event-editor/EventEditor';
import { Size } from '../../../models/Util.type';
import useReactiveTextInput from './useReactiveTextInput';
interface BaseProps {
isTextArea?: boolean;
isFullHeight?: boolean;
size?: Size;
field: EventEditorSubmitActions;
initialText?: string;
submitHandler: (field: EventEditorSubmitActions, newValue: string) => void;
}
interface TextAreaProps {
isTextArea: true;
resize?: 'horizontal' | 'vertical' | 'none';
}
type TextInputProps = BaseProps & TextAreaProps;
export default function TextInput(props: TextInputProps) {
const { isTextArea, isFullHeight, size = 'sm', field, initialText = '', submitHandler, resize = 'none' } = props;
const inputRef = useRef(null);
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
const textInputProps = useReactiveTextInput(initialText, submitCallback, { submitOnEnter: true });
const textAreaProps = useReactiveTextInput(initialText, submitCallback);
return isTextArea ? (
<Textarea
ref={inputRef}
size={size}
resize={resize}
variant='ontime-filled'
{...textAreaProps}
style={{ height: isFullHeight ? '100%' : undefined }}
data-testid='input-textarea'
/>
) : (
<Input ref={inputRef} size={size} variant='ontime-filled' {...textInputProps} data-testid='input-textfield' />
);
}
@@ -1,88 +0,0 @@
import { ChangeEvent, useCallback, useEffect, useState } from 'react';
interface UseReactiveTextInputReturn {
value: string;
onChange: (event: ChangeEvent) => void;
onBlur: (event: ChangeEvent) => void;
onKeyDown: (event: KeyboardEvent) => void;
}
export default function useReactiveTextInput(
initialText: string,
submitCallback: (newValue: string) => void,
options?: {
submitOnEnter?: boolean;
},
): UseReactiveTextInputReturn {
const [text, setText] = useState(initialText);
useEffect(() => {
if (typeof initialText === 'undefined') {
setText('');
} else {
setText(initialText);
}
}, [initialText]);
/**
* @description Handles Input value change
* @param {string} newValue
*/
const handleChange = useCallback(
(newValue: string) => {
if (newValue !== text) {
setText(newValue);
}
},
[text],
);
/**
* @description Handles submit events
* @param {string} valueToSubmit
*/
const handleSubmit = useCallback(
(valueToSubmit: string) => {
// No need to update if it hasn't changed
if (valueToSubmit === initialText) {
return;
}
const cleanVal = valueToSubmit.trim();
submitCallback(cleanVal);
if (cleanVal !== valueToSubmit) {
setText(cleanVal);
}
},
[initialText, submitCallback],
);
/**
* @description Handles common keys for submit and cancel
* @param {string} key
*/
const keyHandler = useCallback(
(key: string) => {
switch (key) {
case 'Escape':
setText(initialText);
break;
case 'Enter':
if (options?.submitOnEnter) {
handleSubmit(text);
}
break;
}
},
[initialText, options?.submitOnEnter, handleSubmit, text],
);
return {
value: text,
onChange: (event) => handleChange((event.target as HTMLInputElement).value),
onBlur: (event) => handleSubmit((event.target as HTMLInputElement).value),
onKeyDown: (event) => keyHandler(event.key),
};
}
@@ -1,23 +0,0 @@
$input-font-size: 15px;
$input-delayed-border-color: #E69056;
.timeInput {
width: fit-content !important;
.inputButton {
aspect-ratio: 1;
}
.inputField {
font-size: $input-font-size;
letter-spacing: 1px;
width: 7.5em;
padding: 0 0 0 2.6em;
}
&.delayed {
.inputField {
border: 1px solid $input-delayed-border-color;
}
}
}
@@ -1,191 +0,0 @@
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import { Button, Input, InputGroup, InputLeftElement, Tooltip } from '@chakra-ui/react';
import { millisToString } from 'ontime-utils';
import { EventEditorSubmitActions } from '../../../../features/event-editor/EventEditor';
import { tooltipDelayFast } from '../../../../ontimeConfig';
import { useEmitLog } from '../../../stores/logger';
import { forgivingStringToMillis } from '../../../utils/dateConfig';
import { TimeEntryField } from '../../../utils/timesManager';
import style from './TimeInput.module.scss';
interface TimeInputProps {
name: TimeEntryField;
submitHandler: (field: EventEditorSubmitActions, value: number) => void;
time?: number;
delay?: number;
placeholder: string;
validationHandler: (entry: TimeEntryField, val: number) => boolean;
previousEnd?: number;
}
export default function TimeInput(props: TimeInputProps) {
const { name, submitHandler, time = 0, delay = 0, placeholder, validationHandler, previousEnd = 0 } = props;
const { emitError } = useEmitLog();
const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState('');
/**
* @description Resets input value to given
*/
const resetValue = useCallback(() => {
// Todo: check if change is necessary
try {
setValue(millisToString(time + delay));
} catch (error) {
emitError(`Unable to parse date: ${error}`);
}
}, [delay, emitError, time]);
/**
* @description Selects input text on focus
*/
const handleFocus = useCallback(() => {
inputRef.current?.select();
}, []);
/**
* @description Submit handler
* @param {string} newValue
*/
const handleSubmit = useCallback(
(newValue: string) => {
// Check if there is anything there
if (newValue === '') {
return false;
}
let newValMillis = 0;
// check for known aliases
if (newValue === 'p' || newValue === 'prev' || newValue === 'previous') {
// string to pass should be the time of the end before
if (previousEnd != null) {
newValMillis = previousEnd;
}
} else if (newValue.startsWith('+') || newValue.startsWith('p+') || newValue.startsWith('p +')) {
// string to pass should add to the end before
const val = newValue.substring(1);
newValMillis = previousEnd + forgivingStringToMillis(val);
} else {
// convert entered value to milliseconds
newValMillis = forgivingStringToMillis(newValue);
}
// Time now and time submittedVal
const originalMillis = time + delay;
// check if time is different from before
if (newValMillis === originalMillis) return false;
// validate with parent
if (!validationHandler(name, newValMillis)) return false;
// update entry
submitHandler(name, newValMillis);
return true;
},
[delay, name, previousEnd, submitHandler, time, validationHandler],
);
/**
* @description Prepare time fields
* @param {string} value string to be parsed
*/
const validateAndSubmit = useCallback(
(newValue: string) => {
const success = handleSubmit(newValue);
if (success) {
const ms = forgivingStringToMillis(newValue);
setValue(millisToString(ms + delay));
} else {
resetValue();
}
},
[delay, handleSubmit, resetValue],
);
/**
* @description Handles common keys for submit and cancel
* @param {KeyboardEvent} event
*/
const onKeyDownHandler = useCallback(
(event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
inputRef.current?.blur();
validateAndSubmit((event.target as HTMLInputElement).value);
} else if (event.key === 'Tab') {
validateAndSubmit((event.target as HTMLInputElement).value);
}
if (event.key === 'Escape') {
inputRef.current?.blur();
resetValue();
}
},
[resetValue, validateAndSubmit],
);
const onBlurHandler = useCallback(
(event: FocusEvent<HTMLInputElement>) => {
validateAndSubmit((event.target as HTMLInputElement).value);
},
[validateAndSubmit],
);
useEffect(() => {
if (time == null) return;
resetValue();
}, [emitError, resetValue, time]);
const isDelayed = delay != null && delay !== 0;
const ButtonInitial = () => {
if (name === 'timeStart') return 'S';
if (name === 'timeEnd') return 'E';
if (name === 'durationOverride') return 'D';
return '';
};
const ButtonTooltip = () => {
if (name === 'timeStart') return 'Start';
if (name === 'timeEnd') return 'End';
if (name === 'durationOverride') return 'Duration';
return '';
};
return (
<InputGroup size='sm' className={`${style.timeInput} ${isDelayed ? style.delayed : ''}`}>
<InputLeftElement width='fit-content'>
<Tooltip label={ButtonTooltip()} openDelay={tooltipDelayFast} variant='ontime-ondark'>
<Button
size='sm'
variant='ontime-subtle-white'
className={`${style.inputButton} ${isDelayed ? style.delayed : ''}`}
tabIndex={-1}
border={isDelayed ? '1px solid #E69056' : '1px solid transparent'}
borderRight='1px solid transparent'
borderRadius='2px 0 0 2px'
>
{ButtonInitial()}
</Button>
</Tooltip>
</InputLeftElement>
<Input
ref={inputRef}
data-testid='time-input'
className={style.inputField}
type='text'
placeholder={placeholder}
variant='ontime-filled'
onFocus={handleFocus}
onChange={(event) => setValue(event.target.value)}
onBlur={onBlurHandler}
onKeyDown={onKeyDownHandler}
value={value}
maxLength={8}
/>
</InputGroup>
);
}
@@ -1,93 +0,0 @@
@use "../../../theme/v2Styles" as *;
@use "../../../theme/mixins" as *;
@use "../../../theme/ontimeColours" as *;
$menu-bg: $gray-1200;
$menu-hover-bg: $gray-1350;
$menu-focus-bg: $gray-1300;
$icon-color: $ui-white;
$button-bg: $gray-1050;
$button-size: 48px;
.mirror {
transform: rotate(180deg);
}
.navButton {
z-index: 2;
position: absolute;
left: 0.5em;
top: 0.5em;
transition-property: opacity;
transition-duration: 0.3s;
opacity: 1;
font-size: 24px;
color: $icon-color;
background-color: $button-bg;
width: $button-size;
height: $button-size;
display: grid;
place-content: center;
border-radius: 3px;
&.hidden {
opacity: 0;
}
}
.menuContainer {
top: 0;
left: 0;
height: fit-content;
position: absolute;
background-color: $menu-bg;
min-width: 200px;
border-radius: 0 0 24px 0;
border-right: 1px solid $border-color-ondark;
box-shadow: $box-shadow-l2;
padding-bottom: 1rem;
max-height: 100vh;
overflow-y: auto;
}
.buttonsContainer {
margin-top: calc(56px + 1rem);
}
.link {
@include action-link;
justify-content: space-between;
padding: 0.5rem 1rem;
cursor: pointer;
&:hover {
background-color: $menu-hover-bg;
}
&:active {
background-color: $border-color-ondark;
}
&:focus {
outline: none;
background-color: $menu-focus-bg;
border-left: 2px solid $action-text-color;
}
&.current {
background-color: $menu-hover-bg;
border-left: 4px solid $action-text-color;
}
}
.linkIcon {
display: inline-block;
transform: rotate(45deg);
}
.separator {
border-color: $border-color-ondark;
}
@@ -1,111 +0,0 @@
import { KeyboardEvent, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Link, useLocation } from 'react-router-dom';
import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { IoContract } from '@react-icons/all-files/io5/IoContract';
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { navigatorConstants } from '../../../viewerConfig';
import useClickOutside from '../../hooks/useClickOutside';
import useFullscreen from '../../hooks/useFullscreen';
import { useKeyDown } from '../../hooks/useKeyDown';
import { useViewOptionsStore } from '../../stores/viewOptions';
import style from './NavigationMenu.module.scss';
export default function NavigationMenu() {
const location = useLocation();
const { isFullScreen, toggleFullScreen } = useFullscreen();
const { mirror, toggleMirror } = useViewOptionsStore();
const [showButton, setShowButton] = useState(false);
const [showMenu, setShowMenu] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
useClickOutside(menuRef, () => setShowMenu(false));
const toggleMenu = () => setShowMenu((prev) => !prev);
useKeyDown(toggleMenu, ' ');
useEffect(() => {
let fadeOut: NodeJS.Timeout | null = null;
const setShowMenuTrue = () => {
setShowButton(true);
if (fadeOut) {
clearTimeout(fadeOut);
}
fadeOut = setTimeout(() => setShowButton(false), 3000);
};
document.addEventListener('mousemove', setShowMenuTrue);
return () => {
document.removeEventListener('mousemove', setShowMenuTrue);
if (fadeOut) {
clearTimeout(fadeOut);
}
};
}, []);
const isKeyEnter = (event: KeyboardEvent<HTMLDivElement>) => event.key === 'Enter';
const handleFullscreen = () => toggleFullScreen();
const handleMirror = () => toggleMirror();
return createPortal(
<div id='navigation-menu-portal' ref={menuRef} className={mirror ? style.mirror : ''}>
<button
onClick={toggleMenu}
aria-label='toggle menu'
className={`${style.navButton} ${!showButton && !showMenu ? style.hidden : ''}`}
>
<IoApps />
</button>
{showMenu && (
<div className={style.menuContainer} data-testid='navigation-menu'>
<div className={style.buttonsContainer}>
<div
className={style.link}
tabIndex={0}
role='button'
onClick={handleFullscreen}
onKeyDown={(event) => {
isKeyEnter(event) && handleFullscreen();
}}
>
Toggle Fullscreen
{isFullScreen ? <IoContract /> : <IoExpand />}
</div>
<div
className={style.link}
tabIndex={0}
role='button'
onClick={handleMirror}
onKeyDown={(event) => {
isKeyEnter(event) && handleMirror();
}}
>
Flip Screen
<IoSwapVertical />
</div>
{/*<div className={style.link} tabIndex={0}>*/}
{/* Rename Client*/}
{/*</div>*/}
</div>
<hr className={style.separator} />
{navigatorConstants.map((route) => (
<Link
key={route.url}
to={route.url}
className={`${style.link} ${route.url === location.pathname ? style.current : undefined}`}
tabIndex={0}
>
{route.label}
<IoArrowUp className={style.linkIcon} />
</Link>
))}
</div>
)}
</div>,
document.body,
);
}
@@ -1,23 +0,0 @@
@use '../../../theme/viewerDefs' as *;
$progress-bar-size: 12px;
$progress-bar-br: 6px;
.progress-bar__bg {
width: 100%;
height: $progress-bar-size;
border-radius: $progress-bar-br;
background-color: var(--card-background-color-override, $viewer-card-bg-color);
&--hidden {
display: none;
}
}
.progress-bar__indicator {
height: $progress-bar-size;
border-radius: $progress-bar-br;
background-color: var(--accent-color-override, $accent-color);
transition: 1s linear;
transition-property: width;
}
@@ -1,25 +0,0 @@
import { clamp } from '../../utils/math';
import './ProgressBar.scss';
interface ProgressBarProps {
now?: number;
complete?: number;
hidden?: boolean;
className?: string;
}
export default function ProgressBar(props: ProgressBarProps) {
const { now = 0, complete = 100, hidden, className = '' } = props;
const percentComplete = clamp(100 - (Math.max(now, 0) * 100) / complete, 0, 100);
return (
<div className={`progress-bar__bg ${hidden ? 'progress-bar__bg--hidden' : ''} ${className}`}>
<div
className='progress-bar__indicator'
style={{ width: `${percentComplete}%` }}
/>
</div>
);
}
@@ -1,71 +0,0 @@
@use '../../../theme/viewerDefs' as *;
.schedule {
width: 100%;
border-spacing: 50px;
.entry {
font-size: clamp(16px, 1.5vw, 24px);
.entry-colour {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
height: clamp(8px, 0.75vw, 12px);
width: clamp(8px, 0.75vw, 12px);
border-radius: 6px;
display: inline-block;
}
.entry-times {
font-family: $viewer-font-family;
color: var(--secondary-color-override, $viewer-secondary-color);
font-weight: 300;
letter-spacing: 0.05em;
display: flex;
align-items: center;
gap: 8px;
}
.entry-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&:not(:last-child) {
padding-bottom: clamp(16px, 1.5vw, 24px);
}
&--past {
color: var(--secondary-color-override, $viewer-secondary-color);
}
&--now {
.entry-title {
color: var(--accent-color-override, $accent-color);
font-weight: 600;
}
}
&.skip {
text-decoration: line-through;
}
}
}
.schedule-nav {
display: flex;
justify-content: flex-end;
.schedule-nav__item {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
width: 12px;
height: 12px;
border-radius: 6px;
margin-left: 8px;
&--selected {
background-color: var(--color-override, $viewer-color);
}
}
}
@@ -1,44 +0,0 @@
import Empty from '../state/Empty';
import { useSchedule } from './ScheduleContext';
import ScheduleItem from './ScheduleItem';
import './Schedule.scss';
interface ScheduleProps {
className?: string;
}
export default function Schedule({ className }: ScheduleProps) {
const { paginatedEvents, selectedEventId, isBackstage } = useSchedule();
if (paginatedEvents?.length < 1) {
return <Empty text='No events to show' />;
}
let selectedState: 'past' | 'now' | 'future' = 'past';
return (
<ul className={`schedule ${className}`}>
{paginatedEvents.map((event) => {
if (event.id === selectedEventId) {
selectedState = 'now';
} else if (selectedState === 'now') {
selectedState = 'future';
}
return (
<ScheduleItem
key={event.id}
selected={selectedState}
timeStart={event.timeStart}
timeEnd={event.timeEnd}
title={event.title}
colour={isBackstage ? event.colour : ''}
backstageEvent={!event.isPublic}
skip={event.skip}
/>
);
})}
</ul>
);
}
@@ -1,72 +0,0 @@
import { createContext, PropsWithChildren, useContext, useState } from 'react';
import { OntimeEvent } from 'ontime-types';
import { useInterval } from '../../hooks/useInterval';
interface ScheduleContextState {
events: OntimeEvent[];
paginatedEvents: OntimeEvent[];
selectedEventId: string;
numPages: number;
visiblePage: number;
isBackstage: boolean;
}
const ScheduleContext = createContext<ScheduleContextState | undefined>(undefined);
interface ScheduleProviderProps {
events: OntimeEvent[];
selectedEventId: string;
isBackstage?: boolean;
eventsPerPage?: number;
time?: number;
}
export const ScheduleProvider = (
{
children,
events,
selectedEventId,
isBackstage = false,
eventsPerPage = 4,
time = 10,
}: PropsWithChildren<ScheduleProviderProps>) => {
const [visiblePage, setVisiblePage] = useState(0);
const numPages = Math.ceil(events.length / eventsPerPage);
const eventStart = eventsPerPage * visiblePage;
const eventEnd = eventsPerPage * (visiblePage + 1);
const paginatedEvents = events.slice(eventStart, eventEnd);
// every SCROLL_TIME go to the next array
useInterval(() => {
if (events.length > eventsPerPage) {
const next = (visiblePage + 1) % numPages;
setVisiblePage(next);
}
}, time * 1000);
return (
<ScheduleContext.Provider
value={{
events,
paginatedEvents,
selectedEventId,
numPages,
visiblePage,
isBackstage,
}}
>
{children}
</ScheduleContext.Provider>
);
};
export const useSchedule = () => {
const context = useContext(ScheduleContext);
if (!context) {
throw new Error('useSchedule() can only be used inside a ScheduleContext');
}
return context;
};
@@ -1,45 +0,0 @@
import { formatTime } from '../../utils/time';
import './Schedule.scss';
interface ScheduleItemProps {
selected: 'past' | 'now' | 'future';
timeStart: number;
timeEnd: number;
title: string;
presenter?: string;
backstageEvent: boolean;
colour: string;
skip: boolean;
}
export default function ScheduleItem(props: ScheduleItemProps) {
const {
selected,
timeStart,
timeEnd,
title,
presenter,
backstageEvent,
colour,
skip,
} = props;
const start = formatTime(timeStart, { format: 'hh:mm' });
const end = formatTime(timeEnd, { format: 'hh:mm' });
const userColour = colour !== '' ? colour : '';
const selectStyle = `entry--${selected}`;
return (
<li className={`entry ${selectStyle} ${skip ? 'skip' : ''}`}>
<div className='entry-times'>
<span className='entry-colour' style={{ backgroundColor: userColour }} />
{`${start}${end} ${backstageEvent ? '*' : ''}`}
</div>
<div className='entry-title'>{title}</div>
{presenter && (
<div className='entry-presenter'>{presenter}</div>
)}
</li>
);
}
@@ -1,24 +0,0 @@
import { useSchedule } from './ScheduleContext';
import './Schedule.scss';
interface ScheduleNavProps {
className?: string;
}
export default function ScheduleNav({ className }: ScheduleNavProps) {
const { numPages, visiblePage } = useSchedule();
return (
<div className={`schedule-nav ${className}`}>
{numPages > 1 &&
[...Array(numPages).keys()].map((i) => (
<div
key={i}
className={i === visiblePage ? 'schedule-nav__item schedule-nav__item--selected' : 'schedule-nav__item'}
/>
),
)}
</div>
);
}
@@ -1,17 +0,0 @@
@use '../../../theme/ontimeColours' as *;
.emptyContainer {
width: 100%;
text-align: center;
color: $gray-1350;
.empty {
width: 100%;
opacity: 0.6;
}
.text {
font-weight: 600;
font-size: 2em;
}
}
@@ -1,19 +0,0 @@
import { CSSProperties } from 'react';
import { ReactComponent as Emptyimage } from '@/assets/images/empty.svg';
import style from './Empty.module.scss';
interface EmptyProps {
text: string;
style?: CSSProperties;
}
export default function Empty(props: EmptyProps) {
const { text, ...rest } = props;
return (
<div className={style.emptyContainer} {...rest}>
<Emptyimage className={style.empty} />
<span className={style.text}>{text}</span>
</div>
);
}
@@ -1,15 +0,0 @@
@use '../../../theme/viewerDefs' as *;
.timer {
font-family: var(--font-family-override, $viewer-font-family);
color: var(--timer-color-override, $timer-color);
line-height: 0.9em;
text-align: center;
letter-spacing: 0.1em;
font-weight: 600;
font-size: 3.75em;
&--finished {
color: $timer-finished-color;
}
}
@@ -1,34 +0,0 @@
import { memo } from 'react';
import { formatDisplay, millisToSeconds } from '../../utils/dateConfig';
import './TimerDisplay.scss';
interface TimerDisplayProps {
time?: number | null;
}
/**
* Displays time in ms in formatted timetag
* @param props
* @constructor
*/
const TimerDisplay = (props: TimerDisplayProps) => {
const { time } = props;
let display = '';
if (time === null || typeof time === 'undefined' || isNaN(time)) {
display = '-- : -- : --';
} else {
display = formatDisplay(millisToSeconds(time));
}
const isNegative = (time ?? 0) < 0;
const classes = `timer ${isNegative ? 'timer--finished' : ''}`;
return <div className={classes}>{display}</div>;
};
export default memo(TimerDisplay);
@@ -1,36 +0,0 @@
@use '../../../theme/viewerDefs' as *;
.title-card {
display: flex;
flex-direction: column;
gap: 8px;
.inline {
display: flex;
}
.title {
font-weight: 600;
font-size: clamp(32px, 3.5vw, 50px);
color: var(--color-override, $viewer-color);
line-height: 1.1em;
}
.subtitle, .presenter {
font-size: clamp(24px, 2vw, 35px);
color: var(--secondary-color-override, $viewer-secondary-color);
line-height: 1.1em;
}
.label {
font-size: clamp(16px, 1.5vw, 24px);
font-weight: 400;
color: var(--secondary-color-override, $viewer-secondary-color);
margin-left: auto;
text-transform: uppercase;
&.accent {
color: var(--accent-color-override, $accent-color);
}
}
}
@@ -1,19 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { ALIASES } from '../api/apiConstants';
import { getAliases } from '../api/ontimeApi';
export default function useAliases() {
const { data, status, isError, refetch } = useQuery({
queryKey: ALIASES,
queryFn: getAliases,
placeholderData: [],
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data, status, isError, refetch };
}
@@ -1,20 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { EVENTDATA_TABLE } from '../api/apiConstants';
import { fetchEventData } from '../api/eventDataApi';
import { eventDataPlaceholder } from '../models/EventData';
export default function useEventData() {
const { data, status, isError, refetch } = useQuery({
queryKey: EVENTDATA_TABLE,
queryFn: fetchEventData,
placeholderData: eventDataPlaceholder,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data, status, isError, refetch };
}
@@ -1,20 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { APP_INFO } from '../api/apiConstants';
import { getInfo } from '../api/ontimeApi';
import { ontimePlaceholderInfo } from '../models/Info';
export default function useInfo() {
const { data, status, isError, refetch } = useQuery({
queryKey: APP_INFO,
queryFn: getInfo,
placeholderData: ontimePlaceholderInfo,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data, status, isError, refetch };
}
@@ -1,31 +0,0 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { OSCSettings } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { OSC_SETTINGS } from '../api/apiConstants';
import { getOSC, postOSC } from '../api/ontimeApi';
import { oscPlaceholderSettings } from '../models/OscSettings';
import { ontimeQueryClient } from '../queryClient';
export default function useOscSettings() {
const { data, status, isError, refetch } = useQuery({
queryKey: OSC_SETTINGS,
queryFn: getOSC,
placeholderData: oscPlaceholderSettings,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data! as unknown as OSCSettings, status, isError, refetch };
}
export function useOscSettingsMutation() {
const { isLoading, mutateAsync } = useMutation({
mutationFn: postOSC,
onSuccess: (res) => ontimeQueryClient.setQueryData(OSC_SETTINGS, res.data),
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: OSC_SETTINGS }),
});
return { isLoading, mutateAsync };
}
@@ -1,19 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { queryRefetchInterval } from '../../ontimeConfig';
import { RUNDOWN_TABLE } from '../api/apiConstants';
import { fetchRundown } from '../api/eventsApi';
export default function useRundown() {
const { data, status, isError, refetch } = useQuery({
queryKey: RUNDOWN_TABLE,
queryFn: fetchRundown,
placeholderData: [],
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchInterval,
networkMode: 'always',
});
return { data, status, isError, refetch };
}
@@ -1,20 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { APP_SETTINGS } from '../api/apiConstants';
import { getSettings } from '../api/ontimeApi';
import { ontimePlaceholderSettings } from '../models/OntimeSettings';
export default function useSettings() {
const { data, status, isError, refetch } = useQuery({
queryKey: APP_SETTINGS,
queryFn: getSettings,
placeholderData: ontimePlaceholderSettings,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data, status, isError, refetch };
}
@@ -1,20 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { queryRefetchInterval } from '../../ontimeConfig';
import { USERFIELDS } from '../api/apiConstants';
import { getUserFields } from '../api/ontimeApi';
import { userFieldsPlaceholder } from '../models/UserFields';
export default function useUserFields() {
const { data, status, isError, refetch } = useQuery({
queryKey: USERFIELDS,
queryFn: getUserFields,
placeholderData: userFieldsPlaceholder,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchInterval,
networkMode: 'always',
});
return { data, status, isError, refetch };
}
@@ -1,20 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { VIEW_SETTINGS } from '../api/apiConstants';
import { getView } from '../api/ontimeApi';
import { viewsSettingsPlaceholder } from '../models/ViewSettings.type';
export default function useViewSettings() {
const { data, status, isError, refetch } = useQuery({
queryKey: VIEW_SETTINGS,
queryFn: getView,
placeholderData: viewsSettingsPlaceholder,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data, status, isError, refetch };
}
@@ -1,46 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import useClickOutside from '../useClickOutside';
describe('useClickOutside', () => {
let target: HTMLElement;
let anotherElement: HTMLElement;
beforeAll(() => {
target = global.document.createElement('div');
global.document.body.appendChild(target);
anotherElement = global.document.createElement('div');
global.document.body.appendChild(anotherElement);
});
it('should trigger clicking outside', () => {
const ref = { current: target };
const callback = vi.fn();
renderHook(() => useClickOutside(ref, callback));
act(() => {
global.document.dispatchEvent(new Event('click'));
});
expect(callback).toHaveBeenCalled();
act(() => {
anotherElement.click();
});
expect(callback).toHaveBeenCalledTimes(2);
});
it('should not trigger clicking inside', () => {
const ref = { current: target };
const callback = vi.fn();
renderHook(() => useClickOutside(ref, callback));
act(() => {
target.click();
});
expect(callback).not.toHaveBeenCalled();
});
});
@@ -1,26 +0,0 @@
import { RefObject, useEffect } from 'react';
type ClickOutsideEventHandler = (event: MouseEvent) => void;
export default function useClickOutside<T extends HTMLElement = HTMLElement>(
ref: RefObject<T>,
callback: ClickOutsideEventHandler,
) {
useEffect(() => {
function handleClick(event: MouseEvent) {
const element = ref?.current;
// Do nothing if clicking ref's element or descendent element
if (!element || element.contains(event.target as Node)) {
return;
}
callback(event);
}
document.addEventListener('click', handleClick);
return () => {
document.removeEventListener('click', handleClick);
};
}, [ref, callback]);
}
@@ -1,345 +0,0 @@
import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import axios, { AxiosError } from 'axios';
import { OntimeRundown, OntimeRundownEntry, SupportedEvent } from 'ontime-types';
import { RUNDOWN_TABLE, RUNDOWN_TABLE_KEY } from '../api/apiConstants';
import {
ReorderEntry,
requestApplyDelay,
requestDelete,
requestDeleteAll,
requestPostEvent,
requestPutEvent,
requestReorderEvent,
} from '../api/eventsApi';
import { useLocalEvent } from '../stores/localEvent';
import { useEmitLog } from '../stores/logger';
/**
* @description Set of utilities for events
*/
export const useEventAction = () => {
const queryClient = useQueryClient();
const { emitError } = useEmitLog();
const eventSettings = useLocalEvent((state) => state.eventSettings);
const defaultPublic = eventSettings.defaultPublic;
const startTimeIsLastEnd = eventSettings.startTimeIsLastEnd;
/**
* Calls mutation to add new event
* @private
*/
const _addEventMutation = useMutation(requestPostEvent, {
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: () => {
queryClient.invalidateQueries(RUNDOWN_TABLE);
},
networkMode: 'always',
});
type BaseOptions = {
after?: string;
};
type EventOptions = BaseOptions & {
defaultPublic?: boolean;
lastEventId?: string;
startTimeIsLastEnd?: boolean;
};
/**
* Adds an event to rundown
*/
const addEvent = useCallback(
async (event: Partial<OntimeRundownEntry>, options?: EventOptions) => {
const newEvent: Partial<OntimeRundownEntry> = { ...event };
// ************* CHECK OPTIONS specific to events
if (newEvent.type === SupportedEvent.Event) {
const applicationOptions = {
defaultPublic: options?.defaultPublic ?? defaultPublic,
startTimeIsLastEnd: options?.startTimeIsLastEnd ?? startTimeIsLastEnd,
lastEventId: options?.lastEventId,
after: options?.after,
};
// hard coding duration value to be as expected for now
// this until timeOptions gets implemented
if (typeof newEvent?.timeStart !== 'undefined' && typeof newEvent.timeEnd !== 'undefined') {
newEvent.duration = Math.max(0, newEvent?.timeEnd - newEvent?.timeStart) || 0;
}
if (applicationOptions.startTimeIsLastEnd && applicationOptions?.lastEventId) {
const rundown = queryClient.getQueryData(RUNDOWN_TABLE) as OntimeRundown;
const previousEvent = rundown.find((event) => event.id === applicationOptions.lastEventId);
if (typeof previousEvent !== 'undefined' && previousEvent.type === 'event') {
newEvent.timeStart = previousEvent.timeEnd;
}
}
if (applicationOptions.defaultPublic) {
newEvent.isPublic = true;
}
}
// handle adding options that concern all event type
if (options?.after) {
newEvent.after = options.after;
}
try {
// @ts-expect-error -- we know that the object is well formed now
await _addEventMutation.mutateAsync(newEvent);
} catch (error) {
if (!axios.isAxiosError(error)) {
emitError(`Error fetching data: ${(error as AxiosError).message}`);
} else {
emitError(`Error fetching data: ${error}`);
}
}
},
[_addEventMutation, defaultPublic, emitError, queryClient, startTimeIsLastEnd],
);
/**
* Calls mutation to update existing event
* @private
*/
const _updateEventMutation = useMutation(requestPutEvent, {
// we optimistically update here
onMutate: async (newEvent) => {
// cancel ongoing queries
await queryClient.cancelQueries([RUNDOWN_TABLE_KEY, newEvent.id]);
// Snapshot the previous value
const previousEvent = queryClient.getQueryData([RUNDOWN_TABLE_KEY, newEvent.id]);
// optimistically update object
queryClient.setQueryData([RUNDOWN_TABLE_KEY, newEvent.id], newEvent);
// Return a context with the previous and new events
return { previousEvent, newEvent };
},
// Mutation fails, rollback undoes optimist update
onError: (_error, _newEvent, context) => {
queryClient.setQueryData([RUNDOWN_TABLE_KEY, context?.newEvent.id], context?.previousEvent);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: async () => {
await queryClient.invalidateQueries([RUNDOWN_TABLE_KEY]);
},
networkMode: 'always',
});
/**
* Updates existing event
*/
const updateEvent = useCallback(
async (event: Partial<OntimeRundownEntry>) => {
try {
await _updateEventMutation.mutateAsync(event);
} catch (error) {
if (!axios.isAxiosError(error)) {
emitError(`Error updating event: ${(error as AxiosError).message}`);
} else {
emitError(`Error updating event: ${error}`);
}
}
},
[_updateEventMutation, emitError],
);
/**
* Calls mutation to delete an event
* @private
*/
const _deleteEventMutation = useMutation(requestDelete, {
// we optimistically update here
onMutate: async (eventId) => {
// cancel ongoing queries
await queryClient.cancelQueries([RUNDOWN_TABLE_KEY, eventId]);
// Snapshot the previous value
const previousEvents = queryClient.getQueryData(RUNDOWN_TABLE);
const filtered = [...(previousEvents as OntimeRundown)].filter((e) => e.id !== eventId);
// optimistically update object
queryClient.setQueryData(RUNDOWN_TABLE, filtered);
// Return a context with the previous and new events
return { previousEvents };
},
// Mutation fails, rollback undoes optimist update
onError: (_error, _eventId, context) => {
queryClient.setQueryData(RUNDOWN_TABLE, context?.previousEvents);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: () => {
queryClient.invalidateQueries(RUNDOWN_TABLE);
},
networkMode: 'always',
});
/**
* Deletes an event form the list
*/
const deleteEvent = useCallback(
async (eventId: string) => {
try {
await _deleteEventMutation.mutateAsync(eventId);
} catch (error) {
if (!axios.isAxiosError(error)) {
emitError(`Error deleting event: ${(error as AxiosError).message}`);
} else {
emitError(`Error deleting event: ${error}`);
}
}
},
[_deleteEventMutation, emitError],
);
/**
* Calls mutation to delete all events
* @private
*/
const _deleteAllEventsMutation = useMutation(requestDeleteAll, {
// we optimistically update here
onMutate: async () => {
// cancel ongoing queries
await queryClient.cancelQueries(RUNDOWN_TABLE, { exact: true });
// Snapshot the previous value
const previousEvents = queryClient.getQueryData(RUNDOWN_TABLE);
// optimistically update object
queryClient.setQueryData(RUNDOWN_TABLE, []);
// Return a context with the previous and new events
return { previousEvents };
},
// Mutation fails, rollback undos optimist update
onError: (_error, _eventId, context) => {
queryClient.setQueryData(RUNDOWN_TABLE, context?.previousEvents);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: () => {
queryClient.invalidateQueries(RUNDOWN_TABLE);
},
networkMode: 'always',
});
/**
* Deletes all events from list
*/
const deleteAllEvents = useCallback(async () => {
try {
await _deleteAllEventsMutation.mutateAsync();
} catch (error) {
if (!axios.isAxiosError(error)) {
emitError(`Error deleting events: ${(error as AxiosError).message}`);
} else {
emitError(`Error deleting events: ${error}`);
}
}
}, [_deleteAllEventsMutation, emitError]);
/**
* Calls mutation to apply a delay
* @private
*/
const _applyDelayMutation = useMutation(requestApplyDelay, {
// Mutation finished, failed or successful
onSettled: () => {
queryClient.invalidateQueries(RUNDOWN_TABLE);
},
networkMode: 'always',
});
/**
* Applies a given delay block
*/
const applyDelay = useCallback(
async (delayEventId: string) => {
try {
await _applyDelayMutation.mutateAsync(delayEventId);
} catch (error) {
if (!axios.isAxiosError(error)) {
emitError(`Error applying delay: ${(error as AxiosError).message}`);
} else {
emitError(`Error applying delay: ${error}`);
}
}
},
[_applyDelayMutation, emitError],
);
/**
* Calls mutation to reorder an event
* @private
*/
const _reorderEventMutation = useMutation(requestReorderEvent, {
// we optimistically update here
onMutate: async (data) => {
// cancel ongoing queries
await queryClient.cancelQueries(RUNDOWN_TABLE, { exact: true });
// Snapshot the previous value
const previousEvents = queryClient.getQueryData(RUNDOWN_TABLE);
const e = [...(previousEvents as OntimeRundown)];
const [reorderedItem] = e.splice(data.from, 1);
e.splice(data.to, 0, reorderedItem);
// optimistically update object
queryClient.setQueryData(RUNDOWN_TABLE, e);
// Return a context with the previous and new events
return { previousEvents };
},
// Mutation fails, rollback undoes optimist update
onError: (_error, _eventId, context) => {
queryClient.setQueryData(RUNDOWN_TABLE, context?.previousEvents);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: () => {
queryClient.invalidateQueries(RUNDOWN_TABLE);
},
networkMode: 'always',
});
/**
* Reorders a given event
*/
const reorderEvent = useCallback(
async (eventId: string, from: number, to: number) => {
try {
const reorderObject: ReorderEntry = {
eventId: eventId,
from: from,
to: to,
};
await _reorderEventMutation.mutateAsync(reorderObject);
} catch (error) {
if (!axios.isAxiosError(error)) {
emitError(`Error re-ordering event: ${(error as AxiosError).message}`);
} else {
emitError(`Error re-ordering event: ${error}`);
}
}
},
[_reorderEventMutation, emitError],
);
return { addEvent, updateEvent, deleteEvent, deleteAllEvents, applyDelay, reorderEvent };
};
@@ -1,18 +0,0 @@
import { useEffect } from 'react';
export const useKeyDown = (callback: () => void, targetKey: string) => {
const onKeyDown = (event: KeyboardEvent) => {
const targetKeyPressed = event.key === targetKey && !event.repeat;
if (targetKeyPressed) {
event.preventDefault();
callback();
}
};
useEffect(() => {
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('keydown', onKeyDown);
};
}, []);
};
@@ -1,53 +0,0 @@
import { useEffect, useState } from 'react';
/**
* @description utility hook to handle state in local storage
* @param key
* @param initialValue
*/
export const useLocalStorage = <T>(key: string, initialValue: T): [T, (value: T | ((val: T) => T)) => void] => {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(`ontime-${key}`);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
return initialValue;
}
});
useEffect(() => {
const handleStorageChange = (event: StorageEvent) => {
if (event.storageArea === window.localStorage && event.key === key) {
try {
const newValue = event.newValue ? JSON.parse(event.newValue) : initialValue;
setStoredValue(newValue);
} catch (_) {
/* empty */
}
}
};
window.addEventListener('storage', handleStorageChange);
return () => {
window.removeEventListener('storage', handleStorageChange);
};
}, [initialValue, key]);
/**
* @description Set value to local storage
* @param value
*/
const setValue = (value: T | ((val: T) => T)) => {
try {
// Allow value to be a function so we have same API as useState
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(`ontime-${key}`, JSON.stringify(valueToStore));
} catch (error) {
console.log(error);
}
};
return [storedValue, setValue];
};
-102
View File
@@ -1,102 +0,0 @@
import { RuntimeStore } from 'ontime-types';
import { deepCompare, useRuntimeStore } from '../stores/runtime';
import { socketSendJson } from '../utils/socket';
export const useRundownEditor = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.playback,
selectedEventId: state.loaded.selectedEventId,
nextEventId: state.loaded.nextEventId,
});
return useRuntimeStore(featureSelector, deepCompare);
};
export const useMessageControl = () => {
const featureSelector = (state: RuntimeStore) => ({
timerMessage: state.timerMessage,
publicMessage: state.publicMessage,
lowerMessage: state.lowerMessage,
onAir: state.onAir,
});
return useRuntimeStore(featureSelector, deepCompare);
};
export const setMessage = {
presenterText: (payload: string) => socketSendJson('set-timer-message-text', payload),
presenterVisible: (payload: boolean) => socketSendJson('set-timer-message-visible', payload),
publicText: (payload: string) => socketSendJson('set-public-message-text', payload),
publicVisible: (payload: boolean) => socketSendJson('set-public-message-visible', payload),
lowerText: (payload: string) => socketSendJson('set-lower-message-text', payload),
lowerVisible: (payload: boolean) => socketSendJson('set-lower-message-visible', payload),
onAir: (payload: boolean) => socketSendJson('set-onAir', payload),
};
export const usePlaybackControl = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.playback,
numEvents: state.loaded.numEvents,
});
return useRuntimeStore(featureSelector, deepCompare);
};
export const setPlayback = {
start: () => socketSendJson('start'),
pause: () => socketSendJson('pause'),
roll: () => socketSendJson('roll'),
previous: () => {
socketSendJson('previous');
},
next: () => {
socketSendJson('next');
},
stop: () => {
socketSendJson('stop');
},
reload: () => {
socketSendJson('reload');
},
delay: (amount: number) => {
socketSendJson('delay', amount);
},
};
export const useInfoPanel = () => {
const featureSelector = (state: RuntimeStore) => ({
titles: state.titles,
playback: state.playback,
selectedEventIndex: state.loaded.selectedEventIndex,
numEvents: state.loaded.numEvents,
});
return useRuntimeStore(featureSelector, deepCompare);
};
export const useCuesheet = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.playback,
selectedEventId: state.loaded.selectedEventId,
selectedEventIndex: state.loaded.selectedEventIndex,
numEvents: state.loaded.numEvents,
titleNow: state.titles.titleNow,
});
return useRuntimeStore(featureSelector, deepCompare);
};
export const setEventPlayback = {
loadEvent: (eventId: string) => socketSendJson('loadid', eventId),
startEvent: (eventId: string) => socketSendJson('startid', eventId),
pause: () => socketSendJson('pause'),
};
export const useTimer = () => {
const featureSelector = (state: RuntimeStore) => ({
...state.timer,
});
return useRuntimeStore(featureSelector, deepCompare);
};
-7
View File
@@ -1,7 +0,0 @@
import { Alias } from 'ontime-types';
export const aliasPlaceholder: Alias = {
enabled: false,
alias: '',
pathAndParams: '',
};
@@ -1,10 +0,0 @@
import { EventData } from 'ontime-types';
export const eventDataPlaceholder: EventData = {
title: '',
publicUrl: '',
publicInfo: '',
backstageUrl: '',
backstageInfo: '',
endMessage: '',
};
-26
View File
@@ -1,26 +0,0 @@
export const httpPlaceholder = {
onLoad: {
url: '',
enabled: false,
},
onStart: {
url: '',
enabled: false,
},
onUpdate: {
url: '',
enabled: false,
},
onPause: {
url: '',
enabled: false,
},
onStop: {
url: '',
enabled: false,
},
onFinish: {
url: '',
enabled: false,
},
};
-19
View File
@@ -1,19 +0,0 @@
import { Settings } from 'ontime-types';
type NetworkInterfaceType = {
name: string;
address: string;
};
export type InfoType = {
networkInterfaces: NetworkInterfaceType[];
settings: Pick<Settings, 'version' | 'serverPort'>;
};
export const ontimePlaceholderInfo: InfoType = {
networkInterfaces: [],
settings: {
version: 2,
serverPort: 4001,
},
};
@@ -1,10 +0,0 @@
import { Settings } from 'ontime-types';
export const ontimePlaceholderSettings: Settings = {
app: 'ontime',
version: 2,
serverPort: 4001,
lock: null,
pinCode: null,
timeFormat: '24',
};
@@ -1,30 +0,0 @@
export const ontimeVars = [
{
name: '$timer',
description: 'Current running timer',
},
{
name: '$title',
description: 'Current title',
},
{
name: '$presenter',
description: 'Current timer',
},
{
name: '$subtitle',
description: 'Current subtitle',
},
{
name: '$next-title',
description: 'Next title',
},
{
name: '$next-presenter',
description: 'Next timer',
},
{
name: '$next-subtitle',
description: 'Next subtitle',
},
];
@@ -1,23 +0,0 @@
import { OSCSettings } from 'ontime-types';
// in the placeholder, we pass strings to satisfy input type
export interface PlaceholderSettings extends Omit<OSCSettings, 'portIn' | 'portOut'> {
portIn: string;
portOut: string;
}
export const oscPlaceholderSettings: PlaceholderSettings = {
portIn: '',
portOut: '',
targetIP: '',
enabledIn: false,
enabledOut: false,
subscriptions: {
onLoad: [],
onStart: [],
onPause: [],
onStop: [],
onUpdate: [],
onFinish: [],
},
};
@@ -1,18 +0,0 @@
import { Playback, TimerType } from 'ontime-types';
export type TimeManagerType = {
clock: number;
current: null | number;
elapsed: null | number;
duration: null | number;
timerBehaviour?: string;
timerType: TimerType;
expectedFinish: null | number;
addedTime: number;
startedAt: null | number;
finishedAt: null | number;
secondaryTimer: null | number;
finished: boolean;
playback: Playback;
};
@@ -1,14 +0,0 @@
import { UserFields } from 'ontime-types';
export const userFieldsPlaceholder: UserFields = {
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
};
@@ -1 +0,0 @@
export type Size = 'xs' | 'sm' | 'md' | 'lg';
@@ -1,5 +0,0 @@
import { ViewSettings } from 'ontime-types';
export const viewsSettingsPlaceholder: ViewSettings = {
overrideStyles: false,
};
-3
View File
@@ -1,3 +0,0 @@
import { QueryClient } from '@tanstack/react-query';
export const ontimeQueryClient = new QueryClient();
@@ -1,24 +0,0 @@
import { create } from 'zustand';
import { booleanFromLocalStorage } from '../utils/localStorage';
type CursorStore = {
cursor: number;
isCursorLocked: boolean;
toggleCursorLocked: (newValue?: boolean) => void;
moveCursorTo: (index: number) => void;
};
const cursorLockedKey = 'ontime-cursor-islocked';
export const useCursor = create<CursorStore>()((set) => ({
cursor: 0,
isCursorLocked: booleanFromLocalStorage(cursorLockedKey, false),
toggleCursorLocked: (newValue?: boolean) =>
set((state) => {
const val = typeof newValue === 'undefined' ? !state.isCursorLocked : newValue;
localStorage.setItem(cursorLockedKey, String(val));
return { isCursorLocked: val };
}),
moveCursorTo: (index: number) => set(() => ({ cursor: index })),
}));
@@ -1,13 +0,0 @@
import { create } from 'zustand';
type EventEditorStore = {
openId: string | null;
setOpenEvent: (eventId: string) => void;
removeOpenEvent: () => void;
};
export const useEventEditorStore = create<EventEditorStore>()((set) => ({
openId: null,
setOpenEvent: (eventId: string | null) => set({ openId: eventId }),
removeOpenEvent: () => set({ openId: null }),
}));
@@ -1,57 +0,0 @@
import { create } from 'zustand';
import { booleanFromLocalStorage } from '../utils/localStorage';
type EventSettings = {
showQuickEntry: boolean;
startTimeIsLastEnd: boolean;
defaultPublic: boolean;
};
type LocalEventStore = {
eventSettings: EventSettings;
setLocalEventSettings: (newState: EventSettings) => void;
setShowQuickEntry: (showQuickEntry: boolean) => void;
setStartTimeIsLastEnd: (startTimeIsLastEnd: boolean) => void;
setDefaultPublic: (defaultPublic: boolean) => void;
};
enum LocalEventKeys {
ShowQuickEntry = 'ontime-show-quick-entry',
StartTimeIsLastEnd = 'ontime-start-is-last-end',
DefaultPublic = 'ontime-default-public',
}
export const useLocalEvent = create<LocalEventStore>((set) => ({
eventSettings: {
showQuickEntry: booleanFromLocalStorage(LocalEventKeys.ShowQuickEntry, false),
startTimeIsLastEnd: booleanFromLocalStorage(LocalEventKeys.ShowQuickEntry, true),
defaultPublic: booleanFromLocalStorage(LocalEventKeys.ShowQuickEntry, true),
},
setLocalEventSettings: (value) =>
set(() => {
localStorage.setItem(LocalEventKeys.ShowQuickEntry, String(value.showQuickEntry));
localStorage.setItem(LocalEventKeys.StartTimeIsLastEnd, String(value.startTimeIsLastEnd));
localStorage.setItem(LocalEventKeys.DefaultPublic, String(value.defaultPublic));
return { eventSettings: value };
}),
setShowQuickEntry: (showQuickEntry) =>
set((state) => {
localStorage.setItem(LocalEventKeys.ShowQuickEntry, String(showQuickEntry));
return { eventSettings: { ...state.eventSettings, showQuickEntry } };
}),
setStartTimeIsLastEnd: (startTimeIsLastEnd) =>
set((state) => {
localStorage.setItem(LocalEventKeys.StartTimeIsLastEnd, String(startTimeIsLastEnd));
return { eventSettings: { ...state.eventSettings, startTimeIsLastEnd } };
}),
setDefaultPublic: (defaultPublic) =>
set((state) => {
localStorage.setItem(LocalEventKeys.DefaultPublic, String(defaultPublic));
return { eventSettings: { ...state.eventSettings, defaultPublic } };
}),
}));
-84
View File
@@ -1,84 +0,0 @@
import { useCallback } from 'react';
import { Log, LogLevel } from 'ontime-types';
import { generateId, millisToString } from 'ontime-utils';
import { useStore } from 'zustand';
import { createStore } from 'zustand/vanilla';
import { socketSendJson } from '../utils/socket';
import { nowInMillis } from '../utils/time';
type LogStore = {
logs: Log[];
};
export const logger = createStore<LogStore>(() => ({
logs: [],
}));
export const useLogData = () => useStore(logger);
export const addLog = (log: Log) =>
logger.setState((state) => ({
logs: [log, ...state.logs],
}));
export const clearLogs = () => logger.setState({ logs: [] });
export function useEmitLog() {
/**
* Utility function sends message over socket
* @param text
* @param level
* @private
*/
const _emit = useCallback((text: string, level: LogLevel) => {
const log = {
id: generateId(),
origin: 'CLIENT',
time: millisToString(nowInMillis()),
level,
text,
};
socketSendJson('ontime-log', log);
}, []);
/**
* Sends a message with level INFO
* @param text
*/
const emitInfo = useCallback(
(text: string) => {
_emit(text, LogLevel.Info);
},
[_emit],
);
/**
* Sends a message with level WARN
* @param text
*/
const emitWarning = useCallback(
(text: string) => {
_emit(text, LogLevel.Warn);
},
[_emit],
);
/**
* Sends a message with level ERROR
* @param text
*/
const emitError = useCallback(
(text: string) => {
_emit(text, LogLevel.Error);
},
[_emit],
);
return {
emitInfo,
emitWarning,
emitError,
};
}
-74
View File
@@ -1,74 +0,0 @@
import isEqual from 'react-fast-compare';
import { Playback, RuntimeStore } from 'ontime-types';
import { useStore } from 'zustand';
import { createStore } from 'zustand/vanilla';
export const runtimeStorePlaceholder = {
timer: {
clock: 0,
current: null,
elapsed: null,
expectedFinish: null,
addedTime: 0,
startedAt: null,
finishedAt: null,
secondaryTimer: null,
selectedEventId: null,
duration: null,
timerType: null,
endAction: null,
},
playback: Playback.Stop,
timerMessage: {
text: '',
visible: false,
},
publicMessage: {
text: '',
visible: false,
},
lowerMessage: {
text: '',
visible: false,
},
onAir: false,
loaded: {
numEvents: 0,
selectedEventIndex: null,
selectedEventId: null,
selectedPublicEventId: null,
nextEventId: null,
nextPublicEventId: null,
},
titles: {
titleNow: null,
subtitleNow: null,
presenterNow: null,
noteNow: null,
titleNext: null,
subtitleNext: null,
presenterNext: null,
noteNext: null,
},
titlesPublic: {
titleNow: null,
subtitleNow: null,
presenterNow: null,
noteNow: null,
titleNext: null,
subtitleNext: null,
presenterNext: null,
noteNext: null,
},
};
export const runtime = createStore<RuntimeStore>(() => ({
...runtimeStorePlaceholder,
}));
export const deepCompare = <T>(a: T, b: T) => isEqual(a, b);
export const useRuntimeStore = <T>(
selector: (state: RuntimeStore) => T,
equalityFn?: (a: unknown, b: unknown) => boolean,
) => useStore(runtime, selector, equalityFn);
@@ -1,22 +0,0 @@
import { create } from 'zustand';
import { booleanFromLocalStorage } from '../utils/localStorage';
enum LocalEventKeys {
Mirror = 'ontime-view-mirror',
}
type ViewOptionsStore = {
mirror: boolean;
toggleMirror: (newValue?: boolean) => void;
};
export const useViewOptionsStore = create<ViewOptionsStore>()((set) => ({
mirror: booleanFromLocalStorage(LocalEventKeys.Mirror, false),
toggleMirror: (newValue?: boolean) =>
set((state) => {
const val = typeof newValue === 'undefined' ? !state.mirror : newValue;
localStorage.setItem(LocalEventKeys.Mirror, String(val));
return { mirror: val };
}),
}));
@@ -1,5 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`cx() > ignores falsy values 1`] = `""`;
exports[`cx() > merges styles 1`] = `"_test_98a1e0 _another_98a1e0"`;
@@ -1,27 +0,0 @@
import { isIPAddress, isOnlyNumbers } from '../regex';
describe('simple tests for regex', () => {
test('isOnlyNumbers', () => {
const right = ['1231', '1'];
const wrong = ['a', 'asdas1asdas', '11as', '1_', '1.1'];
right.forEach((t) => {
expect(isOnlyNumbers.test(t)).toBe(true);
});
wrong.forEach((t) => {
expect(isOnlyNumbers.test(t)).toBe(false);
});
});
test('isIPAddress', () => {
const right = ['0.0.0.0', '127.0.0.1'];
const wrong = ['0', 'testing', '123.0.1'];
right.forEach((t) => {
expect(isIPAddress.test(t)).toBe(true);
});
wrong.forEach((t) => {
expect(isIPAddress.test(t)).toBe(false);
});
});
});
@@ -1,2 +0,0 @@
.test {}
.another {}
@@ -1,15 +0,0 @@
import { cx } from '../styleUtils';
import style from './styleUtils.module.scss';
describe('cx()', () => {
it('merges styles', () => {
const merged = cx([style.test, style.another]);
expect(merged).toMatchSnapshot();
});
it('ignores falsy values', () => {
const falsyStuff = false;
const merged = cx([undefined, false, 0, null, falsyStuff ? style.test : null]);
expect(merged).toMatchSnapshot();
});
});
@@ -1,27 +0,0 @@
import { calculateDuration, DAY_TO_MS } from '../timesManager';
describe('calculateDuration()', () => {
describe('Given start and end values', () => {
it('calculates duration correctly', () => {
const testStart = 1;
const testEnd = 2;
const val = calculateDuration(testStart, testEnd);
expect(val).toBe(testEnd - testStart);
});
});
describe('Handles edge cases', () => {
it('when start is after end', () => {
const testStart = 3;
const testEnd = 2;
const val = calculateDuration(testStart, testEnd);
expect(val).toBe(testEnd + DAY_TO_MS - testStart);
});
it('when both are equal', () => {
const testStart = 1;
const testEnd = 1;
const val = calculateDuration(testStart, testEnd);
expect(val).toBe(testEnd - testStart);
});
});
});
@@ -1,9 +0,0 @@
export function booleanFromLocalStorage(key: string, fallback: boolean): boolean {
const valueInStorage = localStorage.getItem(key);
if (valueInStorage) {
return valueInStorage === 'true';
} else {
localStorage.setItem(key, String(fallback));
return fallback;
}
}
-2
View File
@@ -1,2 +0,0 @@
export const isOnlyNumbers = /^\d+$/;
export const isIPAddress = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/;
-138
View File
@@ -1,138 +0,0 @@
import { Log } from 'ontime-types';
import { RUNTIME, websocketUrl } from '../api/apiConstants';
import { ontimeQueryClient } from '../queryClient';
import { addLog } from '../stores/logger';
import { runtime } from '../stores/runtime';
export let websocket: WebSocket | null = null;
let reconnectTimeout: NodeJS.Timeout | null = null;
const reconnectInterval = 1000;
let shouldReconnect = true;
export const connectSocket = () => {
websocket = new WebSocket(websocketUrl);
websocket.onopen = () => {
clearTimeout(reconnectTimeout as NodeJS.Timeout);
};
websocket.onclose = () => {
console.warn('WebSocket disconnected');
if (shouldReconnect) {
reconnectTimeout = setTimeout(() => {
console.warn('WebSocket: attempting reconnect');
if (websocket && websocket.readyState === WebSocket.CLOSED) {
connectSocket();
}
}, reconnectInterval);
}
};
websocket.onerror = (error) => {
console.error('WebSocket error:', error);
};
websocket.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
const { type, payload } = data;
if (!type) {
return;
}
// TODO: implement partial store updates
switch (type) {
case 'ontime-log': {
addLog(payload as Log);
break;
}
case 'ontime': {
runtime.setState(payload);
if (import.meta.env.DEV) {
ontimeQueryClient.setQueryData(RUNTIME, data.payload);
}
break;
}
case 'ontime-playback': {
const state = runtime.getState();
state.playback = payload;
runtime.setState(state);
break;
}
case 'ontime-timer': {
const state = runtime.getState();
state.timer = payload;
runtime.setState(state);
break;
}
case 'ontime-loaded': {
const state = runtime.getState();
state.loaded = payload;
runtime.setState(state);
break;
}
case 'ontime-titles': {
const state = runtime.getState();
state.titles = payload;
runtime.setState(state);
break;
}
case 'ontime-titlesPublic': {
const state = runtime.getState();
state.titlesPublic = payload;
runtime.setState(state);
break;
}
case 'ontime-timerMessage': {
const state = runtime.getState();
state.timerMessage = payload;
runtime.setState(state);
break;
}
case 'ontime-publicMessage': {
const state = runtime.getState();
state.publicMessage = payload;
runtime.setState(state);
break;
}
case 'ontime-lowerMessage': {
const state = runtime.getState();
state.lowerMessage = payload;
runtime.setState(state);
break;
}
case 'ontime-onAir': {
const state = runtime.getState();
state.onAir = payload;
runtime.setState(state);
break;
}
}
} catch (_) {
// ignore unhandled
}
};
};
export const disconnectSocket = () => {
shouldReconnect = false;
websocket?.close();
};
export const socketSend = (message: any) => {
if (websocket && websocket.readyState === WebSocket.OPEN) {
websocket.send(message);
}
};
export const socketSendJson = (type: string, payload?: any) => {
socketSend(
JSON.stringify({
type,
payload,
}),
);
};
@@ -1,29 +0,0 @@
import Color from 'color';
type ColourCombination = {
backgroundColor: string;
color: string;
}
/**
* @description Selects text colour to maintain accessible contrast
* @param bgColour
* @return {{backgroundColor, color: string}}
*/
export const getAccessibleColour = (bgColour: string): ColourCombination => {
if (bgColour) {
try {
const textColor = Color(bgColour).isLight() ? 'black' : '#fffffa';
return { backgroundColor: bgColour, color: textColor };
} catch (error) {
console.log(`Unable to parse colour: ${bgColour}`);
}
}
return { backgroundColor: '#000', color: "#fffffa" };
};
/**
* @description Creates a list of classnames from array of css module conditions
* @param classNames - css modules objects
*/
export const cx = (classNames: any[]) => classNames.filter(Boolean).join(" ");
-60
View File
@@ -1,60 +0,0 @@
import { DateTime } from 'luxon';
import { Settings } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { APP_SETTINGS } from '../api/apiConstants';
import { ontimeQueryClient } from '../queryClient';
/**
* Returns current time in milliseconds
* @returns {number}
*/
export const nowInMillis = () => {
const now = new Date();
// extract milliseconds since midnight
let elapsed = now.getHours() * 3600000;
elapsed += now.getMinutes() * 60000;
elapsed += now.getSeconds() * 1000;
elapsed += now.getMilliseconds();
return elapsed;
};
/**
* @description Resolves format from url and store
* @return {string|undefined}
*/
export const resolveTimeFormat = () => {
const params = new URL(document.location.href).searchParams;
const urlOptions = params.get('format');
const settings: Settings | undefined = ontimeQueryClient.getQueryData(APP_SETTINGS);
return urlOptions || settings?.timeFormat;
};
type FormatOptions = {
showSeconds?: boolean;
format?: string;
};
/**
/**
* @description utility function to format a date in 12 or 24 hour format
* @param {number | null} milliseconds
* @param {object} [options]
* @param {boolean} [options.showSeconds]
* @param {string} [options.format]
* @param {function} resolver
* @return {string}
*/
export const formatTime = (milliseconds: number | null, options: FormatOptions, resolver = resolveTimeFormat) => {
if (milliseconds === null) {
return '...';
}
const timeFormat = resolver();
const { showSeconds = false, format: formatString = 'hh:mm a' } = options || {};
return timeFormat === '12'
? DateTime.fromMillis(milliseconds).toUTC().toFormat(formatString)
: millisToString(milliseconds, showSeconds);
};
@@ -1,45 +0,0 @@
export type TimeEntryField = 'timeStart' |'timeEnd' | 'durationOverride';
/**
* @description Milliseconds in a day
*/
export const DAY_TO_MS = 86400000;
/**
* @description calculates duration from given values
*/
export const calculateDuration = (start: number, end: number): number =>
start > end ? end + DAY_TO_MS - start : end - start;
/**
* @description Checks which field the value relates to
*/
export const handleTimeEntry = (field: TimeEntryField, val: number, timeStart: number, timeEnd: number): {start: number, end: number, durationOverride: boolean} => {
let start = timeStart;
let end = timeEnd;
let durationOverride = false;
if (field === 'timeStart') {
start = val;
} else if (field === 'timeEnd') {
end = val;
} else {
durationOverride = field === 'durationOverride';
}
return { start, end, durationOverride };
};
/**
* @description Validates time entry
*/
export const validateEntry = (field: TimeEntryField, value: number, timeStart: number, timeEnd: number): { value: boolean, catch: string } => {
const validate = { value: true, catch: '' };
const { start, end } = handleTimeEntry(field, value, timeStart, timeEnd);
if (end < start) {
validate.catch = 'Start time later than end time';
}
return validate;
};
-21
View File
@@ -1,21 +0,0 @@
declare module '*.scss' {
const content: Record<string, string>;
export default content;
}
type ListenerType = (event: 'string', args: unknown[]) => void;
declare global {
interface Window {
ipcRenderer: {
send: (channel: string, args?: string | object) => void;
on: (channel: string, listener: ListenerType) => void;
};
process: {
type: string;
}
}
}
// eslint-disable-next-line import/no-anonymous-default-export
export default {}
-11
View File
@@ -1,11 +0,0 @@
import { TestingLibraryMatchers } from '@testing-library/jest-dom/matchers';
import 'vitest';
// ugly hack because vite and pnpm are not playing ball with jest
// https://github.com/testing-library/jest-dom/issues/123
declare global {
namespace Vi {
interface Assertion<T = any> extends TestingLibraryMatchers<T, void> {}
}
}
@@ -1,8 +0,0 @@
@use '../theme/v2Styles' as *;
.wrapper {
background: $bg-container-l1;
width: 100%;
height: 100%;
padding: max(16px, 2vh);
}

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