Compare commits

..

2 Commits

Author SHA1 Message Date
Carlos Valente 815c285f87 bump version to alpha release 2024-06-22 20:17:37 +02:00
Carlos Valente 8aeb4c7a01 feat: progress view 2024-06-22 20:16:09 +02:00
719 changed files with 20131 additions and 33959 deletions
+7 -7
View File
@@ -14,12 +14,12 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 9
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -36,7 +36,7 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.TEAMID }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
CSC_LINK: ${{ secrets.CSC_LINK }}
run: pnpm dist-mac --env-mode=loose
run: pnpm dist-mac
timeout-minutes: 60
- name: Release
@@ -56,12 +56,12 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 9
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -89,12 +89,12 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 9
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
+4 -4
View File
@@ -1,8 +1,8 @@
name: Ontime CLI build
on:
release:
types: [published]
push:
tags: [ "*" ]
workflow_dispatch:
jobs:
@@ -20,13 +20,13 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 18.18.2
registry-url: 'https://registry.npmjs.org'
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 9
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
+8 -6
View File
@@ -1,8 +1,8 @@
name: Docker Image CI Ontime
on:
release:
types: [published]
push:
tags: [ "*" ]
workflow_dispatch:
jobs:
@@ -14,6 +14,8 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Setup env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
- name: Docker Login
uses: docker/login-action@v2.1.0
@@ -30,10 +32,10 @@ jobs:
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ env.RELEASE_VERSION }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
- name: Build and push pre-release
if: github.event.release.prerelease == true
@@ -41,8 +43,8 @@ jobs:
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ env.RELEASE_VERSION }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
+4 -4
View File
@@ -17,12 +17,12 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 9
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -73,12 +73,12 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 9
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
+3 -2
View File
@@ -28,7 +28,6 @@ test-results
playwright-report
/playwright/.cache/
automated-screenshots
e2e/tests/fixtures/tmp/*
# production
build/
@@ -40,7 +39,9 @@ dist/
# docker utils
ontime-db
ontime-external/
ontime-data/
# working database
apps/server/src/preloaded-db/db.json
# versioning file
**/ONTIME_VERSION.js
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm lint-staged
+1 -1
View File
@@ -1 +1 @@
v20.15.1
v18.18.2
+3 -3
View File
@@ -9,8 +9,8 @@ Ontime consists of 3 distinct parts
The steps below will assume you have locally installed the necessary dependencies.
Other dependencies will be installed as part of the setup
- __node__ (~20)
- __pnpm__ (~9)
- __node__ (~18.18.2)
- __pnpm__ (>=8)
- __docker__ (only necessary to run and build docker images)
## LOCAL DEVELOPMENT
@@ -91,7 +91,7 @@ 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
- __Build docker image from__ by running `docker build -t getontime/ontime .`
- __Build docker image from__ by running `docker build -t getontime/ontime`
- __Run docker image from compose__ by running `docker-compose up -d`
Other useful commands
+6 -7
View File
@@ -1,19 +1,19 @@
FROM node:20-bullseye AS builder
FROM node:18.18-alpine AS builder
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN npm install -g pnpm@9.5.0
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:20-alpine
FROM node:18.18-alpine
# Set environment variables
# Environment Variable to signal that we are running production
ENV NODE_ENV=docker
# Ontime Data path
ENV ONTIME_DATA=/data/
ENV ONTIME_DATA=/external/
WORKDIR /app/
@@ -22,9 +22,8 @@ COPY --from=builder /app/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 --from=builder /app/apps/server/src/user/ ./user/
COPY --from=builder /app/apps/server/src/html/ ./html/
# Export default ports
EXPOSE 4001/tcp 8888/udp 9999/udp
@@ -34,4 +33,4 @@ CMD ["node", "server/docker.cjs"]
# Build and run commands
# !!! Note that this command needs pre-build versions of the UI and server apps
# docker buildx build . -t getontime/ontime
# docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/data/ getontime/ontime
# docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/external/db/ -v ./ontime-styles:/external/styles/ getontime/ontime
+18 -25
View File
@@ -1,8 +1,6 @@
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-green.svg)](https://www.gnu.org/licenses/gpl-3.0)
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/cpvalente/ontime/total)
![Docker Pulls](https://img.shields.io/docker/pulls/getontime/ontime)
![NPM Downloads](https://img.shields.io/npm/dy/%40getontime%2Fcli)
![Homebrew Cask Version](https://img.shields.io/homebrew/cask/v/ontime)
[![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/cpvalente)
[![](https://img.shields.io/static/v1?label=Buy%20me%20a%20coffee&message=%E2%9D%A4&logo=buymeacoffee&color=%23fe8e86)](https://www.buymeacoffee.com/cpvalente)
@@ -13,16 +11,10 @@
- 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>
... or
- Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a>
- Install from <a href="https://www.npmjs.com/package/ontime">NPM</a>
- Install from <a href="https://formulae.brew.sh/cask/ontime">Homebrew</a>
## Need help?
We do our best to have most topics covered by the documentation. However, if your question is not covered, you are welcome to [fill in a bug report in an issue](https://github.com/cpvalente/ontime/issues), [ask a question in GitHub discussions](https://github.com/cpvalente/ontime/discussions) or hop in the [discord server](https://discord.com/invite/eje3CSUEXm) for a chat.
## 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.
@@ -42,16 +34,6 @@ Ontime is made by entertainment and broadcast engineers and used by
- Theatres and opera houses
- Houses of worship
## Main features
- [x] **Multiplatform**: Available for Windows / MacOS, Linux. You can also self host with the docker image
- [x] **In any device**: Ontime is available in the local network to any device with a browser, eg: tablets, mobile phones, laptops, signage, media servers...
- [x] **Made for teams**: Ontime caters to different roles in your production team: directors, operators, backstage and front of house signage...
- [x] **Delay workflows**: Manage and communicate runtime delays in real-time to your team
- [x] **Automatable**: Ontime can be fully or partially controlled by an operator, or run standalone with the system clock
- [x] **Focus on integrations**: Use one of the APIs provided (OSC, HTTP, Websocket) or the available [Companion module](https://bitfocus.io/connections/getontime-ontime) to integrate into your workflow (vMix, disguise, Qlab, OBS)
... and a lot more ...
### For live environments
Ontime is designed for use in live environments. \
@@ -100,7 +82,6 @@ IP.ADDRESS:4001/clock > Simple clock view
IP.ADDRESS:4001/backstage > Stage Manager / Backstage view
IP.ADDRESS:4001/countdown > Countdown to anything
IP.ADDRESS:4001/studio > Studio Clock
IP.ADDRESS:4001/timeline > Timeline
```
```
@@ -118,13 +99,26 @@ IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
IP.ADDRESS:4001/operator > automated views for operators
```
More information is available [in our docs](https://docs.getontime.no)
More documentation is available [in our docs](https://docs.getontime.no)
## Main features
- [x] Distribute data over network and render it in the browser
- [x] Collaborative
- [x] Extendable
- [x] Send 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
- [x] Multi-platform (available on Windows, MacOS and Linux)
- [x] Companion integration [follow link](https://bitfocus.io/connections/getontime-ontime)
## Roadmap
### Continued development
Ontime is under active development. We continue adding and improving features in collaboration with users.
Ontime is under active development. We continue adding and tweaking features in collaboration with users.
Have an idea? Reach out via [email](mail@getontime.no)
or [open an issue](https://github.com/cpvalente/ontime/issues/new)
@@ -166,9 +160,8 @@ If you are a developer and would like to contribute with code, please open an is
Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md)
## Links
- [Ontime website](https://getontime.no)
- [Documentation](https://docs.getontime.no)
- [Ontime discord server](https://discord.com/invite/eje3CSUEXm)
See the [Ontime website](https://getontime.no) here and the link to the [documentation](https://docs.getontime.no)
## License
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "3.14.3",
"version": "3.4.0-alpha-timeline",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
@@ -20,8 +20,8 @@
"server"
],
"devDependencies": {
"eslint": "catalog:",
"eslint-config-prettier": "catalog:",
"prettier": "catalog:"
"eslint": "^8.53.0",
"eslint-config-prettier": "^9.0.0",
"prettier": "^3.0.3"
}
}
+1
View File
@@ -15,6 +15,7 @@
],
"plugins": [
"react",
"testing-library",
"simple-import-sort",
"@tanstack/query"
],
+35 -26
View File
@@ -1,28 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<base href="/" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#101010" />
<meta name="ontime" content="ontime - time keeping for live events" />
<link rel="apple-touch-icon" href="/ontime-logo.png" />
<link rel="icon" type="image/png" href="/ontime-logo.png" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="manifest" href="/manifest.json" />
<meta name="robots" content="noindex" />
<title>ontime</title>
<style>
body,
html {
background-color: rgba(0, 0, 0, 0) !important;
}
</style>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
<html lang='en'>
<head>
<meta charset='utf-8' />
<link rel='icon' href='/favicon.ico' />
<meta name='viewport' content='width=device-width, initial-scale=1' />
<meta name='theme-color' content='#121212' />
<meta name='ontime' content='ontime - event timer manager' />
<link rel='apple-touch-icon' href='/logo192.png' />
<link
rel='icon'
type='image/png'
sizes='32x32'
href='/favicon-32x32.png'
/>
<link
rel='icon'
type='image/png'
sizes='16x16'
href='/favicon-16x16.png'
/>
<link rel='manifest' href='/site.webmanifest' />
<link rel='manifest' href='/manifest.json' />
<title>ontime</title>
<style>
body,
html {
background-color: rgba(0, 0, 0, 0) !important;
}
</style>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id='root'></div>
<script type='module' src='/src/index.tsx'></script>
</body>
</html>
+28 -24
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "3.14.3",
"version": "3.4.0-alpha-timeline",
"private": true,
"type": "module",
"dependencies": {
@@ -8,31 +8,29 @@
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/is-prop-valid": "^1.3.1",
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
"@fontsource/open-sans": "^5.0.28",
"@mantine/hooks": "^7.13.3",
"@sentry/react": "^8.43.0",
"@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.62.7",
"@tanstack/react-query-devtools": "^5.62.7",
"@tanstack/react-table": "^8.20.5",
"@mantine/hooks": "^7.6.2",
"@react-icons/all-files": "^4.1.0",
"@sentry/react": "^7.92.0",
"@tanstack/react-query": "^5.17.9",
"@tanstack/react-query-devtools": "^5.17.9",
"@tanstack/react-table": "^8.11.3",
"autosize": "^6.0.1",
"axios": "^1.2.0",
"color": "^4.2.3",
"csv-stringify": "^6.4.5",
"framer-motion": "^10.10.0",
"react": "^18.3.1",
"react": "^18.2.0",
"react-colorful": "^5.6.1",
"react-dom": "^18.3.1",
"react-dom": "^18.2.0",
"react-fast-compare": "^3.2.2",
"react-hook-form": "^7.53.1",
"react-icons": "5.4.0",
"react-hook-form": "^7.49.2",
"react-qr-code": "^2.0.12",
"react-router-dom": "^6.3.0",
"web-vitals": "^3.1.1",
"zustand": "^5.0.3"
"zustand": "^4.5.2"
},
"scripts": {
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
@@ -41,9 +39,10 @@
"build": "vite build",
"build:local": "cross-env NODE_ENV=local vite build",
"build:electron": "cross-env NODE_ENV=local vite build",
"build:docker": "cross-env VITE_IS_DOCKER=true vite build",
"build:docker": "vite build",
"build:localdocker": "cross-env NODE_ENV=local vite build",
"lint": "eslint . --quiet",
"lint-staged": "eslint",
"test": "vitest",
"test:pipeline": "vitest run",
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build",
@@ -64,29 +63,34 @@
"devDependencies": {
"@sentry/vite-plugin": "^2.16.1",
"@tanstack/eslint-plugin-query": "^5.8.4",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.1.1",
"@testing-library/user-event": "^14.1.1",
"@types/color": "^3.0.3",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:",
"@types/testing-library__jest-dom": "^5.14.5",
"@typescript-eslint/eslint-plugin": "^v7.12.0",
"@typescript-eslint/parser": "^7.12.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "catalog:",
"eslint-config-prettier": "catalog:",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-jest": "^28.6.0",
"eslint-plugin-prettier": "catalog:",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-react": "^7.32.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-simple-import-sort": "^8.0.0",
"happy-dom": "^16.7.2",
"eslint-plugin-testing-library": "^5.9.1",
"jsdom": "^21.1.0",
"ontime-types": "workspace:*",
"ontime-utils": "workspace:*",
"prettier": "catalog:",
"prettier": "^3.3.1",
"sass": "^1.57.1",
"typescript": "catalog:",
"typescript": "^5.4.3",
"vite": "^5.2.11",
"vite-plugin-compression2": "^1.3.3",
"vite-plugin-compression2": "^0.12.0",
"vite-plugin-svgr": "^4.2.0",
"vite-tsconfig-paths": "^4.3.1",
"vitest": "catalog:"
"vitest": "^1.6.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

+9 -2
View File
@@ -4,11 +4,18 @@
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "ontime-logo.png",
"type": "image/png"
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"scope": "/",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

+1 -10
View File
@@ -1,10 +1 @@
{
"name": "",
"short_name": "",
"icons": [
{ "src": "/ontime-logo.png", "sizes": "295x295", "type": "image/png" }
],
"theme_color": "#2B5ABC",
"background_color": "#101010",
"display": "standalone"
}
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
+1 -2
View File
@@ -11,7 +11,6 @@ import { connectSocket } from './common/utils/socket';
import theme from './theme/theme';
import { TranslationProvider } from './translation/TranslationProvider';
import AppRouter from './AppRouter';
import { baseURI } from './externals';
connectSocket();
@@ -20,7 +19,7 @@ function App() {
<ChakraProvider disableGlobalStyle resetCSS theme={theme}>
<QueryClientProvider client={ontimeQueryClient}>
<AppContextProvider>
<BrowserRouter basename={baseURI}>
<BrowserRouter>
<div className='App'>
<ErrorBoundary>
<TranslationProvider>
+42 -141
View File
@@ -1,171 +1,72 @@
import React from 'react';
import {
createRoutesFromChildren,
matchRoutes,
Navigate,
Route,
Routes,
useLocation,
useNavigationType,
} from 'react-router-dom';
import * as Sentry from '@sentry/react';
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';
import ViewLoader from './views/ViewLoader';
import { ONTIME_VERSION } from './ONTIME_VERSION';
import { sentryDsn, sentryRecommendedIgnore } from './sentry.config';
const Editor = React.lazy(() => import('./features/editors/ProtectedEditor'));
const Cuesheet = React.lazy(() => import('./views/cuesheet/ProtectedCuesheet'));
const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
const Editor = lazy(() => import('./features/editors/ProtectedEditor'));
const Cuesheet = lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
const Operator = lazy(() => import('./features/operator/OperatorExport'));
const TimerView = React.lazy(() => import('./views/timer/Timer'));
const MinimalTimerView = React.lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
const ClockView = React.lazy(() => import('./features/viewers/clock/Clock'));
const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown'));
const TimerView = lazy(() => import('./features/viewers/timer/Timer'));
const MinimalTimerView = lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
const ClockView = lazy(() => import('./features/viewers/clock/Clock'));
const Countdown = lazy(() => import('./features/viewers/countdown/Countdown'));
const Backstage = React.lazy(() => import('./views/backstage/Backstage'));
const Timeline = React.lazy(() => import('./views/timeline/TimelinePage'));
const Public = React.lazy(() => import('./views/public/Public'));
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
const ProjectInfo = React.lazy(() => import('./views/project-info/ProjectInfo'));
const Backstage = lazy(() => import('./features/viewers/backstage/Backstage'));
const Public = lazy(() => import('./features/viewers/public/Public'));
const Timeline = lazy(() => import('./features/timeline/TimelinePage'));
const Lower = lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
const StudioClock = lazy(() => import('./features/viewers/studio/StudioClock'));
const STimer = withPreset(withData(TimerView));
const SMinimalTimer = withPreset(withData(MinimalTimerView));
const SClock = withPreset(withData(ClockView));
const SCountdown = withPreset(withData(Countdown));
const SBackstage = withPreset(withData(Backstage));
const SProjectInfo = withPreset(withData(ProjectInfo));
const SPublic = withPreset(withData(Public));
const STimeline = withPreset(withData(Timeline));
const SLowerThird = withPreset(withData(Lower));
const SStudio = withPreset(withData(StudioClock));
const STimeline = withPreset(withData(Timeline));
const PCuesheet = withPreset(Cuesheet);
const POperator = withPreset(Operator);
const EditorFeatureWrapper = React.lazy(() => import('./features/EditorFeatureWrapper'));
const RundownPanel = React.lazy(() => import('./features/rundown/RundownExport'));
const TimerControl = React.lazy(() => import('./features/control/playback/TimerControlExport'));
const MessageControl = React.lazy(() => import('./features/control/message/MessageControlExport'));
Sentry.init({
dsn: sentryDsn,
integrations: [
Sentry.reactRouterV6BrowserTracingIntegration({
useEffect: React.useEffect,
useLocation,
useNavigationType,
createRoutesFromChildren,
matchRoutes,
}),
],
tracesSampleRate: 0.3,
release: ONTIME_VERSION,
enabled: import.meta.env.PROD,
ignoreErrors: [...sentryRecommendedIgnore, /Unable to preload CSS/i, /dynamically imported module/i],
denyUrls: [/extensions\//i, /^chrome:\/\//i, /^chrome-extension:\/\//i],
});
const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes);
const EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper'));
const RundownPanel = lazy(() => import('./features/rundown/RundownExport'));
const TimerControl = lazy(() => import('./features/control/playback/TimerControlExport'));
const MessageControl = lazy(() => import('./features/control/message/MessageControlExport'));
export default function AppRouter() {
// handle client path changes
useClientPath();
return (
<React.Suspense fallback={null}>
<SentryRoutes>
<Suspense fallback={null}>
<Routes>
<Route path='/' element={<Navigate to='/timer' />} />
<Route
path='/timer'
element={
<ViewLoader>
<STimer />
</ViewLoader>
}
/>
<Route
path='/public'
element={
<ViewLoader>
<SPublic />
</ViewLoader>
}
/>
<Route
path='/minimal'
element={
<ViewLoader>
<SMinimalTimer />
</ViewLoader>
}
/>
<Route
path='/clock'
element={
<ViewLoader>
<SClock />
</ViewLoader>
}
/>
<Route
path='/countdown'
element={
<ViewLoader>
<SCountdown />
</ViewLoader>
}
/>
<Route
path='/backstage'
element={
<ViewLoader>
<SBackstage />
</ViewLoader>
}
/>
<Route
path='/studio'
element={
<ViewLoader>
<SStudio />
</ViewLoader>
}
/>
{/*/!* Lower third cannot have a loading screen *!/*/}
<Route path='/timer' element={<STimer />} />
<Route path='/minimal' element={<SMinimalTimer />} />
<Route path='/clock' element={<SClock />} />
<Route path='/countdown' element={<SCountdown />} />
<Route path='/backstage' element={<SBackstage />} />
<Route path='/public' element={<SPublic />} />
<Route path='/studio' element={<SStudio />} />
<Route path='/lower' element={<SLowerThird />} />
<Route
path='/timeline'
element={
<ViewLoader>
<STimeline />
</ViewLoader>
}
/>
<Route
path='/info'
element={
<ViewLoader>
<SProjectInfo />
</ViewLoader>
}
/>
<Route path='/op' element={<Operator />} />
<Route path='/timeline' element={<STimeline />} />
{/*/!* Protected Routes *!/*/}
<Route path='/editor' element={<Editor />} />
<Route path='/cuesheet' element={<PCuesheet />} />
<Route
path='/op'
element={
<ViewLoader>
<POperator />
</ViewLoader>
}
/>
<Route path='/cuesheet' element={<Cuesheet />} />
{/*/!* Protected Routes - Elements *!/*/}
<Route
@@ -202,7 +103,7 @@ export default function AppRouter() {
/>
{/*/!* Send to default if nothing found *!/*/}
<Route path='*' element={<STimer />} />
</SentryRoutes>
</React.Suspense>
</Routes>
</Suspense>
);
}
-85
View File
@@ -1,85 +0,0 @@
import axios from 'axios';
import type {
Automation,
AutomationDTO,
AutomationOutput,
AutomationSettings,
Trigger,
TriggerDTO,
} from 'ontime-types';
import { apiEntryUrl } from './constants';
const automationsPath = `${apiEntryUrl}/automations`;
/**
* HTTP request to get the automations settings
*/
export async function getAutomationSettings(): Promise<AutomationSettings> {
const res = await axios.get(automationsPath);
return res.data;
}
/**
* HTTP request to edit the automations settings
*/
export async function editAutomationSettings(
automationSettings: Partial<AutomationSettings>,
): Promise<AutomationSettings> {
const res = await axios.post(automationsPath, automationSettings);
return res.data;
}
/**
* HTTP request to create a new automation trigger
*/
export async function addTrigger(trigger: TriggerDTO): Promise<Trigger> {
const res = await axios.post(`${automationsPath}/trigger`, trigger);
return res.data;
}
/**
* HTTP request to update an automation trigger
*/
export async function editTrigger(id: string, trigger: Trigger): Promise<Trigger> {
const res = await axios.put(`${automationsPath}/trigger/${id}`, trigger);
return res.data;
}
/**
* HTTP request to delete an automation trigger
*/
export function deleteTrigger(id: string): Promise<void> {
return axios.delete(`${automationsPath}/trigger/${id}`);
}
/**
* HTTP request to create a new automation
*/
export async function addAutomation(automation: AutomationDTO): Promise<Automation> {
const res = await axios.post(`${automationsPath}/automation`, automation);
return res.data;
}
/**
* HTTP request to update a automation
*/
export async function editAutomation(id: string, automation: Automation): Promise<Automation> {
const res = await axios.put(`${automationsPath}/automation/${id}`, automation);
return res.data;
}
/**
* HTTP request to delete a automation
*/
export function deleteAutomation(id: string): Promise<void> {
return axios.delete(`${automationsPath}/automation/${id}`);
}
/**
* HTTP request to test automation output
* The return is irrelevant as we care for the resolution of the promise
*/
export function testOutput(output: AutomationOutput): Promise<void> {
return axios.post(`${automationsPath}/test`, output);
}
+16 -10
View File
@@ -1,11 +1,9 @@
import { serverURL } from '../../externals';
// keys in tanstack store
export const APP_INFO = ['appinfo'];
export const APP_SETTINGS = ['appSettings'];
export const APP_VERSION = ['appVersion'];
export const AUTOMATION = ['automation'];
export const CUSTOM_FIELDS = ['customFields'];
export const HTTP_SETTINGS = ['httpSettings'];
export const OSC_SETTINGS = ['oscSettings'];
export const PROJECT_DATA = ['project'];
export const PROJECT_LIST = ['projectList'];
export const RUNDOWN = ['rundown'];
@@ -14,16 +12,24 @@ export const SHEET_STATE = ['sheetState'];
export const URL_PRESETS = ['urlpresets'];
export const VIEW_SETTINGS = ['viewSettings'];
export const CLIENT_LIST = ['clientList'];
export const REPORT = ['report'];
// API URLs
// resolve location
const location = window.location;
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
export const isProduction = import.meta.env.MODE === 'production';
export const isDev = !isProduction;
// resolve port
const STATIC_PORT = 4001;
export const serverPort = isProduction ? location.port : STATIC_PORT;
export const serverURL = `${location.protocol}//${location.hostname}:${serverPort}`;
export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`;
export const apiEntryUrl = `${serverURL}/data`;
export const projectDataURL = `${serverURL}/project`;
export const rundownURL = `${serverURL}/events`;
export const ontimeURL = `${serverURL}/ontime`;
export const userAssetsPath = 'user';
export const cssOverridePath = 'styles/override.css';
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`;
export const stylesPath = 'external/styles/override.css';
export const overrideStylesURL = `${serverURL}/${stylesPath}`;
+14 -23
View File
@@ -1,8 +1,7 @@
import axios, { AxiosResponse } from 'axios';
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
import { DatabaseModel, GetInfo, MessageResponse, ProjectData, ProjectFileListResponse } from 'ontime-types';
import { makeTable } from '../../views/cuesheet/cuesheet.utils';
import { makeCSVFromArrayOfArrays } from '../utils/csv';
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
import { apiEntryUrl } from './constants';
import { createBlob, downloadBlob } from './utils';
@@ -12,8 +11,8 @@ const dbPath = `${apiEntryUrl}/db`;
/**
* HTTP request to the current DB
*/
export function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> {
return axios.post(`${dbPath}/download`, { filename });
async function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> {
return axios.post(`${dbPath}/download/`, { filename });
}
/**
@@ -43,7 +42,7 @@ export async function downloadCSV(fileName: string = 'rundown') {
const { project, rundown, customFields } = data;
const sheetData = makeTable(project, rundown, customFields);
const fileContent = makeCSVFromArrayOfArrays(sheetData);
const fileContent = makeCSV(sheetData);
const blob = createBlob(fileContent, 'text/csv;charset=utf-8;');
downloadBlob(blob, `${name}.csv`);
@@ -87,14 +86,6 @@ export async function createProject(
return res.data;
}
/**
* HTTP request to create a project file
*/
export async function quickProject(data: QuickStartData): Promise<MessageResponse> {
const res = await axios.post(`${dbPath}/quick`, data);
return res.data;
}
/**
* HTTP request to get the list of available project files
*/
@@ -113,14 +104,6 @@ export async function loadProject(filename: string): Promise<MessageResponse> {
return res.data;
}
/**
* HTTP request to load the demo project file
*/
export async function loadDemo(): Promise<MessageResponse> {
const res = await axios.post(`${dbPath}/demo`);
return res.data;
}
/**
* HTTP request to duplicate a project file
*/
@@ -140,7 +123,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, {
newFilename,
filename: newFilename,
});
return res.data;
}
@@ -155,6 +138,14 @@ export async function deleteProject(filename: string): Promise<MessageResponse>
return res.data;
}
/**
* HTTP request to retrieve application info
*/
export async function getInfo(): Promise<GetInfo> {
const res = await axios.get(`${dbPath}/info`);
return res.data;
}
/**
* Utility function gets project from db
* @param fileName
+21
View File
@@ -0,0 +1,21 @@
import axios, { AxiosResponse } from 'axios';
import { HttpSettings } from 'ontime-types';
import { apiEntryUrl } from './constants';
const httpPath = `${apiEntryUrl}/http`;
/**
* HTTP request to retrieve http settings
*/
export async function getHTTP(): Promise<HttpSettings> {
const res = await axios.get(httpPath);
return res.data;
}
/**
* HTTP request to mutate http settings
*/
export async function postHTTP(data: HttpSettings): Promise<AxiosResponse<HttpSettings>> {
return axios.post(httpPath, data);
}
+21
View File
@@ -0,0 +1,21 @@
import axios, { AxiosResponse } from 'axios';
import { OSCSettings } from 'ontime-types';
import { apiEntryUrl } from './constants';
const oscPath = `${apiEntryUrl}/osc`;
/**
* HTTP request to retrieve osc settings
*/
export async function getOSC(): Promise<OSCSettings> {
const res = await axios.get(oscPath);
return res.data;
}
/**
* HTTP request to mutate osc settings
*/
export async function postOSC(data: OSCSettings): Promise<AxiosResponse<OSCSettings>> {
return axios.post(oscPath, data);
}
+1 -16
View File
@@ -1,5 +1,5 @@
import axios, { AxiosResponse } from 'axios';
import { ProjectData, ProjectLogoResponse } from 'ontime-types';
import { ProjectData } from 'ontime-types';
import { apiEntryUrl } from './constants';
@@ -19,18 +19,3 @@ export async function getProjectData(): Promise<ProjectData> {
export async function postProjectData(data: ProjectData): Promise<AxiosResponse<ProjectData>> {
return axios.post(projectPath, data);
}
/**
* HTTP request to upload a project logo
*/
export async function uploadProjectLogo(file: File): Promise<AxiosResponse<ProjectLogoResponse>> {
const formData = new FormData();
formData.append('image', file);
const response = await axios.post(`${projectPath}/upload`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response;
}
-26
View File
@@ -1,26 +0,0 @@
import axios from 'axios';
import { OntimeReport } from 'ontime-types';
import { ontimeQueryClient } from '../../common/queryClient';
import { apiEntryUrl, REPORT } from './constants';
export const reportUrl = `${apiEntryUrl}/report`;
/**
* HTTP request to fetch all reports
*/
export async function fetchReport(): Promise<OntimeReport> {
const res = await axios.get(`${reportUrl}/`);
return res.data;
}
export async function deleteReport(id: string) {
await axios.delete(`${reportUrl}/${id}`);
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
}
export async function deleteAllReport() {
await axios.delete(`${reportUrl}/all`);
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
}
+2 -2
View File
@@ -1,5 +1,5 @@
import axios, { AxiosResponse } from 'axios';
import { MessageResponse, OntimeEvent, OntimeRundownEntry, RundownCached, TransientEventPayload } from 'ontime-types';
import { MessageResponse, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
import { apiEntryUrl } from './constants';
@@ -16,7 +16,7 @@ export async function fetchNormalisedRundown(): Promise<RundownCached> {
/**
* HTTP request to post new event
*/
export async function requestPostEvent(data: TransientEventPayload): Promise<AxiosResponse<OntimeRundownEntry>> {
export async function requestPostEvent(data: Partial<OntimeRundownEntry>): Promise<AxiosResponse<OntimeRundownEntry>> {
return axios.post(rundownPath, data);
}
-27
View File
@@ -1,27 +0,0 @@
import axios from 'axios';
import { GetInfo } from 'ontime-types';
import { apiEntryUrl } from './constants';
const sessionPath = `${apiEntryUrl}/session`;
/**
* HTTP request to retrieve application info
*/
export async function getInfo(): Promise<GetInfo> {
const res = await axios.get(`${sessionPath}/info`);
return res.data;
}
/**
* HTTP request to get a pre-authenticated URL
*/
export async function generateUrl(
baseUrl: string,
path: string,
lock: boolean,
authenticate: boolean,
): Promise<string> {
const res = await axios.post(`${sessionPath}/url`, { baseUrl, path, lock, authenticate });
return res.data.url;
}
-7
View File
@@ -19,10 +19,3 @@ export async function getSettings(): Promise<Settings> {
export async function postSettings(data: Settings): Promise<AxiosResponse<Settings>> {
return axios.post(settingsPath, data);
}
/**
* Allows setting the welcome modal dialog state from the clients
*/
export async function postShowWelcomeDialog(show: boolean) {
axios.post(`${settingsPath}/welcomedialog`, { show });
}
-2
View File
@@ -23,8 +23,6 @@ export function maybeAxiosError(error: unknown) {
}
}
return `${statusText}: ${data}`;
} else if (error instanceof Error) {
return error.message;
} else {
if (typeof error !== 'string') {
return JSON.stringify(error);
@@ -0,0 +1,12 @@
@use "../../../theme/mixins" as *;
.link {
@include action-link;
font-size: $inner-section-text-size;
}
.linkIcon {
margin-left: $element-inner-spacing;
display: inline-block;
transform: rotate(45deg);
}
@@ -0,0 +1,26 @@
import { MouseEvent, ReactNode } from 'react';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { openLink } from '../../utils/linkUtils';
import style from './AppLink.module.scss';
interface AppLinkProps {
href: string;
children: ReactNode;
}
export default function AppLink(props: AppLinkProps) {
const { href, children } = props;
const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
openLink(href);
};
return (
<a href='#!' target='_blank' rel='noreferrer' className={style.link} onClick={handleClick}>
{children} <IoArrowUp className={style.linkIcon} />
</a>
);
}
@@ -1,26 +0,0 @@
.subtle {
aspect-ratio: 1;
height: 2rem;
height: 2rem;
display: grid;
place-content: center;
background: $gray-1050;
color: $blue-400;
border: 1px solid transparent;
border-radius: 3px;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $blue-500;
}
&:active {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
background: $gray-1050;
}
}
@@ -1,14 +0,0 @@
import { ButtonHTMLAttributes } from 'react';
import { cx } from '../../utils/styleUtils';
import style from './IconButton.module.scss';
export default function IconButton(props: ButtonHTMLAttributes<HTMLButtonElement>) {
const { className, children, ...buttonProps } = props;
return (
<button className={cx([style.subtle, className])} {...buttonProps}>
{children}
</button>
);
}
@@ -0,0 +1,6 @@
.buttonSection {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $element-spacing;
margin-top: -$element-spacing;
}
@@ -1,24 +0,0 @@
.textEntry {
grid-area: input;
display: flex;
align-items: center;
gap: 0.5rem;
color: $label-gray;
}
.inlineEntry {
display: grid;
grid-template-areas:
'label label'
'input btn';
grid-template-columns: 1fr auto;
column-gap: 0.5rem;
}
.redirect {
grid-area: btn;
}
.label {
color: $label-gray;
}
@@ -1,125 +1,68 @@
import { useState } from 'react';
import { IoArrowForward } from 'react-icons/io5';
import {
IconButton,
Button,
Input,
InputGroup,
InputLeftAddon,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Select,
} from '@chakra-ui/react';
import { navigatorConstants } from '../../../viewerConfig';
import { setClientRemote } from '../../hooks/useSocket';
import useUrlPresets from '../../hooks-query/useUrlPresets';
import Info from '../info/Info';
import AppLink from '../link/app-link/AppLink';
import style from './RedirectClientModal.module.scss';
import style from './ClientModal.module.scss';
interface RedirectClientModalProps {
id: string;
name: string;
currentPath: string;
origin: string;
name?: string;
path?: string;
isOpen: boolean;
onClose: () => void;
}
export function RedirectClientModal(props: RedirectClientModalProps) {
const { id, isOpen, name, currentPath, origin, onClose } = props;
const { data } = useUrlPresets();
const { id, isOpen, name = '', path: currentPath = '', onClose } = props;
const [path, setPath] = useState(currentPath);
const [selected, setSelected] = useState('/');
const { setRedirect } = setClientRemote;
const handleRedirect = (newPath: string) => {
if (newPath === '/' || newPath === currentPath) {
return;
const handleRedirect = () => {
if (path !== currentPath && path !== '') {
setRedirect({ target: id, redirect: path });
}
setRedirect({ target: id, redirect: newPath });
onClose();
};
const enabledPresets = data.filter((preset) => preset.enabled);
const host = `${window.location.origin}/`;
const canSubmit = path !== currentPath && path !== '';
return (
<Modal isOpen={isOpen} onClose={onClose} variant='ontime'>
<ModalOverlay />
<ModalContent maxWidth='max(480px, 35vw)'>
<ModalContent>
<ModalHeader>Redirect: {name}</ModalHeader>
<ModalCloseButton />
<ModalBody>
<Info>
Remotely redirect the client to a different URL. <br />
Either by selecting a URL Preset or entering a custom path.
<br />
<br />
<AppLink search='settings=feature_settings__urlpresets'>Manage URL Presets</AppLink>
</Info>
<div>
<span className={style.label}>Select View or URL Preset</span>
<div className={style.textEntry}>
<Select
size='md'
variant='ontime'
isDisabled={enabledPresets.length === 0}
onChange={(event) => setSelected(event.target.value)}
>
<option value='/'>Select view or preset</option>
{navigatorConstants.map((view) => {
return (
<option key={view.url} value={`/${view.url}`}>
{view.label}
</option>
);
})}
{enabledPresets.map((preset) => {
return (
<option key={preset.pathAndParams} value={preset.pathAndParams}>
{`Preset: ${preset.alias}`}
</option>
);
})}
</Select>
<IconButton
variant='ontime-filled'
size='md'
aria-label='Redirect to preset'
className={style.redirect}
icon={<IoArrowForward />}
isDisabled={enabledPresets.length === 0 || selected === '/'}
onClick={() => handleRedirect(selected)}
/>
</div>
</div>
<div className={style.inlineEntry}>
<span className={style.label}>Enter custom path</span>
<label className={style.textEntry}>
{origin}
<Input
variant='ontime-filled'
size='md'
placeholder='eg. /minimal?key=0000ffff'
value={path}
onChange={(event) => setPath(event.target.value)}
/>
</label>
<IconButton
variant='ontime-filled'
size='md'
aria-label='Redirect'
isDisabled={path === currentPath || path === ''}
className={style.redirect}
icon={<IoArrowForward />}
onClick={() => handleRedirect(path)}
/>
</div>
<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>
);
@@ -13,6 +13,8 @@ import {
import { setClientRemote } from '../../hooks/useSocket';
import style from './ClientModal.module.scss';
interface RenameClientModalProps {
id: string;
name?: string;
@@ -51,12 +53,14 @@ export function RenameClientModal(props: RenameClientModalProps) {
/>
</ModalBody>
<ModalFooter>
<Button size='md' variant='ontime-subtle' onClick={onClose}>
Cancel
</Button>
<Button size='md' variant='ontime-filled' onClick={handleRename} isDisabled={!canSubmit}>
Submit
</Button>
<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>
@@ -2,8 +2,8 @@
// https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx
import { ReactElement } from 'react';
import { IconType } from 'react-icons';
import { Menu, MenuButton, MenuGroup, MenuList } from '@chakra-ui/react';
import { IconType } from '@react-icons/all-files';
import { create } from 'zustand';
import { ContextMenuOption } from './ContextMenuOption';
@@ -1,41 +1,32 @@
import { PropsWithChildren, useState } from 'react';
import { IoCheckmark } from 'react-icons/io5';
import { IoCopy } from 'react-icons/io5';
import { PropsWithChildren } from 'react';
import { Button, ButtonGroup, IconButton, Tooltip } from '@chakra-ui/react';
import { IoCopy } from '@react-icons/all-files/io5/IoCopy';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { Size } from '../../models/Util.type';
import copyToClipboard from '../../utils/copyToClipboard';
interface CopyTagProps {
copyValue: string;
label: string;
className?: string;
size?: Size;
disabled?: boolean;
onClick?: () => void;
}
export default function CopyTag(props: PropsWithChildren<CopyTagProps>) {
const { copyValue, label, size = 'xs', disabled, children, onClick } = props;
const [copied, setCopied] = useState(false);
const { label, className, size = 'xs', disabled, children } = props;
const handleClick = () => {
copyToClipboard(copyValue);
setCopied(true);
// reset copied state
setTimeout(() => setCopied(false), 1000);
};
const handleClick = () => copyToClipboard(children as string);
return (
<Tooltip label={label} openDelay={tooltipDelayFast}>
<ButtonGroup size={size} isAttached>
<Button variant='ontime-subtle' tabIndex={-1} onClick={onClick} isDisabled={disabled}>
<ButtonGroup size={size} isAttached className={className}>
<Button variant='ontime-subtle' tabIndex={-1} isDisabled={disabled}>
{children}
</Button>
<IconButton
aria-label={label}
icon={copied ? <IoCheckmark /> : <IoCopy />}
icon={<IoCopy />}
variant='ontime-filled'
tabIndex={-1}
onClick={handleClick}
@@ -1,5 +1,6 @@
import { IoChevronDown, IoChevronUp } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/react';
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
import { IoChevronUp } from '@react-icons/all-files/io5/IoChevronUp';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { millisToDelayString } from '../../utils/dateConfig';
@@ -8,23 +9,32 @@ import style from './DelayIndicator.module.scss';
interface DelayIndicatorProps {
delayValue?: number;
tooltipPrefix?: string;
}
export default function DelayIndicator(props: DelayIndicatorProps) {
const { delayValue, tooltipPrefix } = props;
const { delayValue } = props;
if (typeof delayValue !== 'number' || delayValue === 0) {
return null;
if (typeof delayValue === 'number') {
if (delayValue < 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<span className={style.delaySymbol}>
<IoChevronDown />
</span>
</Tooltip>
);
}
if (delayValue > 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<span className={style.delaySymbol}>
<IoChevronUp />
</span>
</Tooltip>
);
}
}
const delayString = tooltipPrefix
? `${tooltipPrefix} ${millisToDelayString(delayValue)}`
: millisToDelayString(delayValue);
return (
<Tooltip openDelay={tooltipDelayFast} label={delayString}>
<span className={style.delaySymbol}>{delayValue < 0 ? <IoChevronDown /> : <IoChevronUp />}</span>
</Tooltip>
);
return null;
}
@@ -26,22 +26,15 @@ class ErrorBoundary extends React.Component {
error,
errorInfo: info,
});
this.reportContent = `${error} ${info.componentStack}`;
const appState = runtimeStore.getState();
if (appState.ping < 0) {
return;
}
Sentry.withScope((scope) => {
scope.setExtras({
error,
store: appState,
hasSocket: { hasConnected, shouldReconnect, reconnectAttempts },
});
scope.setExtras('error', error);
scope.setExtras('store', runtimeStore.getState());
scope.setExtras('hasSocket', { hasConnected, shouldReconnect, reconnectAttempts });
const eventId = Sentry.captureException(error);
this.setState({ eventId, info });
});
this.reportContent = `${error} ${info.componentStack}`;
}
render() {
@@ -1,8 +1,8 @@
import { MouseEvent, ReactNode } from 'react';
import { IoOpenOutline } from 'react-icons/io5';
import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline';
import { openLink } from '../../../utils/linkUtils';
import { cx } from '../../../utils/styleUtils';
import { openLink } from '../../utils/linkUtils';
import { cx } from '../../utils/styleUtils';
import style from './ExternalLink.module.scss';
@@ -14,6 +14,7 @@ interface ExternalLinkProps {
export default function ExternalLink(props: ExternalLinkProps) {
const { href, inline, children } = props;
const classes = cx([style.link, inline ? style.inline : null]);
const handleClick = (event: MouseEvent) => {
event.preventDefault();
@@ -21,14 +22,8 @@ export default function ExternalLink(props: ExternalLinkProps) {
};
return (
<a
href='#!'
target='_blank'
rel='noreferrer'
className={cx([style.link, inline && style.inline])}
onClick={handleClick}
>
{children} <IoOpenOutline style={{ fontSize: '1em' }} />
<a href='#!' target='_blank' rel='noreferrer' className={classes} onClick={handleClick}>
{children} <IoOpenOutline />
</a>
);
}
@@ -1,63 +0,0 @@
/**
* Copied from
* https://github.com/namhong2001/react-textfit/blob/master/index.tsx
*/
import { HTMLAttributes, PropsWithChildren, useCallback, useEffect, useRef } from 'react';
import { bsearch } from './fitText.utils';
interface FitTextProps extends HTMLAttributes<HTMLDivElement> {
mode?: 'single' | 'multi';
min?: number; // inclusive
max?: number; // inclusive
}
export function FitText(props: PropsWithChildren<FitTextProps>) {
const { children, mode = 'multi', min = 16, max = 256, ...elementProps } = props;
const ref = useRef<HTMLDivElement>(null);
const isOverflown = useCallback(() => {
const el = ref.current;
if (!el) return false;
return el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth;
}, []);
const setFontSize = useCallback(() => {
const el = ref.current;
if (!el) return;
const originVisibility = el.style.visibility;
el.style.visibility = 'hidden';
const fontSize = bsearch(min, max + 1, (mid) => {
el.style.fontSize = `${mid}px`;
return !isOverflown();
});
el.style.fontSize = `${fontSize}px`;
el.style.visibility = originVisibility;
}, [isOverflown, min, max]);
useEffect(() => {
const el = ref.current;
if (!el) return;
setFontSize();
const observer = new ResizeObserver(setFontSize);
observer.observe(el);
return () => observer.disconnect();
}, [children, mode, setFontSize]);
return (
<div
ref={ref}
style={{
whiteSpace: mode === 'single' ? 'nowrap' : 'normal',
}}
{...elementProps}
>
{children}
</div>
);
}
@@ -1,18 +0,0 @@
/**
* @param low inclusive, must be true on predicate function
* @param high exclusive,
* @param predicate predicate function
*/
export const bsearch = (low: number, high: number, predicate: (mid: number) => boolean): number => {
while (low < high) {
const mid = Math.floor((low + high) / 2);
if (mid === low) break;
if (predicate(mid)) {
low = mid;
} else {
high = mid;
}
}
return low;
};
@@ -54,6 +54,7 @@ function Overlay() {
};
}, [showOverlay, id, setIdentify, handleClose]);
console.log('here2');
return (
<div className={style.overlay} data-testid='identify-overlay' onClick={handleClose}>
<div className={style.name}>{name}</div>
@@ -1,21 +0,0 @@
.infoLabel {
display: flex;
align-items: center;
gap: $element-spacing;
background-color: $gray-1200;
border-radius: 3px;
font-size: $text-body-size;
padding: 1rem;
.content {
color: $gray-200;
}
svg {
min-width: 1.5rem;
align-self: start;
font-size: 1.5rem;
color: $info-blue;
}
}
@@ -1,21 +0,0 @@
import { PropsWithChildren } from 'react';
import { IoAlertCircle } from 'react-icons/io5';
import { cx } from '../../utils/styleUtils';
import style from './Info.module.scss';
interface InfoProps {
className?: string;
}
export default function Info(props: PropsWithChildren<InfoProps>) {
const { className, children } = props;
return (
<div className={cx([style.infoLabel, className])}>
<IoAlertCircle />
<div>{children}</div>
</div>
);
}
@@ -1,4 +1,4 @@
import { IoBan } from 'react-icons/io5';
import { IoBan } from '@react-icons/all-files/io5/IoBan';
import { cx } from '../../../utils/styleUtils';
@@ -16,11 +16,11 @@ export default function Swatch(props: SwatchProps) {
const handleClick = () => {
onClick?.(color);
};
const classes = cx([style.swatch, isSelected && style.selected, onClick && style.selectable]);
const classes = cx([style.swatch, isSelected ? style.selected : null, onClick ? style.selectable : null]);
if (!color) {
return (
<div className={classes} onClick={handleClick}>
<div className={`${classes} ${style.center}`} onClick={handleClick}>
<IoBan />
</div>
);
@@ -1,60 +0,0 @@
import { useCallback } from 'react';
import { useController, UseControllerProps } from 'react-hook-form';
import { IoEyedrop } from 'react-icons/io5';
import { ViewSettings } from 'ontime-types';
import { debounce } from '../../../utils/debounce';
import { cx, getAccessibleColour } from '../../../utils/styleUtils';
import PopoverPicker from '../popover-picker/PopoverPicker';
import style from './SwatchSelect.module.scss';
interface SwatchPickerProps {
color: string;
isSelected?: boolean;
onChange: (name: string) => void;
alwaysDisplayColor?: boolean;
}
export default function SwatchPicker(props: SwatchPickerProps) {
const { color, onChange, isSelected, alwaysDisplayColor } = props;
const debouncedOnChange = useCallback(
debounce((newValue: string) => {
onChange(newValue);
}, 500),
[onChange],
);
const displayColor = alwaysDisplayColor || isSelected ? color : '';
const { color: iconColor } = getAccessibleColour(displayColor);
return (
<PopoverPicker color={displayColor} onChange={debouncedOnChange}>
<div
className={cx([style.swatch, isSelected && style.selected, style.selectable])}
style={{ backgroundColor: displayColor }}
>
<IoEyedrop color={iconColor} />
</div>
</PopoverPicker>
);
}
export function SwatchPickerRHF(props: UseControllerProps<ViewSettings>) {
const { name, control } = props;
const {
field: { onChange, value },
} = useController({ control, name });
const displayColor = typeof value === 'string' ? value : '';
const { color: iconColor } = getAccessibleColour(displayColor);
return (
<PopoverPicker color={value as string} onChange={onChange}>
<div className={cx([style.swatch, style.selectable])} style={{ backgroundColor: displayColor }}>
<IoEyedrop color={iconColor} />
</div>
</PopoverPicker>
);
}
@@ -1,18 +1,15 @@
.list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
gap: 0.5rem
}
.swatch {
width: 2rem;
height: 2rem;
aspect-ratio: 1;
border-radius: 99px;
border: 2px solid $gray-1200;
color: $ui-white;
display: grid;
place-content: center;
&.selected {
border: 2px solid $blue-500;
@@ -20,9 +17,11 @@
&.selectable {
cursor: pointer;
&:hover {
border: 2px solid $blue-300;
}
}
}
.center {
display: grid;
place-content: center;
color: $ui-white;
}
@@ -1,7 +1,6 @@
import { useCallback } from 'react';
import Swatch from './Swatch';
import SwatchPicker from './SwatchPicker';
import style from './SwatchSelect.module.scss';
@@ -44,7 +43,6 @@ export default function SwatchSelect(props: ColourInputProps) {
{colours.map((colour) => (
<Swatch key={colour} color={colour} onClick={setColour} isSelected={value === colour} />
))}
<SwatchPicker color={value} onChange={setColour} isSelected={!colours.includes(value)} />
</div>
);
}
@@ -1,20 +1,16 @@
.input {
color: $gray-200;
border: 1px solid transparent;
border-radius: 0 0 8px 8px;
background-color: $gray-1200;
padding: 0 0.5rem;
font-size: 1rem;
height: 2rem;
outline: none;
.swatch {
width: 24px;
height: 24px;
border-radius: 12px;
border: 1px solid white;
box-shadow: 0 0 0 1px $gray-300;
cursor: pointer;
&:hover {
background-color: $gray-1100;
}
&:focus {
background-color: $gray-1000;
color: $gray-50;
border: 1px solid $blue-500;
}
transition-property: box-shadow;
transition-duration: $transition-time-action;
}
.swatch:hover {
box-shadow: 0 0 0 1px $blue-300;
}
@@ -1,22 +1,34 @@
import { PropsWithChildren } from 'react';
import { HexAlphaColorPicker, HexColorInput } from 'react-colorful';
import { HexAlphaColorPicker } from 'react-colorful';
import { useController, UseControllerProps } from 'react-hook-form';
import { Popover, PopoverContent, PopoverTrigger } from '@chakra-ui/react';
import { ViewSettings } from 'ontime-types';
import style from './PopoverPicker.module.scss';
export function PopoverPickerRHF(props: UseControllerProps<ViewSettings>) {
const { name, control } = props;
const {
field: { onChange, value },
} = useController({ control, name });
return <PopoverPicker color={value as string} onChange={onChange} />;
}
interface PopoverPickerProps {
color: string;
onChange: (color: string) => void;
}
export default function PopoverPicker(props: PropsWithChildren<PopoverPickerProps>) {
const { color, onChange, children } = props;
export default function PopoverPicker(props: PopoverPickerProps) {
const { color, onChange } = props;
return (
<Popover>
<PopoverTrigger>{children}</PopoverTrigger>
<PopoverContent className={style.small} style={{ borderRadius: '9px', width: 'auto' }}>
<PopoverTrigger>
<div className={style.swatch} style={{ backgroundColor: color }} />
</PopoverTrigger>
<PopoverContent style={{ width: 'auto' }}>
<HexAlphaColorPicker color={color} onChange={onChange} />
<HexColorInput color={color} onChange={onChange} className={style.input} prefixed />
</PopoverContent>
</Popover>
);
@@ -15,9 +15,6 @@ export default function useReactiveTextInput(
options?: {
submitOnEnter?: boolean;
submitOnCtrlEnter?: boolean;
onCancelUpdate?: () => void;
allowSubmitSameValue?: boolean;
allowKeyboardNavigation?: boolean;
},
): UseReactiveTextInputReturn {
const [text, setText] = useState<string>(initialText);
@@ -50,25 +47,16 @@ export default function useReactiveTextInput(
const handleSubmit = useCallback(
(valueToSubmit: string) => {
// No need to update if it hasn't changed
if (valueToSubmit === initialText && !options?.allowSubmitSameValue) {
options?.onCancelUpdate?.();
} else {
if (valueToSubmit !== initialText) {
const cleanVal = valueToSubmit.trim();
submitCallback(cleanVal);
if (cleanVal !== valueToSubmit) {
setText(cleanVal);
}
}
setTimeout(() => {
if (options?.allowKeyboardNavigation) {
ref.current?.parentElement?.focus(); // Focus on parent element to continue keyboard navigation
} else {
ref.current?.blur();
}
}); // Immediate timeout to ensure text is set before blurring
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
},
[initialText, options, ref, submitCallback],
[initialText, ref, submitCallback],
);
/**
@@ -78,25 +66,11 @@ export default function useReactiveTextInput(
const handleEscape = useCallback(() => {
// No need to update if it hasn't changed
setText(initialText);
// force the text to be the initial value
if (ref.current) {
ref.current.value = initialText;
}
options?.onCancelUpdate?.();
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before blurring
}, [initialText, options, ref]);
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
}, [initialText, ref]);
const keyHandler = useMemo(() => {
const hotKeys: HotkeyItem[] = [
[
'Escape',
(event) => {
event.preventDefault();
handleEscape();
},
{ preventDefault: true },
],
];
const hotKeys: HotkeyItem[] = [['Escape', handleEscape, { preventDefault: true }]];
if (options?.submitOnEnter) {
hotKeys.push(['Enter', () => handleSubmit(text)]);
@@ -105,22 +79,7 @@ export default function useReactiveTextInput(
if (options?.submitOnCtrlEnter) {
hotKeys.push(['mod + Enter', () => handleSubmit(text)]);
}
const hotKeyHandler = getHotkeyHandler(hotKeys);
return (event: KeyboardEvent<HTMLElement>) => {
// allow moving in input field with arrow keys
if (
event.key === 'ArrowLeft' ||
event.key === 'ArrowRight' ||
event.key === 'ArrowUp' ||
event.key === 'ArrowDown'
) {
event.stopPropagation();
}
hotKeyHandler(event);
};
return getHotkeyHandler(hotKeys);
}, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, text]);
return {
@@ -5,7 +5,6 @@ import { millisToString, parseUserTime } from 'ontime-utils';
import { useEmitLog } from '../../../stores/logger';
interface TimeInputProps<T extends string> {
id?: T;
name: T;
submitHandler: (field: T, value: string) => void;
time?: number;
@@ -16,7 +15,7 @@ interface TimeInputProps<T extends string> {
}
export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
const { id, name, submitHandler, time = 0, placeholder, disabled, align = 'center', className } = props;
const { name, submitHandler, time = 0, placeholder, disabled, align = 'center', className } = props;
const { emitError } = useEmitLog();
const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState<string>('');
@@ -55,7 +54,6 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
// we dont know the values in the rundown, escalate to handler
if (newValue.startsWith('p') || newValue.startsWith('+')) {
submitHandler(name, newValue);
return true;
}
const valueInMillis = parseUserTime(newValue);
@@ -119,7 +117,6 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
return (
<Input
id={id}
disabled={disabled}
size='sm'
ref={inputRef}
@@ -2,8 +2,6 @@ $input-delayed-border-color: $ontime-delay-text;
.timeInput {
border: 1px solid transparent;
color: $label-gray;
&.delayed {
border: 1px solid $input-delayed-border-color;
}
@@ -1,10 +0,0 @@
.link {
color: $label-gray;
font-size: $text-body-size;
text-decoration: underline;
text-underline-offset: 2px;
&:hover {
color: $blue-400;
}
}
@@ -1,25 +0,0 @@
import { type PropsWithChildren } from 'react';
import { useNavigate } from 'react-router-dom';
import style from './AppLink.module.scss';
interface AppLinkProps {
search: string;
}
/**
* Component used to navigate to an editor link inside the same window
* Handles the path to respect Ontime Clouds base URL
*/
export default function AppLink(props: PropsWithChildren<AppLinkProps>) {
const { search, children } = props;
const navigate = useNavigate();
const handleClick = () => navigate({ search });
return (
<button onClick={handleClick} className={style.link}>
{children}
</button>
);
}
@@ -1,6 +1,6 @@
@use '../../../theme/viewerDefs' as *;
$progress-bar-size: 1.5rem;
$progress-bar-size: 12px;
$progress-bar-br: 3px;
.multiprogress-bar {
@@ -53,6 +53,3 @@ $progress-bar-br: 3px;
.multiprogress-bar__bg-danger {
flex-shrink: 0;
}
.multiprogress-bar__bg-danger--overtime {
background: var(--timer-overtime-color-override, $timer-finished-color);
}
@@ -1,20 +1,18 @@
import { MaybeNumber } from 'ontime-types';
import { getProgress } from '../../utils/getProgress';
import { cx } from '../../utils/styleUtils';
import { clamp } from '../../utils/math';
import './MultiPartProgressBar.scss';
interface MultiPartProgressBar {
now: MaybeNumber;
complete: MaybeNumber;
complete: number;
normalColor: string;
warning?: MaybeNumber;
warningColor: string;
danger?: MaybeNumber;
dangerColor: string;
hidden?: boolean;
hideOvertime?: boolean;
ignoreCssOverride?: boolean;
className?: string;
}
@@ -29,25 +27,20 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
danger,
dangerColor,
hidden,
hideOvertime,
ignoreCssOverride,
className = '',
} = props;
const percentRemaining = 100 - getProgress(now, complete);
const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0;
const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0;
const isOvertime = now !== null && now < 0;
const showOvertime = isOvertime && !hideOvertime;
const percentRemaining = complete === 0 ? 0 : 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;
return (
<div
className={cx([
'multiprogress-bar',
hidden && 'multiprogress-bar--hidden',
ignoreCssOverride && 'multiprogress-bar--ignore-css-override',
className,
])}
className={`multiprogress-bar ${hidden ? 'multiprogress-bar--hidden' : ''} ${
ignoreCssOverride ? 'multiprogress-bar--ignore-css-override' : ''
} ${className}`}
>
{now !== null && (
<>
@@ -58,8 +51,8 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
style={{ width: `${warningWidth}%`, backgroundColor: warningColor }}
/>
<div
className={cx(['multiprogress-bar__bg-danger', showOvertime && 'multiprogress-bar__bg-danger--overtime'])}
style={{ width: `${dangerWidth}%`, backgroundColor: showOvertime ? undefined : dangerColor }}
className='multiprogress-bar__bg-danger'
style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }}
/>
</div>
<div className='multiprogress-bar__indicator'>
@@ -1,8 +1,8 @@
import { IoApps } from 'react-icons/io5';
import { IoSettingsOutline } from 'react-icons/io5';
import { useEffect, useState } from 'react';
import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { useFadeOutOnInactivity } from '../../hooks/useFadeOutOnInactivity';
import { cx } from '../../utils/styleUtils';
import { debounce } from '../../utils/debounce';
import style from './NavigationMenu.module.scss';
@@ -13,10 +13,32 @@ interface FloatingNavigationProps {
export default function FloatingNavigation(props: FloatingNavigationProps) {
const { toggleMenu, toggleSettings } = props;
const isButtonShown = useFadeOutOnInactivity();
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={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])}>
<div className={`${style.buttonContainer} ${!showButton ? style.hidden : ''}`}>
<button
onClick={toggleMenu}
aria-label='toggle menu'
@@ -7,38 +7,24 @@ $icon-color: $ui-white;
$button-bg: $gray-1050;
$button-size: 3rem;
.fadeable {
opacity: 1;
transition-property: opacity;
transition-duration: 0.3s;
&.hidden {
opacity: 0;
}
}
.lockIcon {
font-size: 1.5rem;
color: $active-indicator;
padding: 0.5em;
position: fixed;
left: 0;
top: 0;
z-index: 12;
}
.buttonContainer {
display: flex;
flex-direction: column;
row-gap: 1rem;
padding: 0.5em;
transition-property: opacity;
transition-duration: 0.3s;
opacity: 1;
position: fixed;
left: 0;
top: 0;
z-index: 12;
&.hidden {
opacity: 0;
}
}
.button {
@@ -61,8 +47,6 @@ $button-size: 3rem;
@include action-link;
padding: 0.75rem 1.5rem;
gap: 0.5rem;
width: 100%;
cursor: pointer;
&:hover {
background-color: $menu-hover-bg;
@@ -86,33 +70,9 @@ $button-size: 3rem;
.linkIcon {
margin-left: auto;
@include rotate-fourty-five;
transform: rotate(45deg);
}
.separator {
border-color: $border-color-ondark;
}
.sectionHeader {
font-size: calc(1rem - 2px);
margin-left: 1rem;
color: $gray-700;
}
.bottom {
margin-top: auto;
margin-bottom: 1rem;
}
.interfaces {
padding: 0.5rem 1rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.goIcon {
@include rotate-fourty-five;
margin-left: 0.25rem;
margin-bottom: 0.25rem;
}
@@ -1,7 +1,6 @@
import { memo, PropsWithChildren } from 'react';
import { memo, useRef } from 'react';
import { createPortal } from 'react-dom';
import { IoArrowUp, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
import { Link, useLocation } from 'react-router-dom';
import { Link } from 'react-router-dom';
import {
Drawer,
DrawerBody,
@@ -12,18 +11,18 @@ import {
useDisclosure,
} from '@chakra-ui/react';
import { useFullscreen } from '@mantine/hooks';
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 { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { isLocalhost } from '../../../externals';
import { navigatorConstants } from '../../../viewerConfig';
import { useElectronEvent } from '../../hooks/useElectronEvent';
import useInfo from '../../hooks-query/useInfo';
import useClickOutside from '../../hooks/useClickOutside';
import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions';
import { isKeyEnter } from '../../utils/keyEvent';
import { handleLinks, linkToOtherHost, openLink } from '../../utils/linkUtils';
import { cx } from '../../utils/styleUtils';
import { RenameClientModal } from '../client-modal/RenameClientModal';
import CopyTag from '../copy-tag/CopyTag';
import style from './NavigationMenu.module.scss';
@@ -40,15 +39,18 @@ function NavigationMenu(props: NavigationMenuProps) {
const { isOpen: isOpenRename, onOpen: onRenameOpen, onClose: onCloseRename } = useDisclosure();
const { fullscreen, toggle } = useFullscreen();
const { mirror, toggleMirror } = useViewOptionsStore();
const location = useLocation();
const { toggleMirror } = useViewOptionsStore();
const menuRef = useRef<HTMLDivElement | null>(null);
useClickOutside(menuRef, () => onClose);
return createPortal(
<div id='navigation-menu-portal'>
<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 maxWidth='22rem'>
<DrawerContent>
<DrawerHeader>
<DrawerCloseButton size='lg' />
Ontime
@@ -56,7 +58,7 @@ function NavigationMenu(props: NavigationMenuProps) {
<DrawerBody padding={0}>
<div className={style.buttonsContainer}>
<div
className={cx([style.link, fullscreen && style.current])}
className={style.link}
tabIndex={0}
role='button'
onClick={toggle}
@@ -68,7 +70,7 @@ function NavigationMenu(props: NavigationMenuProps) {
{fullscreen ? <IoContract /> : <IoExpand />}
</div>
<div
className={cx([style.link, mirror && style.current])}
className={style.link}
tabIndex={0}
role='button'
onClick={() => toggleMirror()}
@@ -94,27 +96,39 @@ function NavigationMenu(props: NavigationMenuProps) {
<hr className={style.separator} />
<Link
to='/editor'
className={`${style.link} ${location.pathname === '/editor' ? style.current : ''}`}
tabIndex={0}
className={`${style.link} ${location.pathname === '/editor' && style.current}`}
>
<IoLockClosedOutline />
Editor
<IoArrowUp className={style.linkIcon} />
</Link>
<ClientLink to='cuesheet' current={location.pathname === '/cuesheet'}>
<Link
to='/cuesheet'
className={`${style.link} ${location.pathname === '/cuesheet' ? style.current : ''}`}
tabIndex={0}
>
<IoLockClosedOutline />
Cuesheet
</ClientLink>
<ClientLink to='op' current={location.pathname === '/op'}>
<IoArrowUp className={style.linkIcon} />
</Link>
<Link to='/op' className={`${style.link} ${location.pathname === '/op' ? style.current : ''}`} tabIndex={0}>
<IoLockClosedOutline />
Operator
</ClientLink>
<IoArrowUp className={style.linkIcon} />
</Link>
<hr className={style.separator} />
{navigatorConstants.map((route) => (
<ClientLink key={route.url} to={route.url} current={location.pathname === `/${route.url}`}>
<Link
key={route.url}
to={route.url}
className={`${style.link} ${route.url === location.pathname ? style.current : undefined}`}
tabIndex={0}
>
{route.label}
</ClientLink>
<IoArrowUp className={style.linkIcon} />
</Link>
))}
{isLocalhost && <OtherAddresses currentLocation={location.pathname} />}
</DrawerBody>
</DrawerContent>
</Drawer>
@@ -123,71 +137,4 @@ function NavigationMenu(props: NavigationMenuProps) {
);
}
interface OtherAddressesProps {
currentLocation: string;
}
function OtherAddresses(props: OtherAddressesProps) {
const { currentLocation } = props;
const { data } = useInfo();
// there is no point showing this if we only have one interface
if (data.networkInterfaces.length < 2) {
return null;
}
return (
<div className={style.bottom}>
<div className={style.sectionHeader}>Accessible on external networks</div>
<div className={style.interfaces}>
{data?.networkInterfaces?.map((nif) => {
if (nif.name === 'localhost') {
return null;
}
const address = linkToOtherHost(nif.address, currentLocation);
return (
<CopyTag
key={nif.name}
copyValue={address}
onClick={() => openLink(address)}
label='Copy IP or navigate to address'
>
{nif.address} <IoArrowUp className={style.goIcon} />
</CopyTag>
);
})}
</div>
</div>
);
}
interface ClientLinkProps {
current: boolean;
to: string;
}
function ClientLink(props: PropsWithChildren<ClientLinkProps>) {
const { current, to, children } = props;
const { isElectron } = useElectronEvent();
const classes = cx([style.link, current && style.current]);
if (isElectron) {
return (
<button className={classes} tabIndex={0} onClick={(event) => handleLinks(event, to)}>
{children}
<IoArrowUp className={style.linkIcon} />
</button>
);
}
return (
<Link to={`/${to}`} className={classes} tabIndex={0}>
{children}
</Link>
);
}
export default memo(NavigationMenu);
@@ -1,16 +0,0 @@
import { IoLockClosedOutline } from 'react-icons/io5';
import { useFadeOutOnInactivity } from '../../hooks/useFadeOutOnInactivity';
import { cx } from '../../utils/styleUtils';
import style from './NavigationMenu.module.scss';
export default function ViewLockedIcon() {
const isLockIconShown = useFadeOutOnInactivity();
return (
<div className={cx([style.fadeable, style.lockIcon, !isLockIconShown && style.hidden])}>
<IoLockClosedOutline />
</div>
);
}
@@ -1,27 +1,21 @@
import { memo } from 'react';
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';
import useViewEditor from './useViewEditor';
import ViewLockedIcon from './ViewLockedIcon';
interface ViewNavigationMenuProps {
isLockable?: boolean;
}
function ViewNavigationMenu(props: ViewNavigationMenuProps) {
const { isLockable } = props;
function ViewNavigationMenu() {
const [searchParams, setSearchParams] = useSearchParams();
const { isOpen: isMenuOpen, onOpen: onMenuOpen, onClose: onMenuClose } = useDisclosure();
const { showEditFormDrawer, isViewLocked } = useViewEditor({ isLockable });
const showEditFormDrawer = useCallback(() => {
searchParams.set('edit', 'true');
setSearchParams(searchParams);
}, [searchParams, setSearchParams]);
const toggleMenu = () => (isMenuOpen ? onMenuClose() : onMenuOpen());
if (isViewLocked) {
return <ViewLockedIcon />;
}
return (
<>
<FloatingNavigation toggleMenu={toggleMenu} toggleSettings={showEditFormDrawer} />
@@ -1,26 +0,0 @@
import { useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
interface EditorVisibilityOptions {
isLockable?: boolean;
}
export default function useViewEditor({ isLockable }: EditorVisibilityOptions) {
const [searchParams, setSearchParams] = useSearchParams();
const showEditFormDrawer = useCallback(() => {
searchParams.set('edit', 'true');
setSearchParams(searchParams);
}, [searchParams, setSearchParams]);
const isViewLocked = useMemo(() => {
if (!isLockable) {
return false;
}
return isStringBoolean(searchParams.get('locked'));
}, [isLockable, searchParams]);
return { showEditFormDrawer, isViewLocked };
}
@@ -1,5 +1,7 @@
import { IoPause, IoPlay, IoStop } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/react';
import { IoPause } from '@react-icons/all-files/io5/IoPause';
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
import { IoStop } from '@react-icons/all-files/io5/IoStop';
import { Playback } from 'ontime-types';
import { tooltipDelayFast } from '../../../ontimeConfig';
@@ -9,6 +9,10 @@ $progress-bar-br: 3px;
border-radius: $progress-bar-br;
background-color: var(--timer-progress-bg-override, $viewer-card-bg-color);
overflow: clip;
&--hidden {
display: none;
}
}
.progress-bar__indicator {
@@ -1,22 +1,22 @@
import { MaybeNumber } from 'ontime-types';
import { getProgress } from '../../utils/getProgress';
import { clamp } from '../../utils/math';
import './ProgressBar.scss';
interface ProgressBarProps {
current: MaybeNumber;
duration: MaybeNumber;
now?: number;
complete?: number;
hidden?: boolean;
className?: string;
}
export default function ProgressBar(props: ProgressBarProps) {
const { current, duration, className } = props;
const progress = getProgress(current, duration);
const { now = 0, complete = 100, hidden, className = '' } = props;
const percentComplete = clamp(100 - (Math.max(now, 0) * 100) / complete, 0, 100);
return (
<div className={`progress-bar__bg ${className}`}>
<div className='progress-bar__indicator' style={{ width: `${progress}%` }} />
<div className={`progress-bar__bg ${hidden ? 'progress-bar__bg--hidden' : ''} ${className}`}>
<div className='progress-bar__indicator' style={{ width: `${percentComplete}%` }} />
</div>
);
}
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import { IoCheckmark } from 'react-icons/io5';
import { IconButton, PinInput, PinInputField } from '@chakra-ui/react';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import style from './ProtectRoute.module.scss';
@@ -1,13 +1,9 @@
.container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
padding-top: 25vh;
background: $ui-black;
color: $ontime-color;
font-weight: 200;
font-size: 3rem;
@@ -46,4 +42,4 @@
to {
background: rgba($red-500, 0);
}
}
}
@@ -1,6 +1,5 @@
import { PropsWithChildren, useCallback, useContext } from 'react';
import { isLocalhost } from '../../../externals';
import { AppContext } from '../../context/AppContext';
import PinPage from './PinPage';
@@ -10,6 +9,7 @@ interface ProtectRouteProps {
}
export default function ProtectRoute({ permission, children }: PropsWithChildren<ProtectRouteProps>) {
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
const { editorAuth, operatorAuth, validate } = useContext(AppContext);
const handleValidation = useCallback(
@@ -29,7 +29,7 @@ export default function ProtectRoute({ permission, children }: PropsWithChildren
return false;
};
if (isLocalhost || hasRelevantAuth()) {
if (isLocal || hasRelevantAuth()) {
// eslint-disable-next-line react/jsx-no-useless-fragment -- trying to make typescript happy
return <>{children}</>;
}
@@ -0,0 +1,76 @@
@use '../../../theme/viewerDefs' as *;
.schedule {
width: 100%;
border-spacing: 50px;
.entry {
font-size: clamp(16px, 1.5vw, 24px);
.entry-colour {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
height: clamp(8px, 0.75vw, 12px);
width: clamp(8px, 0.75vw, 12px);
border-radius: 6px;
display: inline-block;
}
.entry-times {
font-family: $viewer-font-family;
color: var(--secondary-color-override, $viewer-secondary-color);
font-weight: 300;
letter-spacing: 0.05em;
display: flex;
align-items: center;
gap: 8px;
}
.entry-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.entry-secondary {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&:not(:last-child) {
padding-bottom: 8px;
}
&--past {
color: var(--secondary-color-override, $viewer-secondary-color);
}
&--now {
.entry-title {
color: var(--accent-color-override, $accent-color);
font-weight: 600;
}
}
&.skip {
text-decoration: line-through;
}
}
}
.schedule-nav {
display: flex;
justify-content: flex-end;
.schedule-nav__item {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
width: 12px;
height: 12px;
border-radius: 6px;
margin-left: 8px;
&--selected {
background-color: var(--color-override, $viewer-color);
}
}
}
@@ -0,0 +1,52 @@
import { useSchedule } from './ScheduleContext';
import ScheduleItem from './ScheduleItem';
import './Schedule.scss';
interface ScheduleProps {
isProduction?: boolean;
className?: string;
}
export default function Schedule({ isProduction, className }: ScheduleProps) {
const { paginatedEvents, selectedEventId, isBackstage, scheduleType } = useSchedule();
// TODO: design a nice placeholder for empty schedules
if (paginatedEvents?.length < 1) {
return null;
}
let selectedState: 'past' | 'now' | 'future' = 'past';
return (
<ul className={`schedule ${className}`}>
{paginatedEvents.map((event) => {
if (scheduleType === 'past' || scheduleType === 'future') {
selectedState = scheduleType;
} else {
if (event.id === selectedEventId) {
selectedState = 'now';
} else if (selectedState === 'now') {
selectedState = 'future';
}
}
const timeStart = isProduction ? event.timeStart + (event?.delay ?? 0) : event.timeStart;
const timeEnd = isProduction ? event.timeEnd + (event?.delay ?? 0) : event.timeEnd;
return (
<ScheduleItem
key={event.id}
selected={selectedState}
timeStart={timeStart}
timeEnd={timeEnd}
title={event.title}
colour={isBackstage ? event.colour : ''}
backstageEvent={!event.isPublic}
skip={event.skip}
/>
);
})}
</ul>
);
}
@@ -0,0 +1,102 @@
import { createContext, PropsWithChildren, useContext, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { OntimeEvent } from 'ontime-types';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
import { useInterval } from '../../hooks/useInterval';
interface ScheduleContextState {
events: OntimeEvent[];
paginatedEvents: OntimeEvent[];
selectedEventId: string | null;
scheduleType: 'past' | 'now' | 'future';
numPages: number;
visiblePage: number;
isBackstage: boolean;
}
const ScheduleContext = createContext<ScheduleContextState | undefined>(undefined);
interface ScheduleProviderProps {
events: OntimeEvent[];
selectedEventId: string | null;
isBackstage?: boolean;
time?: number;
}
const numEventsPerPage = 8;
export const ScheduleProvider = ({
children,
events,
selectedEventId,
isBackstage = false,
time = 10,
}: PropsWithChildren<ScheduleProviderProps>) => {
const [visiblePage, setVisiblePage] = useState(0);
const [searchParams] = useSearchParams();
// look for overrides from views
const hidePast = isStringBoolean(searchParams.get('hidePast'));
const stopCycle = isStringBoolean(searchParams.get('stopCycle'));
const eventsPerPage = Number(searchParams.get('eventsPerPage') ?? numEventsPerPage);
let selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
const viewEvents = [...events];
if (hidePast) {
// we want to show the event after the next
viewEvents.splice(0, selectedEventIndex + 2);
selectedEventIndex = 0;
}
const numPages = Math.ceil(viewEvents.length / eventsPerPage);
const eventStart = eventsPerPage * visiblePage;
const eventEnd = eventsPerPage * (visiblePage + 1);
const paginatedEvents = viewEvents.slice(eventStart, eventEnd);
const resolveScheduleType = () => {
if (selectedEventIndex >= eventStart && selectedEventIndex < eventEnd) {
return 'now';
}
if (selectedEventIndex > eventEnd) {
return 'past';
}
return 'future';
};
const scheduleType = resolveScheduleType();
// every SCROLL_TIME go to the next array
useInterval(() => {
if (stopCycle) {
setVisiblePage(0);
} else if (events.length > eventsPerPage) {
const next = (visiblePage + 1) % numPages;
setVisiblePage(next);
}
}, time * 1000);
return (
<ScheduleContext.Provider
value={{
events,
paginatedEvents,
selectedEventId,
scheduleType,
numPages,
visiblePage,
isBackstage,
}}
>
{children}
</ScheduleContext.Provider>
);
};
export const useSchedule = () => {
const context = useContext(ScheduleContext);
if (!context) {
throw new Error('useSchedule() can only be used inside a ScheduleContext');
}
return context;
};
@@ -0,0 +1,43 @@
import SuperscriptTime from '../../../features/viewers/common/superscript-time/SuperscriptTime';
import { formatTime } from '../../utils/time';
import './Schedule.scss';
const formatOptions = {
format12: 'hh:mm a',
format24: 'HH:mm',
};
interface ScheduleItemProps {
selected: 'past' | 'now' | 'future';
timeStart: number;
timeEnd: number;
title: string;
backstageEvent: boolean;
colour: string;
skip: boolean;
}
export default function ScheduleItem(props: ScheduleItemProps) {
const { selected, timeStart, timeEnd, title, backstageEvent, colour, skip } = props;
const start = formatTime(timeStart, formatOptions);
const end = formatTime(timeEnd, formatOptions);
const userColour = colour !== '' ? colour : '';
const selectStyle = `entry--${selected}`;
return (
<li className={`entry ${selectStyle} ${skip ? 'skip' : ''}`}>
<div className='entry-times'>
<span className='entry-colour' style={{ backgroundColor: userColour }} />
<div style={{ display: 'flex' }}>
<SuperscriptTime time={start} />
{' → '}
<SuperscriptTime time={end} />
{backstageEvent ? '*' : ''}
</div>
</div>
<div className='entry-title'>{title}</div>
</li>
);
}
@@ -0,0 +1,23 @@
import { useSchedule } from './ScheduleContext';
import './Schedule.scss';
interface ScheduleNavProps {
className?: string;
}
export default function ScheduleNav({ className }: ScheduleNavProps) {
const { numPages, visiblePage } = useSchedule();
return (
<div className={`schedule-nav ${className}`}>
{numPages > 1 &&
[...Array(numPages).keys()].map((i) => (
<div
key={i}
className={i === visiblePage ? 'schedule-nav__item schedule-nav__item--selected' : 'schedule-nav__item'}
/>
))}
</div>
);
}
@@ -5,7 +5,7 @@
.empty {
width: 100%;
opacity: 0.8;
opacity: 0.6;
}
.text {
@@ -1,20 +1,18 @@
import { CSSProperties } from 'react';
import EmptyImage from '../../../assets/images/empty.svg?react';
import { cx } from '../../utils/styleUtils';
import style from './Empty.module.scss';
interface EmptyProps {
text?: string;
style?: CSSProperties;
className?: string;
}
export default function Empty(props: EmptyProps) {
const { text, className, ...rest } = props;
const { text, ...rest } = props;
return (
<div className={cx([style.emptyContainer, className])} {...rest}>
<div className={style.emptyContainer} {...rest}>
<EmptyImage className={style.empty} />
{text && <span className={style.text}>{text}</span>}
</div>
@@ -1,20 +0,0 @@
@use '../../../theme/viewerDefs' as *;
/* share the same style as a page layout */
.page {
margin: 0;
box-sizing: border-box; /* reset */
overflow: hidden;
width: 100%; /* restrict the page width to viewport */
height: 100vh;
font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, $viewer-background-color);
color: var(--color-override, $viewer-color);
padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
display: flex;
flex-direction: column;
align-items: center;
padding-top: 5rem;
}
@@ -1,20 +0,0 @@
import { CSSProperties } from 'react';
import Empty from './Empty';
import style from './EmptyPage.module.scss';
interface EmptyPageProps {
text?: string;
style?: CSSProperties;
}
export default function EmptyPage(props: EmptyPageProps) {
const { text, ...rest } = props;
return (
<div className={style.page}>
<Empty text={text} {...rest} />
</div>
);
}
@@ -1,42 +1,40 @@
@use '../../../theme/viewerDefs' as *;
.title-card {
position: relative;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.title-card__title,
.title-card__placeholder {
font-weight: 600;
font-size: $title-font-size;
line-height: 1.2em;
.inline {
display: flex;
}
.title-card__title {
font-weight: 600;
font-size: clamp(32px, 3.5vw, 50px);
color: var(--color-override, $viewer-color);
padding-right: 1em;
}
.title-card__placeholder {
color: var(--label-color-override, $viewer-label-color);
}
.title-card__secondary {
font-size: $base-font-size;
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: $timer-label-size;
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;
&--accent {
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,33 +1,28 @@
import { ForwardedRef, forwardRef } from 'react';
import { useTranslation } from '../../../translation/TranslationProvider';
import { cx } from '../../utils/styleUtils';
import './TitleCard.scss';
interface TitleCardProps {
title?: string;
label?: 'now' | 'next';
label: 'now' | 'next';
title: string;
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={cx(['title-card', className])} ref={ref}>
<span className='title-card__title'>{title}</span>
<span className={cx(['title-card__label', accent && 'title-card__label--accent'])}>
{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,5 +0,0 @@
.viewLogo {
max-width: 100%;
max-height: 75px;
display: block;
}
@@ -1,19 +0,0 @@
import { projectLogoPath } from '../../api/constants';
import './ViewLogo.scss';
interface ViewLogoProps {
name: string;
className: string;
}
export default function ViewLogo(props: ViewLogoProps) {
const { name, className } = props;
// we wrap the image in a div to help maintain the aspect ratio
return (
<div className={className}>
<img alt='' src={`${projectLogoPath}/${name}`} className='viewLogo' />
</div>
);
}
@@ -1,27 +0,0 @@
.inline {
display: flex;
align-items: center;
gap: 1rem;
}
// attempt to match with ontimeTextInputs
.input {
color: $gray-200;
border: 1px solid transparent;
border-radius: 3px;
background-color: $gray-1200;
padding: 0 1rem;
font-size: 1rem;
height: 2.5rem;
outline: none;
&:hover {
background-color: $gray-1100;
}
&:focus {
background-color: $gray-1000;
color: $gray-50;
border: 1px solid $blue-500;
}
}
@@ -1,30 +0,0 @@
import { useState } from 'react';
import SwatchPicker from '../input/colour-input/SwatchPicker';
import style from './InlineColourPicker.module.scss';
interface InlineColourPickerProps {
name: string;
value: string;
}
const ensureHex = (value: string) => {
if (!value.startsWith('#')) {
return `#${value}`;
}
return value;
};
export default function InlineColourPicker(props: InlineColourPickerProps) {
const { name, value } = props;
const [colour, setColour] = useState(() => ensureHex(value));
return (
<div className={style.inline}>
<SwatchPicker color={colour} onChange={setColour} alwaysDisplayColor />
<span>{colour}</span>
<input type='hidden' name={name} value={colour} />
</div>
);
}
@@ -1,5 +1,4 @@
import { useState } from 'react';
import { IoChevronDown } from 'react-icons/io5';
import { useSearchParams } from 'react-router-dom';
import {
Button,
@@ -17,7 +16,6 @@ import {
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
import InlineColourPicker from './InlineColourPicker';
import { ParamField } from './types';
interface EditFormInputProps {
@@ -25,14 +23,10 @@ interface EditFormInputProps {
}
export default function ParamInput(props: EditFormInputProps) {
const { paramField } = props;
const [searchParams] = useSearchParams();
const { paramField } = props;
const { id, type, defaultValue } = paramField;
if (type === 'persist') {
return null;
}
if (type === 'option') {
const optionFromParams = searchParams.get(id);
const defaultOptionValue = optionFromParams || defaultValue;
@@ -83,12 +77,6 @@ export default function ParamInput(props: EditFormInputProps) {
);
}
if (type === 'colour') {
const currentvalue = `#${searchParams.get(id) ?? defaultValue}`;
return <InlineColourPicker name={id} value={currentvalue} />;
}
const defaultStringValue = searchParams.get(id) ?? defaultValue;
const { prefix, placeholder } = paramField;
@@ -109,26 +97,30 @@ function MultiOption(props: EditFormMultiOptionProps) {
const { paramField } = props;
const { id, defaultValue } = paramField;
const optionFromParams = searchParams.getAll(id);
const [paramState, setParamState] = useState<string[]>(optionFromParams || defaultValue || ['']);
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} <IoChevronDown style={{ display: 'inline' }} />
{paramField.title}
</MenuButton>
<MenuList overflow='auto' maxHeight='200px'>
<MenuList>
<MenuOptionGroup
type='checkbox'
value={paramState}
onChange={(value) => setParamState(Array.isArray(value) ? value : [value])}
value={paramState.split('_')}
onChange={(value) => {
setParamState(typeof value === 'object' ? value.filter((v) => v !== '').join('_') : value);
}}
>
{Object.values(paramField.values).map((option) => {
const { value, label, colour } = option;
const { value, label } = option;
return (
<MenuItemOption value={value} key={value} style={{ borderRight: `8px solid ${colour}` }}>
<MenuItemOption value={value} key={value}>
{label}
</MenuItemOption>
);
@@ -8,15 +8,29 @@
}
}
.sectionList {
.label {
font-size: $inner-section-text-size;
color: $label-gray;
display: flex;
flex-direction: column;
min-height: 100%;
gap: 2rem;
overflow-y: scroll;
padding-right: 0.5rem;
gap: 0.25rem
}
.info {
margin-bottom: 1rem;
.columnSection {
display: flex;
padding: $section-spacing 0;
flex-direction: column;
gap: $element-spacing;
}
.title {
font-size: $inner-section-text-size;
display: block;
width: 100%;
}
.description {
font-size: $inner-section-text-size;
display: block;
color: $modal-note-color;
}
@@ -12,86 +12,37 @@ import {
useDisclosure,
} from '@chakra-ui/react';
import useViewSettings from '../../hooks-query/useViewSettings';
import Info from '../info/Info';
import { ViewOption } from './types';
import ViewParamsSection from './ViewParamsSection';
import ParamInput from './ParamInput';
import { ParamField } from './types';
import style from './ViewParamsEditor.module.scss';
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
/**
* Utility remove the # character from a hex string
*/
function sanitiseColour(colour: string) {
if (colour.startsWith('#')) {
return colour.substring(1);
}
return colour;
}
/**
* Makes a new URLSearchParams object from the given params object
*/
const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ViewOption[]) => {
const newSearchParams = new URLSearchParams();
const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ParamField[]) => {
const defaultValues = paramFields.reduce<Record<string, string>>((acc, { id, defaultValue }) => {
acc[id] = String(defaultValue);
return acc;
}, {});
// Convert paramFields to an object that contains default values
const defaultValues: Record<string, string> = {};
paramFields.forEach((section) => {
section.options.forEach((option) => {
defaultValues[option.id] = String(option.defaultValue);
// extract persisted values
if ('type' in option && option.type === 'persist') {
newSearchParams.set(option.id, option.value);
}
});
});
// compare which values are different from the default values
Object.entries(paramsObj).forEach(([id, value]) => {
if (typeof value === 'string' && value.length) {
// we dont know which values contain colours
// unfortunately this means we run all the strings through the sanitation
const valueWithoutHash = sanitiseColour(value);
if (defaultValues[id] !== valueWithoutHash) {
handleValueString(id, valueWithoutHash);
}
}
});
/** Utility function contains logic to add a value into the searchParams object */
function handleValueString(id: string, value: string) {
const maybeMultipleValues = value.split(',');
// we need to check if the value contains comma separated list, for the case of the multi-select data
if (Array.isArray(maybeMultipleValues) && maybeMultipleValues.length > 1) {
const added = new Set();
maybeMultipleValues.forEach((v) => {
if (!added.has(v)) {
added.add(v);
newSearchParams.append(id, v);
}
});
} else {
return Object.entries(paramsObj).reduce((newSearchParams, [id, value]) => {
if (typeof value === 'string' && value.length && defaultValues[id] !== value) {
newSearchParams.set(id, value);
}
}
return newSearchParams;
return newSearchParams;
}, new URLSearchParams());
};
interface EditFormDrawerProps {
viewOptions: ViewOption[];
paramFields: ParamField[];
}
// TODO: this is a good candidate for memoisation, but needs the paramFields to be stable
export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
const [searchParams, setSearchParams] = useSearchParams();
const { data: viewSettings } = useViewSettings();
const { isOpen, onClose, onOpen } = useDisclosure();
useEffect(() => {
@@ -118,8 +69,10 @@ export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
formEvent.preventDefault();
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, paramFields);
setSearchParams(newSearchParams);
onClose();
};
return (
@@ -132,17 +85,15 @@ export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
</DrawerHeader>
<DrawerBody>
{viewSettings.overrideStyles && (
<Info className={style.info}>This view style is being modified by a custom CSS file.</Info>
)}
<form id='edit-params-form' onSubmit={onParamsFormSubmit} className={style.sectionList}>
{viewOptions.map((section) => (
<ViewParamsSection
key={section.title}
title={section.title}
collapsible={section.collapsible}
options={section.options}
/>
<form id='edit-params-form' onSubmit={onParamsFormSubmit}>
{paramFields.map((field) => (
<div key={field.title} className={style.columnSection}>
<label className={style.label}>
<span className={style.title}>{field.title}</span>
<span className={style.description}>{field.description}</span>
<ParamInput key={field.title} paramField={field} />
</label>
</div>
))}
</form>
</DrawerBody>

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