Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ff6556e4d |
@@ -3,6 +3,7 @@ version = 1
|
||||
test_patterns = [
|
||||
"__mocks__/**",
|
||||
"__tests__/**",
|
||||
"cypress/**",
|
||||
"*.test.*",
|
||||
"*.mock.*",
|
||||
"*.spec.*"
|
||||
|
||||
@@ -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 |
@@ -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,53 @@ 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
|
||||
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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/*
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"trailingComma": "all",
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true,
|
||||
"printWidth": 120
|
||||
}
|
||||
"printWidth": 100
|
||||
}
|
||||
@@ -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>`
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"trailingComma": "all",
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true,
|
||||
"printWidth": 120
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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}`;
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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];
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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: '',
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 } };
|
||||
}),
|
||||
}));
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export const isOnlyNumbers = /^\d+$/;
|
||||
export const isIPAddress = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/;
|
||||
@@ -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(" ");
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||