Compare commits
116 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 63983fc39a | |||
| 41af3b7466 | |||
| 78b386742e | |||
| ad71793cf3 | |||
| 10d7273ec0 | |||
| 6dc2bebeee | |||
| d59885fc20 | |||
| d22cc48565 | |||
| 8196ea584d | |||
| 0c31438e44 | |||
| 10d3986832 | |||
| 088927bbbb | |||
| d87ba12b2e | |||
| 5b01329c37 | |||
| 8df419d835 | |||
| d7392b93d2 | |||
| 429df21557 | |||
| da7ef59216 | |||
| 474f1e2177 | |||
| fc5338903b | |||
| 11d06de133 | |||
| cbc2ae5116 | |||
| a6ff874a2a | |||
| 7ff75835ff | |||
| c890251dad | |||
| c1e2700c38 | |||
| 5a397c6da7 | |||
| c1377544a0 | |||
| 1a420a1ddd | |||
| 436a34aaee | |||
| 4848fdbee3 | |||
| 71dace669b | |||
| 67c448572c | |||
| 53963a9ad7 | |||
| 5355e45b80 | |||
| 12f81c63ce | |||
| 7f78fac162 | |||
| e8b5cb6750 | |||
| 212e5f9e68 | |||
| 1890fc49d7 | |||
| 47a519bac1 | |||
| 083b376113 | |||
| 5b20a47ff6 | |||
| c3880cd5a7 | |||
| 91fd275564 | |||
| cf08bfc796 | |||
| 7eb9ee8d03 | |||
| ec8952be56 | |||
| 6a5020424b | |||
| 39711c1edd | |||
| 3c83f4d15b | |||
| 4c77d26a71 | |||
| 5de40e8679 | |||
| 32d70b3cd5 | |||
| 86cec56a3d | |||
| f6557a9814 | |||
| c146954a2f | |||
| 2df3d376ad | |||
| 74e1ae609c | |||
| e748bff0dd | |||
| b4ad533e6a | |||
| 0cd2ca4191 | |||
| cb8ba776e2 | |||
| c7328a33f6 | |||
| fc5800bc21 | |||
| 3c1be1fc10 | |||
| a2950442d8 | |||
| ee121bcfcb | |||
| 4234334fbb | |||
| 7f63cde7a5 | |||
| f965db148c | |||
| e6bb52ad9c | |||
| 6d067f3d3a | |||
| 506f9a868b | |||
| adb8981191 | |||
| a0b3e56896 | |||
| 229fff05a4 | |||
| 5b7045e6a5 | |||
| 7cc4364a63 | |||
| b4e73ff6b5 | |||
| 9031051465 | |||
| a15a9671d2 | |||
| ecd13fdc5a | |||
| 3fb862628c | |||
| 1f3133d4f3 | |||
| 64573aff8a | |||
| 655e9e8fb5 | |||
| bac2dc6b87 | |||
| 65a5aaa665 | |||
| c889d00e6b | |||
| 3753d51d85 | |||
| cb23b83b9d | |||
| a567fdc446 | |||
| 3d9a9a2226 | |||
| 9a4d659b04 | |||
| a5d8b91f34 | |||
| 5409c0ac6e | |||
| 3998b8927d | |||
| 7b9f64a630 | |||
| c5c0401ac4 | |||
| 96280e5932 | |||
| b2c01a141a | |||
| dc4ba4012c | |||
| d1e201ebb5 | |||
| a463cb491b | |||
| 6ffc314513 | |||
| f2632412aa | |||
| 062dc0c914 | |||
| fe9f12a14b | |||
| 3144888eb1 | |||
| 820eacde91 | |||
| 6886a3236f | |||
| bb541c551f | |||
| 3196bd7802 | |||
| ce0657cc6d | |||
| 44c2b1b5e3 |
@@ -11,13 +11,4 @@
|
||||
|
||||
# Ignore git and cache folders
|
||||
.git
|
||||
.gitignore
|
||||
.cache
|
||||
|
||||
# Ignore build folders
|
||||
node_modules
|
||||
dist
|
||||
|
||||
# Ignore default volumes created by running docker compose up
|
||||
ontime-db
|
||||
ontime-styles
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
"rules": {
|
||||
"no-useless-concat": "warn",
|
||||
"prefer-template": "warn",
|
||||
"no-throw-literal": "error",
|
||||
"no-console": [
|
||||
"warn",
|
||||
{
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
github: cpvalente
|
||||
buy_me_a_coffee: cpvalente
|
||||
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 336 KiB |
|
After Width: | Height: | Size: 660 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
@@ -1,53 +0,0 @@
|
||||
name: Ontime CLI build
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: [ "*" ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build_cli:
|
||||
permissions:
|
||||
id-token: write
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.18.2
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build project packages
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
run: pnpm build
|
||||
|
||||
- name: Copy server
|
||||
run: mkdir -p apps/cli/server && cp apps/server/dist/index.cjs apps/cli/server/index.cjs
|
||||
|
||||
- name: Copy client
|
||||
run: cp -R apps/client/build apps/cli/client
|
||||
|
||||
- name: Copy external
|
||||
run: cp -R apps/server/src/external apps/cli/external
|
||||
|
||||
- name: Publish to NPM
|
||||
run: pnpm publish --access public --no-git-checks
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NPM_CONFIG_PROVENANCE: true
|
||||
working-directory: ./apps/cli
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Docker Image CI Ontime
|
||||
name: Docker Image CI Ontime V2
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -13,10 +13,28 @@ jobs:
|
||||
CI: ''
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- 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
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- 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:
|
||||
@@ -0,0 +1,108 @@
|
||||
name: Ontime build v2
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: [ "*" ]
|
||||
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: 18.18.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- 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-x64.dmg
|
||||
./apps/electron/dist/ontime-macOS-arm64.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: 18.18.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- 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: 18.18.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- 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 }}
|
||||
@@ -1,8 +1,9 @@
|
||||
name: Ontime build
|
||||
name: Ontime build v3
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: [ "*" ]
|
||||
# Only trigger manually for now for testing
|
||||
# push:
|
||||
# tags: [ "*" ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -30,14 +31,7 @@ jobs:
|
||||
run: pnpm build
|
||||
|
||||
- name: Electron - Build app
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLEID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLEIDPASS }}
|
||||
APPLE_TEAM_ID: ${{ secrets.TEAMID }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
run: pnpm dist-mac
|
||||
timeout-minutes: 60
|
||||
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
@@ -0,0 +1,106 @@
|
||||
name: Ontime test v2
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: '*'
|
||||
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: 18.18.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Run code quality per package
|
||||
- name: React - Run linter + TypeScript checks
|
||||
if: always()
|
||||
run: pnpm lint && tsc --noEmit
|
||||
working-directory: ./apps/client
|
||||
|
||||
- name: Server - Run linter + TypeScript checks
|
||||
if: always()
|
||||
run: pnpm lint && tsc --noEmit
|
||||
working-directory: ./apps/server
|
||||
|
||||
- name: Utils - Run linter + TypeScript checks
|
||||
if: always()
|
||||
run: pnpm lint && tsc --noEmit
|
||||
working-directory: ./packages/utils
|
||||
|
||||
- name: Types - Run linter
|
||||
if: always()
|
||||
run: pnpm lint
|
||||
working-directory: ./packages/types
|
||||
|
||||
# We choose to run tests separately
|
||||
- name: React - Run unit tests
|
||||
if: always()
|
||||
run: pnpm test:pipeline
|
||||
working-directory: ./apps/client
|
||||
|
||||
- name: Server - Run unit tests
|
||||
if: always()
|
||||
run: pnpm test:pipeline
|
||||
working-directory: ./apps/server
|
||||
|
||||
- name: Utils - Run unit tests
|
||||
if: always()
|
||||
run: pnpm test:pipeline
|
||||
working-directory: ./packages/utils
|
||||
|
||||
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: 18.18.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- 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
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: automated-screenshots
|
||||
path: automated-screenshots/
|
||||
retention-days: 14
|
||||
@@ -1,8 +1,9 @@
|
||||
name: Ontime test
|
||||
name: Ontime test v3
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: '*'
|
||||
# Only trigger manually for now for testing
|
||||
# pull_request:
|
||||
# branches: '*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -45,6 +45,3 @@ apps/server/src/preloaded-db/db.json
|
||||
|
||||
# versioning file
|
||||
**/ONTIME_VERSION.js
|
||||
|
||||
# temporary write files
|
||||
**.tmp
|
||||
@@ -81,9 +81,6 @@ From the project root, run the following commands
|
||||
|
||||
The build distribution assets will be at `.apps/electron/dist`
|
||||
|
||||
Note: The MacOS build will only work in CI, locally it will fail due to notarisation issues.
|
||||
Use the `turbo dist-mac:local` command to build a MacOS distribution locally.
|
||||
|
||||
## DOCKER
|
||||
|
||||
Ontime provides a docker-compose file to aid with building and running docker images.
|
||||
@@ -91,6 +88,8 @@ While it should allow for a generic setup, it might need to be modified to fit y
|
||||
|
||||
From the project root, run the following commands
|
||||
|
||||
- __Install the project dependencies__ by running `pnpm i`
|
||||
- __Build packages__ by running `pnpm build:localdocker`
|
||||
- __Build docker image from__ by running `docker build -t getontime/ontime`
|
||||
- __Run docker image from compose__ by running `docker-compose up -d`
|
||||
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
FROM node:18.18-alpine AS builder
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
RUN pnpm --filter=ontime-ui --filter=ontime-server --filter=ontime-utils install --config.dedupe-peer-dependents=false --frozen-lockfile
|
||||
RUN pnpm --filter=ontime-ui --filter=ontime-server run build:docker
|
||||
|
||||
FROM node:18.18-alpine
|
||||
FROM node:16-alpine
|
||||
|
||||
# Set environment variables
|
||||
# Environment Variable to signal that we are running production
|
||||
@@ -18,12 +9,12 @@ ENV ONTIME_DATA=/external/
|
||||
WORKDIR /app/
|
||||
|
||||
# Prepare UI
|
||||
COPY --from=builder /app/apps/client/build ./client/
|
||||
COPY /apps/client/build ./client/
|
||||
|
||||
# Prepare Backend
|
||||
COPY --from=builder /app/apps/server/dist/ ./server/
|
||||
COPY ./demo-db/ ./preloaded-db/
|
||||
COPY --from=builder /app/apps/server/src/external/ ./external/
|
||||
COPY /apps/server/dist/ ./server/
|
||||
COPY /demo-db/ ./preloaded-db/
|
||||
COPY /apps/server/src/external/ ./external/
|
||||
|
||||
# Export default ports
|
||||
EXPOSE 4001/tcp 8888/udp 9999/udp
|
||||
|
||||
@@ -1,178 +1,205 @@
|
||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||

|
||||

|
||||
[](https://github.com/sponsors/cpvalente)
|
||||
[](https://www.buymeacoffee.com/cpvalente)
|
||||
[](https://github.com/cpvalente/ontime/actions/workflows/build_v2.yml)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||
|
||||
## Download the latest release
|
||||
<a href="https://www.buymeacoffee.com/cpvalente" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="32"></a>
|
||||
## Download the latest releases here
|
||||
|
||||
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-win64.exe">Windows</a>
|
||||
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg">MacOS Arm</a>
|
||||
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-x64.dmg">MacOS Intel</a>
|
||||
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage">Linux</a>
|
||||
- Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a>
|
||||
|
||||
## Using Ontime?
|
||||
|
||||
Let us know!
|
||||
Ontime improves from the collaboration with its users. We would like to understand how you use Ontime and appreciate your feedback.
|
||||
|
||||
We would also like to include a testimonials section in our ✨new website✨. It would be great to showcase the diversity of users running Ontime.
|
||||
<div style="display: flex; justify-content: space-around">
|
||||
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg"><img alt="Download MacOS" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/mac-download.png"/></a>
|
||||
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-win64.exe"><img alt="Download Windows" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/win-download.png"/></a>
|
||||
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage"><img alt="Download Linux" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/linux-download.png"/></a>
|
||||
<a href="https://hub.docker.com/r/getontime/ontime"><img alt="Get from Dockerhub" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/dockerhub.png"/></a>
|
||||
</div>
|
||||
|
||||
# Ontime
|
||||
|
||||
Ontime is a browser-based application that manages event rundowns, scheduling, and cueing.
|
||||
Ontime is an application for creating and managing event running order and timers.
|
||||
|
||||
With Ontime, you can plan, track your schedule, manage automation and cross-department show information all in one place.
|
||||
The user inputs a list of events along with scheduling and event information.
|
||||
This will then populate a series of screens which are available to be rendered by any device in the Network.
|
||||
|
||||
Ontime is made by entertainment and broadcast engineers and used by
|
||||
This makes for a simple and cheap way to distribute over a venue using a network infrastructure instead of video outputs.
|
||||
|
||||
- Conference organisers
|
||||
- Touring shows and receiving venues
|
||||
- Broadcasters and streamers
|
||||
- Theatres and opera houses
|
||||
- Houses of worship
|
||||

|
||||
|
||||
### For live environments
|
||||
|
||||
Ontime is designed for use in live environments. \
|
||||
This guides the application into being flexible and efficiently integrating into different workflows.
|
||||
|
||||
### For teams
|
||||
|
||||
All information added in Ontime is shared with the production team and other software / hardware in your workflow. \
|
||||
Ontime also improves team collaboration with dedicated views for cuesheets and operators, and for public and production
|
||||
signage.
|
||||
|
||||
### Simple infrastructure
|
||||
|
||||
All the data is distributed over the network, making its distribution and infrastructure flexible and cheap. \
|
||||
With the availability of the docker image, you can also leverage IT infrastructure to make Ontime available online for
|
||||
your team and clients.
|
||||
|
||||
Ontime is made by video engineers and entertainment technicians.
|
||||
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
[Read the docs to learn more](https://docs.getontime.no)
|
||||
|
||||
## Using Ontime
|
||||
|
||||
Ontime can be started by downloading the latest release for your platform. \
|
||||
Alternatively you can also use the docker image, available at [Docker Hub](https://hub.docker.com/r/getontime/ontime)
|
||||
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, any device in the network has access to Ontime.
|
||||
Any device with a browser in the same network can choose one of the supported views to render the available data.
|
||||
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 menu in the top left corner to select the desired view.
|
||||
The menu will be initially hidden until there is mouse interaction.
|
||||
|
||||
Ontime provides different screens which allow for different types of interactions with the data. These are called
|
||||
views. \
|
||||
Each view in Ontime focuses on empowering a specific role or achieving a particular task.
|
||||
|
||||
You can access the different views by reaching the ontime server, in your browser, at (_default port
|
||||
4001_) `http://localhost:4001` or `http://192.168.1.3:4001`
|
||||
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 presets feature
|
||||
|
||||
```
|
||||
For the backstage 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/backstage > Stage Manager / Backstage view
|
||||
IP.ADDRESS:4001/countdown > Countdown to anything
|
||||
IP.ADDRESS:4001/sm > Stage Manager / Backstage view
|
||||
IP.ADDRESS:4001/public > Public / Foyer view
|
||||
IP.ADDRESS:4001/lower > Lower Thirds
|
||||
IP.ADDRESS:4001/studio > Studio Clock
|
||||
```
|
||||
|
||||
```
|
||||
For the public views
|
||||
-------------------------------------------------------------
|
||||
IP.ADDRESS:4001/public > Public / Foyer view
|
||||
IP.ADDRESS:4001/lower > Lower Thirds
|
||||
```
|
||||
|
||||
```
|
||||
For production views
|
||||
For management views
|
||||
-------------------------------------------------------------
|
||||
IP.ADDRESS:4001/editor > the control interface, same as the app
|
||||
IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
|
||||
IP.ADDRESS:4001/operator > automated views for operators
|
||||
```
|
||||
|
||||
More documentation is available [in our docs](https://docs.getontime.no)
|
||||
|
||||
## Main features
|
||||
## Feature List (in no specific order)
|
||||
|
||||
- [x] Distribute data over network and render it in the browser
|
||||
- [x] Collaborative
|
||||
- [x] Extendable
|
||||
- [x] Send messages to different screen types
|
||||
- [x] Different screen types
|
||||
- Stage Timer
|
||||
- Minimal Timer
|
||||
- Clock
|
||||
- Backstage Info
|
||||
- Public Info
|
||||
- Studio Clock
|
||||
- Countdown
|
||||
- [Make your own?](#make-your-own-viewer)
|
||||
- [x] Configurable Lower Thirds
|
||||
- [x] Collaborative editing with the cuesheet view
|
||||
- [x] Send live messages to different screen types
|
||||
- [x] Differentiate between backstage and public data
|
||||
- [x] Workflow for managing delays
|
||||
- [x] Rich protocol integrations for Control and Feedback
|
||||
- [x] For servers: use docker to run Ontime in in a server, configure from a browser anywhere
|
||||
- OSC (Open Sound Control)
|
||||
- HTTP
|
||||
- WebSockets
|
||||
- [x] Roll mode: run standalone 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://docs.getontime.no/features/count-to-anything/): have
|
||||
a countdown to any scheduled event
|
||||
- [x] Multi-platform (available on Windows, MacOS and Linux)
|
||||
- [x] Companion integration [follow link](https://bitfocus.io/connections/getontime-ontime)
|
||||
- [x] [Companion integration](https://bitfocus.io/connections/getontime-ontime)
|
||||
|
||||
## Unopinionated
|
||||
|
||||
We want Ontime to be unique by targeting freelancers instead of roles.
|
||||
|
||||
We believe most freelancers work in different fields and we want to give you a tool that you can leverage across your
|
||||
many environments and workflows.
|
||||
|
||||
We are not interested in forcing workflows and have made Ontime so, it is flexible to whichever way you would like to
|
||||
work.
|
||||
|
||||
## Rich APIs for workflow integrations
|
||||
|
||||
The app is currently being developed for a broad user base, from broadcast to entertainment and
|
||||
conference halls.
|
||||
|
||||
Taking advantage of the integrations, we currently use Ontime with:
|
||||
|
||||
- `disguise`: trigger Ontime from d3's timeline using the **OSC API**, and **render views** using d3's
|
||||
webmodule
|
||||
- `OBS`: **render views** using the Browser Module
|
||||
- `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)
|
||||
|
||||
### Make your own viewer
|
||||
|
||||
Ontime broadcasts its data over WebSockets. This allows you to consume its data outside the application.
|
||||
|
||||
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 />
|
||||
We have prepared a few resources to help here:
|
||||
- Shipped with Ontime there is a small clock to get you started, it is available at `http://localhost:4001/external/demo` and the [code can be found here](https://github.com/cpvalente/ontime/tree/master/apps/server/src/external/demo)
|
||||
- See [this repository](https://github.com/cpvalente/ontime-viewer-template-v2) with a template on
|
||||
how to get you started
|
||||
- See information about the [Websocket API](https://docs.getontime.no/api/osc-and-ws/)
|
||||
<br />
|
||||
More information [in the docs](https://docs.getontime.no/features/custom-views/)
|
||||
|
||||
|
||||
### Headless run️
|
||||
|
||||
You can self-host and run Ontime in a docker image.
|
||||
|
||||
The docker image along with documentation is [available Docker Hub at getontime/ontime](https://hub.docker.com/r/getontime/ontime)
|
||||
|
||||
If you want to run this image in a Raspberry Pi, please see [the docs](https://docs.getontime.no/additional-notes/use-with-rpi/)
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Continued development
|
||||
|
||||
Ontime is under active development. We continue adding and tweaking features in collaboration with users.
|
||||
|
||||
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)
|
||||
|
||||
### Issues
|
||||
|
||||
We use Github's issue tracking for bug reporting and feature requests. \
|
||||
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).
|
||||
|
||||
#### Unsigned App
|
||||
|
||||
When installing the app you would see warning screens from the Operating System like:
|
||||
|
||||
in Windows
|
||||
|
||||
`Microsoft Defender SmartScreen prevented an unrecognised app from starting. Running this app might put your PC at risk.`
|
||||
|
||||
or
|
||||
|
||||
`Ontime can't be opened because it is from an unidentified developer`
|
||||
|
||||
or in Linux
|
||||
|
||||
`Could Not Display "ontime-linux.AppImage`
|
||||
|
||||
We currently only sign MacOS releases. \
|
||||
Purchasing the certificates for both Mac and Windows would mean a recurrent expense which we are not able to cover.
|
||||
|
||||
You can circumvent this by allowing the execution of the app manually.
|
||||
|
||||
- In Windows: click `more` -> `Run Anyway`
|
||||
- In Linux: right-click the AppImage file: `Properties` -> `Permissions` -> `Allow Executing File as a Program`
|
||||
- In Windows: click more and select "Run Anyway"
|
||||
- in macOS: the solution in macOS is different across versions, please refer to the [apple documentation](https://support.apple.com/en-gb/guide/mac-help/mh40616/mac)
|
||||
- In Linux: right-click the AppImage file -> Properties -> Permissions -> select Allow Executing
|
||||
File as a Program
|
||||
|
||||
If you have tips on how to improve this or would like to sponsor the code signing,
|
||||
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)
|
||||
|
||||
## Contributing
|
||||
#### Safari
|
||||
|
||||
There are known 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.
|
||||
|
||||
If you are a developer and would like to contribute with code, please open an issue to discuss before opening a Pull Request.
|
||||
|
||||
<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)
|
||||
|
||||
## Links
|
||||
# Help
|
||||
|
||||
See the [Ontime website](https://getontime.no) here and the link to the [documentation](https://docs.getontime.no)
|
||||
Help is underway! ... and can be found [here](https://docs.getontime.no)
|
||||
|
||||
## License
|
||||
# License
|
||||
|
||||
This project is licensed under the terms of the GNU GPL v3
|
||||
|
||||
## Sponsor
|
||||
|
||||
You can help the development of this project or say thank you with a one time donation. \
|
||||
See [the terms of donations](https://github.com/cpvalente/ontime/blob/master/.github/FUNDING.md).
|
||||
|
||||
<p align="center">
|
||||
<br>
|
||||
<a href="https://www.buymeacoffee.com/cpvalente" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" width="200"></a>
|
||||
</p>
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
# Terms of sponsorship
|
||||
Ontime does not exist as a fiscal entity. The sponsorship recipient is the project owner. In this light, contributors are funding the project owner's capacity to work on the project, not the project itself. As a contribution recipient, the code owner reserves the right to redistribute.
|
||||
|
||||
## Why sponsor
|
||||
Ontime is and will remain free, regardless of your decision to contribute.
|
||||
Regardless, you may choose to provide a monetary contribution:
|
||||
|
||||
### Improve user experience
|
||||
Certain parts of the application user experience are tied to our capacity to pay for services. App signing and Google Sheets integration are good examples of this.
|
||||
|
||||
### Cool stuff
|
||||
We may choose not to implement some features. This could be related to the complexity required, to the skill set, or to the fact that we just feel it doesn't improve the app for a significant percentage of users. In those cases, a feature may be considered "not worth it," regardless of its pertinence.
|
||||
|
||||
As a user, you can choose to sponsor a particular feature or donate to the app so that we can create bounties for features.
|
||||
|
||||
### Priorities
|
||||
Monetary contributions are most immediately needed to offset the costs of presenting and distributing Ontime, i.e., hosting, domains, and certificates.
|
||||
|
||||
Additional funding will be considered according to the needs at the time.
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"parserOptions": {
|
||||
"sourceType": "module"
|
||||
},
|
||||
"env": {
|
||||
"browser": true,
|
||||
"node": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended"
|
||||
],
|
||||
"plugins": [],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-var-requires": "off"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
server
|
||||
external
|
||||
client
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"endOfLine": "lf",
|
||||
"trailingComma": "all",
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true,
|
||||
"printWidth": 120
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
# Ontime CLI
|
||||
|
||||

|
||||
> Ontime is an application for managing rundown and event timers.
|
||||
|
||||
Congratulations! You got this far into Ontime's rabbit hole and want to manage your installation.
|
||||
|
||||
The CLI tool is a fast and lightweight way of installing Ontime and is perfect for self-hosting and headless installs.
|
||||
|
||||
## Getting started
|
||||
|
||||
### Prerequisites
|
||||
You will need to have installed the correct version of node.js
|
||||
Please check [the app engines declaration](https://github.com/cpvalente/ontime/blob/master/package.json) for the correct version.
|
||||
|
||||
### Running Ontime
|
||||
To get Ontime running, all you need to do is install it in your system using your package manager of choice:
|
||||
|
||||
Install globally in your system
|
||||
```bash
|
||||
npm install -g @getontime/cli
|
||||
```
|
||||
|
||||
... and run using the installed script
|
||||
```bash
|
||||
ontime
|
||||
```
|
||||
|
||||
Or install and run ontime (the installation here is temporary for the duration of the session)
|
||||
```bash
|
||||
npx install @getontime/cli
|
||||
```
|
||||
|
||||
## Links
|
||||
- [Ontime's repository](https://github.com/cpvalente/ontime)
|
||||
- [Ontime's documentation](https://docs.getontime.no/)
|
||||
- [Ontime's website](https://getontime.no/)
|
||||
|
||||
## Sponsoring
|
||||
You can help the development of this project or say thank you with a one time donation. \
|
||||
See the [terms of donations](https://github.com/cpvalente/ontime/blob/master/SPONSOR.md)
|
||||
|
||||
[](https://github.com/sponsors/cpvalente)
|
||||
[](https://www.buymeacoffee.com/cpvalente)
|
||||
@@ -1,20 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// NOTE: for now the following needs to be in place: ./server/index.cjs, ./client, ./external
|
||||
|
||||
const ontimeServer = require('./server/index.cjs');
|
||||
const { initAssets, startServer, startIntegrations, shutdown } = ontimeServer;
|
||||
|
||||
async function startOntime() {
|
||||
await initAssets();
|
||||
|
||||
await startServer();
|
||||
|
||||
await startIntegrations();
|
||||
}
|
||||
|
||||
startOntime();
|
||||
|
||||
process.on(['SIGHUP', 'SIGINT', 'SIGTERM'], () => {
|
||||
shutdown();
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "3.3.3",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
"keywords": [
|
||||
"lighdev",
|
||||
"ontime",
|
||||
"timer"
|
||||
],
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "main.js",
|
||||
"bin": {
|
||||
"ontime": "main.js"
|
||||
},
|
||||
"files": [
|
||||
"client",
|
||||
"external",
|
||||
"server"
|
||||
],
|
||||
"devDependencies": {
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"prettier": "^3.0.3"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "3.3.3",
|
||||
"version": "3.0.0-alpha",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
@@ -10,8 +10,6 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/react": "^11.10.6",
|
||||
"@emotion/styled": "^11.10.6",
|
||||
"@fontsource/open-sans": "^5.0.28",
|
||||
"@mantine/hooks": "^7.6.2",
|
||||
"@react-icons/all-files": "^4.1.0",
|
||||
"@sentry/react": "^7.92.0",
|
||||
"@tanstack/react-query": "^5.17.9",
|
||||
@@ -29,8 +27,9 @@
|
||||
"react-hook-form": "^7.49.2",
|
||||
"react-qr-code": "^2.0.12",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"typeface-open-sans": "^1.1.13",
|
||||
"web-vitals": "^3.1.1",
|
||||
"zustand": "^4.5.2"
|
||||
"zustand": "^4.5.0"
|
||||
},
|
||||
"scripts": {
|
||||
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
|
||||
@@ -61,7 +60,7 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sentry/vite-plugin": "^2.16.1",
|
||||
"@sentry/vite-plugin": "^2.14.0",
|
||||
"@tanstack/eslint-plugin-query": "^5.8.4",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^13.1.1",
|
||||
@@ -70,13 +69,13 @@
|
||||
"@types/react": "^18.0.26",
|
||||
"@types/react-dom": "^18.0.10",
|
||||
"@types/testing-library__jest-dom": "^5.14.5",
|
||||
"@typescript-eslint/eslint-plugin": "^v7.12.0",
|
||||
"@typescript-eslint/parser": "^7.12.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||
"@typescript-eslint/parser": "^6.10.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-jest": "^28.6.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-jest": "^27.6.0",
|
||||
"eslint-plugin-prettier": "^5.0.1",
|
||||
"eslint-plugin-react": "^7.32.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-simple-import-sort": "^8.0.0",
|
||||
@@ -84,13 +83,13 @@
|
||||
"jsdom": "^21.1.0",
|
||||
"ontime-types": "workspace:*",
|
||||
"ontime-utils": "workspace:*",
|
||||
"prettier": "^3.3.1",
|
||||
"prettier": "^3.0.3",
|
||||
"sass": "^1.57.1",
|
||||
"typescript": "^5.4.3",
|
||||
"vite": "^5.2.11",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.1.0",
|
||||
"vite-plugin-compression2": "^0.12.0",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
"vite-tsconfig-paths": "^4.3.1",
|
||||
"vitest": "^1.6.0"
|
||||
"vitest": "^1.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,68 @@
|
||||
import { 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 { ContextMenu } from './common/components/context-menu/ContextMenu';
|
||||
import ErrorBoundary from './common/components/error-boundary/ErrorBoundary';
|
||||
import IdentifyOverlay from './common/components/identify-overlay/IdentifyOverlay';
|
||||
import { AppContextProvider } from './common/context/AppContext';
|
||||
import useElectronEvent from './common/hooks/useElectronEvent';
|
||||
import { ontimeQueryClient } from './common/queryClient';
|
||||
import { socketClientName } from './common/stores/connectionName';
|
||||
import { connectSocket } from './common/utils/socket';
|
||||
import theme from './theme/theme';
|
||||
import { TranslationProvider } from './translation/TranslationProvider';
|
||||
import AppRouter from './AppRouter';
|
||||
|
||||
connectSocket();
|
||||
// Load Open Sans typeface
|
||||
// @ts-expect-error no types from font import
|
||||
import('typeface-open-sans');
|
||||
|
||||
const preferredClientName = socketClientName.getState().name;
|
||||
connectSocket(preferredClientName);
|
||||
|
||||
function App() {
|
||||
const { isElectron, sendToElectron } = useElectronEvent();
|
||||
|
||||
useEffect(() => {
|
||||
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');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (isElectron) {
|
||||
document.addEventListener('keydown', handleKeyPress);
|
||||
}
|
||||
return () => {
|
||||
if (isElectron) {
|
||||
document.removeEventListener('keydown', handleKeyPress);
|
||||
}
|
||||
};
|
||||
}, [isElectron, sendToElectron]);
|
||||
|
||||
return (
|
||||
<ChakraProvider disableGlobalStyle resetCSS theme={theme}>
|
||||
<ChakraProvider resetCSS theme={theme}>
|
||||
<QueryClientProvider client={ontimeQueryClient}>
|
||||
<AppContextProvider>
|
||||
<BrowserRouter>
|
||||
<div className='App'>
|
||||
<ErrorBoundary>
|
||||
<TranslationProvider>
|
||||
<IdentifyOverlay />
|
||||
<AppRouter />
|
||||
<ContextMenu>
|
||||
<AppRouter />
|
||||
</ContextMenu>
|
||||
</TranslationProvider>
|
||||
</ErrorBoundary>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</div>
|
||||
<ErrorBoundary>
|
||||
<div id='identify-portal' />
|
||||
</ErrorBoundary>
|
||||
</BrowserRouter>
|
||||
</AppContextProvider>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import { useClientPath } from './common/hooks/useClientPath';
|
||||
import Log from './features/log/Log';
|
||||
import withPreset from './features/PresetWrapper';
|
||||
import withData from './features/viewers/ViewWrapper';
|
||||
|
||||
const Editor = lazy(() => import('./features/editors/ProtectedEditor'));
|
||||
const Cuesheet = lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
|
||||
const Operator = lazy(() => import('./features/operator/OperatorExport'));
|
||||
const Operator = lazy(() => import('./features/operator/Operator'));
|
||||
|
||||
const TimerView = lazy(() => import('./features/viewers/timer/Timer'));
|
||||
const MinimalTimerView = lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
|
||||
@@ -35,9 +34,6 @@ const TimerControl = lazy(() => import('./features/control/playback/TimerControl
|
||||
const MessageControl = lazy(() => import('./features/control/message/MessageControlExport'));
|
||||
|
||||
export default function AppRouter() {
|
||||
// handle client path changes
|
||||
useClientPath();
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<Routes>
|
||||
@@ -53,12 +49,12 @@ export default function AppRouter() {
|
||||
<Route path='/backstage' element={<SBackstage />} />
|
||||
|
||||
<Route path='/public' element={<SPublic />} />
|
||||
|
||||
<Route path='/studio' element={<SStudio />} />
|
||||
|
||||
{/*/!* Lower cannot have fallback *!/*/}
|
||||
<Route path='/lower' element={<SLowerThird />} />
|
||||
|
||||
<Route path='/op' element={<Operator />} />
|
||||
<Route path='/operator' element={<Operator />} />
|
||||
|
||||
{/*/!* Protected Routes *!/*/}
|
||||
<Route path='/editor' element={<Editor />} />
|
||||
|
||||
@@ -11,7 +11,6 @@ export const RUNTIME = ['runtimeStore'];
|
||||
export const SHEET_STATE = ['sheetState'];
|
||||
export const URL_PRESETS = ['urlpresets'];
|
||||
export const VIEW_SETTINGS = ['viewSettings'];
|
||||
export const CLIENT_LIST = ['clientList'];
|
||||
|
||||
// resolve location
|
||||
const location = window.location;
|
||||
|
||||
@@ -1,54 +1,36 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { DatabaseModel, GetInfo, MessageResponse, ProjectData, ProjectFileListResponse } from 'ontime-types';
|
||||
|
||||
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
|
||||
import {
|
||||
CustomFields,
|
||||
DatabaseModel,
|
||||
GetInfo,
|
||||
MessageResponse,
|
||||
OntimeRundown,
|
||||
ProjectData,
|
||||
ProjectFileListResponse,
|
||||
} from 'ontime-types';
|
||||
import { ImportMap } from 'ontime-utils';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
import { createBlob, downloadBlob } from './utils';
|
||||
import fileDownload from './utils';
|
||||
|
||||
const dbPath = `${apiEntryUrl}/db`;
|
||||
|
||||
/**
|
||||
* HTTP request to the current DB
|
||||
* HTTP request to download db in JSON format
|
||||
*/
|
||||
async function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> {
|
||||
return axios.post(`${dbPath}/download/`, { filename });
|
||||
export async function downloadRundown(fileName?: string) {
|
||||
return fileDownload(
|
||||
dbPath,
|
||||
{ name: fileName ?? 'rundown', type: 'json' },
|
||||
{ type: 'application/json;charset=utf-8;' },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request download of the current project file
|
||||
* @param fileName
|
||||
* HTTP request to download db in CSV format
|
||||
*/
|
||||
export async function downloadProject(fileName: string) {
|
||||
try {
|
||||
const { data, name } = await fileDownload(fileName);
|
||||
|
||||
const fileContent = JSON.stringify(data, null, 2);
|
||||
|
||||
const blob = createBlob(fileContent, 'application/json;charset=utf-8;');
|
||||
downloadBlob(blob, `${name}.json`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request download of the current rundown as a CSV file
|
||||
* @param fileName
|
||||
*/
|
||||
export async function downloadCSV(fileName: string = 'rundown') {
|
||||
try {
|
||||
const { data, name } = await fileDownload(fileName);
|
||||
const { project, rundown, customFields } = data;
|
||||
|
||||
const sheetData = makeTable(project, rundown, customFields);
|
||||
const fileContent = makeCSV(sheetData);
|
||||
|
||||
const blob = createBlob(fileContent, 'text/csv;charset=utf-8;');
|
||||
downloadBlob(blob, `${name}.csv`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
export async function downloadCSV(fileName?: string) {
|
||||
return fileDownload(dbPath, { name: fileName ?? 'rundown', type: 'csv' }, { type: 'text/csv;charset=utf-8;' });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,7 +105,7 @@ export async function renameProject(filename: string, newFilename: string): Prom
|
||||
const url = `${dbPath}/${filename}/rename`;
|
||||
const decodedUrl = decodeURIComponent(url);
|
||||
const res = await axios.put(decodedUrl, {
|
||||
filename: newFilename,
|
||||
newFilename,
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
@@ -146,23 +128,28 @@ export async function getInfo(): Promise<GetInfo> {
|
||||
return res.data;
|
||||
}
|
||||
|
||||
type PreviewSpreadsheetResponse = {
|
||||
rundown: OntimeRundown;
|
||||
customFields: CustomFields;
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility function gets project from db
|
||||
* @param fileName
|
||||
* @returns
|
||||
* Make patch changes to the objects in the db
|
||||
*/
|
||||
async function fileDownload(fileName: string): Promise<{ data: DatabaseModel; name: string }> {
|
||||
const response = await getDb(fileName);
|
||||
export async function importSpreadsheetPreview(file: File, options: ImportMap): Promise<PreviewSpreadsheetResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append('spreadsheet', file);
|
||||
formData.append('options', JSON.stringify(options));
|
||||
|
||||
const headerLine = response.headers['Content-Disposition'];
|
||||
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(
|
||||
`${dbPath}/spreadsheet/preview`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// try and get the filename from the response
|
||||
let name = fileName;
|
||||
if (headerLine != null) {
|
||||
const startFileNameIndex = headerLine.indexOf('"') + 1;
|
||||
const endFileNameIndex = headerLine.lastIndexOf('"');
|
||||
name = headerLine.substring(startFileNameIndex, endFileNameIndex);
|
||||
}
|
||||
|
||||
return { data: response.data, name };
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { CustomFields, OntimeRundown } from 'ontime-types';
|
||||
import { ImportMap } from 'ontime-utils';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
const excelPath = `${apiEntryUrl}/excel`;
|
||||
|
||||
type PreviewSpreadsheetResponse = {
|
||||
rundown: OntimeRundown;
|
||||
customFields: CustomFields;
|
||||
};
|
||||
|
||||
/**
|
||||
* upload Excel file to server
|
||||
* @return string - file ID op the uploaded file
|
||||
*/
|
||||
export async function upload(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append('excel', file);
|
||||
await axios.post(`${excelPath}/upload`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Worksheet names
|
||||
* @return string[] - array of available worksheets
|
||||
*/
|
||||
export async function getWorksheetNames(): Promise<string[]> {
|
||||
const response: AxiosResponse<string[]> = await axios.get(`${excelPath}/worksheets`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> {
|
||||
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, {
|
||||
options,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
@@ -74,8 +74,8 @@ export async function requestApplyDelay(eventId: string): Promise<AxiosResponse<
|
||||
/**
|
||||
* HTTP request to delete given event
|
||||
*/
|
||||
export async function requestDelete(eventIds: string[]): Promise<AxiosResponse<MessageResponse>> {
|
||||
return axios.delete(rundownPath, { data: { ids: eventIds } });
|
||||
export async function requestDelete(eventId: string): Promise<AxiosResponse<MessageResponse>> {
|
||||
return axios.delete(`${rundownPath}/${eventId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import axios from 'axios';
|
||||
import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types';
|
||||
import { ImportMap } from 'ontime-utils';
|
||||
|
||||
@@ -9,10 +9,7 @@ const sheetsPath = `${apiEntryUrl}/sheets`;
|
||||
/**
|
||||
* HTTP request to verify whether we are authenticated with Google Sheet service
|
||||
*/
|
||||
export const verifyAuthenticationStatus = async (): Promise<{
|
||||
authenticated: AuthenticationStatus;
|
||||
sheetId: string;
|
||||
}> => {
|
||||
export const verifyAuthenticationStatus = async (): Promise<{ authenticated: AuthenticationStatus }> => {
|
||||
const response = await axios.get(`${sheetsPath}/connect`);
|
||||
return response.data;
|
||||
};
|
||||
@@ -61,11 +58,6 @@ export const previewRundown = async (
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getWorksheetNames = async (sheetId: string): Promise<string[]> => {
|
||||
const response: AxiosResponse<string[]> = await axios.post(`${sheetsPath}/${sheetId}/worksheets`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* HTTP request to upload the rundown to a google sheet
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,7 @@ import axios, { AxiosError } from 'axios';
|
||||
import { LogLevel } from 'ontime-types';
|
||||
import { generateId, millisToString } from 'ontime-utils';
|
||||
|
||||
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
|
||||
import { ontimeQueryClient } from '../queryClient';
|
||||
import { addLog } from '../stores/logger';
|
||||
import { nowInMillis } from '../utils/time';
|
||||
@@ -55,29 +56,63 @@ export async function invalidateAllCaches() {
|
||||
await ontimeQueryClient.invalidateQueries();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates blob from content
|
||||
* @param fileContent
|
||||
* @param type
|
||||
* @returns
|
||||
*/
|
||||
export function createBlob(fileContent: string, type: string): Blob {
|
||||
return new Blob([fileContent], { type });
|
||||
}
|
||||
type FileOptions = {
|
||||
name: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
type BlobOptions = {
|
||||
type: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* downloads a blob
|
||||
* @param downloadUrl
|
||||
* @param fileName
|
||||
* Gets DB from backend and prepares a file to be downloaded
|
||||
* @param url
|
||||
* @param fileOptions
|
||||
* @param blobOptions
|
||||
* @returns
|
||||
*/
|
||||
export function downloadBlob(blob: Blob, fileName: string) {
|
||||
export default async function fileDownload(url: string, fileOptions: FileOptions, blobOptions: BlobOptions) {
|
||||
const response = await axios({
|
||||
url: `${url}/db`,
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
const headerLine = response.headers['Content-Disposition'];
|
||||
let { name: fileName } = fileOptions;
|
||||
const { type: fileType } = fileOptions;
|
||||
const { project, rundown, customFields } = response.data;
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
let fileContent = '';
|
||||
|
||||
if (fileType === 'json') {
|
||||
fileContent = JSON.stringify(response.data);
|
||||
fileName += '.json';
|
||||
}
|
||||
|
||||
if (fileType === 'csv') {
|
||||
const sheetData = makeTable(project, rundown, customFields);
|
||||
fileContent = makeCSV(sheetData);
|
||||
fileName += '.csv';
|
||||
}
|
||||
|
||||
const blob = new Blob([fileContent], { type: blobOptions.type });
|
||||
const downloadUrl = URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('href', downloadUrl);
|
||||
link.setAttribute('download', fileName);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
// Clean up the URL.createObjectURL to release resources
|
||||
URL.revokeObjectURL(downloadUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogBody,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogOverlay,
|
||||
Button,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
} from '@chakra-ui/react';
|
||||
import { IoPowerOutline } from '@react-icons/all-files/io5/IoPowerOutline';
|
||||
|
||||
import { useEmitLog } from '../../stores/logger';
|
||||
|
||||
interface QuitIconBtnProps {
|
||||
clickHandler: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const quitBtnStyle = {
|
||||
color: '#D20300', // $red-700
|
||||
borderColor: '#D20300', // $red-700
|
||||
_focus: { boxShadow: 'none' },
|
||||
_hover: {
|
||||
background: '#D20300', // $red-700
|
||||
color: 'white',
|
||||
_disabled: {
|
||||
color: '#D20300', // $red-700
|
||||
background: 'none',
|
||||
},
|
||||
},
|
||||
_active: {
|
||||
background: '#9A0000', // $red-1000
|
||||
color: 'white',
|
||||
},
|
||||
variant: 'outline',
|
||||
isRound: true,
|
||||
};
|
||||
|
||||
export default function QuitIconBtn(props: QuitIconBtnProps) {
|
||||
const { clickHandler, disabled, ...rest } = props;
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { emitInfo } = useEmitLog();
|
||||
const onClose = () => setIsOpen(false);
|
||||
const cancelRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (window.process?.type === 'renderer') {
|
||||
window.ipcRenderer.on('user-request-shutdown', () => {
|
||||
emitInfo('Shutdown request');
|
||||
setIsOpen(true);
|
||||
});
|
||||
}
|
||||
}, [emitInfo]);
|
||||
|
||||
const handleShutdown = useCallback(() => {
|
||||
onClose();
|
||||
clickHandler();
|
||||
}, [clickHandler]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip label='Quit Application'>
|
||||
<IconButton
|
||||
aria-label='Quit Application'
|
||||
size='lg'
|
||||
icon={<IoPowerOutline />}
|
||||
onClick={() => setIsOpen(true)}
|
||||
isDisabled={disabled}
|
||||
{...quitBtnStyle}
|
||||
{...rest}
|
||||
/>
|
||||
</Tooltip>
|
||||
<AlertDialog isOpen={isOpen} leastDestructiveRef={cancelRef} onClose={onClose}>
|
||||
<AlertDialogOverlay>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader fontSize='lg' fontWeight='bold'>
|
||||
Ontime Shutdown
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogBody>This will shutdown the Ontime server. Are you sure?</AlertDialogBody>
|
||||
<AlertDialogFooter>
|
||||
<Button ref={cancelRef} onClick={onClose} variant='ghost'>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button colorScheme='red' onClick={handleShutdown} ml={3}>
|
||||
Shutdown
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogOverlay>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
.buttonSection {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: $element-spacing;
|
||||
margin-top: -$element-spacing;
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftAddon,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { setClientRemote } from '../../hooks/useSocket';
|
||||
|
||||
import style from './ClientModal.module.scss';
|
||||
|
||||
interface RedirectClientModalProps {
|
||||
id: string;
|
||||
name?: string;
|
||||
path?: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function RedirectClientModal(props: RedirectClientModalProps) {
|
||||
const { id, isOpen, name = '', path: currentPath = '', onClose } = props;
|
||||
const [path, setPath] = useState(currentPath);
|
||||
|
||||
const { setRedirect } = setClientRemote;
|
||||
|
||||
const handleRedirect = () => {
|
||||
if (path !== currentPath && path !== '') {
|
||||
setRedirect({ target: id, redirect: path });
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const host = `${window.location.origin}/`;
|
||||
const canSubmit = path !== currentPath && path !== '';
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} variant='ontime'>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Redirect: {name}</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<InputGroup variant='ontime-filled' size='md'>
|
||||
<InputLeftAddon>{host}</InputLeftAddon>
|
||||
<Input placeholder='minimal?key=0000ffff' value={path} onChange={(event) => setPath(event.target.value)} />
|
||||
</InputGroup>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<div className={style.buttonSection}>
|
||||
<Button size='md' variant='ontime-subtle' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size='md' variant='ontime-filled' onClick={handleRedirect} isDisabled={!canSubmit}>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { setClientRemote } from '../../hooks/useSocket';
|
||||
|
||||
import style from './ClientModal.module.scss';
|
||||
|
||||
interface RenameClientModalProps {
|
||||
id: string;
|
||||
name?: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function RenameClientModal(props: RenameClientModalProps) {
|
||||
const { id, name: currentName = '', isOpen, onClose } = props;
|
||||
const [name, setName] = useState(currentName);
|
||||
|
||||
const { setClientName } = setClientRemote;
|
||||
|
||||
const handleRename = () => {
|
||||
if (name !== currentName && name !== '') {
|
||||
setClientName({ target: id, rename: name });
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const canSubmit = name !== currentName && name !== '';
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} variant='ontime'>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Rename: {currentName}</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<Input
|
||||
variant='ontime-filled'
|
||||
size='md'
|
||||
placeholder='new name'
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<div className={style.buttonSection}>
|
||||
<Button size='md' variant='ontime-subtle' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size='md' variant='ontime-filled' onClick={handleRename} isDisabled={!canSubmit}>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -75,7 +75,6 @@ export const ContextMenu = ({ children }: ContextMenuProps) => {
|
||||
w={1}
|
||||
h={1}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: coords.x,
|
||||
top: coords.y,
|
||||
}}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { OptionWithoutGroup } from './ContextMenu';
|
||||
export const ContextMenuOption = ({ label, onClick, isDisabled, icon: Icon, withDivider }: OptionWithoutGroup) => (
|
||||
<>
|
||||
{withDivider && <MenuDivider />}
|
||||
<MenuItem icon={<Icon style={{ fontSize: '1rem' }} />} onClick={onClick} isDisabled={isDisabled}>
|
||||
<MenuItem icon={<Icon />} onClick={onClick} isDisabled={isDisabled}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
</>
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
.overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
margin: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
|
||||
border: 10px solid $ui-white;
|
||||
background-color: $ui-black;
|
||||
|
||||
color: $ui-white;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
text-align: center;
|
||||
z-index: 100;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 600;
|
||||
margin-top: -10vh;
|
||||
line-height: 1em;
|
||||
font-size: 12vw;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.message {
|
||||
color: $label-gray;
|
||||
line-height: 2em;
|
||||
font-size: 3vw;
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||
|
||||
import { setClientRemote } from '../../hooks/useSocket';
|
||||
import { useClientStore } from '../../stores/clientStore';
|
||||
|
||||
import style from './IdentifyOverlay.module.scss';
|
||||
|
||||
export default function IdentifyOverlay() {
|
||||
const clients = useClientStore((store) => store.clients);
|
||||
const id = useClientStore((store) => store.id);
|
||||
const showOverlay = clients[id]?.identify;
|
||||
|
||||
if (!showOverlay) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const portalRoot = document.getElementById('identify-portal');
|
||||
|
||||
if (!portalRoot) {
|
||||
return null;
|
||||
}
|
||||
return createPortal(<Overlay />, portalRoot);
|
||||
}
|
||||
|
||||
function Overlay() {
|
||||
const clients = useClientStore((store) => store.clients);
|
||||
const id = useClientStore((store) => store.id);
|
||||
const name = useClientStore((store) => store.name);
|
||||
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const { setIdentify } = setClientRemote;
|
||||
const showOverlay = clients[id]?.identify;
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
|
||||
setIdentify({ target: id, identify: false });
|
||||
}, [id, setIdentify]);
|
||||
|
||||
// start a timer that will close the overlay after some time
|
||||
useEffect(() => {
|
||||
if (showOverlay) {
|
||||
timerRef.current = setTimeout(handleClose, MILLIS_PER_MINUTE);
|
||||
}
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, [showOverlay, id, setIdentify, handleClose]);
|
||||
|
||||
console.log('here2');
|
||||
return (
|
||||
<div className={style.overlay} data-testid='identify-overlay' onClick={handleClose}>
|
||||
<div className={style.name}>{name}</div>
|
||||
<div className={style.message}>Click to close</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,27 @@
|
||||
import { RefObject, useEffect } from 'react';
|
||||
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';
|
||||
|
||||
export const AutoTextArea = (props: TextareaProps & { inputref: RefObject<unknown> }) => {
|
||||
const { value, inputref } = props;
|
||||
export const AutoTextArea = (props: TextareaProps) => {
|
||||
const { value } = props;
|
||||
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const node = inputref.current;
|
||||
autosize(inputref.current);
|
||||
const node = ref.current;
|
||||
autosize(ref.current);
|
||||
return () => {
|
||||
autosize.destroy(node);
|
||||
};
|
||||
}, [inputref, value]);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
overflow='hidden'
|
||||
w='100%'
|
||||
ref={inputref}
|
||||
resize='none'
|
||||
ref={ref}
|
||||
transition='height none'
|
||||
variant='ontime-transparent'
|
||||
{...props}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { KeyboardEvent, useEffect, useRef, useState } from 'react';
|
||||
import { Input, Radio, RadioGroup } from '@chakra-ui/react';
|
||||
import { millisToString, parseUserTime } from 'ontime-utils';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
|
||||
import { useEventAction } from '../../../hooks/useEventAction';
|
||||
import { forgivingStringToMillis } from '../../../utils/dateConfig';
|
||||
|
||||
import style from './DelayInput.module.scss';
|
||||
|
||||
@@ -20,7 +21,6 @@ export default function DelayInput(props: DelayInputProps) {
|
||||
// avoid wrong submit on cancel
|
||||
let ignoreChange = false;
|
||||
|
||||
// set internal value on duration change
|
||||
useEffect(() => {
|
||||
if (typeof duration === 'undefined') {
|
||||
return;
|
||||
@@ -39,7 +39,7 @@ export default function DelayInput(props: DelayInputProps) {
|
||||
}
|
||||
|
||||
const isNegative = newValue.startsWith('-');
|
||||
let newMillis = parseUserTime(newValue);
|
||||
let newMillis = forgivingStringToMillis(newValue);
|
||||
|
||||
if (isNegative) {
|
||||
newMillis = newMillis * -1;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { Input, Textarea } from '@chakra-ui/react';
|
||||
|
||||
import { EventEditorSubmitActions } from '../../../../features/rundown/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 TextInputProps extends BaseProps {
|
||||
isTextArea?: false;
|
||||
}
|
||||
|
||||
type ResizeOptions = 'horizontal' | 'vertical' | 'none';
|
||||
|
||||
interface TextAreaProps extends BaseProps {
|
||||
isTextArea: true;
|
||||
resize?: ResizeOptions;
|
||||
}
|
||||
|
||||
type InputProps = TextInputProps | TextAreaProps;
|
||||
|
||||
export default function TextInput(props: InputProps) {
|
||||
const { isTextArea, isFullHeight, size = 'sm', field, initialText = '', submitHandler } = 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);
|
||||
|
||||
let resize: ResizeOptions = 'none';
|
||||
if (isTextArea) {
|
||||
resize = (props as TextAreaProps)?.resize ?? 'none';
|
||||
}
|
||||
|
||||
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,20 +1,17 @@
|
||||
import { ChangeEvent, KeyboardEvent, RefObject, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { getHotkeyHandler, HotkeyItem } from '@mantine/hooks';
|
||||
import { ChangeEvent, KeyboardEvent, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
interface UseReactiveTextInputReturn {
|
||||
value: string;
|
||||
onChange: (event: ChangeEvent) => void;
|
||||
onBlur: (event: ChangeEvent) => void;
|
||||
onKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
|
||||
onKeyDown: (event: KeyboardEvent) => void;
|
||||
}
|
||||
|
||||
export default function useReactiveTextInput(
|
||||
initialText: string,
|
||||
submitCallback: (newValue: string) => void,
|
||||
ref: RefObject<HTMLInputElement>,
|
||||
options?: {
|
||||
submitOnEnter?: boolean;
|
||||
submitOnCtrlEnter?: boolean;
|
||||
},
|
||||
): UseReactiveTextInputReturn {
|
||||
const [text, setText] = useState<string>(initialText);
|
||||
@@ -47,45 +44,43 @@ export default function useReactiveTextInput(
|
||||
const handleSubmit = useCallback(
|
||||
(valueToSubmit: string) => {
|
||||
// No need to update if it hasn't changed
|
||||
if (valueToSubmit !== initialText) {
|
||||
const cleanVal = valueToSubmit.trim();
|
||||
submitCallback(cleanVal);
|
||||
if (cleanVal !== valueToSubmit) {
|
||||
setText(cleanVal);
|
||||
}
|
||||
if (valueToSubmit === initialText) {
|
||||
return;
|
||||
}
|
||||
const cleanVal = valueToSubmit.trim();
|
||||
submitCallback(cleanVal);
|
||||
|
||||
if (cleanVal !== valueToSubmit) {
|
||||
setText(cleanVal);
|
||||
}
|
||||
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
|
||||
},
|
||||
[initialText, ref, submitCallback],
|
||||
[initialText, submitCallback],
|
||||
);
|
||||
|
||||
/**
|
||||
* @description Handles escape events
|
||||
* @param {string} valueToSubmit
|
||||
* @description Handles common keys for submit and cancel
|
||||
* @param {string} key
|
||||
*/
|
||||
const handleEscape = useCallback(() => {
|
||||
// No need to update if it hasn't changed
|
||||
setText(initialText);
|
||||
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
|
||||
}, [initialText, ref]);
|
||||
|
||||
const keyHandler = useMemo(() => {
|
||||
const hotKeys: HotkeyItem[] = [['Escape', handleEscape, { preventDefault: true }]];
|
||||
|
||||
if (options?.submitOnEnter) {
|
||||
hotKeys.push(['Enter', () => handleSubmit(text)]);
|
||||
}
|
||||
|
||||
if (options?.submitOnCtrlEnter) {
|
||||
hotKeys.push(['mod + Enter', () => handleSubmit(text)]);
|
||||
}
|
||||
return getHotkeyHandler(hotKeys);
|
||||
}, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, text]);
|
||||
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: ChangeEvent) => handleChange((event.target as HTMLInputElement).value),
|
||||
onBlur: (event: ChangeEvent) => handleSubmit((event.target as HTMLInputElement).value),
|
||||
onKeyDown: keyHandler,
|
||||
onKeyDown: (event: KeyboardEvent) => keyHandler(event.key),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Input } from '@chakra-ui/react';
|
||||
import { millisToString, parseUserTime } from 'ontime-utils';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
|
||||
import { useEmitLog } from '../../../stores/logger';
|
||||
import { forgivingStringToMillis } from '../../../utils/dateConfig';
|
||||
import { cx } from '../../../utils/styleUtils';
|
||||
|
||||
import style from './TimeInput.module.scss';
|
||||
interface TimeInputProps<T extends string> {
|
||||
name: T;
|
||||
submitHandler: (field: T, value: string) => void;
|
||||
time?: number;
|
||||
placeholder: string;
|
||||
disabled?: boolean;
|
||||
align?: 'left' | 'center';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
|
||||
const { name, submitHandler, time = 0, placeholder, disabled, align = 'center', className } = props;
|
||||
const { name, submitHandler, time = 0, placeholder, disabled, className } = props;
|
||||
const { emitError } = useEmitLog();
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [value, setValue] = useState<string>('');
|
||||
@@ -56,7 +58,7 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
|
||||
submitHandler(name, newValue);
|
||||
}
|
||||
|
||||
const valueInMillis = parseUserTime(newValue);
|
||||
const valueInMillis = forgivingStringToMillis(newValue);
|
||||
if (valueInMillis === time) {
|
||||
return false;
|
||||
}
|
||||
@@ -115,13 +117,15 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
|
||||
resetValue();
|
||||
}, [resetValue, time]);
|
||||
|
||||
const timeInputClasses = cx([style.timeInput, className]);
|
||||
|
||||
return (
|
||||
<Input
|
||||
disabled={disabled}
|
||||
size='sm'
|
||||
ref={inputRef}
|
||||
data-testid={`time-input-${name}`}
|
||||
className={className}
|
||||
className={timeInputClasses}
|
||||
fontSize='1rem'
|
||||
type='text'
|
||||
placeholder={placeholder}
|
||||
@@ -132,10 +136,7 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
|
||||
onKeyDown={onKeyDownHandler}
|
||||
value={value}
|
||||
maxLength={8}
|
||||
maxWidth='7.5em'
|
||||
letterSpacing='1px'
|
||||
autoComplete='off'
|
||||
textAlign={align}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,3 +6,7 @@ $input-delayed-border-color: $ontime-delay-text;
|
||||
border: 1px solid $input-delayed-border-color;
|
||||
}
|
||||
}
|
||||
|
||||
.inputField {
|
||||
max-width: 7.75em;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function TimeInputWithButton<T extends string>(props: PropsWithCh
|
||||
submitHandler={submitHandler}
|
||||
time={time}
|
||||
placeholder={placeholder}
|
||||
align='left'
|
||||
className={style.inputField}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{children}
|
||||
|
||||
@@ -7,6 +7,10 @@ $progress-bar-br: 3px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: $progress-bar-size;
|
||||
border-radius: $progress-bar-br;
|
||||
background-color: var(--timer-progress-bg-override, $viewer-card-bg-color);
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
|
||||
&--hidden {
|
||||
display: none;
|
||||
@@ -14,42 +18,31 @@ $progress-bar-br: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.multiprogress-bar__bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
border-radius: var(--progress-bar-br, $progress-bar-br);
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.multiprogress-bar__indicator {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: -1px;
|
||||
margin-left: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.multiprogress-bar__indicator-bar {
|
||||
background-color: var(--background-color-override, $ui-black);
|
||||
height: inherit;
|
||||
background-color: black;
|
||||
opacity: 0.8;
|
||||
transition: 1s linear;
|
||||
transition-property: width;
|
||||
|
||||
.multiprogress-bar--ignore-css-override & {
|
||||
background-color: $ui-black;
|
||||
}
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.multiprogress-bar__bg-normal {
|
||||
flex: 1;
|
||||
position: absolute;
|
||||
height: inherit;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.multiprogress-bar__bg-warning {
|
||||
position: absolute;
|
||||
height: inherit;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.multiprogress-bar__bg-danger {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
position: absolute;
|
||||
height: inherit;
|
||||
right: 0;
|
||||
}
|
||||
@@ -13,51 +13,31 @@ interface MultiPartProgressBar {
|
||||
danger?: MaybeNumber;
|
||||
dangerColor: string;
|
||||
hidden?: boolean;
|
||||
ignoreCssOverride?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function MultiPartProgressBar(props: MultiPartProgressBar) {
|
||||
const {
|
||||
now,
|
||||
complete,
|
||||
normalColor,
|
||||
warning,
|
||||
warningColor,
|
||||
danger,
|
||||
dangerColor,
|
||||
hidden,
|
||||
ignoreCssOverride,
|
||||
className = '',
|
||||
} = props;
|
||||
const { now, complete, normalColor, warning, warningColor, danger, dangerColor, hidden, className = '' } = props;
|
||||
|
||||
const percentRemaining = complete === 0 ? 0 : 100 - clamp(100 - (Math.max(now ?? 0, 0) * 100) / complete, 0, 100);
|
||||
const percentComplete = 100 - clamp(100 - (Math.max(now ?? 0, 0) * 100) / complete, 0, 100);
|
||||
|
||||
const dangerWidth = danger ? clamp((danger / complete) * 100, 0, 100) : 0;
|
||||
const warningWidth = warning ? clamp((warning / complete) * 100 - dangerWidth, 0, 100) : 0;
|
||||
const warningWidth = warning ? clamp((warning / complete) * 100, 0, 100) : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`multiprogress-bar ${hidden ? 'multiprogress-bar--hidden' : ''} ${
|
||||
ignoreCssOverride ? 'multiprogress-bar--ignore-css-override' : ''
|
||||
} ${className}`}
|
||||
>
|
||||
<div className={`multiprogress-bar ${hidden ? 'multiprogress-bar--hidden' : ''} ${className}`}>
|
||||
{now !== null && (
|
||||
<>
|
||||
<div className='multiprogress-bar__bg'>
|
||||
<div className='multiprogress-bar__bg-normal' style={{ backgroundColor: normalColor }} />
|
||||
<div
|
||||
className='multiprogress-bar__bg-warning'
|
||||
style={{ width: `${warningWidth}%`, backgroundColor: warningColor }}
|
||||
/>
|
||||
<div
|
||||
className='multiprogress-bar__bg-danger'
|
||||
style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }}
|
||||
/>
|
||||
</div>
|
||||
<div className='multiprogress-bar__indicator'>
|
||||
<div className='multiprogress-bar__indicator-bar' style={{ width: `${percentRemaining}%` }} />
|
||||
</div>
|
||||
<div className='multiprogress-bar__bg-normal' style={{ backgroundColor: normalColor }} />
|
||||
<div
|
||||
className='multiprogress-bar__bg-warning'
|
||||
style={{ width: `${warningWidth}%`, backgroundColor: warningColor }}
|
||||
/>
|
||||
<div
|
||||
className='multiprogress-bar__bg-danger'
|
||||
style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }}
|
||||
/>
|
||||
<div className='multiprogress-bar__indicator' style={{ width: `${percentComplete}%` }} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { IoApps } from '@react-icons/all-files/io5/IoApps';
|
||||
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
||||
|
||||
import { debounce } from '../../utils/debounce';
|
||||
|
||||
import style from './NavigationMenu.module.scss';
|
||||
|
||||
interface FloatingNavigationProps {
|
||||
toggleMenu: () => void;
|
||||
toggleSettings: () => void;
|
||||
}
|
||||
|
||||
export default function FloatingNavigation(props: FloatingNavigationProps) {
|
||||
const { toggleMenu, toggleSettings } = props;
|
||||
const [showButton, setShowButton] = useState(false);
|
||||
|
||||
// show on mouse move
|
||||
useEffect(() => {
|
||||
let fadeOut: NodeJS.Timeout | null = null;
|
||||
const setShowMenuTrue = () => {
|
||||
setShowButton(true);
|
||||
if (fadeOut) {
|
||||
clearTimeout(fadeOut);
|
||||
}
|
||||
fadeOut = setTimeout(() => setShowButton(false), 3000);
|
||||
};
|
||||
|
||||
const debouncedShowMenu = debounce(setShowMenuTrue, 1000);
|
||||
|
||||
document.addEventListener('mousemove', debouncedShowMenu);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', debouncedShowMenu);
|
||||
if (fadeOut) {
|
||||
clearTimeout(fadeOut);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`${style.buttonContainer} ${!showButton ? style.hidden : ''}`}>
|
||||
<button
|
||||
onClick={toggleMenu}
|
||||
aria-label='toggle menu'
|
||||
className={style.navButton}
|
||||
data-testid='navigation__toggle-menu'
|
||||
>
|
||||
<IoApps />
|
||||
</button>
|
||||
<button
|
||||
className={style.button}
|
||||
onClick={toggleSettings}
|
||||
aria-label='toggle settings'
|
||||
data-testid='navigation__toggle-settings'
|
||||
>
|
||||
<IoSettingsOutline />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
@use '../../../theme/mixins' 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: 3rem;
|
||||
$button-size: 48px;
|
||||
|
||||
.buttonContainer {
|
||||
display: flex;
|
||||
@@ -20,7 +21,7 @@ $button-size: 3rem;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 12;
|
||||
z-index: 2;
|
||||
|
||||
&.hidden {
|
||||
opacity: 0;
|
||||
@@ -28,7 +29,7 @@ $button-size: 3rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
font-size: 1.5rem;
|
||||
font-size: 24px;
|
||||
color: $icon-color;
|
||||
background-color: $button-bg;
|
||||
width: $button-size;
|
||||
@@ -43,10 +44,31 @@ $button-size: 3rem;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.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;
|
||||
padding: 0.75rem 1.5rem;
|
||||
gap: 0.5rem;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 1rem;
|
||||
|
||||
&:hover {
|
||||
background-color: $menu-hover-bg;
|
||||
@@ -69,7 +91,7 @@ $button-size: 3rem;
|
||||
}
|
||||
|
||||
.linkIcon {
|
||||
margin-left: auto;
|
||||
display: inline-block;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,81 +1,99 @@
|
||||
import { memo, useRef } from 'react';
|
||||
import { memo, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Drawer,
|
||||
DrawerBody,
|
||||
DrawerCloseButton,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerOverlay,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
import { useFullscreen } from '@mantine/hooks';
|
||||
import { Link, useLocation, useSearchParams } from 'react-router-dom';
|
||||
import { useDisclosure } from '@chakra-ui/react';
|
||||
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 { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
|
||||
import { IoPencilSharp } from '@react-icons/all-files/io5/IoPencilSharp';
|
||||
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
|
||||
|
||||
import { navigatorConstants } from '../../../viewerConfig';
|
||||
import useClickOutside from '../../hooks/useClickOutside';
|
||||
import { useClientStore } from '../../stores/clientStore';
|
||||
import useFullscreen from '../../hooks/useFullscreen';
|
||||
import { useViewOptionsStore } from '../../stores/viewOptions';
|
||||
import { isKeyEnter } from '../../utils/keyEvent';
|
||||
import { RenameClientModal } from '../client-modal/RenameClientModal';
|
||||
|
||||
import RenameClientModal from './rename-client-modal/RenameClientModal';
|
||||
|
||||
import style from './NavigationMenu.module.scss';
|
||||
|
||||
interface NavigationMenuProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
function NavigationMenu() {
|
||||
const location = useLocation();
|
||||
|
||||
function NavigationMenu(props: NavigationMenuProps) {
|
||||
const { isOpen, onClose } = props;
|
||||
|
||||
const id = useClientStore((store) => store.id);
|
||||
const name = useClientStore((store) => store.name);
|
||||
|
||||
const { isOpen: isOpenRename, onOpen: onRenameOpen, onClose: onCloseRename } = useDisclosure();
|
||||
const { fullscreen, toggle } = useFullscreen();
|
||||
const { isFullScreen, toggleFullScreen } = useFullscreen();
|
||||
const { toggleMirror } = useViewOptionsStore();
|
||||
|
||||
const [showButton, setShowButton] = useState(false);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useClickOutside(menuRef, () => onClose);
|
||||
useClickOutside(menuRef, () => setShowMenu(false));
|
||||
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
const toggleMenu = () => setShowMenu((prev) => !prev);
|
||||
|
||||
// show on mouse move
|
||||
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 handleFullscreen = () => toggleFullScreen();
|
||||
const handleMirror = () => toggleMirror();
|
||||
|
||||
const showEditFormDrawer = () => {
|
||||
searchParams.set('edit', 'true');
|
||||
setSearchParams(searchParams);
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div id='navigation-menu-portal' ref={menuRef}>
|
||||
<RenameClientModal id={id} name={name} isOpen={isOpenRename} onClose={onCloseRename} />
|
||||
<Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
|
||||
<DrawerOverlay />
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerCloseButton size='lg' />
|
||||
Ontime
|
||||
</DrawerHeader>
|
||||
<DrawerBody padding={0}>
|
||||
<RenameClientModal isOpen={isOpen} onClose={onClose} />
|
||||
<div className={`${style.buttonContainer} ${!showButton && !showMenu ? style.hidden : ''}`}>
|
||||
<button onClick={toggleMenu} aria-label='toggle menu' className={style.navButton}>
|
||||
<IoApps />
|
||||
</button>
|
||||
<button className={style.button} onClick={showEditFormDrawer}>
|
||||
<IoPencilSharp />
|
||||
</button>
|
||||
{showMenu && (
|
||||
<div className={style.menuContainer} data-testid='navigation-menu'>
|
||||
<div className={style.buttonsContainer}>
|
||||
<div
|
||||
className={style.link}
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
onClick={toggle}
|
||||
onClick={handleFullscreen}
|
||||
onKeyDown={(event) => {
|
||||
isKeyEnter(event) && toggle();
|
||||
isKeyEnter(event) && handleFullscreen();
|
||||
}}
|
||||
>
|
||||
Toggle Fullscreen
|
||||
{fullscreen ? <IoContract /> : <IoExpand />}
|
||||
{isFullScreen ? <IoContract /> : <IoExpand />}
|
||||
</div>
|
||||
<div
|
||||
className={style.link}
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
onClick={() => toggleMirror()}
|
||||
onClick={handleMirror}
|
||||
onKeyDown={(event) => {
|
||||
isKeyEnter(event) && toggleMirror();
|
||||
isKeyEnter(event) && handleMirror();
|
||||
}}
|
||||
>
|
||||
Flip Screen
|
||||
@@ -85,35 +103,20 @@ function NavigationMenu(props: NavigationMenuProps) {
|
||||
className={style.link}
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
onClick={onRenameOpen}
|
||||
onClick={onOpen}
|
||||
onKeyDown={(event) => {
|
||||
isKeyEnter(event) && onRenameOpen();
|
||||
isKeyEnter(event) && onOpen();
|
||||
}}
|
||||
>
|
||||
Rename Client
|
||||
</div>
|
||||
</div>
|
||||
<hr className={style.separator} />
|
||||
<Link
|
||||
to='/editor'
|
||||
className={`${style.link} ${location.pathname === '/editor' ? style.current : ''}`}
|
||||
tabIndex={0}
|
||||
>
|
||||
<IoLockClosedOutline />
|
||||
Editor
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</Link>
|
||||
<Link
|
||||
to='/cuesheet'
|
||||
className={`${style.link} ${location.pathname === '/cuesheet' ? style.current : ''}`}
|
||||
tabIndex={0}
|
||||
>
|
||||
<IoLockClosedOutline />
|
||||
<Link to='/cuesheet' className={style.link} tabIndex={0}>
|
||||
Cuesheet
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</Link>
|
||||
<Link to='/op' className={`${style.link} ${location.pathname === '/op' ? style.current : ''}`} tabIndex={0}>
|
||||
<IoLockClosedOutline />
|
||||
<Link to='/op' className={style.link} tabIndex={0}>
|
||||
Operator
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</Link>
|
||||
@@ -129,10 +132,11 @@ function NavigationMenu(props: NavigationMenuProps) {
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</Link>
|
||||
))}
|
||||
</DrawerBody>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
import NavigationMenu from './NavigationMenu';
|
||||
|
||||
interface ProductionNavigationMenuProps {
|
||||
isMenuOpen: boolean;
|
||||
onMenuClose: () => void;
|
||||
}
|
||||
|
||||
function ProductionNavigationMenu(props: ProductionNavigationMenuProps) {
|
||||
const { isMenuOpen, onMenuClose } = props;
|
||||
|
||||
return <NavigationMenu isOpen={isMenuOpen} onClose={onMenuClose} />;
|
||||
}
|
||||
|
||||
export default memo(ProductionNavigationMenu);
|
||||
@@ -1,27 +0,0 @@
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useDisclosure } from '@chakra-ui/react';
|
||||
|
||||
import FloatingNavigation from './FloatingNavigation';
|
||||
import NavigationMenu from './NavigationMenu';
|
||||
|
||||
function ViewNavigationMenu() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { isOpen: isMenuOpen, onOpen: onMenuOpen, onClose: onMenuClose } = useDisclosure();
|
||||
|
||||
const showEditFormDrawer = useCallback(() => {
|
||||
searchParams.set('edit', 'true');
|
||||
setSearchParams(searchParams);
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
const toggleMenu = () => (isMenuOpen ? onMenuClose() : onMenuOpen());
|
||||
|
||||
return (
|
||||
<>
|
||||
<FloatingNavigation toggleMenu={toggleMenu} toggleSettings={showEditFormDrawer} />
|
||||
<NavigationMenu isOpen={isMenuOpen} onClose={onMenuClose} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ViewNavigationMenu);
|
||||
@@ -0,0 +1,5 @@
|
||||
.modalBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { setClientName } from '../../../hooks/useSocket';
|
||||
import { useSocketClientName } from '../../../stores/connectionName';
|
||||
|
||||
import style from './RenameClientModal.module.scss';
|
||||
|
||||
interface RenameClientModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function RenameClientModal({ isOpen, onClose }: RenameClientModalProps) {
|
||||
const { name: clientName, persistName } = useSocketClientName();
|
||||
const [newName, setNewName] = useState(clientName);
|
||||
|
||||
useEffect(() => {
|
||||
setNewName(clientName);
|
||||
}, [isOpen, clientName]);
|
||||
|
||||
const handleRename = async () => {
|
||||
if (newName) {
|
||||
setClientName(newName);
|
||||
persistName(newName);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size='sm'
|
||||
closeOnOverlayClick={false}
|
||||
motionPreset='slideInBottom'
|
||||
scrollBehavior='inside'
|
||||
preserveScrollBarGap
|
||||
variant='ontime-small'
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Rename client</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody className={style.modalBody}>
|
||||
<Input
|
||||
placeholder='Connection must have a name'
|
||||
defaultValue={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
variant='ontime-filled-on-light'
|
||||
/>
|
||||
<Button
|
||||
isDisabled={newName === clientName || !newName}
|
||||
onClick={handleRename}
|
||||
width='100%'
|
||||
variant='ontime-filled'
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
@use '../../../theme/viewerDefs' as *;
|
||||
|
||||
$progress-bar-size: 12px;
|
||||
$progress-bar-br: 3px;
|
||||
$progress-bar-br: 6px;
|
||||
|
||||
.progress-bar__bg {
|
||||
width: 100%;
|
||||
height: $progress-bar-size;
|
||||
border-radius: $progress-bar-br;
|
||||
background-color: var(--timer-progress-bg-override, $viewer-card-bg-color);
|
||||
overflow: clip;
|
||||
|
||||
&--hidden {
|
||||
display: none;
|
||||
@@ -19,5 +18,6 @@ $progress-bar-br: 3px;
|
||||
height: $progress-bar-size;
|
||||
background-color: var(--timer-progress-override, $accent-color);
|
||||
transition: 1s linear;
|
||||
border-radius: $progress-bar-br 0 0 $progress-bar-br;
|
||||
transition-property: width;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { IconButton, PinInput, PinInputField } from '@chakra-ui/react';
|
||||
import { HStack, IconButton, PinInput, PinInputField } from '@chakra-ui/react';
|
||||
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
|
||||
|
||||
import style from './ProtectRoute.module.scss';
|
||||
@@ -39,8 +39,8 @@ export default function PinPage(props: PinPageProps) {
|
||||
|
||||
return (
|
||||
<div className={style.container}>
|
||||
{`Ontime ${permission}`}
|
||||
<div className={failed ? style.pin__failed : style.pin}>
|
||||
{`Ontime ${permission || ''}`}
|
||||
<HStack spacing='10px' className={failed ? style.pin__failed : style.pin}>
|
||||
<PinInput
|
||||
type='alphanumeric'
|
||||
size='lg'
|
||||
@@ -57,15 +57,8 @@ export default function PinPage(props: PinPageProps) {
|
||||
<PinInputField />
|
||||
<PinInputField />
|
||||
</PinInput>
|
||||
<IconButton
|
||||
variant='ontime-filled'
|
||||
aria-label='Enter'
|
||||
size='lg'
|
||||
isRound
|
||||
icon={<IoCheckmark />}
|
||||
onClick={validate}
|
||||
/>
|
||||
</div>
|
||||
<IconButton aria-label='Enter' size='lg' isRound icon={<IoCheckmark />} onClick={validate} />
|
||||
</HStack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,27 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 25vh;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
height: 100vh;
|
||||
padding-bottom: 30vh;
|
||||
|
||||
background: $bg-container-l1;
|
||||
color: $ontime-color;
|
||||
font-family: $ontime-font-family;
|
||||
font-weight: 200;
|
||||
font-size: 3rem;
|
||||
text-align: center;
|
||||
font-size: 3vw;
|
||||
}
|
||||
|
||||
.pin,
|
||||
.pin__failed {
|
||||
display: flex;
|
||||
gap: 0.125em;
|
||||
padding-block: 0.5em;
|
||||
padding: 20px;
|
||||
|
||||
input {
|
||||
border-radius: 99px;
|
||||
border-color: $gray-500;
|
||||
|
||||
&:hover {
|
||||
border-color: $blue-500;
|
||||
}
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-left: 1em;
|
||||
margin-left: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,9 +33,9 @@
|
||||
|
||||
@keyframes colourFade {
|
||||
from {
|
||||
background: $red-500;
|
||||
background: $action-blue;
|
||||
}
|
||||
to {
|
||||
background: rgba($red-500, 0);
|
||||
background: rgba($action-blue, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,8 +24,6 @@ interface ScheduleProviderProps {
|
||||
time?: number;
|
||||
}
|
||||
|
||||
const numEventsPerPage = 8;
|
||||
|
||||
export const ScheduleProvider = ({
|
||||
children,
|
||||
events,
|
||||
@@ -39,7 +37,7 @@ export const ScheduleProvider = ({
|
||||
// look for overrides from views
|
||||
const hidePast = isStringBoolean(searchParams.get('hidePast'));
|
||||
const stopCycle = isStringBoolean(searchParams.get('stopCycle'));
|
||||
const eventsPerPage = Number(searchParams.get('eventsPerPage') ?? numEventsPerPage);
|
||||
const eventsPerPage = Number(searchParams.get('eventsPerPage') ?? 7);
|
||||
|
||||
let selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
|
||||
|
||||
|
||||
@@ -1,37 +1,25 @@
|
||||
@use '../../../theme/viewerDefs' as *;
|
||||
|
||||
.title-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.title-card__title,
|
||||
.title-card__secondary {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
.inline {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.title-card__title {
|
||||
font-weight: 600;
|
||||
font-size: clamp(1.5rem, 3vw, 3rem);
|
||||
font-size: clamp(32px, 3.5vw, 50px);
|
||||
color: var(--color-override, $viewer-color);
|
||||
line-height: 1.2em;
|
||||
}
|
||||
|
||||
.title-card__secondary {
|
||||
font-size: clamp(1rem, 2vw, 2.25rem);
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
line-height: 1.2em;
|
||||
line-height: 1.1em;
|
||||
}
|
||||
|
||||
.title-card__label {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: 0.5rem;
|
||||
font-size: clamp(1rem, 1.5vw, 1.5rem);
|
||||
font-weight: 400;
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
margin-left: auto;
|
||||
text-transform: uppercase;
|
||||
@@ -40,3 +28,13 @@
|
||||
color: var(--accent-color-override, $accent-color);
|
||||
}
|
||||
}
|
||||
|
||||
.title-card__secondary {
|
||||
font-size: clamp(1.5rem, 2vw, 2.25rem);
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
line-height: 1.1em;
|
||||
|
||||
&::after {
|
||||
content: '\200b';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,28 @@
|
||||
import { ForwardedRef, forwardRef } from 'react';
|
||||
|
||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||
|
||||
import './TitleCard.scss';
|
||||
|
||||
interface TitleCardProps {
|
||||
label: 'now' | 'next';
|
||||
title: string;
|
||||
label?: 'now' | 'next';
|
||||
secondary?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TitleCard = forwardRef((props: TitleCardProps, ref: ForwardedRef<HTMLDivElement>) => {
|
||||
const { label, title, secondary, className = '' } = props;
|
||||
export default function TitleCard(props: TitleCardProps) {
|
||||
const { label, title, secondary } = props;
|
||||
const { getLocalizedString } = useTranslation();
|
||||
|
||||
const accent = label === 'now';
|
||||
|
||||
return (
|
||||
<div className={`title-card ${className}`} ref={ref}>
|
||||
<span className='title-card__title'>{title}</span>
|
||||
<span className={accent ? 'title-card__label title-card__label--accent' : 'title-card__label'}>
|
||||
{label && getLocalizedString(`common.${label}`)}
|
||||
</span>
|
||||
<div className='title-card'>
|
||||
<div className='inline'>
|
||||
<span className='title-card__title'>{title}</span>
|
||||
<span className={accent ? 'title-card__label title-card__label--accent' : 'title-card__label'}>
|
||||
{getLocalizedString(`common.${label}`)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='title-card__secondary'>{secondary}</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
TitleCard.displayName = 'TitleCard';
|
||||
export default TitleCard;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItemOption,
|
||||
MenuList,
|
||||
MenuOptionGroup,
|
||||
Select,
|
||||
Switch,
|
||||
} from '@chakra-ui/react';
|
||||
import { Input, InputGroup, InputLeftElement, Select, Switch } from '@chakra-ui/react';
|
||||
|
||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||
|
||||
@@ -47,10 +34,6 @@ export default function ParamInput(props: EditFormInputProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'multi-option') {
|
||||
return <MultiOption paramField={paramField} />;
|
||||
}
|
||||
|
||||
if (type === 'boolean') {
|
||||
const defaultCheckedValue = isStringBoolean(searchParams.get(id)) || defaultValue;
|
||||
|
||||
@@ -87,47 +70,3 @@ export default function ParamInput(props: EditFormInputProps) {
|
||||
</InputGroup>
|
||||
);
|
||||
}
|
||||
|
||||
interface EditFormMultiOptionProps {
|
||||
paramField: ParamField & { type: 'multi-option' };
|
||||
}
|
||||
|
||||
function MultiOption(props: EditFormMultiOptionProps) {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { paramField } = props;
|
||||
const { id, defaultValue } = paramField;
|
||||
|
||||
const optionFromParams = (searchParams.get(id) ?? '').toLocaleLowerCase();
|
||||
const defaultOptionValue = optionFromParams || defaultValue?.toLocaleLowerCase() || '';
|
||||
|
||||
const [paramState, setParamState] = useState<string>(defaultOptionValue);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input name={id} hidden readOnly value={paramState} />
|
||||
<Menu isLazy closeOnSelect={false} variant='ontime-on-dark'>
|
||||
<MenuButton as={Button} variant='ontime-subtle-white' position='relative' width='fit-content' fontWeight={400}>
|
||||
{paramField.title}
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
<MenuOptionGroup
|
||||
type='checkbox'
|
||||
value={paramState.split('_')}
|
||||
onChange={(value) => {
|
||||
setParamState(typeof value === 'object' ? value.filter((v) => v !== '').join('_') : value);
|
||||
}}
|
||||
>
|
||||
{Object.values(paramField.values).map((option) => {
|
||||
const { value, label } = option;
|
||||
return (
|
||||
<MenuItemOption value={value} key={value}>
|
||||
{label}
|
||||
</MenuItemOption>
|
||||
);
|
||||
})}
|
||||
</MenuOptionGroup>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
.drawerContent {
|
||||
background-color: $gray-1250;
|
||||
}
|
||||
|
||||
.drawerHeader {
|
||||
@extend .drawerContent;
|
||||
color: $section-white;
|
||||
}
|
||||
|
||||
.drawerFooter {
|
||||
@extend .drawerContent;
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
justify-content: start;
|
||||
gap: $section-spacing;
|
||||
|
||||
button {
|
||||
button[type='reset'] {
|
||||
padding: 0 2em;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
button[type='submit'] {
|
||||
padding: 0 2em;
|
||||
}
|
||||
}
|
||||
@@ -11,9 +26,6 @@
|
||||
.label {
|
||||
font-size: $inner-section-text-size;
|
||||
color: $label-gray;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem
|
||||
}
|
||||
|
||||
.columnSection {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FormEvent, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useLocation, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
Drawer,
|
||||
@@ -12,26 +12,30 @@ import {
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { useLocalStorage } from '../../hooks/useLocalStorage';
|
||||
|
||||
import ParamInput from './ParamInput';
|
||||
import { ParamField } from './types';
|
||||
|
||||
import style from './ViewParamsEditor.module.scss';
|
||||
|
||||
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
|
||||
type SavedViewParams = Record<string, ViewParamsObj>;
|
||||
|
||||
/**
|
||||
* Makes a new URLSearchParams object from the given params object
|
||||
*/
|
||||
const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ParamField[]) => {
|
||||
const defaultValues = paramFields.reduce<Record<string, string>>((acc, { id, defaultValue }) => {
|
||||
acc[id] = String(defaultValue);
|
||||
return acc;
|
||||
return { ...acc, [id]: String(defaultValue) };
|
||||
}, {});
|
||||
|
||||
return Object.entries(paramsObj).reduce((newSearchParams, [id, value]) => {
|
||||
if (typeof value === 'string' && value.length && defaultValues[id] !== value) {
|
||||
if (typeof value === 'string' && value.length) {
|
||||
if (defaultValues[id] === value) {
|
||||
return newSearchParams;
|
||||
}
|
||||
newSearchParams.set(id, value);
|
||||
|
||||
return newSearchParams;
|
||||
}
|
||||
|
||||
return newSearchParams;
|
||||
}, new URLSearchParams());
|
||||
};
|
||||
@@ -43,6 +47,8 @@ interface EditFormDrawerProps {
|
||||
export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { isOpen, onClose, onOpen } = useDisclosure();
|
||||
const { pathname } = useLocation();
|
||||
const [storedViewParams, setStoredViewParams] = useLocalStorage<SavedViewParams>('ontime-views', {});
|
||||
|
||||
useEffect(() => {
|
||||
const isEditing = searchParams.get('edit');
|
||||
@@ -52,16 +58,37 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
|
||||
}
|
||||
}, [searchParams, onOpen]);
|
||||
|
||||
const handleClose = () => {
|
||||
/**
|
||||
* disabling this for now, this feature needs more testing
|
||||
* - we seem to have a bug where this is conflicting with the aliases
|
||||
* - I wonder if the logic below needs to be inside an effect,
|
||||
* both localStorage and searchParams should trigger a component update when they change
|
||||
|
||||
useEffect(() => {
|
||||
const viewParamsObjFromLocalStorage = storedViewParams[pathname];
|
||||
|
||||
if (viewParamsObjFromLocalStorage !== undefined) {
|
||||
const defaultSearchParams = getURLSearchParamsFromObj(viewParamsObjFromLocalStorage);
|
||||
setSearchParams(defaultSearchParams);
|
||||
}
|
||||
|
||||
// linter is asking for `setSearchParams` & `storedViewParams` in the useEffect deps
|
||||
// rule is disabled since adding `setSearchParams` & `storedViewParams` results in unnecessary re-renders
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pathname]);
|
||||
|
||||
*/
|
||||
|
||||
const onCloseWithoutSaving = () => {
|
||||
onClose();
|
||||
|
||||
searchParams.delete('edit');
|
||||
setSearchParams(searchParams);
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
const resetParams = () => {
|
||||
setStoredViewParams({ ...storedViewParams, [pathname]: {} });
|
||||
setSearchParams();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
|
||||
@@ -69,21 +96,21 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
|
||||
|
||||
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
|
||||
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, paramFields);
|
||||
setSearchParams(newSearchParams);
|
||||
|
||||
onClose();
|
||||
setStoredViewParams({ ...storedViewParams, [pathname]: newParamsObject });
|
||||
setSearchParams(newSearchParams);
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer isOpen={isOpen} placement='right' onClose={handleClose} variant='ontime' size='lg'>
|
||||
<Drawer isOpen={isOpen} placement='right' onClose={onCloseWithoutSaving} size='lg'>
|
||||
<DrawerOverlay />
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerCloseButton size='lg' />
|
||||
<DrawerHeader className={style.drawerHeader}>
|
||||
<DrawerCloseButton _hover={{ bg: '#ebedf0', color: '#333' }} size='lg' />
|
||||
Customise
|
||||
</DrawerHeader>
|
||||
|
||||
<DrawerBody>
|
||||
<DrawerBody className={style.drawerContent}>
|
||||
<form id='edit-params-form' onSubmit={onParamsFormSubmit}>
|
||||
{paramFields.map((field) => (
|
||||
<div key={field.title} className={style.columnSection}>
|
||||
@@ -99,7 +126,10 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
|
||||
|
||||
<DrawerFooter className={style.drawerFooter}>
|
||||
<Button variant='ontime-ghosted' onClick={resetParams} type='reset'>
|
||||
Reset to default
|
||||
Reset
|
||||
</Button>
|
||||
<Button variant='ontime-subtle' onClick={onCloseWithoutSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='ontime-filled' form='edit-params-form' type='submit'>
|
||||
Save
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { CustomFields } from 'ontime-types';
|
||||
|
||||
import { type ParamField } from './types';
|
||||
import { capitaliseFirstLetter } from '../../../features/viewers/common/viewUtils';
|
||||
|
||||
import { ParamField } from './types';
|
||||
|
||||
const makeOptionsFromCustomFields = (customFields: CustomFields, additionalOptions?: Record<string, string>) => {
|
||||
const customFieldOptions = Object.entries(customFields).reduce((acc, [key, value]) => {
|
||||
return { ...acc, [`custom-${key}`]: `Custom: ${value.label}` };
|
||||
const customFieldOptions = Object.keys(customFields).reduce((acc, key) => {
|
||||
return { ...acc, [`custom-${key}`]: `Custom: ${capitaliseFirstLetter(key)}` };
|
||||
}, additionalOptions ?? {});
|
||||
return customFieldOptions;
|
||||
};
|
||||
@@ -28,14 +30,6 @@ const hideTimerSeconds: ParamField = {
|
||||
defaultValue: false,
|
||||
};
|
||||
|
||||
const showLeadingZeros: ParamField = {
|
||||
id: 'showLeadingZeros',
|
||||
title: 'Show leading zeros in timer',
|
||||
description: 'Whether to show leading zeros in the running timer',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
};
|
||||
|
||||
export const getClockOptions = (timeFormat: string): ParamField[] => [
|
||||
getTimeOption(timeFormat),
|
||||
{
|
||||
@@ -109,12 +103,10 @@ export const getClockOptions = (timeFormat: string): ParamField[] => [
|
||||
];
|
||||
|
||||
export const getTimerOptions = (timeFormat: string, customFields: CustomFields): ParamField[] => {
|
||||
const mainOptions = makeOptionsFromCustomFields(customFields, { title: 'Title' });
|
||||
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
|
||||
const secondaryOptions = makeOptionsFromCustomFields(customFields);
|
||||
return [
|
||||
getTimeOption(timeFormat),
|
||||
hideTimerSeconds,
|
||||
showLeadingZeros,
|
||||
{
|
||||
id: 'hideClock',
|
||||
title: 'Hide Time Now',
|
||||
@@ -122,14 +114,6 @@ export const getTimerOptions = (timeFormat: string, customFields: CustomFields):
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
id: 'main',
|
||||
title: 'Main text',
|
||||
description: 'Select the data source for the main text',
|
||||
type: 'option',
|
||||
values: mainOptions,
|
||||
defaultValue: 'Title',
|
||||
},
|
||||
{
|
||||
id: 'secondary-src',
|
||||
title: 'Secondary text',
|
||||
@@ -246,6 +230,13 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
id: 'hidemessages',
|
||||
title: 'Hide Message Overlay',
|
||||
description: 'Whether to hide the overlay from showing timer screen messages',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
id: 'hideendmessage',
|
||||
title: 'Hide End Message',
|
||||
@@ -258,11 +249,12 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
|
||||
export const getLowerThirdOptions = (customFields: CustomFields): ParamField[] => {
|
||||
const topSourceOptions = makeOptionsFromCustomFields(customFields, {
|
||||
title: 'Title',
|
||||
lowerMsg: 'Lower Third Message',
|
||||
});
|
||||
|
||||
const bottomSourceOptions = makeOptionsFromCustomFields(customFields, {
|
||||
title: 'Title',
|
||||
none: 'None',
|
||||
lowerMsg: 'Lower Third Message',
|
||||
});
|
||||
|
||||
return [
|
||||
@@ -275,7 +267,7 @@ export const getLowerThirdOptions = (customFields: CustomFields): ParamField[] =
|
||||
event: 'Event Load',
|
||||
manual: 'Manual',
|
||||
},
|
||||
defaultValue: 'manual',
|
||||
defaultValue: 'event',
|
||||
},
|
||||
{
|
||||
id: 'top-src',
|
||||
@@ -291,7 +283,7 @@ export const getLowerThirdOptions = (customFields: CustomFields): ParamField[] =
|
||||
description: 'Select the data source for the bottom element',
|
||||
type: 'option',
|
||||
values: bottomSourceOptions,
|
||||
defaultValue: 'none',
|
||||
defaultValue: 'lowerMsg',
|
||||
},
|
||||
{
|
||||
id: 'top-colour',
|
||||
@@ -402,9 +394,9 @@ export const getBackstageOptions = (timeFormat: string, customFields: CustomFiel
|
||||
{
|
||||
id: 'eventsPerPage',
|
||||
title: 'Events per page',
|
||||
description: 'Sets the number of events on the page, can cause overflow',
|
||||
description: 'Sets the number of events on the page, can cause overlow',
|
||||
type: 'number',
|
||||
placeholder: '8 (default)',
|
||||
placeholder: '7 (default)',
|
||||
},
|
||||
{
|
||||
id: 'secondary-src',
|
||||
@@ -439,9 +431,9 @@ export const getPublicOptions = (timeFormat: string, customFields: CustomFields)
|
||||
{
|
||||
id: 'eventsPerPage',
|
||||
title: 'Events per page',
|
||||
description: 'Sets the number of events on the page, can cause overflow',
|
||||
description: 'Sets the number of events on the page, can cause overlow',
|
||||
type: 'number',
|
||||
placeholder: '8 (default)',
|
||||
placeholder: '7 (default)',
|
||||
},
|
||||
{
|
||||
id: 'secondary-src',
|
||||
@@ -462,8 +454,8 @@ export const getStudioClockOptions = (timeFormat: string): ParamField[] => [
|
||||
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ParamField[] => {
|
||||
const fieldOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
|
||||
|
||||
const customFieldSelect = Object.entries(customFields).reduce((acc, [key, field]) => {
|
||||
return { ...acc, [key]: { value: key, label: field.label, colour: field.colour } };
|
||||
const customFieldSelect = Object.keys(customFields).reduce((acc, key) => {
|
||||
return { ...acc, [key]: `Custom: ${capitaliseFirstLetter(key)}` };
|
||||
}, {});
|
||||
|
||||
return [
|
||||
@@ -495,8 +487,9 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin
|
||||
id: 'subscribe',
|
||||
title: 'Highlight Field',
|
||||
description: 'Choose a custom field to highlight',
|
||||
type: 'multi-option',
|
||||
type: 'option',
|
||||
values: customFieldSelect,
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
id: 'shouldEdit',
|
||||
|
||||
@@ -9,15 +9,8 @@ type OptionsField = {
|
||||
values: Record<string, string>;
|
||||
defaultValue?: string;
|
||||
};
|
||||
|
||||
type MultiOptionsField = {
|
||||
type: 'multi-option';
|
||||
values: Record<string, { value: string; label: string; colour: string }>;
|
||||
defaultValue?: string;
|
||||
};
|
||||
|
||||
type StringField = { type: 'string'; defaultValue?: string; prefix?: string; placeholder?: string };
|
||||
type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string };
|
||||
type BooleanField = { type: 'boolean'; defaultValue: boolean };
|
||||
|
||||
export type ParamField = BaseField & (StringField | BooleanField | NumberField | OptionsField | MultiOptionsField);
|
||||
export type ParamField = BaseField & (StringField | BooleanField | NumberField | OptionsField);
|
||||
|
||||
@@ -26,6 +26,7 @@ export const AppContextProvider = ({ children }: PropsWithChildren) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'pending') return;
|
||||
if (!data) return;
|
||||
const previousEditor = sessionStorage.getItem(storageKeys.editor);
|
||||
|
||||
if (previousEditor && previousEditor === data.editorKey) {
|
||||
@@ -52,6 +53,10 @@ export const AppContextProvider = ({ children }: PropsWithChildren) => {
|
||||
return savedPin == null || savedPin === '' || pin === savedPin;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (permission === 'editor') {
|
||||
const correct = isValid(pin, data.editorKey);
|
||||
if (correct) {
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function useCustomFields() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: CUSTOM_FIELDS,
|
||||
queryFn: getCustomFields,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
placeholderData: placeholder,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchInterval,
|
||||
|
||||
@@ -11,13 +11,14 @@ export function useHttpSettings() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: HTTP_SETTINGS,
|
||||
queryFn: getHTTP,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
placeholderData: httpPlaceholder,
|
||||
retry: 5,
|
||||
retryDelay: (attempt: number) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
// we need to jump through some hoops because of the type op port
|
||||
return { data: data ?? httpPlaceholder, status, isFetching, isError, refetch };
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function useInfo() {
|
||||
const { data, status, isError, refetch, isFetching } = useQuery<GetInfo>({
|
||||
queryKey: APP_INFO,
|
||||
queryFn: getInfo,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
placeholderData: ontimePlaceholderInfo,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function useOscSettings() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: OSC_SETTINGS,
|
||||
queryFn: getOSC,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
placeholderData: oscPlaceholderSettings,
|
||||
retry: 5,
|
||||
retryDelay: (attempt: number) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function useProjectData() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: PROJECT_DATA,
|
||||
queryFn: getProjectData,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
placeholderData: projectDataPlaceholder,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
|
||||
@@ -14,7 +14,7 @@ export function useProjectList() {
|
||||
const { data, status, refetch } = useQuery({
|
||||
queryKey: PROJECT_LIST,
|
||||
queryFn: getProjects,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
placeholderData: placeholderProjectList,
|
||||
retry: 5,
|
||||
retryDelay: (attempt: number) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
|
||||
@@ -13,7 +13,7 @@ export default function useRundown() {
|
||||
const { data, status, isError, refetch, isFetching } = useQuery<RundownCached>({
|
||||
queryKey: RUNDOWN,
|
||||
queryFn: fetchNormalisedRundown,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
placeholderData: cachedRundownPlaceholder,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchInterval,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { unobfuscate } from 'ontime-utils';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { APP_SETTINGS } from '../api/constants';
|
||||
@@ -10,22 +9,12 @@ export default function useSettings() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: APP_SETTINGS,
|
||||
queryFn: getSettings,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
placeholderData: ontimePlaceholderSettings,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
networkMode: 'always',
|
||||
select: (data) => {
|
||||
const unobfuscated = { ...data };
|
||||
if (data.editorKey) {
|
||||
unobfuscated.editorKey = unobfuscate(data.editorKey);
|
||||
}
|
||||
if (data.operatorKey) {
|
||||
unobfuscated.operatorKey = unobfuscate(data.operatorKey);
|
||||
}
|
||||
return unobfuscated;
|
||||
},
|
||||
});
|
||||
|
||||
return { data: data ?? ontimePlaceholderSettings, status, isFetching, isError, refetch };
|
||||
return { data, status, isFetching, isError, refetch };
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export default function useUrlPresets() {
|
||||
const { data, status, isError, refetch } = useQuery({
|
||||
queryKey: URL_PRESETS,
|
||||
queryFn: getUrlPresets,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
placeholderData: [],
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
|
||||
@@ -9,12 +9,12 @@ export default function useViewSettings() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: VIEW_SETTINGS,
|
||||
queryFn: getView,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
placeholderData: viewsSettingsPlaceholder,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
return { data: data ?? viewsSettingsPlaceholder, status, isError, refetch, isFetching };
|
||||
return { data, status, isError, refetch, isFetching };
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useClientStore } from '../stores/clientStore';
|
||||
import { socketSendJson } from '../utils/socket';
|
||||
|
||||
export const useClientPath = () => {
|
||||
const navigate = useNavigate();
|
||||
const { pathname, search } = useLocation();
|
||||
const redirect = useClientStore((store) => store.redirect);
|
||||
const setRedirect = useClientStore((store) => store.setRedirect);
|
||||
|
||||
// notify of client path changes
|
||||
useEffect(() => {
|
||||
//remove leading '/' from path
|
||||
const fullPath = (pathname.startsWith('/') ? pathname.slice(1) : pathname) + search;
|
||||
socketSendJson('set-client-path', fullPath);
|
||||
}, [pathname, search]);
|
||||
|
||||
// navigate to new path when received from server
|
||||
useEffect(() => {
|
||||
if (redirect === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
// clear redirect
|
||||
setRedirect('');
|
||||
|
||||
// navigate if there is a path change
|
||||
if (redirect !== pathname + search) {
|
||||
navigate(redirect);
|
||||
}
|
||||
}, [navigate, pathname, redirect, search, setRedirect]);
|
||||
};
|
||||
@@ -1,15 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { isOntimeEvent, MaybeString, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
|
||||
import {
|
||||
dayInMs,
|
||||
getLinkedTimes,
|
||||
getPreviousEventNormal,
|
||||
MILLIS_PER_SECOND,
|
||||
parseUserTime,
|
||||
reorderArray,
|
||||
swapEventData,
|
||||
} from 'ontime-utils';
|
||||
import { getLinkedTimes, getPreviousEventNormal, reorderArray, swapEventData } from 'ontime-utils';
|
||||
|
||||
import { RUNDOWN } from '../api/constants';
|
||||
import {
|
||||
@@ -26,13 +18,16 @@ import {
|
||||
} from '../api/rundown';
|
||||
import { logAxiosError } from '../api/utils';
|
||||
import { useEditorSettings } from '../stores/editorSettings';
|
||||
import { forgivingStringToMillis } from '../utils/dateConfig';
|
||||
|
||||
/**
|
||||
* @description Set of utilities for events //TODO: should this be called useEntryAction and so on
|
||||
* @description Set of utilities for events
|
||||
*/
|
||||
export const useEventAction = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { defaultPublic, linkPrevious, defaultDuration, defaultWarnTime, defaultDangerTime } = useEditorSettings();
|
||||
const eventSettings = useEditorSettings((state) => state.eventSettings);
|
||||
const defaultPublic = eventSettings.defaultPublic;
|
||||
const startTimeIsLastEnd = eventSettings.startTimeIsLastEnd;
|
||||
|
||||
/**
|
||||
* Calls mutation to add new event
|
||||
@@ -50,14 +45,11 @@ export const useEventAction = () => {
|
||||
after?: string;
|
||||
};
|
||||
|
||||
type EventOptions = BaseOptions &
|
||||
Partial<{
|
||||
defaultPublic: boolean;
|
||||
linkPrevious: boolean;
|
||||
lastEventId: string;
|
||||
defaultWarnTime: number;
|
||||
defaultDangerTime: number;
|
||||
}>;
|
||||
type EventOptions = BaseOptions & {
|
||||
defaultPublic?: boolean;
|
||||
lastEventId?: string;
|
||||
startTimeIsLastEnd?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds an event to rundown
|
||||
@@ -69,41 +61,27 @@ export const useEventAction = () => {
|
||||
// ************* CHECK OPTIONS specific to events
|
||||
if (isOntimeEvent(newEvent)) {
|
||||
const applicationOptions = {
|
||||
after: options?.after,
|
||||
defaultPublic: options?.defaultPublic ?? defaultPublic,
|
||||
startTimeIsLastEnd: options?.startTimeIsLastEnd ?? startTimeIsLastEnd,
|
||||
lastEventId: options?.lastEventId,
|
||||
linkPrevious: options?.linkPrevious ?? linkPrevious,
|
||||
defaultWarnTime,
|
||||
defaultDangerTime,
|
||||
after: options?.after,
|
||||
};
|
||||
|
||||
if (applicationOptions.linkPrevious && applicationOptions?.lastEventId) {
|
||||
newEvent.linkStart = applicationOptions.lastEventId;
|
||||
} else if (applicationOptions?.lastEventId) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
|
||||
const rundownData = queryClient.getQueryData<RundownCached>(RUNDOWN)!;
|
||||
const { rundown } = rundownData;
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this has a value
|
||||
const rundownData = queryClient.getQueryData<RundownCached>(RUNDOWN)!;
|
||||
const { rundown } = rundownData;
|
||||
|
||||
if (applicationOptions.startTimeIsLastEnd && applicationOptions?.lastEventId) {
|
||||
const previousEvent = rundown[applicationOptions.lastEventId];
|
||||
if (isOntimeEvent(previousEvent)) {
|
||||
newEvent.timeStart = previousEvent.timeEnd;
|
||||
newEvent.timeEnd = previousEvent.timeEnd;
|
||||
}
|
||||
}
|
||||
|
||||
if (applicationOptions.defaultPublic) {
|
||||
newEvent.isPublic = true;
|
||||
}
|
||||
|
||||
if (newEvent.duration === undefined && newEvent.timeEnd === undefined) {
|
||||
newEvent.duration = parseUserTime(defaultDuration);
|
||||
}
|
||||
|
||||
if (newEvent.timeDanger === undefined) {
|
||||
newEvent.timeDanger = parseUserTime(defaultDangerTime);
|
||||
}
|
||||
|
||||
if (newEvent.timeWarning === undefined) {
|
||||
newEvent.timeWarning = parseUserTime(defaultWarnTime);
|
||||
}
|
||||
}
|
||||
|
||||
// handle adding options that concern all event type
|
||||
@@ -117,7 +95,7 @@ export const useEventAction = () => {
|
||||
logAxiosError('Failed adding event', error);
|
||||
}
|
||||
},
|
||||
[_addEventMutation, defaultDangerTime, defaultDuration, defaultPublic, defaultWarnTime, linkPrevious, queryClient],
|
||||
[_addEventMutation, defaultPublic, queryClient, startTimeIsLastEnd],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -174,7 +152,7 @@ export const useEventAction = () => {
|
||||
|
||||
const updateCustomField = useCallback(
|
||||
async (eventId: string, field: string, value: string) => {
|
||||
updateEvent({ id: eventId, custom: { [field]: value } });
|
||||
updateEvent({ id: eventId, custom: { [field]: { value } } });
|
||||
},
|
||||
[updateEvent],
|
||||
);
|
||||
@@ -217,17 +195,14 @@ export const useEventAction = () => {
|
||||
} else if (value.startsWith('+') || value.startsWith('p+') || value.startsWith('p +')) {
|
||||
// TODO: is this logic solid?
|
||||
const remainingString = value.substring(1);
|
||||
newValMillis = getPreviousEnd() + parseUserTime(remainingString);
|
||||
newValMillis = getPreviousEnd() + forgivingStringToMillis(remainingString);
|
||||
} else {
|
||||
newValMillis = parseUserTime(value);
|
||||
newValMillis = forgivingStringToMillis(value);
|
||||
}
|
||||
|
||||
// dont allow timer values over 23:59:59
|
||||
const cappedMillis = Math.min(newValMillis, dayInMs - MILLIS_PER_SECOND);
|
||||
|
||||
const newEvent = {
|
||||
id: eventId,
|
||||
[field]: cappedMillis,
|
||||
[field]: newValMillis,
|
||||
};
|
||||
try {
|
||||
await _updateEventMutation.mutateAsync(newEvent);
|
||||
@@ -337,7 +312,7 @@ export const useEventAction = () => {
|
||||
const _deleteEventMutation = useMutation({
|
||||
mutationFn: requestDelete,
|
||||
// we optimistically update here
|
||||
onMutate: async (eventIds: string[]) => {
|
||||
onMutate: async (eventId) => {
|
||||
// cancel ongoing queries
|
||||
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
||||
|
||||
@@ -346,11 +321,9 @@ export const useEventAction = () => {
|
||||
|
||||
if (previousData) {
|
||||
// optimistically update object
|
||||
const newOrder = previousData.order.filter((id) => !eventIds.includes(id));
|
||||
const newOrder = previousData.order.filter((id) => id !== eventId);
|
||||
const newRundown = { ...previousData.rundown };
|
||||
for (const eventId of eventIds) {
|
||||
delete newRundown[eventId];
|
||||
}
|
||||
delete newRundown[eventId];
|
||||
|
||||
queryClient.setQueryData(RUNDOWN, {
|
||||
order: newOrder,
|
||||
@@ -379,9 +352,9 @@ export const useEventAction = () => {
|
||||
* Deletes an event form the list
|
||||
*/
|
||||
const deleteEvent = useCallback(
|
||||
async (eventIds: string[]) => {
|
||||
async (eventId: string) => {
|
||||
try {
|
||||
await _deleteEventMutation.mutateAsync(eventIds);
|
||||
await _deleteEventMutation.mutateAsync(eventId);
|
||||
} catch (error) {
|
||||
logAxiosError('Error deleting event', error);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-nocheck -- working on it
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
interface WebkitDocument extends Document {
|
||||
webkitFullscreenElement?: Element | null;
|
||||
webkitIsFullScreen?: boolean;
|
||||
webkitExitFullscreen?: () => Promise<void>;
|
||||
webkitRequestFullscreen?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export default function useFullscreen() {
|
||||
const [isFullScreen, setFullScreen] = useState(
|
||||
document.fullscreenElement || (document as WebkitDocument).webkitFullscreenElement,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleChange = () => {
|
||||
if (typeof (document as WebkitDocument).webkitFullscreenElement !== 'undefined') {
|
||||
setFullScreen((document as WebkitDocument).webkitFullscreenElement);
|
||||
} else {
|
||||
setFullScreen(document.fullscreenElement);
|
||||
}
|
||||
};
|
||||
(document as WebkitDocument).addEventListener('webkitfullscreenchange', handleChange, { passive: true });
|
||||
document.addEventListener('fullscreenchange', handleChange, { passive: true });
|
||||
document.addEventListener('resize', handleChange, { passive: true });
|
||||
|
||||
return () => {
|
||||
(document as WebkitDocument).removeEventListener('webkitfullscreenchange', handleChange);
|
||||
document.removeEventListener('fullscreenchange', handleChange);
|
||||
document.removeEventListener('resize', handleChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleFullScreen = useCallback(() => {
|
||||
if (!document.fullscreenElement && !(document as WebkitDocument).webkitIsFullScreen) {
|
||||
// Fullscreen mode is not active, so we can enter fullscreen mode
|
||||
const element = document.documentElement;
|
||||
if (element.requestFullscreen) {
|
||||
// Standard fullscreen API is supported
|
||||
element.requestFullscreen().catch(() => {
|
||||
/* nothing to do */
|
||||
});
|
||||
} else if (element.webkitRequestFullscreen) {
|
||||
// iOS Safari fullscreen API is supported
|
||||
element.webkitRequestFullscreen?.().catch(() => {
|
||||
/* nothing to do */
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Fullscreen mode is active, so we can exit fullscreen mode
|
||||
if (document.exitFullscreen) {
|
||||
// Standard fullscreen API is supported
|
||||
document.exitFullscreen().catch((error) => {
|
||||
console.error('Error while trying to exit fullscreen:', error);
|
||||
});
|
||||
} else if ((document as WebkitDocument).webkitExitFullscreen) {
|
||||
// iOS Safari fullscreen API is supported
|
||||
(document as WebkitDocument).webkitExitFullscreen?.().catch(() => {
|
||||
/* nothing to do */
|
||||
});
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { isFullScreen, toggleFullScreen };
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
const STORAGE_EVENT = 'ontime-storage';
|
||||
|
||||
function getSnapshot(key: string): string | null {
|
||||
try {
|
||||
return window.localStorage.getItem(`ontime-${key}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getParsedJson<T>(localStorageValue: string | null, initialValue: T): T {
|
||||
try {
|
||||
return localStorageValue ? JSON.parse(localStorageValue) : initialValue;
|
||||
} catch {
|
||||
return initialValue;
|
||||
}
|
||||
}
|
||||
|
||||
export const useLocalStorage = <T>(key: string, initialValue: T) => {
|
||||
const localStorageValue = useSyncExternalStore(subscribe, () => getSnapshot(key));
|
||||
const parsedLocalStorageValue = getParsedJson(localStorageValue, initialValue);
|
||||
|
||||
/**
|
||||
* @description Set value to local storage
|
||||
* @param value
|
||||
*/
|
||||
const setLocalStorageValue = (value: T | ((val: T) => T)) => {
|
||||
// Allow value to be a function so we have same API as useState
|
||||
const valueToStore = value instanceof Function ? value(parsedLocalStorageValue) : value;
|
||||
|
||||
localStorage.setItem(`ontime-${key}`, JSON.stringify(valueToStore));
|
||||
window.dispatchEvent(new StorageEvent(STORAGE_EVENT));
|
||||
};
|
||||
|
||||
return [parsedLocalStorageValue, setLocalStorageValue] as const;
|
||||
};
|
||||
|
||||
function subscribe(callback: () => void) {
|
||||
window.addEventListener(STORAGE_EVENT, callback);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(STORAGE_EVENT, callback);
|
||||
};
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export default function useScrollIntoView<T extends HTMLElement>(name: string, location?: string) {
|
||||
const ref = useRef<T>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (location && ref.current) {
|
||||
if (location === name) {
|
||||
ref.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
}, [location, name]);
|
||||
|
||||
return ref;
|
||||
}
|
||||
@@ -3,12 +3,6 @@ import { RuntimeStore, SimpleDirection, SimplePlayback } from 'ontime-types';
|
||||
import { useRuntimeStore } from '../stores/runtime';
|
||||
import { socketSendJson } from '../utils/socket';
|
||||
|
||||
export const setClientRemote = {
|
||||
setIdentify: (payload: { target: string; identify: boolean }) => socketSendJson('client', payload),
|
||||
setRedirect: (payload: { target: string; redirect: string }) => socketSendJson('client', payload),
|
||||
setClientName: (payload: { target: string; rename: string }) => socketSendJson('client', payload),
|
||||
};
|
||||
|
||||
export const useRundownEditor = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
playback: state.timer.playback,
|
||||
@@ -31,6 +25,8 @@ export const useOperator = () => {
|
||||
export const useMessageControl = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
timer: state.message.timer,
|
||||
public: state.message.public,
|
||||
lower: state.message.lower,
|
||||
external: state.message.external,
|
||||
onAir: state.onAir,
|
||||
});
|
||||
@@ -41,6 +37,13 @@ export const useMessageControl = () => {
|
||||
export const setMessage = {
|
||||
timerText: (payload: string) => socketSendJson('message', { timer: { text: payload } }),
|
||||
timerVisible: (payload: boolean) => socketSendJson('message', { timer: { visible: payload } }),
|
||||
publicText: (payload: string) => socketSendJson('message', { public: { text: payload } }),
|
||||
publicVisible: (payload: boolean) => socketSendJson('message', { public: { visible: payload } }),
|
||||
lowerText: (payload: string) => socketSendJson('message', { lower: { text: payload } }),
|
||||
lowerVisible: (payload: boolean) => socketSendJson('message', { lower: { visible: payload } }),
|
||||
externalText: (payload: string) => socketSendJson('message', { external: { visible: payload } }),
|
||||
externalVisible: (payload: boolean) => socketSendJson('message', { external: { visible: payload } }),
|
||||
onAir: (payload: boolean) => socketSendJson('onAir', payload),
|
||||
timerBlink: (payload: boolean) => socketSendJson('message', { timer: { blink: payload } }),
|
||||
timerBlackout: (payload: boolean) => socketSendJson('message', { timer: { blackout: payload } }),
|
||||
};
|
||||
@@ -59,12 +62,12 @@ export const setPlayback = {
|
||||
start: () => socketSendJson('start'),
|
||||
pause: () => socketSendJson('pause'),
|
||||
roll: () => socketSendJson('roll'),
|
||||
startNext: () => socketSendJson('start', 'next'),
|
||||
startNext: () => socketSendJson('start', { next: true }),
|
||||
previous: () => {
|
||||
socketSendJson('load', 'previous');
|
||||
socketSendJson('load', { previous: true });
|
||||
},
|
||||
next: () => {
|
||||
socketSendJson('load', 'next');
|
||||
socketSendJson('load', { next: true });
|
||||
},
|
||||
stop: () => {
|
||||
socketSendJson('stop');
|
||||
@@ -89,27 +92,27 @@ export const useInfoPanel = () => {
|
||||
return useRuntimeStore(featureSelector);
|
||||
};
|
||||
|
||||
export const useAuxTimerTime = () => {
|
||||
const featureSelector = (state: RuntimeStore) => state.auxtimer1.current;
|
||||
export const useExtraTimerTime = () => {
|
||||
const featureSelector = (state: RuntimeStore) => state.timer1.current;
|
||||
|
||||
return useRuntimeStore(featureSelector);
|
||||
};
|
||||
|
||||
export const useAuxTimerControl = () => {
|
||||
export const useExtraTimerControl = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
playback: state.auxtimer1.playback,
|
||||
direction: state.auxtimer1.direction,
|
||||
playback: state.timer1.playback,
|
||||
direction: state.timer1.direction,
|
||||
});
|
||||
|
||||
return useRuntimeStore(featureSelector);
|
||||
};
|
||||
|
||||
export const setAuxTimer = {
|
||||
start: () => socketSendJson('auxtimer', { '1': SimplePlayback.Start }),
|
||||
pause: () => socketSendJson('auxtimer', { '1': SimplePlayback.Pause }),
|
||||
stop: () => socketSendJson('auxtimer', { '1': SimplePlayback.Stop }),
|
||||
setDirection: (direction: SimpleDirection) => socketSendJson('auxtimer', { '1': { direction } }),
|
||||
setDuration: (time: number) => socketSendJson('auxtimer', { '1': { duration: time } }),
|
||||
export const setExtraTimer = {
|
||||
start: () => socketSendJson('extratimer', SimplePlayback.Start),
|
||||
pause: () => socketSendJson('extratimer', SimplePlayback.Pause),
|
||||
stop: () => socketSendJson('extratimer', SimplePlayback.Stop),
|
||||
setDirection: (direction: SimpleDirection) => socketSendJson('extratimer', { direction }),
|
||||
setTime: (time: number) => socketSendJson('extratimer', { settime: time }),
|
||||
};
|
||||
|
||||
export const useCuesheet = () => {
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Sets tab title
|
||||
* @param title
|
||||
*/
|
||||
export function useWindowTitle(title: string) {
|
||||
useEffect(() => {
|
||||
document.title = `ontime - ${title}`;
|
||||
}, []);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MaybeNumber, Playback, TimerPhase, TimerType } from 'ontime-types';
|
||||
import { MaybeNumber, Playback, TimerType } from 'ontime-types';
|
||||
|
||||
// first set extends TimerState
|
||||
export type ViewExtendedTimer = {
|
||||
@@ -8,11 +8,12 @@ export type ViewExtendedTimer = {
|
||||
elapsed: MaybeNumber;
|
||||
expectedFinish: MaybeNumber;
|
||||
finishedAt: MaybeNumber;
|
||||
phase: TimerPhase;
|
||||
playback: Playback;
|
||||
secondaryTimer: MaybeNumber;
|
||||
startedAt: MaybeNumber;
|
||||
|
||||
clock: number;
|
||||
timeDanger: MaybeNumber;
|
||||
timeWarning: MaybeNumber;
|
||||
timerType: TimerType;
|
||||
};
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { ViewSettings } from 'ontime-types';
|
||||
|
||||
export const viewsSettingsPlaceholder: ViewSettings = {
|
||||
overrideStyles: false,
|
||||
normalColor: '#ffffffcc',
|
||||
warningColor: '#FFAB33',
|
||||
dangerColor: '#ED3333',
|
||||
endMessage: '',
|
||||
freezeEnd: false,
|
||||
normalColor: '#ffffffcc',
|
||||
overrideStyles: false,
|
||||
warningColor: '#FFAB33',
|
||||
};
|
||||
|
||||
@@ -19,10 +19,13 @@ function persistModeToSession(mode: AppMode) {
|
||||
type AppModeStore = {
|
||||
mode: AppMode;
|
||||
setMode: (mode: AppMode) => void;
|
||||
cursor: string | null;
|
||||
setCursor: (cursor: string | null) => void;
|
||||
};
|
||||
|
||||
export const useAppMode = create<AppModeStore>()((set) => ({
|
||||
mode: getModeFromSession(),
|
||||
cursor: null,
|
||||
setMode: (mode: AppMode) => {
|
||||
persistModeToSession(mode);
|
||||
|
||||
@@ -30,4 +33,5 @@ export const useAppMode = create<AppModeStore>()((set) => ({
|
||||
return { mode };
|
||||
});
|
||||
},
|
||||
setCursor: (cursor: string | null) => set(() => ({ cursor })),
|
||||
}));
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { ClientList } from 'ontime-types';
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface ClientStore {
|
||||
name?: string;
|
||||
setName: (newValue: string) => void;
|
||||
|
||||
id: string;
|
||||
setId: (newValue: string) => void;
|
||||
|
||||
redirect: string;
|
||||
setRedirect: (newValue: string) => void;
|
||||
|
||||
clients: ClientList;
|
||||
setClients: (clients: ClientList) => void;
|
||||
}
|
||||
|
||||
const clientNameKey = 'ontime-client-name';
|
||||
|
||||
function persistNameInStorage(newValue: string) {
|
||||
localStorage.setItem(clientNameKey, newValue);
|
||||
}
|
||||
|
||||
export const useClientStore = create<ClientStore>((set) => ({
|
||||
name: localStorage.getItem(clientNameKey) ?? undefined,
|
||||
setName: (name: string) =>
|
||||
set(() => {
|
||||
persistNameInStorage(name);
|
||||
return { name };
|
||||
}),
|
||||
|
||||
id: '',
|
||||
setId: (id) => set({ id }),
|
||||
|
||||
redirect: '',
|
||||
setRedirect: (redirect: string) => set({ redirect }),
|
||||
|
||||
clients: {},
|
||||
setClients: (clients: ClientList) => set({ clients }),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Allows getting client name (outside react)
|
||||
*/
|
||||
export function getClientName(): string | undefined {
|
||||
return useClientStore.getState().name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows updating current client name (outside react)
|
||||
*/
|
||||
export function setClientName(name: string): void {
|
||||
useClientStore.getState().setName(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows updating current client name (outside react)
|
||||
*/
|
||||
export function setClientId(id: string): void {
|
||||
useClientStore.getState().setId(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows getting client name (outside react)
|
||||
*/
|
||||
export function getClientId(): string | undefined {
|
||||
return useClientStore.getState().id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows updating redirect (outside react)
|
||||
*/
|
||||
export function setClientRedirect(path: string): void {
|
||||
useClientStore.getState().setRedirect(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows setting clients (outside react)
|
||||
*/
|
||||
export function setClients(clients: ClientList): void {
|
||||
return useClientStore.getState().setClients(clients);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useStore } from 'zustand';
|
||||
import { createStore } from 'zustand/vanilla';
|
||||
|
||||
interface SocketClientNameState {
|
||||
name?: string;
|
||||
setName: (newValue: string) => void;
|
||||
persistName: (newValue: string) => void;
|
||||
}
|
||||
|
||||
const clientNameKey = 'ontime-client-name';
|
||||
|
||||
function persistKeyToStorage(newValue: string) {
|
||||
localStorage.setItem(clientNameKey, newValue);
|
||||
}
|
||||
|
||||
export const socketClientName = createStore<SocketClientNameState>((set) => ({
|
||||
name: localStorage.getItem(clientNameKey) ?? undefined,
|
||||
setName: (newValue: string) => set(() => ({ name: newValue })),
|
||||
persistName: (newValue: string) =>
|
||||
set(() => {
|
||||
persistKeyToStorage(newValue);
|
||||
return { name: newValue };
|
||||
}),
|
||||
}));
|
||||
|
||||
export const useSocketClientName = () => useStore(socketClientName);
|
||||
@@ -2,68 +2,56 @@ import { create } from 'zustand';
|
||||
|
||||
import { booleanFromLocalStorage } from '../utils/localStorage';
|
||||
|
||||
type EditorSettingsStore = {
|
||||
defaultDuration: string;
|
||||
linkPrevious: boolean;
|
||||
defaultWarnTime: string;
|
||||
defaultDangerTime: string;
|
||||
type EditorSettings = {
|
||||
showQuickEntry: boolean;
|
||||
startTimeIsLastEnd: boolean;
|
||||
defaultPublic: boolean;
|
||||
setDefaultDuration: (defaultDuration: string) => void;
|
||||
setLinkPrevious: (linkPrevious: boolean) => void;
|
||||
setWarnTime: (warnTime: string) => void;
|
||||
setDangerTime: (dangerTime: string) => void;
|
||||
};
|
||||
|
||||
type EditorSettingsStore = {
|
||||
eventSettings: EditorSettings;
|
||||
setLocalEventSettings: (newState: EditorSettings) => void;
|
||||
setShowQuickEntry: (showQuickEntry: boolean) => void;
|
||||
setStartTimeIsLastEnd: (startTimeIsLastEnd: boolean) => void;
|
||||
setDefaultPublic: (defaultPublic: boolean) => void;
|
||||
};
|
||||
|
||||
export const editorSettingsDefaults = {
|
||||
duration: '00:10:00',
|
||||
linkPrevious: true,
|
||||
warnTime: '00:02:00', // 120000 same as backend
|
||||
dangerTime: '00:01:00', // 60000 same as backend
|
||||
isPublic: true,
|
||||
};
|
||||
|
||||
enum EditorSettingsKeys {
|
||||
DefaultDuration = 'ontime-default-duration',
|
||||
LinkPrevious = 'ontime-link-previous',
|
||||
DefaultWarnTime = 'ontime-default-warn-time',
|
||||
DefaultDangerTime = 'ontime-default-danger-time',
|
||||
ShowQuickEntry = 'ontime-show-quick-entry',
|
||||
StartTimeIsLastEnd = 'ontime-start-is-last-end',
|
||||
DefaultPublic = 'ontime-default-public',
|
||||
}
|
||||
|
||||
export const useEditorSettings = create<EditorSettingsStore>((set) => {
|
||||
return {
|
||||
defaultDuration: localStorage.getItem(EditorSettingsKeys.DefaultDuration) ?? editorSettingsDefaults.duration,
|
||||
linkPrevious: booleanFromLocalStorage(EditorSettingsKeys.LinkPrevious, editorSettingsDefaults.linkPrevious),
|
||||
defaultWarnTime: localStorage.getItem(EditorSettingsKeys.DefaultWarnTime) ?? editorSettingsDefaults.warnTime,
|
||||
defaultDangerTime: localStorage.getItem(EditorSettingsKeys.DefaultDangerTime) ?? editorSettingsDefaults.dangerTime,
|
||||
defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.DefaultPublic, editorSettingsDefaults.isPublic),
|
||||
export const useEditorSettings = create<EditorSettingsStore>((set) => ({
|
||||
eventSettings: {
|
||||
showQuickEntry: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, false),
|
||||
startTimeIsLastEnd: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, true),
|
||||
defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, true),
|
||||
},
|
||||
|
||||
setDefaultDuration: (defaultDuration) =>
|
||||
set(() => {
|
||||
localStorage.setItem(EditorSettingsKeys.DefaultDuration, String(defaultDuration));
|
||||
return { defaultDuration };
|
||||
}),
|
||||
setLocalEventSettings: (value) =>
|
||||
set(() => {
|
||||
localStorage.setItem(EditorSettingsKeys.ShowQuickEntry, String(value.showQuickEntry));
|
||||
localStorage.setItem(EditorSettingsKeys.StartTimeIsLastEnd, String(value.startTimeIsLastEnd));
|
||||
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(value.defaultPublic));
|
||||
return { eventSettings: value };
|
||||
}),
|
||||
|
||||
setLinkPrevious: (linkPrevious) =>
|
||||
set(() => {
|
||||
localStorage.setItem(EditorSettingsKeys.LinkPrevious, String(linkPrevious));
|
||||
return { linkPrevious };
|
||||
}),
|
||||
setWarnTime: (defaultWarnTime) =>
|
||||
set(() => {
|
||||
localStorage.setItem(EditorSettingsKeys.DefaultWarnTime, String(defaultWarnTime));
|
||||
return { defaultWarnTime };
|
||||
}),
|
||||
setDangerTime: (defaultDangerTime) =>
|
||||
set(() => {
|
||||
localStorage.setItem(EditorSettingsKeys.DefaultDangerTime, String(defaultDangerTime));
|
||||
return { defaultDangerTime };
|
||||
}),
|
||||
setDefaultPublic: (defaultPublic) =>
|
||||
set(() => {
|
||||
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(defaultPublic));
|
||||
return { defaultPublic };
|
||||
}),
|
||||
};
|
||||
});
|
||||
setShowQuickEntry: (showQuickEntry) =>
|
||||
set((state) => {
|
||||
localStorage.setItem(EditorSettingsKeys.ShowQuickEntry, String(showQuickEntry));
|
||||
return { eventSettings: { ...state.eventSettings, showQuickEntry } };
|
||||
}),
|
||||
|
||||
setStartTimeIsLastEnd: (startTimeIsLastEnd) =>
|
||||
set((state) => {
|
||||
localStorage.setItem(EditorSettingsKeys.StartTimeIsLastEnd, String(startTimeIsLastEnd));
|
||||
return { eventSettings: { ...state.eventSettings, startTimeIsLastEnd } };
|
||||
}),
|
||||
|
||||
setDefaultPublic: (defaultPublic) =>
|
||||
set((state) => {
|
||||
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(defaultPublic));
|
||||
return { eventSettings: { ...state.eventSettings, defaultPublic } };
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
type EntryCopyStore = {
|
||||
entryCopyId: string | null;
|
||||
setEntryCopyId: (eventId: string | null) => void;
|
||||
};
|
||||
|
||||
export const useEntryCopy = create<EntryCopyStore>()((set) => ({
|
||||
entryCopyId: null,
|
||||
setEntryCopyId: (entryCopyId: string | null) => set({ entryCopyId }),
|
||||
}));
|
||||
@@ -1,5 +1,5 @@
|
||||
import isEqual from 'react-fast-compare';
|
||||
import { Playback, RuntimeStore, SimpleDirection, SimplePlayback, TimerPhase } from 'ontime-types';
|
||||
import { Playback, RuntimeStore, SimpleDirection, SimplePlayback } from 'ontime-types';
|
||||
import { createWithEqualityFn, useStoreWithEqualityFn } from 'zustand/traditional';
|
||||
|
||||
export const runtimeStorePlaceholder: RuntimeStore = {
|
||||
@@ -11,7 +11,6 @@ export const runtimeStorePlaceholder: RuntimeStore = {
|
||||
elapsed: null,
|
||||
expectedFinish: null,
|
||||
finishedAt: null,
|
||||
phase: TimerPhase.None,
|
||||
playback: Playback.Stop,
|
||||
secondaryTimer: null,
|
||||
startedAt: null,
|
||||
@@ -24,6 +23,14 @@ export const runtimeStorePlaceholder: RuntimeStore = {
|
||||
blink: false,
|
||||
blackout: false,
|
||||
},
|
||||
public: {
|
||||
text: '',
|
||||
visible: false,
|
||||
},
|
||||
lower: {
|
||||
text: '',
|
||||
visible: false,
|
||||
},
|
||||
external: {
|
||||
text: '',
|
||||
visible: false,
|
||||
@@ -42,7 +49,7 @@ export const runtimeStorePlaceholder: RuntimeStore = {
|
||||
eventNext: null,
|
||||
publicEventNow: null,
|
||||
publicEventNext: null,
|
||||
auxtimer1: {
|
||||
timer1: {
|
||||
current: 0,
|
||||
direction: SimpleDirection.CountUp,
|
||||
duration: 0,
|
||||
|
||||
@@ -1,4 +1,263 @@
|
||||
import { millisToDelayString } from '../dateConfig';
|
||||
import { forgivingStringToMillis, millisToDelayString } from '../dateConfig';
|
||||
|
||||
describe('test forgivingStringToMillis()', () => {
|
||||
describe('function handles time with no separators', () => {
|
||||
const testData = [
|
||||
{ value: '', expect: 0 },
|
||||
{ value: '0', expect: 0 },
|
||||
{ value: '-0', expect: 0 },
|
||||
{ value: '1', expect: 60 * 1000 },
|
||||
{ value: '-1', expect: 60 * 1000 },
|
||||
{ value: '0h0m0s', expect: 0 },
|
||||
{ value: '0h0m1s', expect: 1000 },
|
||||
{ value: '0h1m0s', expect: 1000 * 60 },
|
||||
{ value: '1h0m0s', expect: 1000 * 60 * 60 },
|
||||
{ value: '23h0m0s', expect: 1000 * 60 * 60 * 23 },
|
||||
{ value: '12h12m12s', expect: 12 * 1000 + 12 * 60 * 1000 + 12 * 1000 * 60 * 60 },
|
||||
{ value: '12H12M12S', expect: 12 * 1000 + 12 * 60 * 1000 + 12 * 1000 * 60 * 60 },
|
||||
{ value: '2m', expect: 2 * 60 * 1000 },
|
||||
{ value: '1h5s', expect: 1000 * 60 * 60 + 1000 * 5 },
|
||||
{ value: '1h2m', expect: 1000 * 60 * 60 + 1000 * 60 * 2 },
|
||||
];
|
||||
|
||||
for (const s of testData) {
|
||||
it(`handles ${s.value} to left`, () => {
|
||||
expect(typeof forgivingStringToMillis(s.value)).toBe('number');
|
||||
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('parses strings correctly', () => {
|
||||
const ts = [
|
||||
{ value: '1.1.1', expect: 60 * 60 * 1000 + 60 * 1000 + 1000 },
|
||||
{ value: '12.1.1', expect: 12 * 60 * 60 * 1000 + 60 * 1000 + 1000 },
|
||||
{ value: '12.55.1', expect: 12 * 60 * 60 * 1000 + 55 * 60 * 1000 + 1000 },
|
||||
{ value: '12.55.40', expect: 12 * 60 * 60 * 1000 + 55 * 60 * 1000 + 40 * 1000 },
|
||||
];
|
||||
|
||||
for (const s of ts) {
|
||||
it(`handles ${s.value} to the left`, () => {
|
||||
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('parses time strings', () => {
|
||||
const ts = [
|
||||
{ value: '1h2m3s', expect: 60 * 60 * 1000 + 2 * 60 * 1000 + 3 * 1000 },
|
||||
{ value: '1h3s', expect: 60 * 60 * 1000 + 3 * 1000 },
|
||||
{ value: '1h2m', expect: 60 * 60 * 1000 + 2 * 60 * 1000 },
|
||||
{ value: '10h', expect: 10 * 60 * 60 * 1000 },
|
||||
{ value: '10m', expect: 10 * 60 * 1000 },
|
||||
{ value: '10s', expect: 10 * 1000 },
|
||||
{ value: '120h', expect: 120 * 60 * 60 * 1000 },
|
||||
{ value: '120m', expect: 120 * 60 * 1000 },
|
||||
{ value: '120s', expect: 120 * 1000 },
|
||||
];
|
||||
|
||||
for (const s of ts) {
|
||||
it(`handles ${s.value}`, () => {
|
||||
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('handles am/pm', () => {
|
||||
const ampm = [
|
||||
{ value: '9:10:11am', expect: 9 * 60 * 60 * 1000 + 10 * 60 * 1000 + 11 * 1000 },
|
||||
{ value: '9:10:11a', expect: 9 * 60 * 60 * 1000 + 10 * 60 * 1000 + 11 * 1000 },
|
||||
{ value: '9:10:11pm', expect: (12 + 9) * 60 * 60 * 1000 + 10 * 60 * 1000 + 11 * 1000 },
|
||||
{ value: '9:10:11p', expect: (12 + 9) * 60 * 60 * 1000 + 10 * 60 * 1000 + 11 * 1000 },
|
||||
{ value: '9:10am', expect: 9 * 60 * 60 * 1000 + 10 * 60 * 1000 },
|
||||
{ value: '9:10a', expect: 9 * 60 * 60 * 1000 + 10 * 60 * 1000 },
|
||||
{ value: '9:10pm', expect: (12 + 9) * 60 * 60 * 1000 + 10 * 60 * 1000 },
|
||||
{ value: '9:10p', expect: (12 + 9) * 60 * 60 * 1000 + 10 * 60 * 1000 },
|
||||
{ value: '9am', expect: 9 * 60 * 60 * 1000 },
|
||||
{ value: '9a', expect: 9 * 60 * 60 * 1000 },
|
||||
{ value: '9pm', expect: (12 + 9) * 60 * 60 * 1000 },
|
||||
{ value: '9p', expect: (12 + 9) * 60 * 60 * 1000 },
|
||||
{ value: '12am', expect: 0 },
|
||||
{ value: '12pm', expect: 12 * 60 * 60 * 1000 },
|
||||
];
|
||||
|
||||
for (const s of ampm) {
|
||||
it(`handles ${s.value}`, () => {
|
||||
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('it infers separators when non existent', () => {
|
||||
const testCases = [
|
||||
{ value: '1', expect: 1000 * 60 }, // 00:01:00
|
||||
{ value: '12', expect: 1000 * 60 * 12 }, // 00:12:00
|
||||
{ value: '123', expect: 1000 * 60 * 23 + 1000 * 60 * 60 }, // 01:23:00
|
||||
{ value: '1234', expect: 1000 * 60 * 34 + 1000 * 60 * 60 * 12 }, // 12:34:00
|
||||
{ value: '12345', expect: 1000 * 60 * 34 + 1000 * 60 * 60 * 12 + 5 * 1000 }, // 12:34:05
|
||||
{ value: '123456', expect: 1000 * 60 * 34 + 1000 * 60 * 60 * 12 + 56 * 1000 }, // 12:34:56
|
||||
];
|
||||
|
||||
for (const s of testCases) {
|
||||
it(`handles basic strings digits: ${s.value}`, () => {
|
||||
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
|
||||
});
|
||||
}
|
||||
|
||||
const sixDigits = [
|
||||
{ value: '000000', expect: 0 },
|
||||
{ value: '000001', expect: 1000 },
|
||||
{ value: '000100', expect: 1000 * 60 },
|
||||
{ value: '010000', expect: 1000 * 60 * 60 },
|
||||
{ value: '230000', expect: 1000 * 60 * 60 * 23 },
|
||||
{ value: '121212', expect: 12 * 1000 + 12 * 60 * 1000 + 12 * 1000 * 60 * 60 },
|
||||
];
|
||||
|
||||
for (const s of sixDigits) {
|
||||
it(`handles string with 6 digits: ${s.value}`, () => {
|
||||
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
|
||||
});
|
||||
}
|
||||
|
||||
const fiveDigits = [
|
||||
{ value: '00000', expect: 0 },
|
||||
{ value: '00001', expect: 1000 }, // 00:00:01
|
||||
{ value: '00010', expect: 1000 * 60 }, // 00:01:00
|
||||
{ value: '00100', expect: 1000 * 60 * 10 }, // 00:10:00
|
||||
{ value: '01000', expect: 1000 * 60 * 60 }, // 01:00:00
|
||||
{ value: '10000', expect: 1000 * 60 * 60 * 10 }, // 10:00:00
|
||||
{ value: '23000', expect: 1000 * 60 * 60 * 23 }, // 23:00:00
|
||||
{ value: '12121', expect: 1000 + 12 * 60 * 1000 + 12 * 1000 * 60 * 60 }, // 12:12:01
|
||||
];
|
||||
|
||||
for (const s of fiveDigits) {
|
||||
it(`handles string with 5 digits: ${s.value}`, () => {
|
||||
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
|
||||
});
|
||||
}
|
||||
|
||||
const fourDigits = [
|
||||
{ value: '0000', expect: 0 },
|
||||
{ value: '0001', expect: 1000 * 60 }, // 00:01:00
|
||||
{ value: '0010', expect: 1000 * 60 * 10 }, // 00:10:00
|
||||
{ value: '0100', expect: 1000 * 60 * 60 }, // 01:00:00
|
||||
{ value: '1000', expect: 1000 * 60 * 60 * 10 }, // 10:00:00
|
||||
{ value: '2300', expect: 1000 * 60 * 60 * 23 }, // 23:00:00
|
||||
{ value: '1212', expect: 12 * 60 * 1000 + 12 * 1000 * 60 * 60 }, // 12:12:00
|
||||
];
|
||||
|
||||
for (const s of fourDigits) {
|
||||
it(`handles string with 4 digits: ${s.value}`, () => {
|
||||
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
|
||||
});
|
||||
}
|
||||
|
||||
const threeDigits = [
|
||||
{ value: '000', expect: 0 },
|
||||
{ value: '001', expect: 1000 * 60 }, // 00:01:00
|
||||
{ value: '010', expect: 1000 * 60 * 10 }, // 00:10:00
|
||||
{ value: '100', expect: 1000 * 60 * 60 }, // 01:00:00
|
||||
{ value: '230', expect: 2 * 1000 * 60 * 60 + 30 * 1000 * 60 }, // 02:30:00
|
||||
{ value: '121', expect: 21 * 60 * 1000 + 1000 * 60 * 60 }, // 01:21:00
|
||||
];
|
||||
|
||||
for (const s of threeDigits) {
|
||||
it(`handles string with 3 digits: ${s.value}`, () => {
|
||||
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
|
||||
});
|
||||
}
|
||||
|
||||
const twoDigits = [
|
||||
{ value: '00', expect: 0 },
|
||||
{ value: '01', expect: 1000 * 60 }, // 00:01:00
|
||||
{ value: '10', expect: 1000 * 60 * 10 }, // 00:10:00
|
||||
{ value: '23', expect: 1000 * 60 * 23 }, // 00:23:00
|
||||
];
|
||||
|
||||
for (const s of twoDigits) {
|
||||
it(`handles string with 2 digits: ${s.value}`, () => {
|
||||
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
|
||||
});
|
||||
}
|
||||
|
||||
const singleDigit = [...Array(10).keys()];
|
||||
|
||||
for (const s of singleDigit) {
|
||||
it(`handles string with a single digits ${s}`, () => {
|
||||
expect(forgivingStringToMillis(`${s}`)).toBe(s * 1000 * 60);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('handles overflows', () => {
|
||||
const ts = [
|
||||
// minutes overflow
|
||||
{ value: '2.0.0', expect: 1000 * 60 * 120 },
|
||||
{ value: '99', expect: 1000 * 60 * 99 },
|
||||
{ value: '1.39.0', expect: 1000 * 60 * 99 },
|
||||
// seconds overflow
|
||||
{ value: '0.0.120', expect: 120 * 1000 },
|
||||
{ value: '0.2.0', expect: 120 * 1000 },
|
||||
{ value: '0.0.99', expect: 99 * 1000 },
|
||||
{ value: '0.1.39', expect: 99 * 1000 },
|
||||
// hours overflow
|
||||
{ value: '25.0.0', expect: 1000 * 60 * 60 * 25 },
|
||||
// hours overflow
|
||||
{ value: '50.0.0', expect: 1000 * 60 * 60 * 50 },
|
||||
];
|
||||
|
||||
for (const s of ts) {
|
||||
it(`handles ${s.value} to the left`, () => {
|
||||
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('test fillLeft', () => {
|
||||
describe('function handles separators', () => {
|
||||
const testData = [
|
||||
{ value: '1:2:3:10', expect: 3723000 },
|
||||
{ value: '2,10', expect: 2 * 60 * 60 * 1000 + 60 * 10 * 1000 },
|
||||
{ value: '2.10', expect: 2 * 60 * 60 * 1000 + 60 * 10 * 1000 },
|
||||
{ value: '2 10', expect: 2 * 60 * 60 * 1000 + 60 * 10 * 1000 },
|
||||
];
|
||||
|
||||
for (const s of testData) {
|
||||
it(`handles ${s.value}`, () => {
|
||||
expect(typeof forgivingStringToMillis(s.value)).toBe('number');
|
||||
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('parses strings correctly', () => {
|
||||
const ts = [
|
||||
{ value: '1.2', expect: 60 * 60 * 1000 + 2 * 60 * 1000 },
|
||||
{ value: '1.70', expect: 60 * 60 * 1000 + 70 * 60 * 1000 },
|
||||
];
|
||||
|
||||
for (const s of ts) {
|
||||
it(`handles ${s.value}`, () => {
|
||||
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('handles overflows', () => {
|
||||
const ts = [
|
||||
// minutes overflow
|
||||
{ value: '0.120', expect: 120 * 60 * 1000 },
|
||||
{ value: '0.99', expect: 99 * 60 * 1000 },
|
||||
];
|
||||
|
||||
for (const s of ts) {
|
||||
it(`handles ${s.value}`, () => {
|
||||
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('millisToDelayString()', () => {
|
||||
it('returns null for null values', () => {
|
||||
|
||||
@@ -22,7 +22,7 @@ describe('cloneEvent()', () => {
|
||||
timeWarning: 120000,
|
||||
timeDanger: 60000,
|
||||
custom: {
|
||||
lighting: '3',
|
||||
lighting: { value: '3' },
|
||||
} as EventCustomFields,
|
||||
} as OntimeEvent;
|
||||
|
||||
|
||||